1use crate::{Result, RuntimeError, ToolRegistry};
2use futures::stream::Stream;
3use reqwest::Client;
4use serde_json::{json, Value};
5use std::path::PathBuf;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::sync::Mutex;
9use std::time::Duration;
10use tokio::sync::{mpsc, RwLock};
11use tokio_util::sync::CancellationToken;
12
13mod api;
14mod api_sync;
15mod auth;
16#[cfg(test)]
17mod body_golden;
18pub mod budget;
19pub mod capture_worker;
20pub mod chat_capture;
21pub(crate) mod cloud_invoke;
22pub mod compaction;
23pub mod context;
24pub mod google_gemini;
25pub mod google_vertex;
26pub(crate) mod helpers;
27pub mod memory_context;
28pub mod memory_history;
29pub mod openai;
30pub mod relay;
31mod request;
32mod sse;
33mod sse_types;
34mod stream;
35pub mod subagent;
36pub mod telemetry;
37pub mod trace;
38pub(crate) mod transport;
39mod types;
40
41use api::ApiMethods;
42use auth::AuthMethods;
43use helpers::HelperMethods;
44use stream::StreamMethods;
45use types::AuthState;
46pub use types::{AgentEvent, LlmEvent, SessionEvent, StreamEvent};
47
48pub enum BeforeToolCallDecision {
50 Continue { input: Value },
51 Block { reason: String },
52}
53
54pub async fn emit_before_tool_call(
57 hook_bus: &Arc<crate::extensions::hooks::HookBus>,
58 tool_name: &str,
59 runtime_tool_name: Option<&str>,
60 input: Value,
61) -> crate::extensions::hooks::events::HookResult {
62 let mut event = crate::extensions::hooks::events::HookEvent::before_tool_call(tool_name, input);
63 if let Some(runtime_tool_name) = runtime_tool_name {
64 event.tool_runtime_name = Some(runtime_tool_name.to_string());
65 }
66 hook_bus.emit(&event).await
67}
68
69pub async fn resolve_before_tool_call_result(
74 hook_result: crate::extensions::hooks::events::HookResult,
75 secret_prompt: Option<&crate::tools::SecretPromptHandle>,
76 auto_approve_confirms: bool,
77) -> crate::extensions::hooks::events::HookResult {
78 match hook_result {
79 crate::extensions::hooks::events::HookResult::Confirm { message } => {
80 if auto_approve_confirms {
81 tracing::info!(message = %message, "confirm auto-approved (auto_approve_confirms=true)");
82 return crate::extensions::hooks::events::HookResult::Continue;
83 }
84
85 let Some(prompt) = secret_prompt else {
86 return crate::extensions::hooks::events::HookResult::Block {
87 reason: format!(
88 "Tool call requires confirmation but no interactive prompt is available: {}",
89 message
90 ),
91 };
92 };
93
94 let response = prompt
95 .prompt(
96 "Confirm tool call".to_string(),
97 format!("{}\n\nType 'yes' or 'y' to allow.", message),
98 )
99 .await;
100
101 match response.as_deref().map(str::trim) {
102 Some(answer)
103 if answer.eq_ignore_ascii_case("yes") || answer.eq_ignore_ascii_case("y") =>
104 {
105 crate::extensions::hooks::events::HookResult::Continue
106 }
107 _ => crate::extensions::hooks::events::HookResult::Block {
108 reason: format!("Tool call confirmation denied: {}", message),
109 },
110 }
111 }
112 other => other,
113 }
114}
115
116pub async fn resolve_before_tool_call_decision(
118 original_input: Value,
119 hook_result: crate::extensions::hooks::events::HookResult,
120 secret_prompt: Option<&crate::tools::SecretPromptHandle>,
121 auto_approve_confirms: bool,
122) -> BeforeToolCallDecision {
123 match resolve_before_tool_call_result(hook_result, secret_prompt, auto_approve_confirms).await {
124 crate::extensions::hooks::events::HookResult::Block { reason } => {
125 BeforeToolCallDecision::Block { reason }
126 }
127 crate::extensions::hooks::events::HookResult::Modify { input } => {
128 BeforeToolCallDecision::Continue { input }
129 }
130 _ => BeforeToolCallDecision::Continue {
131 input: original_input,
132 },
133 }
134}
135
136const MAX_REPLACE_OUTPUT: usize = 1024 * 1024; pub async fn emit_after_tool_call(
161 hook_bus: &Arc<crate::extensions::hooks::HookBus>,
162 tool_name: &str,
163 runtime_tool_name: Option<&str>,
164 input: Value,
165 output: String,
166 max_tool_output: usize,
167) -> String {
168 use crate::extensions::hooks::events::HookResult;
169 let original = output.clone();
171 let mut event =
172 crate::extensions::hooks::events::HookEvent::after_tool_call(tool_name, input, output);
173 if let Some(runtime_tool_name) = runtime_tool_name {
174 event.tool_runtime_name = Some(runtime_tool_name.to_string());
175 }
176 let post_hook = match hook_bus.emit(&event).await {
177 HookResult::Replace { mut output } => {
178 if output.len() > MAX_REPLACE_OUTPUT {
179 tracing::warn!(
180 tool = %tool_name,
181 len = output.len(),
182 cap = MAX_REPLACE_OUTPUT,
183 "Extension Replace output exceeds cap — clamping",
184 );
185 let mut boundary = MAX_REPLACE_OUTPUT;
187 while boundary > 0 && !output.is_char_boundary(boundary) {
188 boundary -= 1;
189 }
190 output.truncate(boundary);
191 }
192 output
193 }
194 HookResult::Continue => original,
195 other => {
196 tracing::warn!(
197 tool = %tool_name,
198 ?other,
199 "Unexpected after_tool_call result — using original output",
200 );
201 original
202 }
203 };
204 crate::runtime::helpers::HelperMethods::truncate_tool_result(&post_hook, max_tool_output)
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub struct ReasoningClamp {
214 pub from: agent_core::reasoning::ReasoningLevel,
215 pub to: agent_core::reasoning::ReasoningLevel,
216}
217
218#[derive(Clone)]
221struct PromptReloadSource {
222 manifest: PathBuf,
223 context: agent_core::prompt::SelectionContext,
224 user_module: Option<agent_core::prompt::PromptModule>,
225 delegation_policy_digest: Option<String>,
226}
227
228pub struct Runtime {
229 client: Client,
230 auth: Arc<RwLock<AuthState>>,
231 model: String,
232 tools: Arc<RwLock<ToolRegistry>>,
233 system_prompt: Option<String>,
234 effective_prompt: Option<agent_core::prompt::PromptStack>,
236 prompt_generation: u64,
237 prompt_reload_source: Option<PromptReloadSource>,
239 thinking_budget: u32,
240 named_level: Option<agent_core::reasoning::ReasoningLevel>,
244 explicit_reasoning: bool,
249 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole,
252 context_window_override: Option<u64>,
257 compaction_model: Option<String>,
259 compaction_mode: agent_core::compaction::CompactionMode,
261 compaction_exclusions: Vec<agent_core::compaction::ContentClass>,
263 remote_summarization_attempts: Arc<std::sync::atomic::AtomicU64>,
268 subagent_registry: Arc<Mutex<crate::runtime::subagent::SubagentRegistry>>,
270 orchestration: Option<Arc<crate::orchestration::OrchestrationRuntime>>,
272 event_queue: Arc<crate::events::EventQueue>,
274 pub watcher_exit_path: Option<PathBuf>,
276 max_tool_output: usize,
278 bash_timeout: u64,
279 bash_max_timeout: u64,
280 subagent_timeout: u64,
281 api_retries: u32,
282 refusal_retries: u32,
283 telemetry_level: crate::runtime::telemetry::TelemetryLevel,
285 telemetry_writer: Option<crate::runtime::telemetry::TelemetryWriter>,
291 trace_ctx: trace::TraceContext,
296 trace_controls: std::sync::Arc<trace::TraceControls>,
300 memory_context_state: std::sync::Arc<std::sync::Mutex<memory_context::SessionMemoryState>>,
312 #[cfg(any(test, feature = "testing"))]
316 capture_provider_for_test: std::sync::Arc<
317 std::sync::Mutex<Option<std::sync::Arc<dyn capture_worker::CaptureProvider>>>,
318 >,
319 pending_history_import_preview:
323 std::sync::Arc<std::sync::Mutex<Option<memory_history::HistoryImportPreview>>>,
324 history_import_plan: std::sync::Arc<std::sync::Mutex<Option<memory_history::ImportPlan>>>,
326 pending_memory_segment:
337 std::sync::Arc<std::sync::Mutex<Option<memory_context::ContextSegment>>>,
338 retained_recall_turn:
345 std::sync::Arc<std::sync::Mutex<Option<memory_context::RetainedRecallTurn>>>,
346 one_shot_trace_writer:
354 std::sync::Arc<std::sync::Mutex<Option<crate::runtime::telemetry::TelemetryWriter>>>,
355 capture_dir: std::path::PathBuf,
361 cache_diagnostics: bool,
363 cache_ttl: crate::core::config::CacheTtl,
366 ttl_downgrade_notified: std::sync::Arc<std::sync::atomic::AtomicBool>,
369 saw_1h_honored: std::sync::Arc<std::sync::atomic::AtomicBool>,
373 #[allow(dead_code)]
377 last_msg_id: Arc<Mutex<Option<String>>>,
378 session_manager: std::sync::Arc<crate::tools::shell::SessionManager>,
379 hook_bus: Arc<crate::extensions::hooks::HookBus>,
381 #[allow(dead_code)]
383 reaper_handle: Option<tokio::task::JoinHandle<()>>,
384 #[allow(dead_code)]
385 reaper_cancel: Option<tokio_util::sync::CancellationToken>,
386 credential_source: crate::auth::CredentialSource,
390 token_cache: crate::auth::TokenCache,
393 trusted_worker_models: Vec<agent_core::prompt::QualifiedModelId>,
397 progressive_tool_disclosure: bool,
401 delegation_parent: Option<String>,
404 mcp_runtime: Option<std::sync::Arc<crate::mcp::McpRuntimeManager>>,
408 mcp_session_scope: Option<std::sync::Arc<crate::mcp::McpSessionEndGuard>>,
412 extension_runtime: Option<std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>>,
416 extension_session_scope:
420 Option<std::sync::Arc<crate::extensions::lease::ExtensionSessionEndGuard>>,
421 turn_budget: crate::runtime::budget::TurnBudget,
424 host_tool_session: crate::tools::activation::SessionId,
432}
433
434fn fresh_host_tool_session() -> crate::tools::activation::SessionId {
438 crate::tools::activation::SessionId::parse(&format!(
439 "runtime-{}-{}",
440 std::process::id(),
441 uuid::Uuid::new_v4()
442 ))
443 .expect("generated runtime session id is always valid")
444}
445
446fn fresh_memory_context_state(
453) -> std::sync::Arc<std::sync::Mutex<memory_context::SessionMemoryState>> {
454 let session = memory_context::SessionId::parse(&format!(
455 "memctx-{}-{}",
456 std::process::id(),
457 uuid::Uuid::new_v4()
458 ))
459 .expect("generated memory session id is always valid");
460 std::sync::Arc::new(std::sync::Mutex::new(
461 memory_context::SessionMemoryState::new(session),
462 ))
463}
464
465fn memory_project_scope() -> std::result::Result<agent_core::memory::store::ProjectScope, String> {
469 let cwd = std::env::current_dir()
470 .map_err(|error| format!("cannot resolve current project directory: {error}"))?;
471 agent_core::memory::store::ProjectScope::discover(&cwd)
472 .map_err(|error| format!("cannot resolve project scope: {error}"))
473}
474
475fn memory_project_id() -> memory_context::ProjectId {
476 match memory_project_scope() {
477 Ok(scope) => memory_context::ProjectId::parse(scope.key())
478 .expect("host project scope key is always a valid identifier"),
479 Err(_) => memory_context::ProjectId::parse("project-unresolved")
480 .expect("static fallback project id is valid"),
481 }
482}
483
484fn memory_capture_worker() -> &'static capture_worker::CaptureWorker {
493 static WORKER: std::sync::OnceLock<capture_worker::CaptureWorker> = std::sync::OnceLock::new();
494 WORKER.get_or_init(|| capture_worker::CaptureWorker::new(32))
495}
496
497struct ExtensionCaptureProvider {
498 manager: std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
499 session: crate::tools::activation::SessionId,
500 plugin: String,
501 digest: crate::tools::catalog::SchemaDigest,
502 handle: tokio::runtime::Handle,
503}
504
505impl capture_worker::CaptureProvider for ExtensionCaptureProvider {
506 fn capture(
507 &self,
508 capture: chat_capture::ChatTurnCapture,
509 ) -> std::result::Result<(), capture_worker::CaptureFailure> {
510 let params = memory_context::capture_request_wire(&capture);
511 self.handle
512 .block_on(
513 crate::extensions::lease::ExtensionLeaseCapability::new(
514 self.session.clone(),
515 self.manager.clone(),
516 )
517 .call_exact(
518 &self.plugin,
519 memory_context::MEMORY_CAPTURE_TOOL_NAME,
520 &self.digest,
521 params,
522 ),
523 )
524 .map(|_| ())
525 .map_err(|_| capture_worker::CaptureFailure {
526 code: "provider_call_failed",
527 })
528 }
529
530 fn contains_capture(
531 &self,
532 capture_id: &[u8; 32],
533 ) -> std::result::Result<capture_worker::CaptureCommitState, capture_worker::CaptureFailure>
534 {
535 let capture_id = chat_capture::CaptureId::from_bytes(*capture_id);
536 let response = self
537 .handle
538 .block_on(
539 crate::extensions::lease::ExtensionLeaseCapability::new(
540 self.session.clone(),
541 self.manager.clone(),
542 )
543 .call_exact(
544 &self.plugin,
545 memory_context::MEMORY_CAPTURE_TOOL_NAME,
546 &self.digest,
547 memory_context::capture_query_wire(&capture_id),
548 ),
549 )
550 .map_err(|_| capture_worker::CaptureFailure {
551 code: "provider_query_failed",
552 })?;
553 Ok(
554 if response
555 .get("committed")
556 .and_then(serde_json::Value::as_bool)
557 == Some(true)
558 {
559 capture_worker::CaptureCommitState::Committed
560 } else {
561 capture_worker::CaptureCommitState::Absent
562 },
563 )
564 }
565
566 fn capture_summary(
567 &self,
568 capture: chat_capture::ConversationSummaryCapture,
569 ) -> std::result::Result<(), capture_worker::CaptureFailure> {
570 let params = memory_context::summary_capture_request_wire(&capture);
571 self.handle
572 .block_on(
573 crate::extensions::lease::ExtensionLeaseCapability::new(
574 self.session.clone(),
575 self.manager.clone(),
576 )
577 .call_exact(
578 &self.plugin,
579 memory_context::MEMORY_CAPTURE_TOOL_NAME,
580 &self.digest,
581 params,
582 ),
583 )
584 .map(|_| ())
585 .map_err(|_| capture_worker::CaptureFailure {
586 code: "provider_call_failed",
587 })
588 }
589}
590
591fn terminal_capture_history(
592 lease: &memory_context::MemoryContextLease,
593 messages: &[crate::SharedMessage],
594 started_at: std::time::SystemTime,
595) -> chat_capture::TerminalTurnHistory {
596 use agent_core::core::disclosure::DisclosureClass;
597 let mut items = Vec::new();
598 for message in messages {
599 let Some(role) = message.get("role").and_then(serde_json::Value::as_str) else {
600 continue;
601 };
602 let class = match role {
603 "user" => chat_capture::CaptureContentClass::UserMessage,
604 "assistant" => chat_capture::CaptureContentClass::AssistantFinal,
605 _ => continue,
606 };
607 let text = message
608 .get("content")
609 .and_then(serde_json::Value::as_str)
610 .map(str::to_owned)
611 .unwrap_or_else(|| {
612 message
613 .get("content")
614 .map(ToString::to_string)
615 .unwrap_or_default()
616 });
617 items.push(chat_capture::CanonicalCaptureItem {
618 project_id: lease.project_id.clone(),
619 class,
620 disclosure: DisclosureClass::ModelVisible,
621 sensitivity: chat_capture::Sensitivity::Normal,
622 text,
623 tool_name: None,
624 });
625 }
626 chat_capture::TerminalTurnHistory {
627 project_id: lease.project_id.clone(),
628 session_id: lease.session_id.clone(),
629 turn_id: memory_context::TurnId::parse(&format!("turn-{}", uuid::Uuid::new_v4()))
630 .expect("generated turn id"),
631 turn_ordinal: 0,
632 started_at,
633 completed_at: std::time::SystemTime::now(),
634 outcome: agent_core::TurnOutcome::Completed,
635 items,
636 compaction: None,
637 }
638}
639
640fn memory_provider_id() -> memory_context::ContextProviderId {
641 memory_context::ContextProviderId::parse("axel-memory")
642 .expect("static provider id is always valid")
643}
644
645const HTTP_READ_TIMEOUT: Duration = Duration::from_secs(90);
650
651fn build_http_client(read_timeout: Duration) -> reqwest::Result<Client> {
664 Client::builder()
665 .tls_built_in_webpki_certs(true)
666 .tls_built_in_native_certs(true)
667 .connect_timeout(Duration::from_secs(10))
668 .read_timeout(read_timeout)
669 .build()
670}
671
672fn canonical_trusted_worker_model(model: &str) -> String {
676 let model = model.trim();
677 if let Some(id) = model.strip_prefix("claude/") {
678 format!("anthropic/{id}")
679 } else if model.starts_with("claude-") {
680 format!("anthropic/{model}")
681 } else {
682 model.to_owned()
683 }
684}
685
686impl Runtime {
687 pub async fn new() -> Result<Self> {
688 let (auth_token, auth_type, refresh_token, token_expires) =
692 (String::new(), "oauth".to_string(), None, Some(0));
693
694 let client = build_http_client(HTTP_READ_TIMEOUT)
695 .map_err(|e| RuntimeError::Config(format!("Failed to build HTTP client: {}", e)))?;
696
697 let capture_dir = trace::default_capture_dir();
702 let _ = trace::sweep_expired_captures(&capture_dir);
703
704 let session_manager = {
705 let config = crate::tools::shell::ShellConfig::default();
706 crate::tools::shell::SessionManager::new(config)
707 };
708
709 let mgr = session_manager.clone();
711 let cancel = tokio_util::sync::CancellationToken::new();
712 let reaper_handle = crate::tools::shell::session::start_reaper(mgr, cancel.clone());
713
714 Ok(Runtime {
715 client,
716 auth: Arc::new(RwLock::new(AuthState {
717 auth_token,
718 auth_type,
719 refresh_token,
720 token_expires,
721 })),
722 model: crate::models::default_model().to_string(),
723 tools: Arc::new(RwLock::new(ToolRegistry::new())),
724 system_prompt: None,
725 effective_prompt: None,
726 prompt_generation: 0,
727 prompt_reload_source: None,
728 thinking_budget: 4096,
729 named_level: None,
730 explicit_reasoning: false,
731 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole::Foreground,
732 context_window_override: None,
733 compaction_model: None,
734 compaction_mode: agent_core::compaction::CompactionMode::default(),
735 compaction_exclusions: Vec::new(),
736 remote_summarization_attempts: Arc::new(std::sync::atomic::AtomicU64::new(0)),
737 subagent_registry: Arc::new(Mutex::new(
738 crate::runtime::subagent::SubagentRegistry::new(),
739 )),
740 orchestration: None,
741 event_queue: Arc::new(crate::events::EventQueue::new(1000)),
742 watcher_exit_path: None,
743 max_tool_output: 30000,
744 bash_timeout: 30,
745 bash_max_timeout: 300,
746 subagent_timeout: 300,
747 api_retries: 3,
748 refusal_retries: 2,
749 telemetry_level: crate::runtime::telemetry::TelemetryLevel::Off,
750 telemetry_writer: None,
751 trace_ctx: trace::TraceContext::disabled(),
752 trace_controls: std::sync::Arc::new(trace::TraceControls::new()),
753 memory_context_state: fresh_memory_context_state(),
756 pending_history_import_preview: std::sync::Arc::new(std::sync::Mutex::new(None)),
757 history_import_plan: std::sync::Arc::new(std::sync::Mutex::new(None)),
758 #[cfg(any(test, feature = "testing"))]
759 capture_provider_for_test: std::sync::Arc::new(std::sync::Mutex::new(None)),
760 pending_memory_segment: std::sync::Arc::new(std::sync::Mutex::new(None)),
763 retained_recall_turn: std::sync::Arc::new(std::sync::Mutex::new(None)),
764 one_shot_trace_writer: std::sync::Arc::new(std::sync::Mutex::new(None)),
765 capture_dir,
766 cache_diagnostics: false,
767 cache_ttl: crate::core::config::CacheTtl::default(),
768 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
769 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
770 last_msg_id: Arc::new(Mutex::new(None)),
771 session_manager,
772 hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
773 reaper_handle: Some(reaper_handle),
774 reaper_cancel: Some(cancel),
775 credential_source: crate::auth::CredentialSource::Local,
776 token_cache: crate::auth::TokenCache::new(),
777 trusted_worker_models: Vec::new(),
778 progressive_tool_disclosure: false,
779 delegation_parent: None,
780 mcp_runtime: None,
781 mcp_session_scope: None,
782 extension_runtime: None,
783 extension_session_scope: None,
784 turn_budget: crate::runtime::budget::TurnBudget::for_role(
785 crate::runtime::budget::TurnRole::Foreground,
786 ),
787 host_tool_session: fresh_host_tool_session(),
788 })
789 }
790
791 #[cfg(any(test, feature = "testing"))]
802 pub fn new_headless() -> Self {
803 let client = build_http_client(HTTP_READ_TIMEOUT)
804 .expect("reqwest client construction is infallible with built-in roots");
805
806 let session_manager = {
807 let config = crate::tools::shell::ShellConfig::default();
808 crate::tools::shell::SessionManager::new(config)
809 };
810
811 Runtime {
812 client,
813 auth: Arc::new(RwLock::new(AuthState {
814 auth_token: "test-token".to_string(),
815 auth_type: "api_key".to_string(),
816 refresh_token: None,
817 token_expires: None,
818 })),
819 model: crate::models::default_model().to_string(),
820 tools: Arc::new(RwLock::new(ToolRegistry::new())),
821 system_prompt: None,
822 effective_prompt: None,
823 prompt_generation: 0,
824 prompt_reload_source: None,
825 thinking_budget: 4096,
826 named_level: None,
827 explicit_reasoning: false,
828 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole::Foreground,
829 context_window_override: None,
830 compaction_model: None,
831 compaction_mode: agent_core::compaction::CompactionMode::default(),
832 compaction_exclusions: Vec::new(),
833 remote_summarization_attempts: Arc::new(std::sync::atomic::AtomicU64::new(0)),
834 subagent_registry: Arc::new(Mutex::new(
835 crate::runtime::subagent::SubagentRegistry::new(),
836 )),
837 orchestration: None,
838 event_queue: Arc::new(crate::events::EventQueue::new(1000)),
839 watcher_exit_path: None,
840 max_tool_output: 30000,
841 bash_timeout: 30,
842 bash_max_timeout: 300,
843 subagent_timeout: 300,
844 api_retries: 3,
845 refusal_retries: 2,
846 telemetry_level: crate::runtime::telemetry::TelemetryLevel::Off,
847 telemetry_writer: None,
848 trace_ctx: trace::TraceContext::disabled(),
849 trace_controls: std::sync::Arc::new(trace::TraceControls::new()),
850 memory_context_state: fresh_memory_context_state(),
853 pending_history_import_preview: std::sync::Arc::new(std::sync::Mutex::new(None)),
854 history_import_plan: std::sync::Arc::new(std::sync::Mutex::new(None)),
855 #[cfg(any(test, feature = "testing"))]
856 capture_provider_for_test: std::sync::Arc::new(std::sync::Mutex::new(None)),
857 pending_memory_segment: std::sync::Arc::new(std::sync::Mutex::new(None)),
860 retained_recall_turn: std::sync::Arc::new(std::sync::Mutex::new(None)),
861 one_shot_trace_writer: std::sync::Arc::new(std::sync::Mutex::new(None)),
862 capture_dir: trace::default_capture_dir(),
863 cache_diagnostics: false,
864 cache_ttl: crate::core::config::CacheTtl::default(),
865 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
866 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
867 last_msg_id: Arc::new(Mutex::new(None)),
868 session_manager,
869 hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
870 reaper_handle: None,
871 reaper_cancel: None,
872 credential_source: crate::auth::CredentialSource::Local,
873 token_cache: crate::auth::TokenCache::new(),
874 trusted_worker_models: Vec::new(),
875 progressive_tool_disclosure: false,
876 delegation_parent: None,
877 mcp_runtime: None,
878 mcp_session_scope: None,
879 extension_runtime: None,
880 extension_session_scope: None,
881 turn_budget: crate::runtime::budget::TurnBudget::for_role(
882 crate::runtime::budget::TurnRole::Foreground,
883 ),
884 host_tool_session: fresh_host_tool_session(),
885 }
886 }
887
888 pub fn set_system_prompt(&mut self, prompt: String) {
889 self.system_prompt = Some(prompt);
890 }
891
892 pub fn system_prompt(&self) -> Option<&str> {
893 self.system_prompt.as_deref()
894 }
895
896 pub async fn effective_system_prompt(&self) -> Option<String> {
908 if self.effective_prompt.is_some() {
909 return self.system_prompt.clone();
910 }
911 if self.tools.read().await.get("subagent_start").is_none() {
912 return self.system_prompt.clone();
913 }
914 let Ok(model) = crate::orchestration::canonical_foreground_identity(&self.model) else {
915 return self.system_prompt.clone();
916 };
917 let workflow_mode = match self.reasoning_level() {
918 agent_core::reasoning::ReasoningLevel::UltraCode => {
919 Some(agent_core::prompt::WorkflowMode::UltraCode)
920 }
921 agent_core::reasoning::ReasoningLevel::Max => {
922 Some(agent_core::prompt::WorkflowMode::Max)
923 }
924 agent_core::reasoning::ReasoningLevel::XHigh => {
925 Some(agent_core::prompt::WorkflowMode::XHigh)
926 }
927 _ => None,
928 };
929 let Ok(context) = agent_core::prompt::SelectionContext::new(model, None)
930 .map(|context| context.with_workflow_mode(workflow_mode))
931 else {
932 return self.system_prompt.clone();
933 };
934 agent_core::prompt::compose_orchestration_prompt(self.system_prompt.as_deref(), &context)
935 }
936
937 pub fn effective_prompt(&self) -> Option<&agent_core::prompt::PromptStack> {
938 self.effective_prompt.as_ref()
939 }
940
941 pub fn prompt_generation(&self) -> u64 {
942 self.prompt_generation
943 }
944
945 pub fn retain_prompt_reload_source(
946 &mut self,
947 manifest: PathBuf,
948 context: agent_core::prompt::SelectionContext,
949 user_module: Option<agent_core::prompt::PromptModule>,
950 delegation_policy_digest: Option<String>,
951 ) {
952 self.prompt_reload_source = Some(PromptReloadSource {
953 manifest,
954 context,
955 user_module,
956 delegation_policy_digest,
957 });
958 }
959
960 pub fn reload_prompt(&mut self) -> std::result::Result<u64, agent_core::prompt::PromptError> {
962 let source = self.prompt_reload_source.clone().ok_or_else(|| {
963 agent_core::prompt::PromptError::Invalid("no prompt manifest is active".into())
964 })?;
965 let raw = std::fs::read_to_string(&source.manifest).map_err(|_| {
966 agent_core::prompt::PromptError::Invalid("prompt manifest is unavailable".into())
967 })?;
968 let manifest = agent_core::prompt::PromptManifest::parse(&raw)?;
969 let reload_catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
970 source.context.model(),
971 manifest.delegation_catalog_candidates(),
972 )
973 .map_err(|error| agent_core::prompt::PromptError::Invalid(error.into()))?;
974 let candidate_policy_digest = manifest
975 .delegation_policy(source.context.model().clone(), &reload_catalog)?
976 .map(|policy| policy.digest());
977 if candidate_policy_digest != source.delegation_policy_digest {
978 return Err(agent_core::prompt::PromptError::Invalid(
979 "hot reload cannot safely change delegation policy".into(),
980 ));
981 }
982 let registry = manifest.registry(source.manifest.parent())?;
983 let candidate = agent_core::prompt::compile_prompt_stack(
984 &manifest,
985 ®istry,
986 &source.context,
987 source.user_module,
988 )?;
989 self.apply_prompt_stack(candidate)
990 }
991
992 pub fn apply_prompt_stack(
994 &mut self,
995 candidate: agent_core::prompt::PromptStack,
996 ) -> std::result::Result<u64, agent_core::prompt::PromptError> {
997 if let Some(current) = &self.effective_prompt {
998 current.validate_hot_reload(&candidate)?;
999 }
1000 let composed = candidate.composed().to_owned();
1001 self.effective_prompt = Some(candidate);
1002 self.system_prompt = Some(composed);
1003 self.prompt_generation = self.prompt_generation.saturating_add(1);
1004 Ok(self.prompt_generation)
1005 }
1006
1007 pub fn prompt_inspection_json(&self) -> Option<String> {
1008 let mode = self
1009 .orchestration
1010 .as_ref()
1011 .map(|runtime| runtime.enforcement_mode())
1012 .unwrap_or(agent_core::orchestration::EnforcementMode::Off);
1013 self.effective_prompt.as_ref().and_then(|stack| {
1014 serde_json::to_string(&serde_json::json!({
1015 "generation": self.prompt_generation,
1016 "effective": stack.inspect(mode),
1017 "token_estimate": stack.composed().len().div_ceil(4),
1018 }))
1019 .ok()
1020 })
1021 }
1022
1023 pub fn install_mcp_runtime(&mut self, manager: std::sync::Arc<crate::mcp::McpRuntimeManager>) {
1028 self.mcp_session_scope = Some(std::sync::Arc::new(crate::mcp::McpSessionEndGuard::new(
1029 self.host_tool_session.clone(),
1030 std::sync::Arc::clone(&manager),
1031 )));
1032 self.mcp_runtime = Some(manager);
1033 }
1034
1035 pub fn set_turn_budget(&mut self, budget: crate::runtime::budget::TurnBudget) {
1039 self.turn_budget = budget;
1040 }
1041
1042 pub fn turn_budget(&self) -> &crate::runtime::budget::TurnBudget {
1044 &self.turn_budget
1045 }
1046
1047 pub fn install_extension_runtime(
1052 &mut self,
1053 manager: std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
1054 ) {
1055 manager.bind_host_scope(self.host_tool_session.clone());
1059 self.extension_session_scope = Some(std::sync::Arc::new(
1060 crate::extensions::lease::ExtensionSessionEndGuard::new(
1061 self.host_tool_session.clone(),
1062 std::sync::Arc::clone(&manager),
1063 ),
1064 ));
1065 self.extension_runtime = Some(manager);
1066 }
1067
1068 pub fn install_orchestration(
1069 &mut self,
1070 runtime: Arc<crate::orchestration::OrchestrationRuntime>,
1071 ) {
1072 for model in &self.trusted_worker_models {
1073 if let Err(error) = runtime.grant_worker_model(model.as_str()) {
1074 tracing::warn!(
1075 model = model.as_str(),
1076 error = %error,
1077 "failed to apply configured worker-model trust"
1078 );
1079 }
1080 }
1081 self.orchestration = Some(runtime);
1082 }
1083
1084 pub fn install_worker_orchestration(
1090 &mut self,
1091 runtime: Arc<crate::orchestration::OrchestrationRuntime>,
1092 ) {
1093 self.orchestration = Some(runtime);
1094 }
1095
1096 pub fn set_delegation_parent(&mut self, parent: Option<String>) {
1097 self.delegation_parent = parent;
1098 }
1099
1100 pub fn orchestration(&self) -> Option<&Arc<crate::orchestration::OrchestrationRuntime>> {
1101 self.orchestration.as_ref()
1102 }
1103
1104 pub fn grant_worker_model(&self, model: &str) -> std::result::Result<(), String> {
1109 self.orchestration
1110 .as_ref()
1111 .ok_or_else(|| "delegation policy unavailable".to_string())?
1112 .grant_worker_model(model)
1113 }
1114
1115 pub fn set_model(&mut self, model: String) -> Option<ReasoningClamp> {
1119 self.try_set_model(model).ok().flatten()
1120 }
1121
1122 pub fn try_set_model(
1129 &mut self,
1130 model: String,
1131 ) -> std::result::Result<Option<ReasoningClamp>, String> {
1132 let trimmed = model.trim();
1137 let cleaned = trimmed
1138 .split_once(char::is_whitespace)
1139 .and_then(|(_, rest)| {
1140 let rest = rest.trim_start();
1141 let (latency, candidate) = rest.split_once(char::is_whitespace)?;
1142 let millis = latency.strip_suffix("ms")?;
1143 if !millis.is_empty() && millis.chars().all(|c| c.is_ascii_digit()) {
1144 let candidate = candidate.trim();
1145 if !candidate.is_empty() && !candidate.chars().any(char::is_whitespace) {
1146 return Some(candidate);
1147 }
1148 }
1149 None
1150 })
1151 .unwrap_or(trimmed);
1152 if self.prompt_reload_source.is_none()
1158 && self
1159 .orchestration
1160 .as_ref()
1161 .is_some_and(|current| !current.unreconciled_runtime_handles().is_empty())
1162 {
1163 return Err("model change blocked: workers require collection/reconciliation".into());
1164 }
1165 let orchestration_replacement = if self.prompt_reload_source.is_none()
1166 && self.orchestration.is_some()
1167 {
1168 let foreground = crate::orchestration::canonical_foreground_identity(cleaned)
1169 .map_err(|_| "model change blocked: unresolved foreground model".to_string())?;
1170 let replacement = crate::orchestration::OrchestrationRuntime::baseline(
1171 foreground, 8, 64,
1172 )
1173 .map_err(|_| "model change blocked: trusted worker catalog unavailable".to_string())?;
1174 for trusted in &self.trusted_worker_models {
1175 replacement
1176 .grant_worker_model(trusted.as_str())
1177 .map_err(|_| {
1178 "model change blocked: configured worker trust invalid".to_string()
1179 })?;
1180 }
1181 Some(Arc::new(replacement))
1182 } else {
1183 None
1184 };
1185 self.model = cleaned.to_owned();
1186 if let Some(replacement) = orchestration_replacement {
1190 self.orchestration = Some(replacement);
1191 }
1192 if !self.explicit_reasoning {
1198 if let Some(level) =
1199 crate::runtime::openai::catalog::validation::default_level_for_model(cleaned)
1200 {
1201 self.set_reasoning_level(level);
1202 }
1203 }
1204 Ok(self.clamp_reasoning_to_model())
1209 }
1210
1211 fn clamp_reasoning_to_model(&mut self) -> Option<ReasoningClamp> {
1217 use crate::runtime::openai::catalog::validation::{
1218 default_level_for_model, thinking_options_for_model, validate_reasoning_mutation,
1219 };
1220 let model = self.model.as_str();
1221 let from = self.reasoning_level();
1222 if validate_reasoning_mutation(model, from).is_ok() {
1223 return None;
1224 }
1225 let to = default_level_for_model(model)
1226 .filter(|level| validate_reasoning_mutation(model, *level).is_ok())
1227 .or_else(|| {
1228 thinking_options_for_model(model)
1229 .iter()
1230 .filter_map(|opt| agent_core::reasoning::ReasoningLevel::parse(opt))
1231 .find(|level| validate_reasoning_mutation(model, *level).is_ok())
1232 })?;
1233 let explicit = self.explicit_reasoning;
1234 self.set_reasoning_level(to);
1235 self.explicit_reasoning = explicit;
1236 Some(ReasoningClamp { from, to })
1237 }
1238
1239 pub fn set_tools(&mut self, tools: ToolRegistry) {
1240 self.tools = Arc::new(RwLock::new(tools));
1241 }
1242
1243 pub fn subagent_registry(&self) -> &Arc<Mutex<crate::runtime::subagent::SubagentRegistry>> {
1244 &self.subagent_registry
1245 }
1246
1247 pub fn event_queue(&self) -> &Arc<crate::events::EventQueue> {
1248 &self.event_queue
1249 }
1250
1251 pub fn hook_bus(&self) -> &Arc<crate::extensions::hooks::HookBus> {
1253 &self.hook_bus
1254 }
1255
1256 pub fn host_tool_session_id(&self) -> &crate::tools::activation::SessionId {
1260 &self.host_tool_session
1261 }
1262
1263 pub fn tools_shared(&self) -> Arc<RwLock<ToolRegistry>> {
1265 Arc::clone(&self.tools)
1266 }
1267
1268 pub fn model(&self) -> &str {
1269 &self.model
1270 }
1271
1272 pub fn http_client(&self) -> &Client {
1273 &self.client
1274 }
1275 pub fn set_thinking_budget(&mut self, budget: u32) {
1276 self.thinking_budget = budget;
1277 self.named_level = Some(agent_core::reasoning::ReasoningLevel::from_legacy_budget(
1279 budget,
1280 ));
1281 self.explicit_reasoning = false;
1283 }
1284
1285 pub fn set_reasoning_level(&mut self, level: agent_core::reasoning::ReasoningLevel) {
1291 self.named_level = Some(level);
1292 if let Some(budget) = level.to_legacy_budget() {
1293 self.thinking_budget = budget;
1294 }
1295 self.explicit_reasoning = false;
1297 }
1298
1299 pub fn set_reasoning_level_explicit(&mut self, level: agent_core::reasoning::ReasoningLevel) {
1304 self.set_reasoning_level(level);
1305 self.explicit_reasoning = true;
1306 }
1307
1308 pub fn restore_session_reasoning(&mut self, thinking_level: &str) -> Option<ReasoningClamp> {
1314 if let Some(level) = agent_core::reasoning::ReasoningLevel::parse(thinking_level) {
1315 self.set_reasoning_level_explicit(level);
1316 } else if let Some(budget) = crate::models::budget_for_thinking_level(thinking_level) {
1317 self.set_thinking_budget_explicit(budget);
1318 } else {
1319 return None;
1320 }
1321 self.clamp_reasoning_to_model()
1322 }
1323
1324 pub fn set_thinking_budget_explicit(&mut self, budget: u32) {
1329 self.thinking_budget = budget;
1330 self.named_level = Some(agent_core::reasoning::ReasoningLevel::from_legacy_budget(
1331 budget,
1332 ));
1333 self.explicit_reasoning = true;
1334 }
1335
1336 pub fn is_reasoning_explicit(&self) -> bool {
1338 self.explicit_reasoning
1339 }
1340
1341 pub fn thinking_budget_raw(&self) -> u32 {
1343 self.thinking_budget
1344 }
1345
1346 pub fn set_reasoning_level_checked(
1351 &mut self,
1352 level: agent_core::reasoning::ReasoningLevel,
1353 ) -> std::result::Result<(), String> {
1354 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
1355 &self.model,
1356 level,
1357 )?;
1358 if self.model.starts_with("anthropic/")
1359 && level == agent_core::reasoning::ReasoningLevel::UltraCode
1360 {
1361 if self.codex_request_role()
1362 != crate::runtime::openai::catalog::ExecutionRole::Foreground
1363 {
1364 return Err("ultracode_requires_foreground".into());
1365 }
1366 if self.effective_prompt.is_some() {
1367 return Err("typed_prompt_manifest_blocks_required_doctrine".into());
1368 }
1369 if self.orchestration.is_none() {
1370 return Err("ultracode_requires_orchestration".into());
1371 }
1372 let tools = self.tools.try_read().map_err(|_| {
1373 "ultracode prerequisite state is busy; refusing mutation".to_string()
1374 })?;
1375 let required = ["subagent_start", "subagent_status", "subagent_collect"];
1376 if !required.iter().all(|name| {
1377 tools
1378 .get(name)
1379 .is_some_and(|tool| tool.extension_id().is_none())
1380 }) {
1381 return Err("ultracode_requires_lifecycle_tools".into());
1382 }
1383 }
1384 self.set_reasoning_level_explicit(level);
1385 Ok(())
1386 }
1387
1388 pub fn set_thinking_budget_checked(&mut self, budget: u32) -> std::result::Result<(), String> {
1395 let level = agent_core::reasoning::ReasoningLevel::from_legacy_budget(budget);
1396 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
1397 &self.model,
1398 level,
1399 )?;
1400 self.set_thinking_budget_explicit(budget);
1401 Ok(())
1402 }
1403
1404 pub fn reasoning_level(&self) -> agent_core::reasoning::ReasoningLevel {
1405 self.named_level.unwrap_or_else(|| {
1406 agent_core::reasoning::ReasoningLevel::from_legacy_budget(self.thinking_budget)
1407 })
1408 }
1409
1410 pub(crate) fn set_codex_request_role(
1411 &mut self,
1412 role: crate::runtime::openai::catalog::CodexRequestRole,
1413 ) {
1414 self.codex_request_role = role;
1415 }
1416
1417 pub(crate) fn codex_request_role(&self) -> crate::runtime::openai::catalog::CodexRequestRole {
1418 self.codex_request_role
1419 }
1420
1421 async fn authorized_anthropic_plan(
1422 &self,
1423 ) -> Result<Option<crate::runtime::openai::catalog::AnthropicExecutionPlan>> {
1424 if !self.model.starts_with("anthropic/") {
1425 return Ok(None);
1426 }
1427 let tools = self.tools.read().await;
1428 let builtin = |name: &str| {
1429 tools
1430 .get(name)
1431 .is_some_and(|tool| tool.extension_id().is_none())
1432 };
1433 let readiness = self
1434 .orchestration
1435 .as_ref()
1436 .and_then(|runtime| runtime.ultracode_readiness(&self.model).ok());
1437 let prerequisites = crate::runtime::openai::catalog::AnthropicPlanPrerequisites {
1438 orchestration_policy: self.orchestration.is_some(),
1439 foreground_worker_authorized: readiness.is_some(),
1440 concurrent_limit: readiness.map_or(0, |limits| limits.0),
1441 total_limit: readiness.map_or(0, |limits| limits.1),
1442 lifecycle_start: builtin("subagent_start"),
1443 lifecycle_status: builtin("subagent_status"),
1444 lifecycle_steer: builtin("subagent_steer"),
1445 lifecycle_collect: builtin("subagent_collect"),
1446 lifecycle_resume: builtin("subagent_resume"),
1447 };
1448 crate::runtime::openai::catalog::plan_anthropic_execution(
1449 &self.model,
1450 self.reasoning_level(),
1451 self.codex_request_role(),
1452 prerequisites,
1453 crate::runtime::openai::catalog::capability_cache::get(&self.model).and_then(|entry| {
1454 match entry.reasoning {
1455 crate::runtime::openai::catalog::ReasoningSupport::AnthropicAdaptive {
1456 adaptive,
1457 } => Some(adaptive),
1458 crate::runtime::openai::catalog::ReasoningSupport::None => Some(false),
1459 _ => None,
1460 }
1461 }),
1462 )
1463 .map(Some)
1464 .map_err(|error| RuntimeError::Config(error.to_string()))
1465 }
1466
1467 async fn validate_request_preflight(&self) -> Result<()> {
1470 self.validate_request_preflight_for(&self.model, self.codex_request_role)
1471 .await
1472 }
1473
1474 async fn validate_request_preflight_for(
1475 &self,
1476 model: &str,
1477 role: crate::runtime::openai::catalog::CodexRequestRole,
1478 ) -> Result<()> {
1479 let level = self.reasoning_level();
1480 if model.starts_with("anthropic/")
1481 && level == agent_core::reasoning::ReasoningLevel::UltraCode
1482 && self.effective_prompt.is_some()
1483 {
1484 return Err(RuntimeError::Config(
1485 "Anthropic execution plan rejected: typed_prompt_manifest_blocks_required_doctrine"
1486 .into(),
1487 ));
1488 }
1489 if model.starts_with("anthropic/")
1490 && matches!(
1491 level,
1492 agent_core::reasoning::ReasoningLevel::Max
1493 | agent_core::reasoning::ReasoningLevel::UltraCode
1494 | agent_core::reasoning::ReasoningLevel::Ultra
1495 )
1496 {
1497 let tools = self.tools.read().await;
1498 let builtin = |name: &str| {
1499 tools
1500 .get(name)
1501 .is_some_and(|tool| tool.extension_id().is_none())
1502 };
1503 let readiness = self
1504 .orchestration
1505 .as_ref()
1506 .and_then(|runtime| runtime.ultracode_readiness(model).ok());
1507 let prerequisites = crate::runtime::openai::catalog::AnthropicPlanPrerequisites {
1508 orchestration_policy: self.orchestration.is_some(),
1509 foreground_worker_authorized: readiness.is_some(),
1510 concurrent_limit: readiness.map_or(0, |limits| limits.0),
1511 total_limit: readiness.map_or(0, |limits| limits.1),
1512 lifecycle_start: builtin("subagent_start"),
1513 lifecycle_status: builtin("subagent_status"),
1514 lifecycle_steer: builtin("subagent_steer"),
1515 lifecycle_collect: builtin("subagent_collect"),
1516 lifecycle_resume: builtin("subagent_resume"),
1517 };
1518 drop(tools);
1519 let result = crate::runtime::openai::catalog::plan_anthropic_execution(
1520 model,
1521 level,
1522 role,
1523 prerequisites,
1524 crate::runtime::openai::catalog::capability_cache::get(model).and_then(|entry| {
1525 match entry.reasoning {
1526 crate::runtime::openai::catalog::ReasoningSupport::AnthropicAdaptive {
1527 adaptive,
1528 } => Some(adaptive),
1529 crate::runtime::openai::catalog::ReasoningSupport::None => Some(false),
1530 _ => None,
1531 }
1532 }),
1533 );
1534 match result {
1535 Ok(plan) => {
1536 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);
1537 return Ok(());
1538 }
1539 Err(error) => {
1540 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);
1541 return Err(RuntimeError::Config(error.to_string()));
1542 }
1543 }
1544 }
1545 if model.starts_with("openai-codex/") {
1546 let plan =
1547 crate::runtime::openai::catalog::plan_codex_execution(model, level, role, None)
1548 .map_err(|error| {
1549 tracing::debug!(
1550 event = "codex_mode_plan",
1551 qualified_model = %model,
1552 requested_level = %level,
1553 runtime_role = role.as_str(),
1554 decision = "deny",
1555 deny_code = error.code().as_str(),
1556 network_attempted = false,
1557 "Codex request preflight denied"
1558 );
1559 RuntimeError::Config(error.to_string())
1560 })?;
1561
1562 if plan.automatic_delegation() {
1563 if self.orchestration.is_none() {
1564 tracing::debug!(
1565 event = "codex_mode_plan",
1566 qualified_model = %model,
1567 requested_level = %level,
1568 runtime_role = role.as_str(),
1569 decision = "deny",
1570 deny_code = "ultra_requires_orchestration",
1571 network_attempted = false,
1572 "Codex request preflight denied"
1573 );
1574 return Err(RuntimeError::Config(
1575 "Ultra requires an installed orchestration policy".to_string(),
1576 ));
1577 }
1578
1579 let tools = self.tools.read().await;
1580 let required = ["subagent_start", "subagent_status", "subagent_collect"];
1581 let tools_ready = required.iter().all(|name| {
1582 tools
1583 .get(name)
1584 .is_some_and(|tool| tool.extension_id().is_none())
1585 });
1586 if !tools_ready {
1587 tracing::debug!(
1588 event = "codex_mode_plan",
1589 qualified_model = %model,
1590 requested_level = %level,
1591 runtime_role = role.as_str(),
1592 decision = "deny",
1593 deny_code = "ultra_requires_subagent_tools",
1594 network_attempted = false,
1595 "Codex request preflight denied"
1596 );
1597 return Err(RuntimeError::Config(
1598 "Ultra requires built-in subagent_start, subagent_status, and subagent_collect tools"
1599 .to_string(),
1600 ));
1601 }
1602 }
1603 return Ok(());
1604 }
1605
1606 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(model, level)
1607 .map_err(|error| {
1608 tracing::debug!(
1609 event = "codex_mode_plan",
1610 qualified_model = %model,
1611 requested_level = %level,
1612 runtime_role = role.as_str(),
1613 decision = "deny",
1614 deny_code = "unsupported_reasoning_level",
1615 network_attempted = false,
1616 "request preflight denied"
1617 );
1618 RuntimeError::Config(error)
1619 })
1620 }
1621
1622 pub fn set_compaction_model(&mut self, model: Option<String>) {
1623 self.compaction_model = model;
1624 }
1625
1626 pub fn set_compaction_mode(&mut self, mode: agent_core::compaction::CompactionMode) {
1628 self.compaction_mode = mode;
1629 }
1630
1631 pub fn set_compaction_exclusions(
1633 &mut self,
1634 exclude: Vec<agent_core::compaction::ContentClass>,
1635 ) {
1636 self.compaction_exclusions = exclude;
1637 }
1638
1639 pub fn remote_summarization_attempts(&self) -> u64 {
1642 self.remote_summarization_attempts
1643 .load(std::sync::atomic::Ordering::SeqCst)
1644 }
1645
1646 pub fn compaction_policy(&self) -> crate::runtime::compaction::DisclosurePolicy {
1649 crate::runtime::compaction::DisclosurePolicy {
1650 mode: self.compaction_mode,
1651 exclude: self.compaction_exclusions.clone(),
1652 }
1653 }
1654
1655 pub fn set_context_window(&mut self, window: Option<u64>) {
1656 self.context_window_override = window;
1657 }
1658
1659 pub fn compaction_model(&self) -> &str {
1662 self.compaction_model
1663 .as_deref()
1664 .unwrap_or("claude-sonnet-4-6")
1665 }
1666
1667 pub fn context_window(&self) -> u64 {
1668 self.context_window_override
1669 .unwrap_or_else(|| crate::models::context_window_for_model(&self.model))
1670 }
1671
1672 pub async fn assess_context(
1686 &self,
1687 messages: &[crate::SharedMessage],
1688 ) -> context::ContextAssessment {
1689 let system = self.effective_system_prompt().await;
1690 let schema = self.tools.read().await.tools_schema();
1691 let memory_rendered: Option<String> = self
1696 .pending_memory_segment
1697 .lock()
1698 .unwrap_or_else(std::sync::PoisonError::into_inner)
1699 .as_ref()
1700 .map(|segment| segment.rendered_text().to_string());
1701 let memory_contents: Vec<&str> = memory_rendered.as_deref().into_iter().collect();
1702 context::assess(&context::ContextBudgetInputs {
1703 model: &self.model,
1704 provider_window: self.context_window(),
1705 system_prompt: system.as_deref(),
1706 tools_schema: &schema,
1707 messages,
1708 skill_contents: &[],
1709 memory_contents: &memory_contents,
1710 thinking_budget_tokens: self.thinking_budget as u64,
1711 next_tool_result_bytes: self.max_tool_output as u64,
1712 output_reserve_tokens: HelperMethods::max_tokens_for_model(&self.model),
1713 })
1714 }
1715
1716 #[allow(dead_code)] pub(crate) fn hold_memory_contribution(
1734 &self,
1735 contribution: memory_context::MemoryContextContribution,
1736 ) -> std::result::Result<(), memory_context::MemoryContextError> {
1737 let budget = memory_context::memory_budget_tokens(self.context_window())
1738 .ok_or(memory_context::MemoryContextError::RecallBudgetBelowMinimum)?;
1739 memory_context::validate_contribution(
1743 &contribution,
1744 &memory_project_id(),
1745 budget,
1746 &memory_context::DisclosureGrantSet::model_visible_only(),
1747 )?;
1748 *self
1749 .pending_memory_segment
1750 .lock()
1751 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1752 Some(memory_context::ContextSegment::Memory(contribution));
1753 Ok(())
1754 }
1755
1756 pub(crate) fn clear_memory_contribution(&self) {
1760 *self
1761 .pending_memory_segment
1762 .lock()
1763 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1764 }
1765
1766 pub fn memory_recall_why(&self) -> Option<memory_context::RecallTurnMetadata> {
1772 self.retained_recall_turn
1773 .lock()
1774 .unwrap_or_else(std::sync::PoisonError::into_inner)
1775 .as_ref()
1776 .map(|retained| retained.why.clone())
1777 }
1778
1779 async fn apply_turn_memory_recall(&self, messages: &mut Vec<crate::SharedMessage>) {
1797 let extension_runtime = self.extension_runtime.clone();
1798 let session = self.host_tool_session.clone();
1799 let outcome = memory_context::resolve_turn_recall(
1800 &self.memory_context_state,
1801 &self.retained_recall_turn,
1802 &memory_project_id(),
1803 self.context_window(),
1804 messages,
1805 memory_context::RECALL_HARD_TIMEOUT,
1806 move |lease: memory_context::MemoryContextLease,
1807 request: memory_context::RecallRequest| async move {
1808 let Some(manager) = extension_runtime else {
1809 return Err(memory_context::RecallCallError::ProviderUnavailable);
1810 };
1811 let mut parts = lease.provider_id.as_str().splitn(3, ':');
1812 let (Some("extension"), Some(plugin), Some(_local)) =
1813 (parts.next(), parts.next(), parts.next())
1814 else {
1815 return Err(memory_context::RecallCallError::ProviderUnavailable);
1816 };
1817 let Some(digest) =
1818 manager.declared_tool_digest(plugin, memory_context::MEMORY_RECALL_TOOL_NAME)
1819 else {
1820 return Err(memory_context::RecallCallError::ProviderUnavailable);
1821 };
1822 crate::extensions::lease::ExtensionLeaseCapability::new(session, manager)
1823 .call_exact(
1824 plugin,
1825 memory_context::MEMORY_RECALL_TOOL_NAME,
1826 &digest,
1827 memory_context::recall_request_wire(&request),
1828 )
1829 .await
1830 .map_err(|_| memory_context::RecallCallError::CallFailed)
1831 },
1832 )
1833 .await;
1834 match outcome {
1835 memory_context::TurnRecallOutcome::Injected
1836 | memory_context::TurnRecallOutcome::ReusedRetained => {
1837 let contribution = self
1838 .retained_recall_turn
1839 .lock()
1840 .unwrap_or_else(std::sync::PoisonError::into_inner)
1841 .as_ref()
1842 .map(|retained| retained.contribution.clone());
1843 if let Some(contribution) = contribution {
1844 *self
1845 .pending_memory_segment
1846 .lock()
1847 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1848 Some(memory_context::ContextSegment::Memory(contribution));
1849 }
1850 }
1851 memory_context::TurnRecallOutcome::NotEligible => {
1852 self.clear_memory_contribution();
1853 }
1854 memory_context::TurnRecallOutcome::SkippedOpen(reason) => {
1855 self.clear_memory_contribution();
1856 tracing::debug!(
1859 reason = reason.as_str(),
1860 "memory recall skipped; turn proceeds without memory"
1861 );
1862 }
1863 }
1864 }
1865
1866 #[cfg(any(test, feature = "testing"))]
1876 pub async fn apply_turn_memory_recall_for_harness(
1877 &self,
1878 messages: &mut Vec<crate::SharedMessage>,
1879 ) {
1880 self.apply_turn_memory_recall(messages).await;
1881 }
1882
1883 #[cfg(any(test, feature = "testing"))]
1887 pub fn capture_completed_turn_for_harness(
1888 &self,
1889 messages: Vec<crate::SharedMessage>,
1890 turn_ordinal: u64,
1891 ) -> std::result::Result<(), &'static str> {
1892 let started_at = std::time::SystemTime::now();
1893 let lease = self
1894 .memory_context_lock()
1895 .capture_lease_at(started_at)
1896 .ok_or("capture lease unavailable")?;
1897 let provider = self
1898 .extension_capture_provider(&lease)
1899 .ok_or("capture provider unavailable")?;
1900 let mut history = terminal_capture_history(&lease, &messages, started_at);
1901 history.turn_ordinal = turn_ordinal;
1902 memory_capture_worker()
1903 .submit_terminal(
1904 &lease,
1905 history,
1906 memory_context::RetentionClass::Standard,
1907 provider,
1908 )
1909 .map_err(|_| "capture build failed")?;
1910 Ok(())
1911 }
1912
1913 #[cfg(any(test, feature = "testing"))]
1916 pub fn capture_compaction_summary_for_harness(
1917 &self,
1918 first_turn_ordinal: u64,
1919 last_turn_ordinal: u64,
1920 source_digest: &str,
1921 source_message_count: usize,
1922 summary_text: &str,
1923 ) -> std::result::Result<(), &'static str> {
1924 let summarized_at = std::time::SystemTime::now();
1925 let lease = self
1926 .memory_context_lock()
1927 .capture_lease_at(summarized_at)
1928 .ok_or("capture lease unavailable")?;
1929 let source_digest = chat_capture::MessageRangeDigest::from_hex(source_digest)
1930 .ok_or("invalid source digest")?;
1931 let provider = self
1932 .extension_capture_provider(&lease)
1933 .ok_or("capture provider unavailable")?;
1934 let source = chat_capture::CompactionSource {
1935 schema: chat_capture::CompactionSchemaVersion::V1,
1936 project_id: lease.project_id.clone(),
1937 source_session_id: lease.session_id.clone(),
1938 first_turn_ordinal,
1939 last_turn_ordinal,
1940 source_digest,
1941 summary_origin: chat_capture::CompactionSummaryOrigin::LocalOnly,
1942 prompt_stack_digest: chat_capture::PromptStackDigest::from_bytes([7; 32]),
1943 redaction: chat_capture::RedactionPolicy::HostRedacted,
1944 content_classes: vec![agent_core::compaction::ContentClass::UserText],
1945 summarized_at,
1946 };
1947 memory_capture_worker()
1948 .submit_summary(
1949 &lease,
1950 source,
1951 source_message_count,
1952 summary_text,
1953 agent_core::compaction::RedactionPolicy::PolicyExclusions,
1954 memory_context::RetentionClass::Standard,
1955 provider,
1956 )
1957 .map_err(|_| "summary capture build failed")?;
1958 Ok(())
1959 }
1960
1961 #[cfg(any(test, feature = "testing"))]
1964 pub fn cancel_memory_forwarding_for_harness(&self) {
1965 self.clear_memory_contribution();
1966 self.memory_context_disable();
1967 }
1968
1969 #[cfg(any(test, feature = "testing"))]
1971 pub fn extension_lease_count_for_harness(&self) -> usize {
1972 self.extension_runtime
1973 .as_ref()
1974 .map_or(0, |manager| manager.lease_count())
1975 }
1976
1977 pub fn apply_config(&mut self, config: &crate::config::SynapsConfig) {
1979 if let Some(ref model) = config.model {
1980 self.set_model(model.clone());
1981 }
1982 if let Some(level) = config.thinking_level {
1985 self.set_reasoning_level_explicit(level);
1986 } else if let Some(budget) = config.thinking_budget {
1987 self.set_thinking_budget_explicit(budget);
1988 }
1989 if let Some(clamp) = self.clamp_reasoning_to_model() {
1992 tracing::warn!(
1993 from = clamp.from.as_str(),
1994 to = clamp.to.as_str(),
1995 model = %self.model,
1996 "configured reasoning level not supported by model; clamped"
1997 );
1998 }
1999 self.context_window_override = config.context_window;
2000 self.compaction_model = config.compaction_model.clone();
2001 self.compaction_mode = config.compaction_mode;
2002 self.compaction_exclusions = config.compaction_exclude.clone();
2003 self.max_tool_output = config.max_tool_output;
2004 self.bash_timeout = config.bash_timeout;
2005 self.bash_max_timeout = config.bash_max_timeout;
2006 self.subagent_timeout = config.subagent_timeout;
2007 self.api_retries = config.api_retries;
2008 self.refusal_retries = config.refusal_retries;
2009 self.telemetry_level =
2010 crate::runtime::telemetry::TelemetryLevel::from_str_key(&config.telemetry);
2011 self.sync_observability();
2012 self.cache_diagnostics = config.cache_diagnostics;
2013 self.cache_ttl = config.cache_ttl;
2014 self.progressive_tool_disclosure = config.progressive_tool_disclosure;
2015 self.trusted_worker_models = config
2016 .favorite_models
2017 .iter()
2018 .filter_map(|model| {
2019 let canonical = canonical_trusted_worker_model(model);
2020 match agent_core::prompt::QualifiedModelId::parse(canonical) {
2021 Ok(model) => Some(model),
2022 Err(_) => {
2023 tracing::warn!(
2024 model = model.as_str(),
2025 "ignoring invalid favorite worker-model identity"
2026 );
2027 None
2028 }
2029 }
2030 })
2031 .collect();
2032 self.trusted_worker_models.sort();
2033 self.trusted_worker_models.dedup();
2034 if let Some(orchestration) = &self.orchestration {
2035 for model in &self.trusted_worker_models {
2036 if let Err(error) = orchestration.grant_worker_model(model.as_str()) {
2037 tracing::warn!(
2038 model = model.as_str(),
2039 error = %error,
2040 "failed to apply configured worker-model trust"
2041 );
2042 }
2043 }
2044 }
2045 self.apply_auth_config(config);
2046
2047 if !config.disabled_tools.is_empty() {
2051 if let Ok(mut reg) = self.tools.try_write() {
2052 reg.disable(&config.disabled_tools);
2053 }
2054 }
2055 }
2056
2057 pub fn apply_auth_config(&mut self, config: &crate::config::SynapsConfig) {
2065 let new_source = config.auth.credential_source();
2066 if new_source != self.credential_source {
2067 self.token_cache.invalidate("anthropic");
2068 }
2069 self.credential_source = new_source;
2070 if self.credential_source.is_remote() {
2071 if let Ok(mut auth) = self.auth.try_write() {
2072 AuthMethods::scrub_for_remote(&mut auth);
2073 }
2074 }
2075 crate::auth::set_global_broker(crate::auth::broker_from_source(
2080 &self.credential_source,
2081 &self.token_cache,
2082 self.client.clone(),
2083 ));
2084 }
2085
2086 pub fn thinking_budget(&self) -> u32 {
2087 self.thinking_budget
2088 }
2089
2090 pub fn max_tool_output(&self) -> usize {
2091 self.max_tool_output
2092 }
2093
2094 pub fn bash_timeout(&self) -> u64 {
2095 self.bash_timeout
2096 }
2097
2098 pub fn bash_max_timeout(&self) -> u64 {
2099 self.bash_max_timeout
2100 }
2101
2102 pub fn subagent_timeout(&self) -> u64 {
2103 self.subagent_timeout
2104 }
2105
2106 pub fn api_retries(&self) -> u32 {
2107 self.api_retries
2108 }
2109
2110 pub fn set_max_tool_output(&mut self, v: usize) {
2111 self.max_tool_output = v;
2112 }
2113
2114 pub fn set_bash_timeout(&mut self, v: u64) {
2115 self.bash_timeout = v;
2116 }
2117
2118 pub fn set_bash_max_timeout(&mut self, v: u64) {
2119 self.bash_max_timeout = v;
2120 }
2121
2122 pub fn set_subagent_timeout(&mut self, v: u64) {
2123 self.subagent_timeout = v;
2124 }
2125
2126 pub fn set_api_retries(&mut self, v: u32) {
2127 self.api_retries = v;
2128 }
2129
2130 pub fn telemetry_level(&self) -> crate::runtime::telemetry::TelemetryLevel {
2131 self.telemetry_level
2132 }
2133
2134 pub fn set_telemetry_level(&mut self, level: crate::runtime::telemetry::TelemetryLevel) {
2135 self.telemetry_level = level;
2136 self.sync_observability();
2137 }
2138
2139 fn sync_observability(&mut self) {
2146 if self.telemetry_level.enabled() {
2147 if self.telemetry_writer.is_none() {
2148 let writer = crate::runtime::telemetry::TelemetryWriter::new(
2149 crate::runtime::telemetry::WriterOptions::default(),
2150 );
2151 self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
2152 crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
2153 ));
2154 self.telemetry_writer = Some(writer);
2155 }
2156 } else {
2157 self.telemetry_writer = None;
2161 self.trace_ctx = trace::TraceContext::disabled();
2162 }
2163 }
2164
2165 pub fn trace_context(&self) -> trace::TraceContext {
2167 self.trace_ctx.clone()
2168 }
2169
2170 pub(crate) fn effective_trace_context(&self) -> trace::TraceContext {
2191 match self.trace_controls.consume() {
2192 None => self.trace_ctx.clone(),
2193 Some(with_content) => {
2194 let base = if self.trace_ctx.enabled() {
2195 self.trace_ctx.clone()
2196 } else {
2197 let writer = crate::runtime::telemetry::TelemetryWriter::new(
2198 crate::runtime::telemetry::WriterOptions::default(),
2199 );
2200 *self
2204 .one_shot_trace_writer
2205 .lock()
2206 .expect("one-shot trace writer lock poisoned") = Some(writer.clone());
2207 self.trace_ctx
2208 .fork_with_sink(Arc::new(crate::runtime::telemetry::WriterTraceSink::new(
2209 writer,
2210 )))
2211 .with_one_shot_request_gate()
2212 };
2213 if with_content {
2214 base.with_content_capture(Arc::new(trace::ContentCapture::new(
2215 self.capture_dir.clone(),
2216 )))
2217 } else {
2218 base
2219 }
2220 }
2221 }
2222 }
2223
2224 pub fn trace_arm_next(&self, with_content: bool) {
2228 self.trace_controls.arm_next(with_content);
2229 }
2230
2231 pub fn trace_status(&self) -> trace::TraceStatusReport {
2236 let _ = trace::sweep_expired_captures(&self.capture_dir);
2237 trace::TraceStatusReport {
2238 persistent_enabled: self.telemetry_writer.is_some(),
2239 arm: self.trace_controls.peek(),
2240 trace_path: crate::runtime::telemetry::default_trace_log_path(),
2241 writer_stats: self.telemetry_writer.as_ref().map(|w| w.stats()),
2242 degraded_records: self.trace_ctx.degraded_records(),
2243 }
2244 }
2245
2246 fn memory_context_lock(&self) -> std::sync::MutexGuard<'_, memory_context::SessionMemoryState> {
2253 self.memory_context_state
2254 .lock()
2255 .unwrap_or_else(std::sync::PoisonError::into_inner)
2256 }
2257
2258 pub fn memory_context_status(&self) -> memory_context::MemoryContextStatus {
2261 self.memory_context_lock().status()
2262 }
2263
2264 pub fn memory_context_disable(&self) -> memory_context::MemoryContextStatus {
2278 let (status, bound, session) = {
2279 let mut state = self.memory_context_lock();
2280 let session = state.session_id().clone();
2281 let bound = state.bound_provider_ids();
2282 let status = match memory_context::apply_memory_context_action(
2283 &mut state,
2284 memory_context::AuthorizedMemoryAction::Disable {
2285 session: session.clone(),
2286 },
2287 ) {
2288 Ok(status) => status,
2289 Err(_) => state.status(),
2293 };
2294 (status, bound, session)
2295 };
2296 memory_context::emit_memory_observability_event(
2299 &memory_context::MemoryObservabilityEvent::context_disabled(
2300 &session,
2301 &memory_project_id(),
2302 ),
2303 );
2304 self.revoke_memory_provider_leases(&bound);
2308 status
2309 }
2310
2311 fn revoke_memory_provider_leases(&self, bound: &[memory_context::ContextProviderId]) {
2319 let Some(extension_runtime) = &self.extension_runtime else {
2320 return;
2321 };
2322 for provider in bound {
2323 let mut parts = provider.as_str().splitn(3, ':');
2324 if let (Some("extension"), Some(plugin), Some(_local_id)) =
2325 (parts.next(), parts.next(), parts.next())
2326 {
2327 if !plugin.is_empty() {
2328 extension_runtime.revoke_plugin_lease(&self.host_tool_session, plugin);
2329 }
2330 }
2331 }
2332 }
2333
2334 fn resolve_memory_provider(
2354 &self,
2355 requested: Option<&str>,
2356 ) -> std::result::Result<memory_context::ContextProviderId, memory_context::MemoryContextError>
2357 {
2358 use crate::extensions::context_provider as ext_cp;
2359 let Some(extension_runtime) = &self.extension_runtime else {
2360 return Ok(memory_provider_id());
2361 };
2362 let requested_id = match requested {
2363 None => None,
2364 Some(raw) => Some(
2367 ext_cp::ContextProviderId::parse(raw)
2368 .map_err(|_| memory_context::MemoryContextError::ProviderNotRegistered)?,
2369 ),
2370 };
2371 match extension_runtime.resolve_context_provider(requested_id.as_ref()) {
2372 Ok(descriptor) => {
2373 memory_context::ContextProviderId::parse(&descriptor.runtime_address())
2374 }
2375 Err(ext_cp::ContextProviderLookupError::NotRegistered) => {
2376 Err(memory_context::MemoryContextError::ProviderNotRegistered)
2377 }
2378 Err(ext_cp::ContextProviderLookupError::Ambiguous) => {
2379 Err(memory_context::MemoryContextError::ProviderAmbiguous)
2380 }
2381 }
2382 }
2383
2384 pub(crate) fn memory_context_enable(
2392 &self,
2393 mode: memory_context::MemoryContextMode,
2394 proof: memory_context::UserIntentProof,
2395 ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
2396 {
2397 self.memory_context_enable_resolved(mode, proof, None)
2398 }
2399
2400 pub(crate) fn memory_context_enable_resolved(
2405 &self,
2406 mode: memory_context::MemoryContextMode,
2407 proof: memory_context::UserIntentProof,
2408 requested_provider: Option<&str>,
2409 ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
2410 {
2411 let mut state = self.memory_context_lock();
2412 let lease = self.grant_command_memory_lease(&state, mode, proof, requested_provider)?;
2413 let enabled_event = memory_context::MemoryObservabilityEvent::context_enabled(&lease);
2417 let result = memory_context::apply_memory_context_action(
2418 &mut state,
2419 memory_context::AuthorizedMemoryAction::Enable { lease },
2420 );
2421 if result.is_ok() {
2422 memory_context::emit_memory_observability_event(&enabled_event);
2423 }
2424 result
2425 }
2426
2427 pub(crate) fn memory_context_recall_once(
2432 &self,
2433 proof: memory_context::UserIntentProof,
2434 ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
2435 {
2436 let mut state = self.memory_context_lock();
2437 let lease = self.grant_command_memory_lease(
2438 &state,
2439 memory_context::MemoryContextMode::RecallOnce,
2440 proof,
2441 None,
2442 )?;
2443 let enabled_event = memory_context::MemoryObservabilityEvent::context_enabled(&lease);
2446 let result = memory_context::apply_memory_context_action(
2447 &mut state,
2448 memory_context::AuthorizedMemoryAction::RecallOnce { lease },
2449 );
2450 if result.is_ok() {
2451 memory_context::emit_memory_observability_event(&enabled_event);
2452 }
2453 result
2454 }
2455
2456 fn grant_command_memory_lease(
2463 &self,
2464 state: &memory_context::SessionMemoryState,
2465 mode: memory_context::MemoryContextMode,
2466 proof: memory_context::UserIntentProof,
2467 requested_provider: Option<&str>,
2468 ) -> std::result::Result<memory_context::MemoryContextLease, memory_context::MemoryContextError>
2469 {
2470 let provider_id = self.resolve_memory_provider(requested_provider)?;
2471 memory_context::MemoryContextLease::grant(
2472 memory_context::MemoryLeaseId::parse(&format!("memctx-cmd-{}", uuid::Uuid::new_v4()))?,
2473 state.session_id().clone(),
2474 memory_project_id(),
2475 provider_id,
2476 mode,
2477 memory_context::CapturePolicy::default(),
2478 memory_context::RecallPolicy::default(),
2479 proof,
2480 std::time::SystemTime::now(),
2481 None,
2482 )
2483 }
2484
2485 #[cfg(test)]
2486 pub(crate) fn set_capture_provider_for_test(
2487 &self,
2488 provider: std::sync::Arc<dyn capture_worker::CaptureProvider>,
2489 ) {
2490 *self
2491 .capture_provider_for_test
2492 .lock()
2493 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(provider);
2494 }
2495
2496 pub(crate) fn compaction_capture_lease_at(
2497 &self,
2498 now: std::time::SystemTime,
2499 ) -> Option<memory_context::MemoryContextLease> {
2500 self.memory_context_lock().capture_lease_at(now)
2501 }
2502
2503 pub(crate) fn submit_compaction_summary_capture(
2504 &self,
2505 capture_lease: Option<memory_context::MemoryContextLease>,
2506 current: &agent_core::session::Session,
2507 api_messages: &[crate::SharedMessage],
2508 outcome: &compaction::CompactionOutcome,
2509 summarized_at: std::time::SystemTime,
2510 ) {
2511 let Some(lease) = capture_lease else {
2512 return;
2513 };
2514 let Ok(source_session_id) = memory_context::SessionId::parse(¤t.id) else {
2515 return;
2516 };
2517 if api_messages.is_empty() {
2522 return;
2523 }
2524 let first_turn_ordinal = 0;
2525 let last_turn_ordinal = (api_messages.len() - 1) as u64;
2526 let Some(source_digest) = chat_capture::MessageRangeDigest::from_hex(
2527 &agent_core::compaction::message_range_digest(api_messages),
2528 ) else {
2529 return;
2530 };
2531 let Some(prompt_stack_digest) =
2532 chat_capture::PromptStackDigest::from_hex(&outcome.prompt_stack_digest)
2533 else {
2534 return;
2535 };
2536 let summary_origin = if outcome.local_only {
2537 chat_capture::CompactionSummaryOrigin::LocalOnly
2538 } else {
2539 chat_capture::CompactionSummaryOrigin::Provider {
2540 provider_id: outcome.summary_provider.clone(),
2541 model_id: outcome.summary_model.clone(),
2542 }
2543 };
2544 let content_classes = outcome.included_classes.clone();
2545 let source = chat_capture::CompactionSource {
2546 schema: chat_capture::CompactionSchemaVersion::V1,
2547 project_id: lease.project_id.clone(),
2548 source_session_id,
2549 first_turn_ordinal,
2550 last_turn_ordinal,
2551 source_digest,
2552 summary_origin,
2553 prompt_stack_digest,
2554 redaction: match outcome.redaction_policy {
2555 agent_core::compaction::RedactionPolicy::TruncationOnly => {
2556 chat_capture::RedactionPolicy::None
2557 }
2558 agent_core::compaction::RedactionPolicy::PolicyExclusions => {
2559 chat_capture::RedactionPolicy::HostRedacted
2560 }
2561 },
2562 content_classes,
2563 summarized_at,
2564 };
2565
2566 #[cfg(any(test, feature = "testing"))]
2567 let test_provider = self
2568 .capture_provider_for_test
2569 .lock()
2570 .unwrap_or_else(std::sync::PoisonError::into_inner)
2571 .clone();
2572 #[cfg(not(any(test, feature = "testing")))]
2573 let test_provider: Option<std::sync::Arc<dyn capture_worker::CaptureProvider>> = None;
2574 let provider = test_provider.or_else(|| self.extension_capture_provider(&lease));
2575 if let Some(provider) = provider {
2576 let _ = memory_capture_worker().submit_summary(
2577 &lease,
2578 source,
2579 api_messages.len(),
2580 &outcome.summary_text,
2581 outcome.redaction_policy,
2582 memory_context::RetentionClass::Standard,
2583 provider,
2584 );
2585 }
2586 }
2587
2588 fn extension_capture_provider(
2589 &self,
2590 lease: &memory_context::MemoryContextLease,
2591 ) -> Option<std::sync::Arc<dyn capture_worker::CaptureProvider>> {
2592 let manager = self.extension_runtime.clone()?;
2593 let mut parts = lease.provider_id.as_str().splitn(3, ':');
2594 let (Some("extension"), Some(plugin), Some(_)) = (parts.next(), parts.next(), parts.next())
2595 else {
2596 return None;
2597 };
2598 let digest =
2599 manager.declared_tool_digest(plugin, memory_context::MEMORY_CAPTURE_TOOL_NAME)?;
2600 let handle = tokio::runtime::Handle::try_current().ok()?;
2601 Some(std::sync::Arc::new(ExtensionCaptureProvider {
2602 manager,
2603 session: self.host_tool_session.clone(),
2604 plugin: plugin.to_owned(),
2605 digest,
2606 handle,
2607 }))
2608 }
2609
2610 pub fn memory_history_preview(
2614 &self,
2615 ) -> std::result::Result<memory_history::HistoryImportPreview, memory_history::HistoryImportError>
2616 {
2617 let host = memory_history::HistoryImportHostState::from_current_host()?;
2618 let mut io = memory_history::CanonicalHistoryMetadataIo::new();
2619 let preview = memory_history::preview_history_import(&host, &mut io)?;
2620 *self
2621 .pending_history_import_preview
2622 .lock()
2623 .expect("history preview mutex poisoned") = Some(preview.clone());
2624 Ok(preview)
2625 }
2626
2627 pub(crate) fn memory_history_confirm(
2631 &self,
2632 ) -> std::result::Result<memory_history::HistoryImportReport, memory_history::HistoryImportError>
2633 {
2634 let preview = self
2635 .pending_history_import_preview
2636 .lock()
2637 .expect("history preview mutex poisoned")
2638 .take()
2639 .ok_or(memory_history::HistoryImportError::ConsentRequired)?;
2640 let intent = memory_history::ConfirmedUserIntent::from_explicit_command(
2641 memory_context::mint_explicit_command_id(),
2642 );
2643 let mut io = memory_history::CanonicalHistoryMetadataIo::new();
2644 match memory_history::authorize_history_import(
2645 preview,
2646 memory_history::HistoryImportConsent::Confirmed(intent),
2647 memory_history::RequestAuthority::UserFrontend,
2648 &mut io,
2649 )? {
2650 memory_history::HistoryImportOutcome::Ready(plan) => {
2651 *self
2652 .history_import_plan
2653 .lock()
2654 .expect("history plan mutex poisoned") = Some(plan.clone());
2655 let lease = self
2656 .memory_context_lock()
2657 .capture_lease_at(std::time::SystemTime::now())
2658 .ok_or(memory_history::HistoryImportError::CaptureLeaseUnavailable)?;
2659 let provider = self
2662 .extension_capture_provider(&lease)
2663 .ok_or(memory_history::HistoryImportError::CaptureProviderUnavailable)?;
2664 let sessions_dir = agent_core::config::get_active_config_dir().join("sessions");
2665 memory_history::import_history_from_dir(
2666 &plan,
2667 &lease,
2668 &sessions_dir,
2669 memory_history::IMPORT_BATCH_MAX_RECORDS,
2670 memory_capture_worker(),
2671 provider,
2672 )
2673 }
2674 memory_history::HistoryImportOutcome::Declined => {
2675 Err(memory_history::HistoryImportError::ConsentRequired)
2676 }
2677 }
2678 }
2679
2680 pub(crate) fn memory_history_decline(&self) -> bool {
2682 self.pending_history_import_preview
2683 .lock()
2684 .expect("history preview mutex poisoned")
2685 .take()
2686 .is_some()
2687 }
2688
2689 #[cfg(test)]
2691 pub(crate) fn memory_durable_proof_for_test(&self) -> Option<memory_context::UserIntentProof> {
2692 self.memory_context_lock().durable_proof().cloned()
2693 }
2694
2695 #[cfg(test)]
2697 pub(crate) fn memory_one_shot_proof_for_test(&self) -> Option<memory_context::UserIntentProof> {
2698 self.memory_context_lock().one_shot_pending_proof().cloned()
2699 }
2700
2701 #[cfg(test)]
2704 pub(crate) fn memory_bound_providers_for_test(&self) -> Vec<memory_context::ContextProviderId> {
2705 self.memory_context_lock().bound_provider_ids()
2706 }
2707
2708 pub fn context_report(&self, history: Option<&[crate::SharedMessage]>) -> trace::ContextReport {
2717 use trace::ReportValue;
2718 let (history_messages, history_bytes) = match history {
2719 Some(msgs) => (
2720 ReportValue::Count(msgs.len() as u64),
2721 ReportValue::Count(
2722 msgs.iter()
2723 .map(|m| {
2724 serde_json::to_vec(&**m)
2725 .map(|v| v.len() as u64)
2726 .unwrap_or(0)
2727 })
2728 .sum(),
2729 ),
2730 ),
2731 None => (ReportValue::Unavailable, ReportValue::Unavailable),
2732 };
2733 let tool_count = match self.tools.try_read() {
2736 Ok(tools) => ReportValue::Count(tools.iter_tools_sorted().len() as u64),
2737 Err(_) => ReportValue::Unavailable,
2738 };
2739 trace::ContextReport {
2740 model: self.model.clone(),
2741 system_prompt_bytes: ReportValue::Count(
2742 self.system_prompt().map(|s| s.len() as u64).unwrap_or(0),
2743 ),
2744 tool_count,
2745 history_messages,
2746 history_bytes,
2747 loaded_skills: ReportValue::Unavailable,
2748 loaded_memories: ReportValue::Unavailable,
2749 cache: self.trace_ctx.cache_snapshots().last_activity(),
2750 trace_enabled: self.trace_ctx.enabled(),
2751 writer_stats: self.telemetry_writer.as_ref().map(|w| w.stats()),
2752 degraded_records: self.trace_ctx.degraded_records(),
2753 }
2754 }
2755
2756 pub fn telemetry_writer(&self) -> Option<crate::runtime::telemetry::TelemetryWriter> {
2758 self.telemetry_writer.clone()
2759 }
2760
2761 pub fn shutdown_observability(
2775 &self,
2776 timeout: std::time::Duration,
2777 ) -> Option<crate::runtime::telemetry::ShutdownOutcome> {
2778 let _ = trace::sweep_expired_captures(&self.capture_dir);
2783 let ephemeral = self
2789 .one_shot_trace_writer
2790 .lock()
2791 .expect("one-shot trace writer lock poisoned")
2792 .take();
2793 let ephemeral_outcome = ephemeral.map(|w| w.shutdown(timeout));
2794 self.telemetry_writer
2795 .as_ref()
2796 .map(|w| w.shutdown(timeout))
2797 .or(ephemeral_outcome)
2798 }
2799
2800 pub async fn shutdown_observability_async(
2813 &self,
2814 timeout: std::time::Duration,
2815 ) -> Option<crate::runtime::telemetry::ShutdownOutcome> {
2816 let capture_dir = self.capture_dir.clone();
2820 let _ =
2821 tokio::task::spawn_blocking(move || trace::sweep_expired_captures(&capture_dir)).await;
2822 let ephemeral = self
2824 .one_shot_trace_writer
2825 .lock()
2826 .expect("one-shot trace writer lock poisoned")
2827 .take();
2828 let ephemeral_outcome = match ephemeral {
2829 Some(writer) => Some(writer.shutdown_async(timeout).await),
2830 None => None,
2831 };
2832 match self.telemetry_writer.clone() {
2833 Some(writer) => Some(writer.shutdown_async(timeout).await),
2834 None => ephemeral_outcome,
2835 }
2836 }
2837
2838 #[cfg(any(test, feature = "testing"))]
2842 pub fn install_observability_for_tests(
2843 &mut self,
2844 writer: crate::runtime::telemetry::TelemetryWriter,
2845 ) {
2846 self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
2847 crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
2848 ));
2849 self.telemetry_writer = Some(writer);
2850 }
2851
2852 pub fn cache_diagnostics(&self) -> bool {
2853 self.cache_diagnostics
2854 }
2855
2856 pub fn set_cache_diagnostics(&mut self, v: bool) {
2857 self.cache_diagnostics = v;
2858 }
2859
2860 pub fn cache_ttl(&self) -> crate::core::config::CacheTtl {
2861 self.cache_ttl
2862 }
2863
2864 pub fn set_cache_ttl(&mut self, ttl: crate::core::config::CacheTtl) {
2868 self.cache_ttl = ttl;
2869 }
2870
2871 pub fn thinking_level(&self) -> &str {
2872 match self.named_level {
2875 Some(level) => level.as_str(),
2876 None => crate::core::models::thinking_level_for_budget(self.thinking_budget),
2877 }
2878 }
2879
2880 pub async fn refresh_if_needed(&self) -> Result<()> {
2882 self.refresh_if_needed_for_model(&self.model).await
2883 }
2884
2885 async fn refresh_if_needed_for_model(&self, model: &str) -> Result<()> {
2886 if !crate::runtime::auth::model_is_anthropic(model) {
2889 return Ok(());
2890 }
2891 AuthMethods::refresh_if_needed(
2892 Arc::clone(&self.auth),
2893 &self.client,
2894 &self.credential_source,
2895 &self.token_cache,
2896 )
2897 .await
2898 }
2899
2900 pub async fn compact_call(&self, messages: Vec<crate::SharedMessage>) -> Result<String> {
2906 self.remote_summarization_attempts
2909 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2910 let model = self.compaction_model();
2911 self.validate_request_preflight_for(
2912 model,
2913 crate::runtime::openai::catalog::CodexRequestRole::Internal,
2914 )
2915 .await?;
2916 self.refresh_if_needed_for_model(model).await?;
2917
2918 use crate::runtime::compaction::COMPACTION_SYSTEM_PROMPT;
2919
2920 ApiMethods::call_api_simple(
2921 &self.auth,
2922 &self.client,
2923 model,
2924 COMPACTION_SYSTEM_PROMPT,
2925 self.thinking_budget,
2926 self.reasoning_level(),
2927 &messages,
2928 self.api_retries,
2929 &api::ApiOptions {
2935 trace: self.trace_ctx.clone(),
2936 telemetry: self.telemetry_writer.clone(),
2937 ..api::ApiOptions::default()
2938 },
2939 )
2940 .await
2941 }
2942
2943 pub async fn run_single(&self, prompt: &str) -> Result<String> {
2946 self.validate_request_preflight().await?;
2947 let anthropic_execution_plan = self.authorized_anthropic_plan().await?;
2948 self.refresh_if_needed().await?;
2950
2951 let mut messages: Vec<crate::SharedMessage> = vec![std::sync::Arc::new(
2952 json!({"role": "user", "content": prompt}),
2953 )];
2954 let system_prompt = self.effective_system_prompt().await;
2955
2956 loop {
2957 let response = ApiMethods::call_api(
2958 &self.auth,
2959 &self.client,
2960 &self.model,
2961 &*self.tools.read().await,
2962 &system_prompt,
2963 self.thinking_budget,
2964 self.reasoning_level(),
2965 &messages,
2966 self.api_retries,
2967 &api::ApiOptions {
2968 use_1m_context: self.context_window_override == Some(1_000_000),
2969 cache_ttl: self.cache_ttl,
2970 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
2971 saw_1h_honored: self.saw_1h_honored.clone(),
2972 credential_source: self.credential_source.clone(),
2973 token_cache: self.token_cache.clone(),
2974 anthropic_base_url: None,
2975 anthropic_execution_plan: anthropic_execution_plan.clone(),
2976 codex_request_role: self.codex_request_role(),
2977 trace: self.effective_trace_context(),
2982 request_correlation: None,
2983 suppress_stream_deltas: true,
2984 telemetry: self.telemetry_writer.clone(),
2985 tool_session_id: Some(self.host_tool_session.clone()),
2992 session_tool_set: None,
2996 request_tools_schema: None,
2997 usage_counters: None,
2998 },
2999 )
3000 .await?;
3001
3002 if let Some(content) = response["content"].as_array() {
3004 let mut response_text = String::new();
3005 let mut tool_uses = Vec::new();
3006
3007 for item in content {
3009 match item["type"].as_str() {
3010 Some("text") => {
3011 if let Some(text) = item["text"].as_str() {
3012 response_text.push_str(text);
3013 }
3014 }
3015 Some("tool_use") => {
3016 tool_uses.push(item.clone());
3017 }
3018 _ => {}
3019 }
3020 }
3021
3022 if tool_uses.is_empty() {
3024 if let Some(orchestration) = &self.orchestration {
3025 if let agent_core::orchestration::CompletionGate::Blocked { workers } =
3026 orchestration.completion_gate()
3027 {
3028 return Err(crate::RuntimeError::Tool(format!(
3029 "completion blocked: {} worker(s) require collection/reconciliation: {} (call subagent_collect with reconciled=true after inspecting each result)",
3030 workers.len(),
3031 workers.join(", ")
3032 )));
3033 }
3034 }
3035 return Ok(response_text);
3036 }
3037
3038 messages.push(std::sync::Arc::new(json!({
3040 "role": "assistant",
3041 "content": content
3042 })));
3043
3044 let mut tool_results = Vec::new();
3046
3047 if tool_uses.len() == 1 {
3048 let tool_use = &tool_uses[0];
3050 if let (Some(tool_name), Some(tool_id)) =
3051 (tool_use["name"].as_str(), tool_use["id"].as_str())
3052 {
3053 let input = &tool_use["input"];
3054 let result = match self.tools.read().await.get(tool_name).cloned() {
3055 Some(tool) => {
3056 let input = self
3057 .tools
3058 .read()
3059 .await
3060 .translate_input_for_api_tool(tool_name, input.clone());
3061 let runtime_name = self
3062 .tools
3063 .read()
3064 .await
3065 .runtime_name_for_api(tool_name)
3066 .to_string();
3067 let ctx = crate::ToolContext {
3068 channels: crate::tools::ToolChannels {
3069 tx_delta: None,
3070 tx_events: None,
3071 },
3072 capabilities: crate::tools::ToolCapabilities {
3073 watcher_exit_path: self.watcher_exit_path.clone(),
3074 tool_register_tx: None,
3075 session_manager: Some(self.session_manager.clone()),
3076 subagent_registry: Some(self.subagent_registry.clone()),
3077 delegation_parent: None,
3078 event_queue: Some(self.event_queue.clone()),
3079 secret_prompt: None,
3080 orchestration: self.orchestration.clone(),
3081 tool_activation: None,
3082 mcp_leases: None,
3083 extension_leases: None,
3084 memory_context: None,
3085 },
3086 limits: crate::tools::ToolLimits {
3087 max_tool_output: self.max_tool_output,
3088 max_tool_buffer: 256 * 1024,
3089 bash_timeout: self.bash_timeout,
3090 bash_max_timeout: self.bash_max_timeout,
3091 subagent_timeout: self.subagent_timeout,
3092 },
3093 };
3094 let decision = resolve_before_tool_call_decision(
3095 input.clone(),
3096 emit_before_tool_call(
3097 &self.hook_bus,
3098 tool_name,
3099 Some(&runtime_name),
3100 input.clone(),
3101 )
3102 .await,
3103 None,
3104 false,
3105 )
3106 .await;
3107 if let BeforeToolCallDecision::Block { reason } = decision {
3108 format!("Tool call blocked by extension: {}", reason)
3109 } else {
3110 let BeforeToolCallDecision::Continue { input } = decision
3111 else {
3112 unreachable!()
3113 };
3114 let input_for_hook = input.clone();
3115 let output = match tool.execute(input, ctx).await {
3116 Ok(output) => output,
3117 Err(e) => e.to_string(),
3118 };
3119 let output = emit_after_tool_call(
3120 &self.hook_bus,
3121 tool_name,
3122 Some(&runtime_name),
3123 input_for_hook,
3124 output,
3125 self.max_tool_output,
3126 )
3127 .await;
3128 output
3129 }
3130 }
3131 None => format!("Unknown tool: {}", tool_name),
3132 };
3133 tool_results.push(json!({
3134 "type": "tool_result",
3135 "tool_use_id": tool_id,
3136 "content": HelperMethods::truncate_tool_result(&result, self.max_tool_output)
3137 }));
3138 }
3139 } else {
3140 let mut join_set = tokio::task::JoinSet::new();
3142
3143 let cfg_max_tool_output = self.max_tool_output;
3145 let cfg_bash_timeout = self.bash_timeout;
3146 let cfg_bash_max_timeout = self.bash_max_timeout;
3147 let cfg_subagent_timeout = self.subagent_timeout;
3148 let session_mgr = self.session_manager.clone();
3149 let cfg_subagent_registry = self.subagent_registry.clone();
3150 let cfg_event_queue = self.event_queue.clone();
3151 let cfg_hook_bus = self.hook_bus.clone();
3152 let cfg_orchestration = self.orchestration.clone();
3153
3154 for tool_use in &tool_uses {
3155 if let (Some(tool_name), Some(tool_id)) = (
3156 tool_use["name"].as_str().map(|s| s.to_string()),
3157 tool_use["id"].as_str().map(|s| s.to_string()),
3158 ) {
3159 let input = tool_use["input"].clone();
3160 let tools_snapshot = self.tools.read().await;
3161 let input =
3162 tools_snapshot.translate_input_for_api_tool(&tool_name, input);
3163 let runtime_name =
3164 tools_snapshot.runtime_name_for_api(&tool_name).to_string();
3165 let tool = tools_snapshot.get(&tool_name).cloned();
3166 drop(tools_snapshot);
3167 let exit_path = self.watcher_exit_path.clone();
3168 let session_mgr_inner = session_mgr.clone();
3169 let registry_inner = cfg_subagent_registry.clone();
3170 let event_queue_inner = cfg_event_queue.clone();
3171 let hook_bus_inner = cfg_hook_bus.clone();
3172 let orchestration_inner = cfg_orchestration.clone();
3173 let tool_name_for_hook = tool_name.clone();
3174 let runtime_name_for_hook = runtime_name.clone();
3175
3176 join_set.spawn(async move {
3177 let result = match tool {
3178 Some(t) => {
3179 let decision =
3180 crate::runtime::resolve_before_tool_call_decision(
3181 input.clone(),
3182 crate::runtime::emit_before_tool_call(
3183 &hook_bus_inner,
3184 &tool_name_for_hook,
3185 Some(&runtime_name_for_hook),
3186 input.clone(),
3187 )
3188 .await,
3189 None,
3190 false,
3191 )
3192 .await;
3193 if let crate::runtime::BeforeToolCallDecision::Block {
3194 reason,
3195 } = decision
3196 {
3197 format!("Tool call blocked by extension: {}", reason)
3198 } else {
3199 let crate::runtime::BeforeToolCallDecision::Continue {
3200 input,
3201 } = decision
3202 else {
3203 unreachable!()
3204 };
3205 let ctx = crate::ToolContext {
3206 channels: crate::tools::ToolChannels {
3207 tx_delta: None,
3208 tx_events: None,
3209 },
3210 capabilities: crate::tools::ToolCapabilities {
3211 watcher_exit_path: exit_path,
3212 tool_register_tx: None,
3213 session_manager: Some(session_mgr_inner),
3214 subagent_registry: Some(registry_inner),
3215 delegation_parent: None,
3216 event_queue: Some(event_queue_inner),
3217 secret_prompt: None,
3218 orchestration: orchestration_inner,
3219 tool_activation: None,
3220 mcp_leases: None,
3221 extension_leases: None,
3222 memory_context: None,
3223 },
3224 limits: crate::tools::ToolLimits {
3225 max_tool_output: cfg_max_tool_output,
3226 max_tool_buffer: 256 * 1024,
3227 bash_timeout: cfg_bash_timeout,
3228 bash_max_timeout: cfg_bash_max_timeout,
3229 subagent_timeout: cfg_subagent_timeout,
3230 },
3231 };
3232 let input_for_hook = input.clone();
3233 let output = match t.execute(input, ctx).await {
3234 Ok(output) => output,
3235 Err(e) => e.to_string(),
3236 };
3237 let output = crate::runtime::emit_after_tool_call(
3238 &hook_bus_inner,
3239 &tool_name_for_hook,
3240 Some(&runtime_name_for_hook),
3241 input_for_hook,
3242 output,
3243 cfg_max_tool_output,
3244 )
3245 .await;
3246 output
3247 }
3248 }
3249 None => format!("Unknown tool: {}", tool_name),
3250 };
3251 (tool_id, result)
3252 });
3253 }
3254 }
3255
3256 let mut results_map = std::collections::HashMap::new();
3258 while let Some(res) = join_set.join_next().await {
3259 match res {
3260 Ok((tool_id, result)) => {
3261 results_map.insert(tool_id, result);
3262 }
3263 Err(e) => {
3264 tracing::error!("Parallel tool task panicked: {}", e);
3266 }
3267 }
3268 }
3269
3270 for tool_use in &tool_uses {
3272 if let Some(tool_id) = tool_use["id"].as_str() {
3273 let result = results_map.remove(tool_id).unwrap_or_else(|| {
3274 "Tool execution failed: task panicked".to_string()
3275 });
3276 tool_results.push(json!({
3277 "type": "tool_result",
3278 "tool_use_id": tool_id,
3279 "content": HelperMethods::truncate_tool_result(&result, self.max_tool_output)
3280 }));
3281 }
3282 }
3283 }
3284
3285 messages.push(std::sync::Arc::new(json!({
3287 "role": "user",
3288 "content": tool_results
3289 })));
3290
3291 } else {
3293 return Err(RuntimeError::Tool("Invalid response format".to_string()));
3294 }
3295 }
3296 }
3297
3298 pub async fn run_stream(
3301 &self,
3302 prompt: String,
3303 cancel: CancellationToken,
3304 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
3305 self.run_stream_with_messages(
3306 vec![std::sync::Arc::new(
3307 json!({"role": "user", "content": prompt}),
3308 )],
3309 cancel,
3310 None,
3311 None,
3312 false,
3313 )
3314 .await
3315 }
3316
3317 pub async fn run_stream_with_messages(
3321 &self,
3322 messages: Vec<crate::SharedMessage>,
3323 cancel: CancellationToken,
3324 steering_rx: Option<mpsc::UnboundedReceiver<String>>,
3325 secret_prompt: Option<crate::tools::SecretPromptHandle>,
3326 auto_approve_confirms: bool,
3327 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
3328 let (tx, internal_rx) = mpsc::unbounded_channel();
3334 let bounded_rx =
3335 crate::runtime::relay::spawn_bounded_stream_relay(internal_rx, cancel.clone());
3336
3337 let turn_correlation_id = agent_core::next_turn_correlation_id();
3340
3341 if let Err(error) = self.validate_request_preflight().await {
3342 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3343 helpers::turn_error_for(&error, &turn_correlation_id),
3344 )));
3345 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3346 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3347 }
3348
3349 let anthropic_execution_plan = match self.authorized_anthropic_plan().await {
3350 Ok(plan) => plan,
3351 Err(error) => {
3352 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3353 helpers::turn_error_for(&error, &turn_correlation_id),
3354 )));
3355 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3356 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3357 }
3358 };
3359
3360 if let Err(e) = self.refresh_if_needed().await {
3362 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3363 helpers::turn_error_for(&e, &turn_correlation_id),
3364 )));
3365 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3366 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3367 }
3368
3369 let capture_started_at = std::time::SystemTime::now();
3379 let capture_lease = self
3382 .memory_context_lock()
3383 .capture_lease_at(capture_started_at);
3384 let mut messages = messages;
3385 self.apply_turn_memory_recall(&mut messages).await;
3386 let messages = messages;
3387
3388 let auth = Arc::clone(&self.auth);
3391 let client = self.client.clone();
3392 let credential_source = self.credential_source.clone();
3393 let token_cache = self.token_cache.clone();
3394 let model = self.model.clone();
3395 let tools = self.tools.clone();
3396 let system_prompt = self.effective_system_prompt().await;
3397 let thinking_budget = self.thinking_budget;
3398 let reasoning_level = self.reasoning_level();
3399 let watcher_exit_path = self.watcher_exit_path.clone();
3400 let max_tool_output = self.max_tool_output;
3401 let bash_timeout = self.bash_timeout;
3402 let bash_max_timeout = self.bash_max_timeout;
3403 let subagent_timeout = self.subagent_timeout;
3404 let api_retries = self.api_retries;
3405 let refusal_retries = self.refusal_retries;
3406 let session_manager = self.session_manager.clone();
3407 let subagent_registry = self.subagent_registry.clone();
3411 let reaper_registry = Arc::clone(&subagent_registry);
3414 let reaper_orchestration = self.orchestration.clone();
3415 let capture_runtime = self.extension_runtime.clone();
3416 let capture_session = self.host_tool_session.clone();
3417 let event_queue = self.event_queue.clone();
3418 let options = api::ApiOptions {
3419 use_1m_context: self.context_window_override == Some(1_000_000),
3420 cache_ttl: self.cache_ttl,
3421 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
3422 saw_1h_honored: self.saw_1h_honored.clone(),
3423 credential_source: self.credential_source.clone(),
3424 token_cache: self.token_cache.clone(),
3425 anthropic_base_url: None,
3426 anthropic_execution_plan,
3427 codex_request_role: self.codex_request_role(),
3428 trace: self.effective_trace_context(),
3429 request_correlation: None,
3430 suppress_stream_deltas: false,
3431 telemetry: self.telemetry_writer.clone(),
3432 tool_session_id: Some(self.host_tool_session.clone()),
3435 session_tool_set: None,
3439 request_tools_schema: None,
3440 usage_counters: None,
3441 };
3442
3443 let session = crate::runtime::stream::StreamSession {
3444 auth,
3445 client,
3446 credential_source,
3447 token_cache,
3448 options,
3449 api_retries,
3450 refusal_retries,
3451 model,
3452 tools,
3453 system_prompt,
3454 thinking_budget,
3455 reasoning_level,
3456 tx: tx.clone(),
3457 cancel,
3458 steering_rx,
3459 watcher_exit_path,
3460 max_tool_output,
3461 bash_timeout,
3462 bash_max_timeout,
3463 subagent_timeout,
3464 session_manager,
3465 subagent_registry,
3466 event_queue,
3467 secret_prompt,
3468 hook_bus: self.hook_bus.clone(),
3469 auto_approve_confirms,
3470 telemetry_level: self.telemetry_level,
3471 orchestration: self.orchestration.clone(),
3472 delegation_parent: self.delegation_parent.clone(),
3473 turn_correlation_id: turn_correlation_id.clone(),
3474 progressive_tool_disclosure: self.progressive_tool_disclosure,
3475 tool_session_id: self.host_tool_session.clone(),
3476 mcp_runtime: self.mcp_runtime.clone(),
3477 mcp_session_scope: self.mcp_session_scope.clone(),
3478 extension_runtime: self.extension_runtime.clone(),
3479 extension_session_scope: self.extension_session_scope.clone(),
3480 turn_budget: self.turn_budget.clone(),
3481 };
3482
3483 tokio::spawn(async move {
3484 let capture_messages = messages.clone();
3485 let completed = match StreamMethods::run_stream_internal(session, messages).await {
3486 Ok(()) => true,
3487 Err(e) => {
3488 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3489 helpers::turn_error_for(&e, &turn_correlation_id),
3490 )));
3491 false
3492 }
3493 };
3494 if completed {
3495 if let (Some(lease), Some(manager)) = (capture_lease, capture_runtime) {
3496 let mut parts = lease.provider_id.as_str().splitn(3, ':');
3497 if let (Some("extension"), Some(plugin), Some(_)) =
3498 (parts.next(), parts.next(), parts.next())
3499 {
3500 if let Some(digest) = manager
3501 .declared_tool_digest(plugin, memory_context::MEMORY_CAPTURE_TOOL_NAME)
3502 {
3503 let provider = std::sync::Arc::new(ExtensionCaptureProvider {
3504 manager,
3505 session: capture_session,
3506 plugin: plugin.to_owned(),
3507 digest,
3508 handle: tokio::runtime::Handle::current(),
3509 });
3510 let history = terminal_capture_history(
3511 &lease,
3512 &capture_messages,
3513 capture_started_at,
3514 );
3515 let _ = memory_capture_worker().submit_terminal(
3516 &lease,
3517 history,
3518 memory_context::RetentionClass::Standard,
3519 provider,
3520 );
3521 }
3522 }
3523 }
3524 }
3525 crate::runtime::subagent::reap_finished(
3529 &reaper_registry,
3530 reaper_orchestration.as_deref(),
3531 );
3532 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3533 });
3534
3535 Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx))
3536 }
3537}
3538
3539impl Clone for Runtime {
3540 fn clone(&self) -> Self {
3541 Self {
3542 client: self.client.clone(),
3543 auth: Arc::clone(&self.auth),
3544 model: self.model.clone(),
3545 tools: self.tools.clone(),
3546 system_prompt: self.system_prompt.clone(),
3547 effective_prompt: self.effective_prompt.clone(),
3548 prompt_generation: self.prompt_generation,
3549 prompt_reload_source: self.prompt_reload_source.clone(),
3550 thinking_budget: self.thinking_budget,
3551 named_level: self.named_level,
3552 explicit_reasoning: self.explicit_reasoning,
3553 codex_request_role: self.codex_request_role,
3554 context_window_override: self.context_window_override,
3555 compaction_model: self.compaction_model.clone(),
3556 compaction_mode: self.compaction_mode,
3557 compaction_exclusions: self.compaction_exclusions.clone(),
3558 remote_summarization_attempts: Arc::clone(&self.remote_summarization_attempts),
3559 subagent_registry: self.subagent_registry.clone(),
3560 orchestration: self.orchestration.clone(),
3561 event_queue: self.event_queue.clone(),
3562 watcher_exit_path: self.watcher_exit_path.clone(),
3563 max_tool_output: self.max_tool_output,
3564 bash_timeout: self.bash_timeout,
3565 bash_max_timeout: self.bash_max_timeout,
3566 subagent_timeout: self.subagent_timeout,
3567 api_retries: self.api_retries,
3568 refusal_retries: self.refusal_retries,
3569 telemetry_level: self.telemetry_level,
3570 telemetry_writer: self.telemetry_writer.clone(),
3574 trace_ctx: self.trace_ctx.clone(),
3575 trace_controls: Arc::clone(&self.trace_controls),
3576 memory_context_state: std::sync::Arc::clone(&self.memory_context_state),
3582 pending_history_import_preview: std::sync::Arc::clone(
3583 &self.pending_history_import_preview,
3584 ),
3585 history_import_plan: std::sync::Arc::clone(&self.history_import_plan),
3586 #[cfg(any(test, feature = "testing"))]
3587 capture_provider_for_test: std::sync::Arc::clone(&self.capture_provider_for_test),
3588 pending_memory_segment: std::sync::Arc::clone(&self.pending_memory_segment),
3591 retained_recall_turn: std::sync::Arc::clone(&self.retained_recall_turn),
3594 one_shot_trace_writer: Arc::clone(&self.one_shot_trace_writer),
3595 capture_dir: self.capture_dir.clone(),
3596 cache_diagnostics: self.cache_diagnostics,
3597 cache_ttl: self.cache_ttl,
3598 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
3602 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
3603 last_msg_id: Arc::new(Mutex::new(None)),
3606 session_manager: self.session_manager.clone(),
3607 hook_bus: self.hook_bus.clone(),
3608 reaper_handle: None, reaper_cancel: None, credential_source: self.credential_source.clone(),
3611 token_cache: self.token_cache.clone(), trusted_worker_models: self.trusted_worker_models.clone(),
3613 progressive_tool_disclosure: self.progressive_tool_disclosure,
3614 delegation_parent: self.delegation_parent.clone(),
3615 mcp_runtime: self.mcp_runtime.clone(),
3616 mcp_session_scope: self.mcp_session_scope.clone(),
3619 extension_runtime: self.extension_runtime.clone(),
3620 extension_session_scope: self.extension_session_scope.clone(),
3622 turn_budget: self.turn_budget.clone(),
3623 host_tool_session: self.host_tool_session.clone(),
3628 }
3629 }
3630}
3631
3632#[cfg(test)]
3633mod tests {
3634 use super::*;
3635
3636 #[test]
3641 fn host_tool_session_shared_by_clones_fresh_per_runtime() {
3642 let rt = Runtime::new_headless();
3643 let clone = rt.clone();
3644 assert_eq!(
3645 rt.host_tool_session_id(),
3646 clone.host_tool_session_id(),
3647 "clones share the live registry and therefore the host session"
3648 );
3649
3650 let other = Runtime::new_headless();
3651 assert_ne!(
3652 rt.host_tool_session_id(),
3653 other.host_tool_session_id(),
3654 "independently constructed runtimes must never share a session"
3655 );
3656 }
3657
3658 #[test]
3663 fn telemetry_level_gates_shared_observability_sink() {
3664 let mut rt = Runtime::new_headless();
3665 assert!(!rt.trace_context().enabled(), "off by default");
3666 assert!(rt.telemetry_writer().is_none());
3667
3668 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Basic);
3669 assert!(rt.trace_context().enabled(), "basic enables the trace sink");
3670 assert!(rt.telemetry_writer().is_some());
3671
3672 let ctx_before = format!("{:?}", rt.trace_context());
3674 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Full);
3675 assert_eq!(format!("{:?}", rt.trace_context()), ctx_before);
3676
3677 let clone = rt.clone();
3679 assert!(clone.trace_context().enabled());
3680 assert_eq!(format!("{:?}", clone.trace_context()), ctx_before);
3681 assert!(clone.telemetry_writer().is_some());
3682
3683 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Off);
3684 assert!(!rt.trace_context().enabled(), "off disables both");
3685 assert!(rt.telemetry_writer().is_none());
3686 assert!(rt
3687 .shutdown_observability(std::time::Duration::ZERO)
3688 .is_none());
3689 }
3690
3691 #[tokio::test]
3697 async fn shutdown_observability_async_epilogue_contract() {
3698 let rt = Runtime::new_headless();
3700 let budget = crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT;
3701 assert!(rt.shutdown_observability_async(budget).await.is_none());
3702 assert!(rt.shutdown_observability_async(budget).await.is_none());
3703
3704 let tmp = tempfile::tempdir().unwrap();
3706 let telemetry_path = tmp.path().join("synaps/api-log.jsonl");
3707 let mut rt = Runtime::new_headless();
3708 let writer = crate::runtime::telemetry::TelemetryWriter::new(
3709 crate::runtime::telemetry::WriterOptions {
3710 telemetry_path: Some(telemetry_path.clone()),
3711 trace_path: Some(tmp.path().join("synaps/request-trace.jsonl")),
3712 ..Default::default()
3713 },
3714 );
3715 rt.install_observability_for_tests(writer.clone());
3716 writer.enqueue_telemetry(crate::runtime::telemetry::TelemetryRecord {
3717 ts: 7,
3718 model: "claude-sonnet-4-6".to_string(),
3719 ..Default::default()
3720 });
3721 let outcome = rt
3722 .shutdown_observability_async(budget)
3723 .await
3724 .expect("writer installed");
3725 assert!(outcome.is_flushed());
3726 assert_eq!(outcome.stats().written, 1);
3727 assert_eq!(
3728 std::fs::read_to_string(&telemetry_path)
3729 .unwrap()
3730 .lines()
3731 .count(),
3732 1,
3733 "queued final record must be on disk after the epilogue flush"
3734 );
3735 assert!(rt
3737 .shutdown_observability_async(budget)
3738 .await
3739 .expect("writer still installed")
3740 .is_flushed());
3741
3742 let mut rt = Runtime::new_headless();
3744 let slow = crate::runtime::telemetry::TelemetryWriter::new(
3745 crate::runtime::telemetry::WriterOptions {
3746 telemetry_path: Some(tmp.path().join("slow/api-log.jsonl")),
3747 trace_path: Some(tmp.path().join("slow/request-trace.jsonl")),
3748 write_delay: Some(std::time::Duration::from_millis(300)),
3749 ..Default::default()
3750 },
3751 );
3752 rt.install_observability_for_tests(slow.clone());
3753 for _ in 0..10 {
3754 slow.enqueue_telemetry(crate::runtime::telemetry::TelemetryRecord::default());
3755 }
3756 let start = std::time::Instant::now();
3757 let outcome = rt
3758 .shutdown_observability_async(std::time::Duration::from_millis(200))
3759 .await
3760 .expect("writer installed");
3761 assert!(!outcome.is_flushed(), "10×300ms cannot drain in 200ms");
3762 assert!(
3765 start.elapsed() < std::time::Duration::from_millis(1500),
3766 "bounded epilogue must not block exit: {:?}",
3767 start.elapsed()
3768 );
3769 }
3770
3771 #[tokio::test]
3776 async fn one_shot_ephemeral_writer_is_retained_and_drained_at_shutdown() {
3777 let rt = Runtime::new_headless();
3778 assert!(!rt.trace_context().enabled(), "telemetry off");
3779 rt.trace_arm_next(false);
3780 let armed = rt.effective_trace_context();
3781 assert!(armed.enabled(), "armed ephemeral context traces");
3782 let budget = crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT;
3785 let outcome = rt
3786 .shutdown_observability_async(budget)
3787 .await
3788 .expect("retained one-shot writer must be drained by the epilogue");
3789 assert!(outcome.is_flushed());
3790 assert!(rt.shutdown_observability_async(budget).await.is_none());
3792 }
3793
3794 #[tokio::test]
3800 #[serial_test::serial(synaps_base_dir)]
3801 async fn startup_and_shutdown_paths_sweep_expired_captures() {
3802 struct BaseDirGuard(Option<String>);
3803 impl Drop for BaseDirGuard {
3804 fn drop(&mut self) {
3805 match self.0.take() {
3806 Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
3807 None => std::env::remove_var("SYNAPS_BASE_DIR"),
3808 }
3809 }
3810 }
3811 let tmp = tempfile::tempdir().unwrap();
3812 let guard = BaseDirGuard(std::env::var("SYNAPS_BASE_DIR").ok());
3813 std::env::set_var("SYNAPS_BASE_DIR", tmp.path());
3814
3815 let cap_dir = trace::default_capture_dir();
3816 assert!(
3817 cap_dir.starts_with(tmp.path()),
3818 "capture dir must be private to the test"
3819 );
3820 let stale_id = trace::TraceId::new("req-stale").unwrap();
3821 let plant_stale = || {
3822 agent_core::core::private_fs::ensure_private_dir(&cap_dir).unwrap();
3823 let stale = trace::controls::ContentCaptureBundle {
3824 schema: trace::CONTENT_CAPTURE_SCHEMA.to_string(),
3825 request_id: stale_id.clone(),
3826 created_unix_ms: 1_000,
3827 expires_unix_ms: 2_000, redacted: true,
3829 over_budget: false,
3830 body: Some(serde_json::json!({"old": true})),
3831 };
3832 let path = trace::controls::capture_path(&cap_dir, &stale_id);
3833 std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap();
3834 path
3835 };
3836
3837 let stale_path = plant_stale();
3839 let rt = Runtime::new().await.expect("runtime constructs");
3840 assert!(
3841 !stale_path.exists(),
3842 "Runtime startup must physically remove expired capture bundles"
3843 );
3844
3845 let stale_path = plant_stale();
3847 let _ = rt.shutdown_observability(std::time::Duration::ZERO);
3848 assert!(
3849 !stale_path.exists(),
3850 "sync shutdown epilogue must remove expired capture bundles"
3851 );
3852
3853 let stale_path = plant_stale();
3855 let _ = rt
3856 .shutdown_observability_async(std::time::Duration::from_millis(100))
3857 .await;
3858 assert!(
3859 !stale_path.exists(),
3860 "async shutdown epilogue must remove expired capture bundles"
3861 );
3862 drop(guard);
3863 }
3864
3865 #[tokio::test]
3870 #[serial_test::serial(synaps_base_dir)]
3871 async fn capture_root_binds_at_construction_and_survives_env_churn() {
3872 let base_a = tempfile::tempdir().unwrap();
3873 let base_b = tempfile::tempdir().unwrap();
3874 let old = std::env::var("SYNAPS_BASE_DIR").ok();
3875
3876 std::env::set_var("SYNAPS_BASE_DIR", base_a.path());
3877 let rt_a = Runtime::new().await.expect("runtime A");
3878 std::env::set_var("SYNAPS_BASE_DIR", base_b.path());
3879 let rt_b = Runtime::new().await.expect("runtime B");
3880
3881 let cap_a = base_a.path().join("trace").join("capture");
3882 let cap_b = base_b.path().join("trace").join("capture");
3883 let plant = |dir: &std::path::Path, name: &str| {
3884 agent_core::core::private_fs::ensure_private_dir(dir).unwrap();
3885 let id = trace::TraceId::new(name).unwrap();
3886 let stale = trace::controls::ContentCaptureBundle {
3887 schema: trace::CONTENT_CAPTURE_SCHEMA.to_string(),
3888 request_id: id.clone(),
3889 created_unix_ms: 1_000,
3890 expires_unix_ms: 2_000,
3891 redacted: true,
3892 over_budget: false,
3893 body: Some(serde_json::json!({"old": true})),
3894 };
3895 let path = trace::controls::capture_path(dir, &id);
3896 std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap();
3897 path
3898 };
3899
3900 let decoy = tempfile::tempdir().unwrap();
3902 std::env::set_var("SYNAPS_BASE_DIR", decoy.path());
3903
3904 let stale_a = plant(&cap_a, "req-stale-a");
3905 let stale_b = plant(&cap_b, "req-stale-b");
3906 let _ = rt_a.shutdown_observability(std::time::Duration::ZERO);
3907 assert!(
3908 !stale_a.exists(),
3909 "runtime A must sweep the root bound at ITS construction"
3910 );
3911 assert!(
3912 stale_b.exists(),
3913 "runtime A must never sweep another runtime's root"
3914 );
3915 let _ = rt_b
3916 .shutdown_observability_async(std::time::Duration::from_millis(100))
3917 .await;
3918 assert!(
3919 !stale_b.exists(),
3920 "runtime B must sweep the root bound at ITS construction"
3921 );
3922
3923 match old {
3924 Some(v) => std::env::set_var("SYNAPS_BASE_DIR", v),
3925 None => std::env::remove_var("SYNAPS_BASE_DIR"),
3926 }
3927 }
3928
3929 #[test]
3930 fn reload_rejects_policy_change_without_partial_apply() {
3931 let dir = tempfile::tempdir().unwrap();
3932 let path = dir.path().join("prompt.yaml");
3933 let manifest = |total: &str, content: &str| {
3934 format!(
3935 "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"
3936 )
3937 };
3938 std::fs::write(&path, manifest("1", "before")).unwrap();
3939 let parsed =
3940 agent_core::prompt::PromptManifest::parse(&std::fs::read_to_string(&path).unwrap())
3941 .unwrap();
3942 let model =
3943 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6").unwrap();
3944 let context = agent_core::prompt::SelectionContext::new(model.clone(), None).unwrap();
3945 let stack = agent_core::prompt::compile_prompt_stack(
3946 &parsed,
3947 &parsed.registry(path.parent()).unwrap(),
3948 &context,
3949 None,
3950 )
3951 .unwrap();
3952 let catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
3953 &model,
3954 parsed.delegation_catalog_candidates(),
3955 )
3956 .unwrap();
3957 let digest = parsed
3958 .delegation_policy(model, &catalog)
3959 .unwrap()
3960 .map(|p| p.digest());
3961 let mut runtime = Runtime::new_headless();
3962 runtime.apply_prompt_stack(stack).unwrap();
3963 runtime.retain_prompt_reload_source(path.clone(), context, None, digest.clone());
3964 let generation = runtime.prompt_generation();
3965 let composed = runtime.effective_prompt().unwrap().composed().to_owned();
3966
3967 std::fs::write(&path, manifest("2", "after")).unwrap();
3968 let error = runtime.reload_prompt().unwrap_err().to_string();
3969 assert!(error.contains("cannot safely change delegation policy"));
3970 assert_eq!(runtime.prompt_generation(), generation);
3971 assert_eq!(runtime.effective_prompt().unwrap().composed(), composed);
3972 assert_eq!(
3973 runtime
3974 .prompt_reload_source
3975 .as_ref()
3976 .unwrap()
3977 .delegation_policy_digest,
3978 digest
3979 );
3980 }
3981
3982 #[test]
3983 fn worker_install_does_not_replay_process_global_favorite_grants() {
3984 let foreground =
3985 crate::orchestration::canonical_foreground_identity("anthropic/claude-fable-5")
3986 .unwrap();
3987 let session = Arc::new(
3988 crate::orchestration::OrchestrationRuntime::baseline(foreground, 4, 8).unwrap(),
3989 );
3990 let favorite = "openai-codex/gpt-5.6-sol";
3991 assert!(
3992 session.preflight(favorite).is_err(),
3993 "precondition: the parent session did not grant this identity"
3994 );
3995
3996 let mut worker = Runtime::new_headless();
3997 worker.apply_config(&crate::config::SynapsConfig {
3998 favorite_models: vec![favorite.to_owned()],
3999 ..Default::default()
4000 });
4001 worker.install_worker_orchestration(Arc::clone(&session));
4002
4003 assert!(
4004 session.preflight(favorite).is_err(),
4005 "constructing a child must not mint/replay a favorite-model grant"
4006 );
4007 assert!(!session.effective_choices().contains(&favorite.to_owned()));
4008 }
4009
4010 #[test]
4011 fn host_install_may_apply_explicit_operator_favorites() {
4012 let mut runtime = Runtime::new_headless();
4013 let config = crate::config::SynapsConfig {
4014 favorite_models: vec![
4015 "openai-codex/gpt-5.6-luna".to_owned(),
4016 "anthropic/claude-opus-4-6".to_owned(),
4017 "claude/claude-fable-5".to_owned(),
4019 "not-qualified".to_owned(),
4021 ],
4022 ..Default::default()
4023 };
4024 runtime.apply_config(&config);
4025 let foreground =
4026 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
4027 runtime.install_orchestration(std::sync::Arc::new(
4028 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap(),
4029 ));
4030
4031 let orchestration = runtime.orchestration().unwrap();
4032 for trusted in [
4033 "openai-codex/gpt-5.6-sol",
4034 "openai-codex/gpt-5.6-luna",
4035 "anthropic/claude-opus-4-6",
4036 "anthropic/claude-fable-5",
4037 ] {
4038 assert!(
4039 orchestration
4040 .effective_choices()
4041 .contains(&trusted.to_owned()),
4042 "missing configured worker choice {trusted}: {:?}",
4043 orchestration.effective_choices()
4044 );
4045 }
4046 let authorized = orchestration
4047 .resolve_and_authorize("sa_cross_provider", Some("anthropic/claude-opus-4-6"))
4048 .expect("an explicitly favorited cross-provider model must be authorized");
4049 assert_eq!(authorized.model.as_str(), "anthropic/claude-opus-4-6");
4050 assert!(!orchestration
4051 .effective_choices()
4052 .contains(&"not-qualified".to_owned()));
4053 }
4054
4055 #[test]
4056 fn configured_worker_choices_survive_manifestless_foreground_changes() {
4057 let mut runtime = Runtime::new_headless();
4058 runtime.apply_config(&crate::config::SynapsConfig {
4059 favorite_models: vec!["anthropic/claude-opus-4-6".to_owned()],
4060 ..Default::default()
4061 });
4062 let foreground =
4063 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
4064 runtime.install_orchestration(std::sync::Arc::new(
4065 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap(),
4066 ));
4067
4068 runtime
4069 .try_set_model("xai-auth/grok-4.5-latest".to_owned())
4070 .unwrap();
4071
4072 let orchestration = runtime.orchestration().unwrap();
4073 assert_eq!(orchestration.foreground_model(), "xai-auth/grok-4.5-latest");
4074 assert!(orchestration
4075 .effective_choices()
4076 .contains(&"anthropic/claude-opus-4-6".to_owned()));
4077 orchestration
4078 .resolve_and_authorize("sa_after_switch", Some("anthropic/claude-opus-4-6"))
4079 .expect("configured worker trust must survive policy replacement");
4080 }
4081
4082 #[test]
4083 fn set_model_replaces_manifestless_orchestration_foreground_snapshot() {
4084 let mut runtime = Runtime::new_headless();
4085 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4086 runtime.install_orchestration(std::sync::Arc::new(
4087 crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
4088 ));
4089
4090 runtime.set_model("openrouter/deepseek/deepseek-v4-pro".to_owned());
4091
4092 let orchestration = runtime.orchestration().unwrap();
4093 assert_eq!(
4094 orchestration.foreground_model(),
4095 "openrouter/deepseek/deepseek-v4-pro"
4096 );
4097 assert!(orchestration
4098 .effective_choices()
4099 .contains(&"openrouter/moonshotai/kimi-k2.7-code".to_owned()));
4100 assert!(orchestration
4101 .resolve_and_authorize("sa_kimi", Some("openrouter/moonshotai/kimi-k2.7-code"))
4102 .is_ok());
4103 }
4104
4105 #[test]
4106 fn set_model_refuses_to_orphan_unreconciled_policy_workers() {
4107 let mut runtime = Runtime::new_headless();
4108 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4109 let orchestration = std::sync::Arc::new(
4110 crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
4111 );
4112 orchestration
4113 .resolve_and_authorize("sa_active", None)
4114 .unwrap();
4115 runtime.install_orchestration(orchestration.clone());
4116 runtime.model = "openrouter/z-ai/glm-5.2".to_owned();
4117
4118 let error = runtime
4119 .try_set_model("openrouter/deepseek/deepseek-v4-pro".to_owned())
4120 .unwrap_err();
4121
4122 assert!(error.contains("collection/reconciliation"));
4123 assert_eq!(runtime.model(), "openrouter/z-ai/glm-5.2");
4124 assert!(std::sync::Arc::ptr_eq(
4125 runtime.orchestration().unwrap(),
4126 &orchestration
4127 ));
4128 assert!(orchestration.is_unreconciled("sa_active"));
4129 }
4130
4131 #[test]
4132 fn set_model_keeps_active_typed_manifest_orchestration_unchanged() {
4133 let dir = tempfile::tempdir().unwrap();
4134 let manifest_path = dir.path().join("prompt.yaml");
4135 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4136 let mut runtime = Runtime::new_headless();
4137 let orchestration = std::sync::Arc::new(
4138 crate::orchestration::OrchestrationRuntime::baseline(glm.clone(), 3, 8).unwrap(),
4139 );
4140 runtime.install_orchestration(orchestration.clone());
4141 runtime.retain_prompt_reload_source(
4142 manifest_path,
4143 agent_core::prompt::SelectionContext::new(glm, None).unwrap(),
4144 None,
4145 None,
4146 );
4147
4148 runtime.set_model("openrouter/deepseek/deepseek-v4-pro".to_owned());
4149
4150 assert!(std::sync::Arc::ptr_eq(
4151 runtime.orchestration().unwrap(),
4152 &orchestration
4153 ));
4154 assert_eq!(
4155 runtime.orchestration().unwrap().foreground_model(),
4156 "openrouter/z-ai/glm-5.2"
4157 );
4158 }
4159
4160 #[test]
4161 fn set_model_preserves_qualified_provider_ids_and_bare_claude() {
4162 let cases = [
4163 (
4164 "github-copilot/claude-opus-4.8",
4165 "github-copilot/claude-opus-4.8",
4166 ),
4167 ("anthropic/claude-sonnet-4-6", "anthropic/claude-sonnet-4-6"),
4168 ("google/gemini-2.5-pro", "google/gemini-2.5-pro"),
4169 ("openai/gpt-5", "openai/gpt-5"),
4170 ("claude-opus-4-8", "claude-opus-4-8"),
4171 ];
4172
4173 for (input, expected) in cases {
4174 let mut runtime = Runtime::new_headless();
4175 runtime.set_model(input.to_owned());
4176 assert_eq!(runtime.model(), expected, "input: {input}");
4177 }
4178 }
4179
4180 #[test]
4181 fn set_model_strips_only_legacy_health_status_decoration() {
4182 let cases = [
4183 ("✅ 339ms groq/llama-3.3-70b", "groq/llama-3.3-70b"),
4184 (
4185 "✅ 339ms github-copilot/claude-opus-4.8",
4186 "github-copilot/claude-opus-4.8",
4187 ),
4188 (
4189 "⚠️ 1200ms anthropic/claude-sonnet-4-6",
4190 "anthropic/claude-sonnet-4-6",
4191 ),
4192 ];
4193
4194 for (input, expected) in cases {
4195 let mut runtime = Runtime::new_headless();
4196 runtime.set_model(input.to_owned());
4197 assert_eq!(runtime.model(), expected, "input: {input}");
4198 }
4199 }
4200
4201 #[tokio::test]
4202 async fn confirm_without_prompt_fails_closed() {
4203 let result = resolve_before_tool_call_result(
4204 crate::extensions::hooks::events::HookResult::Confirm {
4205 message: "Run deploy?".into(),
4206 },
4207 None,
4208 false,
4209 )
4210 .await;
4211
4212 assert!(matches!(
4213 result,
4214 crate::extensions::hooks::events::HookResult::Block { reason }
4215 if reason.contains("requires confirmation") && reason.contains("Run deploy?")
4216 ));
4217 }
4218
4219 #[tokio::test]
4220 async fn modify_result_replaces_tool_input() {
4221 let result = resolve_before_tool_call_decision(
4222 serde_json::json!({"command":"rm -rf /"}),
4223 crate::extensions::hooks::events::HookResult::Modify {
4224 input: serde_json::json!({"command":"echo safe"}),
4225 },
4226 None,
4227 false,
4228 )
4229 .await;
4230
4231 match result {
4232 BeforeToolCallDecision::Continue { input } => {
4233 assert_eq!(input, serde_json::json!({"command":"echo safe"}));
4234 }
4235 BeforeToolCallDecision::Block { reason } => panic!("unexpected block: {reason}"),
4236 }
4237 }
4238
4239 #[tokio::test]
4240 async fn confirm_prompt_yes_continues() {
4241 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4242 let handle = crate::tools::SecretPromptHandle::new(tx);
4243
4244 let task = tokio::spawn(async move {
4245 let request = rx.recv().await.expect("confirm prompt request");
4246 assert_eq!(request.title, "Confirm tool call");
4247 assert!(request.prompt.contains("Run deploy?"));
4248 let _ = request.response_tx.send(Some("yes".to_string()));
4249 });
4250
4251 let result = resolve_before_tool_call_result(
4252 crate::extensions::hooks::events::HookResult::Confirm {
4253 message: "Run deploy?".into(),
4254 },
4255 Some(&handle),
4256 false,
4257 )
4258 .await;
4259
4260 task.await.unwrap();
4261 assert!(matches!(
4262 result,
4263 crate::extensions::hooks::events::HookResult::Continue
4264 ));
4265 }
4266
4267 #[tokio::test]
4268 async fn confirm_prompt_non_yes_blocks() {
4269 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4270 let handle = crate::tools::SecretPromptHandle::new(tx);
4271
4272 let task = tokio::spawn(async move {
4273 let request = rx.recv().await.expect("confirm prompt request");
4274 let _ = request.response_tx.send(Some("no".to_string()));
4275 });
4276
4277 let result = resolve_before_tool_call_result(
4278 crate::extensions::hooks::events::HookResult::Confirm {
4279 message: "Run deploy?".into(),
4280 },
4281 Some(&handle),
4282 false,
4283 )
4284 .await;
4285
4286 task.await.unwrap();
4287 assert!(matches!(
4288 result,
4289 crate::extensions::hooks::events::HookResult::Block { reason }
4290 if reason.contains("confirmation denied")
4291 ));
4292 }
4293
4294 #[test]
4295 fn test_max_tokens_for_model() {
4296 assert_eq!(
4298 HelperMethods::max_tokens_for_model("claude-opus-4-6"),
4299 128000
4300 );
4301 assert_eq!(
4302 HelperMethods::max_tokens_for_model("opus-something"),
4303 128000
4304 );
4305
4306 assert_eq!(
4308 HelperMethods::max_tokens_for_model("claude-sonnet-4-20250514"),
4309 64000
4310 );
4311 assert_eq!(HelperMethods::max_tokens_for_model("haiku"), 64000);
4312 assert_eq!(HelperMethods::max_tokens_for_model("claude-3-haiku"), 64000);
4313 assert_eq!(
4314 HelperMethods::max_tokens_for_model("some-other-model"),
4315 64000
4316 );
4317
4318 assert_eq!(HelperMethods::max_tokens_for_model(""), 64000);
4320 assert_eq!(HelperMethods::max_tokens_for_model("OPUS"), 64000); assert_eq!(
4322 HelperMethods::max_tokens_for_model("model-opus-end"),
4323 128000
4324 ); }
4326
4327 #[test]
4328 fn test_truncate_tool_result() {
4329 let default_max = 30000;
4330
4331 let short = "This is a short string.";
4333 assert_eq!(
4334 HelperMethods::truncate_tool_result(short, default_max),
4335 short
4336 );
4337
4338 let exact = "x".repeat(30000);
4340 assert_eq!(
4341 HelperMethods::truncate_tool_result(&exact, default_max),
4342 exact
4343 );
4344
4345 let too_long = "x".repeat(30001);
4347 let truncated = HelperMethods::truncate_tool_result(&too_long, default_max);
4348
4349 assert!(truncated.starts_with(&"x".repeat(30000)));
4351
4352 assert!(truncated.contains("[truncated — 30001 total bytes, showing first 30000]"));
4354
4355 assert!(truncated.len() > 30000);
4357
4358 let very_long = "a".repeat(50000);
4360 let truncated_very_long = HelperMethods::truncate_tool_result(&very_long, default_max);
4361 assert!(
4362 truncated_very_long.contains("[truncated — 50000 total bytes, showing first 30000]")
4363 );
4364 assert!(truncated_very_long.starts_with(&"a".repeat(30000)));
4365
4366 let custom_truncated = HelperMethods::truncate_tool_result(&very_long, 100);
4368 assert!(custom_truncated.starts_with(&"a".repeat(100)));
4369 assert!(custom_truncated.contains("[truncated — 50000 total bytes, showing first 100]"));
4370 }
4371
4372 #[test]
4376 fn truncate_tool_result_enforces_byte_budget() {
4377 let multibyte = "é".repeat(100); let out = HelperMethods::truncate_tool_result(&multibyte, 50);
4379 let body = out.split("\n\n[truncated").next().unwrap();
4380 assert!(
4381 body.len() <= 50,
4382 "retained body must fit the byte budget (got {} bytes)",
4383 body.len()
4384 );
4385 assert!(std::str::from_utf8(body.as_bytes()).is_ok());
4386 assert!(
4387 out.contains("total bytes"),
4388 "marker must report byte counts, got: {out}"
4389 );
4390 }
4391
4392 #[test]
4393 fn test_thinking_level_ranges() {
4394 use crate::core::models::thinking_level_for_budget;
4395
4396 assert_eq!(thinking_level_for_budget(0), "adaptive");
4398
4399 assert_eq!(thinking_level_for_budget(1), "low");
4401 assert_eq!(thinking_level_for_budget(1024), "low");
4402 assert_eq!(thinking_level_for_budget(2048), "low");
4403
4404 assert_eq!(thinking_level_for_budget(2049), "medium");
4406 assert_eq!(thinking_level_for_budget(3000), "medium");
4407 assert_eq!(thinking_level_for_budget(4096), "medium");
4408
4409 assert_eq!(thinking_level_for_budget(4097), "high");
4411 assert_eq!(thinking_level_for_budget(8192), "high");
4412 assert_eq!(thinking_level_for_budget(16384), "high");
4413
4414 assert_eq!(thinking_level_for_budget(16385), "xhigh");
4416 assert_eq!(thinking_level_for_budget(32768), "xhigh");
4417 assert_eq!(thinking_level_for_budget(100000), "xhigh");
4418 }
4419}
4420
4421#[cfg(test)]
4422mod effective_prompt_tests {
4423 use super::*;
4424
4425 fn headless(model: &str, base: &str) -> Runtime {
4426 let mut runtime = Runtime::new_headless();
4427 runtime.set_model(model.to_string());
4428 runtime.set_system_prompt(base.to_string());
4429 runtime
4430 }
4431
4432 #[tokio::test]
4433 async fn anthropic_foreground_ultracode_composes_standing_doctrine_once() {
4434 let mut runtime = headless("anthropic/claude-fable-5", "BASE.");
4435 runtime.named_level = Some(agent_core::reasoning::ReasoningLevel::UltraCode);
4436 let prompt = runtime.effective_system_prompt().await.expect("doctrine");
4437 assert_eq!(prompt.matches("<anthropic-ultracode-workflow>").count(), 1);
4438 assert!(prompt.contains("subagent_start"));
4439 assert!(prompt.contains("subagent_resume"));
4440 }
4441
4442 #[tokio::test]
4443 async fn codex_foreground_with_subagent_tools_composes_doctrine() {
4444 let runtime = headless("openai-codex/gpt-5.6-sol", "BASE.");
4445 let prompt = runtime
4446 .effective_system_prompt()
4447 .await
4448 .expect("prompt must compose");
4449 assert!(prompt.starts_with("BASE."), "base must lead");
4450 assert!(prompt.contains("## Subagent supervision"));
4451 assert!(prompt.contains("NEVER end your turn"));
4452 }
4453
4454 #[tokio::test]
4455 async fn worker_runtime_without_subagent_tools_stays_clean() {
4456 let mut runtime = headless("openai-codex/gpt-5.6-sol", "WORKER.");
4457 runtime.set_tools(ToolRegistry::without_subagent());
4458 assert_eq!(
4459 runtime.effective_system_prompt().await.as_deref(),
4460 Some("WORKER."),
4461 "workers have no subagent tools; doctrine would be unactionable noise"
4462 );
4463 }
4464
4465 #[tokio::test]
4466 async fn non_codex_foreground_stays_clean() {
4467 let runtime = headless("xai-auth/grok-4.5-latest", "BASE.");
4468 assert_eq!(
4469 runtime.effective_system_prompt().await.as_deref(),
4470 Some("BASE.")
4471 );
4472 }
4473
4474 #[tokio::test]
4475 async fn manifest_prompt_is_returned_verbatim() {
4476 let mut runtime = headless("openai-codex/gpt-5.6-sol", "IGNORED.");
4477 let module = agent_core::prompt::PromptModule::new(
4478 agent_core::prompt::PromptModuleId::parse("kernel.test").unwrap(),
4479 "1.0.0",
4480 agent_core::prompt::PromptModuleSource::User,
4481 0,
4482 agent_core::prompt::PromptSelectors::default(),
4483 agent_core::prompt::ModuleMutability::MutableGuidance,
4484 "KERNEL.",
4485 )
4486 .unwrap();
4487 let context = agent_core::prompt::SelectionContext::new(
4488 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap(),
4489 None,
4490 )
4491 .unwrap();
4492 let stack = agent_core::prompt::PromptStack::new(vec![module], context).unwrap();
4493 runtime.apply_prompt_stack(stack).unwrap();
4494 assert_eq!(
4495 runtime.effective_system_prompt().await.as_deref(),
4496 Some("KERNEL."),
4497 "manifest authors own the full stack; no builtin injection on top"
4498 );
4499 }
4500
4501 #[tokio::test]
4502 async fn unresolvable_model_identity_fails_closed_to_base() {
4503 let runtime = headless("definitely-not-a-model", "BASE.");
4504 assert_eq!(
4505 runtime.effective_system_prompt().await.as_deref(),
4506 Some("BASE.")
4507 );
4508 }
4509}
4510
4511#[cfg(test)]
4512mod set_reasoning_level_checked_tests {
4513 use super::*;
4514 use agent_core::reasoning::ReasoningLevel;
4515
4516 fn codex_runtime(model_id: &str) -> Runtime {
4517 let mut rt = Runtime::new_headless();
4518 rt.set_model(format!("openai-codex/{model_id}"));
4519 rt.set_reasoning_level(ReasoningLevel::Low);
4520 rt
4521 }
4522
4523 fn provisioned_fable_runtime() -> Runtime {
4524 let mut rt = Runtime::new_headless();
4525 rt.set_model("anthropic/claude-fable-5".into());
4526 rt.set_reasoning_level(ReasoningLevel::Low);
4527 let foreground =
4528 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-fable-5").unwrap();
4529 let orchestration =
4530 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap();
4531 rt.install_orchestration(Arc::new(orchestration));
4532 rt
4533 }
4534
4535 fn install_typed_manifest(rt: &mut Runtime) {
4536 let module = agent_core::prompt::PromptModule::new(
4537 agent_core::prompt::PromptModuleId::parse("kernel.test").unwrap(),
4538 "1.0.0",
4539 agent_core::prompt::PromptModuleSource::User,
4540 0,
4541 agent_core::prompt::PromptSelectors::default(),
4542 agent_core::prompt::ModuleMutability::MutableGuidance,
4543 "KERNEL.",
4544 )
4545 .unwrap();
4546 let context = agent_core::prompt::SelectionContext::new(
4547 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-fable-5").unwrap(),
4548 None,
4549 )
4550 .unwrap();
4551 rt.apply_prompt_stack(agent_core::prompt::PromptStack::new(vec![module], context).unwrap())
4552 .unwrap();
4553 }
4554
4555 #[test]
4556 fn provisioned_fable_accepts_max_and_ultracode() {
4557 for level in [ReasoningLevel::Max, ReasoningLevel::UltraCode] {
4558 let mut rt = provisioned_fable_runtime();
4559 rt.set_reasoning_level_checked(level).unwrap();
4560 assert_eq!(rt.reasoning_level(), level);
4561 }
4562 }
4563
4564 #[test]
4565 fn fable_ultracode_prerequisites_fail_without_mutation() {
4566 let mut missing_orchestration = Runtime::new_headless();
4567 missing_orchestration.set_model("anthropic/claude-fable-5".into());
4568 missing_orchestration.set_reasoning_level(ReasoningLevel::Low);
4569 assert!(missing_orchestration
4570 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
4571 .is_err());
4572 assert_eq!(missing_orchestration.reasoning_level(), ReasoningLevel::Low);
4573
4574 let mut missing_tools = provisioned_fable_runtime();
4575 missing_tools.set_tools(ToolRegistry::without_subagent());
4576 assert!(missing_tools
4577 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
4578 .is_err());
4579 assert_eq!(missing_tools.reasoning_level(), ReasoningLevel::Low);
4580
4581 let mut worker = provisioned_fable_runtime();
4582 worker.set_codex_request_role(crate::runtime::openai::catalog::ExecutionRole::Worker);
4583 assert!(worker
4584 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
4585 .is_err());
4586 assert_eq!(worker.reasoning_level(), ReasoningLevel::Low);
4587
4588 let mut internal = provisioned_fable_runtime();
4589 internal.set_codex_request_role(crate::runtime::openai::catalog::ExecutionRole::Internal);
4590 assert!(internal
4591 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
4592 .is_err());
4593 assert_eq!(internal.reasoning_level(), ReasoningLevel::Low);
4594
4595 let mut manifest = provisioned_fable_runtime();
4596 install_typed_manifest(&mut manifest);
4597 assert!(manifest
4598 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
4599 .is_err());
4600 assert_eq!(manifest.reasoning_level(), ReasoningLevel::Low);
4601 }
4602
4603 #[test]
4604 fn checked_accepts_client_omission_modes_for_codex() {
4605 for level in [ReasoningLevel::Off, ReasoningLevel::Adaptive] {
4606 let mut rt = codex_runtime("gpt-5.6-sol");
4607 rt.set_reasoning_level_checked(level).unwrap();
4608 assert_eq!(rt.reasoning_level(), level);
4609 }
4610 }
4611
4612 #[test]
4614 fn checked_rejects_ultra_for_luna_no_mutation() {
4615 let mut rt = codex_runtime("gpt-5.6-luna");
4616 let before = rt.reasoning_level();
4617 let err = rt
4618 .set_reasoning_level_checked(ReasoningLevel::Ultra)
4619 .unwrap_err();
4620 assert!(
4621 err.contains("ultra"),
4622 "error must name the rejected level; got: {err}"
4623 );
4624 assert_eq!(
4625 rt.reasoning_level(),
4626 before,
4627 "runtime must not be mutated when validation fails"
4628 );
4629 }
4630
4631 #[test]
4633 fn checked_rejects_max_for_gpt55_no_mutation() {
4634 let mut rt = codex_runtime("gpt-5.5");
4635 rt.set_reasoning_level(ReasoningLevel::Medium);
4636 let err = rt
4637 .set_reasoning_level_checked(ReasoningLevel::Max)
4638 .unwrap_err();
4639 assert!(
4640 err.contains("max"),
4641 "error must name the rejected level; got: {err}"
4642 );
4643 assert_eq!(rt.reasoning_level(), ReasoningLevel::Medium);
4644 }
4645
4646 #[test]
4648 fn checked_accepts_ultra_for_sol_and_mutates() {
4649 let mut rt = codex_runtime("gpt-5.6-sol");
4650 rt.set_reasoning_level_checked(ReasoningLevel::Ultra)
4651 .expect("sol must accept ultra");
4652 assert_eq!(rt.reasoning_level(), ReasoningLevel::Ultra);
4653 }
4654
4655 #[test]
4657 fn checked_rejects_extended_levels_without_metadata() {
4658 let mut rt = Runtime::new_headless();
4659 rt.set_model("claude-opus-4-7".to_string());
4660 rt.set_reasoning_level(ReasoningLevel::Low);
4661 for level in [ReasoningLevel::Max, ReasoningLevel::Ultra] {
4662 assert!(rt.set_reasoning_level_checked(level).is_err());
4663 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
4664 }
4665 }
4666
4667 #[test]
4670 fn checked_rejects_max_ultra_for_unknown_codex_model() {
4671 let mut rt = codex_runtime("gpt-unknown-future");
4672 rt.set_reasoning_level(ReasoningLevel::Low);
4673 for level in [ReasoningLevel::Max, ReasoningLevel::Ultra] {
4674 let err = rt.set_reasoning_level_checked(level).unwrap_err();
4675 assert!(err.contains("no capability metadata"), "{err}");
4676 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
4677 }
4678 }
4679
4680 #[test]
4683 fn checked_enforces_xai_capability_matrix_no_mutation_on_err() {
4684 let mut rt = Runtime::new_headless();
4685 rt.set_model("xai-auth/grok-4.5".to_string());
4686 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
4688 for level in [
4689 ReasoningLevel::Off,
4690 ReasoningLevel::XHigh,
4691 ReasoningLevel::Ultra,
4692 ] {
4693 let before = rt.reasoning_level();
4694 assert!(rt.set_reasoning_level_checked(level).is_err(), "{level}");
4695 assert_eq!(rt.reasoning_level(), before);
4696 }
4697 rt.set_reasoning_level_checked(ReasoningLevel::Low)
4698 .expect("grok-4.5 supports low");
4699 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
4700 }
4701
4702 #[test]
4705 fn checked_rejects_named_on_intrinsic_xai_model_and_defaults_adaptive() {
4706 let mut rt = Runtime::new_headless();
4707 rt.set_model("xai-auth/grok-4.3".to_string());
4708 assert_eq!(rt.reasoning_level(), ReasoningLevel::Adaptive);
4709 assert!(rt
4710 .set_reasoning_level_checked(ReasoningLevel::Medium)
4711 .is_err());
4712 assert_eq!(rt.reasoning_level(), ReasoningLevel::Adaptive);
4713 rt.set_reasoning_level_checked(ReasoningLevel::Adaptive)
4714 .unwrap();
4715 }
4716
4717 #[test]
4719 fn explicit_level_survives_xai_model_switch() {
4720 let mut rt = Runtime::new_headless();
4721 rt.set_model("xai-auth/grok-4.5".to_string());
4722 rt.set_reasoning_level_checked(ReasoningLevel::Low).unwrap();
4723 rt.set_model("xai-auth/grok-4.5-latest".to_string());
4724 assert_eq!(
4725 rt.reasoning_level(),
4726 ReasoningLevel::Low,
4727 "explicit level must not be overwritten by the model default"
4728 );
4729 }
4730
4731 #[test]
4734 fn explicit_unsupported_level_is_clamped_to_model_default_on_switch() {
4735 let mut rt = Runtime::new_headless();
4736 rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
4737 let clamp = rt
4738 .try_set_model("xai-auth/grok-4.6".to_string())
4739 .expect("model switch succeeds");
4740 assert_eq!(
4741 clamp,
4742 Some(ReasoningClamp {
4743 from: ReasoningLevel::XHigh,
4744 to: ReasoningLevel::High,
4745 })
4746 );
4747 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
4748 assert!(
4749 rt.is_reasoning_explicit(),
4750 "clamp keeps explicit provenance"
4751 );
4752 assert_eq!(rt.model(), "xai-auth/grok-4.6");
4753 }
4754
4755 #[test]
4757 fn explicit_supported_level_is_not_clamped_on_switch() {
4758 let mut rt = Runtime::new_headless();
4759 rt.set_reasoning_level_explicit(ReasoningLevel::High);
4760 let clamp = rt.try_set_model("xai-auth/grok-4.6".to_string()).unwrap();
4761 assert_eq!(clamp, None);
4762 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
4763 assert!(rt.is_reasoning_explicit());
4764 }
4765
4766 #[test]
4769 fn non_explicit_default_application_reports_no_clamp() {
4770 let mut rt = Runtime::new_headless();
4771 rt.set_reasoning_level(ReasoningLevel::XHigh);
4772 let clamp = rt.try_set_model("xai-auth/grok-4.6".to_string()).unwrap();
4773 assert_eq!(clamp, None);
4774 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
4775 assert!(!rt.is_reasoning_explicit());
4776 }
4777
4778 #[test]
4781 fn explicit_max_clamps_to_validating_option_on_multi_agent_model() {
4782 let model = "xai-auth/grok-4.20-multi-agent-0309";
4783 let mut rt = Runtime::new_headless();
4784 rt.set_reasoning_level_explicit(ReasoningLevel::Max);
4785 let clamp = rt
4786 .try_set_model(model.to_string())
4787 .unwrap()
4788 .expect("Max is not supported: must clamp");
4789 assert_eq!(clamp.from, ReasoningLevel::Max);
4790 assert!(
4791 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
4792 model, clamp.to
4793 )
4794 .is_ok(),
4795 "clamped level {} must validate",
4796 clamp.to
4797 );
4798 assert_eq!(rt.reasoning_level(), clamp.to);
4799 assert!(rt.is_reasoning_explicit());
4800 }
4801
4802 #[test]
4804 fn set_model_returns_clamp() {
4805 let mut rt = Runtime::new_headless();
4806 rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
4807 let clamp = rt.set_model("xai-auth/grok-4.6".to_string());
4808 assert_eq!(clamp.map(|c| c.to), Some(ReasoningLevel::High));
4809 }
4810
4811 #[test]
4814 fn restore_session_reasoning_clamps_after_reapply() {
4815 let mut rt = Runtime::new_headless();
4816 rt.set_model("xai-auth/grok-4.6".to_string());
4817 let clamp = rt.restore_session_reasoning("xhigh");
4818 assert_eq!(
4819 clamp,
4820 Some(ReasoningClamp {
4821 from: ReasoningLevel::XHigh,
4822 to: ReasoningLevel::High,
4823 })
4824 );
4825 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
4826 assert!(rt.is_reasoning_explicit());
4827 assert!(rt.set_reasoning_level_checked(rt.reasoning_level()).is_ok());
4828 }
4829
4830 #[test]
4832 fn restore_session_reasoning_keeps_supported_level() {
4833 let mut rt = Runtime::new_headless();
4834 rt.set_model("xai-auth/grok-4.6".to_string());
4835 assert_eq!(rt.restore_session_reasoning("low"), None);
4836 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
4837 assert!(rt.is_reasoning_explicit());
4838 }
4839
4840 #[test]
4843 fn apply_config_clamps_unsupported_explicit_level_against_config_model() {
4844 let mut rt = Runtime::new_headless();
4845 rt.apply_config(&crate::config::SynapsConfig {
4846 model: Some("xai-auth/grok-4.6".to_string()),
4847 thinking_level: Some(ReasoningLevel::XHigh),
4848 ..Default::default()
4849 });
4850 assert_eq!(rt.model(), "xai-auth/grok-4.6");
4851 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
4852 assert!(rt.is_reasoning_explicit());
4853 assert!(rt.set_reasoning_level_checked(rt.reasoning_level()).is_ok());
4854 }
4855}
4856
4857#[cfg(test)]
4858mod codex_execution_preflight_tests {
4859 use super::*;
4860 use crate::runtime::openai::catalog::CodexRequestRole;
4861 use agent_core::prompt::QualifiedModelId;
4862 use agent_core::reasoning::ReasoningLevel;
4863
4864 fn ultra_runtime() -> Runtime {
4865 let mut runtime = Runtime::new_headless();
4866 runtime.set_model("openai-codex/gpt-5.6-sol".to_string());
4867 runtime.set_reasoning_level_explicit(ReasoningLevel::Ultra);
4868 runtime
4869 }
4870
4871 fn install_baseline(runtime: &mut Runtime) {
4872 let foreground =
4873 QualifiedModelId::parse("openai-codex/gpt-5.6-sol").expect("qualified model");
4874 let orchestration = crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8)
4875 .expect("baseline orchestration");
4876 runtime.install_orchestration(Arc::new(orchestration));
4877 }
4878
4879 #[tokio::test]
4880 async fn foreground_ultra_preflight_requires_orchestration() {
4881 let runtime = ultra_runtime();
4882 let error = runtime
4883 .validate_request_preflight()
4884 .await
4885 .expect_err("Ultra without orchestration must fail closed");
4886 assert!(error.to_string().contains("orchestration"), "{error}");
4887 }
4888
4889 #[tokio::test]
4890 async fn foreground_ultra_preflight_requires_actionable_subagent_tools() {
4891 let mut runtime = ultra_runtime();
4892 install_baseline(&mut runtime);
4893 runtime.set_tools(ToolRegistry::without_subagent());
4894
4895 let error = runtime
4896 .validate_request_preflight()
4897 .await
4898 .expect_err("Ultra without subagent tools must fail closed");
4899 assert!(error.to_string().contains("subagent"), "{error}");
4900 }
4901
4902 #[tokio::test]
4903 async fn foreground_ultra_preflight_accepts_exact_model_policy_and_tools() {
4904 let mut runtime = ultra_runtime();
4905 install_baseline(&mut runtime);
4906 runtime
4907 .validate_request_preflight()
4908 .await
4909 .expect("fully provisioned Ultra must pass preflight");
4910 }
4911
4912 #[tokio::test]
4913 async fn worker_ultra_preflight_never_requires_or_enables_orchestration() {
4914 let mut runtime = ultra_runtime();
4915 runtime.set_codex_request_role(CodexRequestRole::Worker);
4916 runtime.set_tools(ToolRegistry::without_subagent());
4917 runtime
4918 .validate_request_preflight()
4919 .await
4920 .expect("worker Ultra is underlying max without proactive orchestration");
4921 assert_eq!(runtime.codex_request_role(), CodexRequestRole::Worker);
4922 }
4923
4924 #[tokio::test]
4925 async fn non_codex_ultra_preflight_fails_before_provider_work() {
4926 let mut runtime = Runtime::new_headless();
4927 runtime.set_model("anthropic/claude-opus-4-7".to_string());
4928 runtime.set_reasoning_level_explicit(ReasoningLevel::Ultra);
4929 let error = runtime
4930 .validate_request_preflight()
4931 .await
4932 .expect_err("non-Codex Ultra must fail closed");
4933 assert!(
4934 error.to_string().contains("unsupported_reasoning_level"),
4935 "{error}"
4936 );
4937 }
4938
4939 #[tokio::test]
4940 async fn compaction_preflight_validates_target_before_credentials() {
4941 let mut runtime = ultra_runtime();
4942 runtime.set_compaction_model(Some("claude-sonnet-4-6".to_string()));
4943 {
4944 let mut auth = runtime.auth.write().await;
4945 auth.auth_token.clear();
4946 auth.auth_type = "none".to_string();
4947 auth.refresh_token = None;
4948 auth.token_expires = None;
4949 }
4950
4951 let error = runtime
4952 .compact_call(Vec::new())
4953 .await
4954 .expect_err("Codex Ultra cannot silently cross into Anthropic compaction");
4955 assert!(
4956 error.to_string().contains("authoritative exact-model"),
4957 "preflight must reject the target before auth access: {error}"
4958 );
4959 }
4960}
4961
4962#[cfg(test)]
4963mod http_client_timeout_tests {
4964 use futures::StreamExt;
4978 use std::time::Duration;
4979 use tokio::io::AsyncWriteExt;
4980
4981 const CHUNKED_HEAD: &[u8] =
4982 b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\n\r\n";
4983
4984 fn chunk(data: &str) -> Vec<u8> {
4985 format!("{:x}\r\n{}\r\n", data.len(), data).into_bytes()
4986 }
4987
4988 async fn spawn_chunked_server(n_chunks: usize, gap: Duration, terminate: bool) -> String {
4991 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4992 let addr = listener.local_addr().unwrap();
4993 tokio::spawn(async move {
4994 let (mut sock, _) = listener.accept().await.unwrap();
4995 let mut buf = [0u8; 4096];
4997 use tokio::io::AsyncReadExt;
4998 let _ = sock.read(&mut buf).await;
4999 sock.write_all(CHUNKED_HEAD).await.unwrap();
5000 for i in 0..n_chunks {
5001 sock.write_all(&chunk(&format!("data {i}\n")))
5002 .await
5003 .unwrap();
5004 sock.flush().await.unwrap();
5005 tokio::time::sleep(gap).await;
5006 }
5007 if terminate {
5008 sock.write_all(b"0\r\n\r\n").await.unwrap();
5009 } else {
5010 tokio::time::sleep(Duration::from_secs(60)).await;
5012 }
5013 });
5014 format!("http://{addr}/stream")
5015 }
5016
5017 async fn drain(
5018 client: &reqwest::Client,
5019 url: &str,
5020 ) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
5021 let resp = client.get(url).send().await?;
5022 let mut stream = resp.bytes_stream();
5023 let mut total = 0usize;
5024 while let Some(c) = stream.next().await {
5025 total += c?.len();
5026 }
5027 Ok(total)
5028 }
5029
5030 #[tokio::test]
5035 async fn active_stream_outliving_read_timeout_survives() {
5036 let url = spawn_chunked_server(15, Duration::from_millis(100), true).await;
5038 let client = super::build_http_client(Duration::from_millis(400)).expect("client builds");
5039 let total = drain(&client, &url)
5040 .await
5041 .expect("active stream must never be killed by the idle timeout");
5042 assert!(total > 0);
5043 }
5044
5045 #[tokio::test]
5048 async fn stalled_stream_is_killed_by_read_timeout() {
5049 let url = spawn_chunked_server(2, Duration::from_millis(10), false).await;
5051 let client = super::build_http_client(Duration::from_millis(300)).expect("client builds");
5052 let start = std::time::Instant::now();
5053 let err = drain(&client, &url)
5054 .await
5055 .expect_err("stalled stream must be detected");
5056 assert!(
5057 start.elapsed() < Duration::from_secs(5),
5058 "stall must be detected promptly, took {:?}",
5059 start.elapsed()
5060 );
5061 let msg = crate::core::error::error_chain_string(err.as_ref());
5062 assert!(
5063 msg.contains("timed out") || msg.contains("timeout"),
5064 "must be a timeout error: {msg}"
5065 );
5066 }
5067
5068 #[tokio::test]
5072 async fn hung_headers_are_killed_by_read_timeout() {
5073 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5074 let addr = listener.local_addr().unwrap();
5075 tokio::spawn(async move {
5076 let (_sock, _) = listener.accept().await.unwrap();
5077 tokio::time::sleep(Duration::from_secs(60)).await;
5078 });
5079 let client = super::build_http_client(Duration::from_millis(300)).expect("client builds");
5080 let start = std::time::Instant::now();
5081 let err = client
5082 .get(format!("http://{addr}/codex/responses"))
5083 .send()
5084 .await
5085 .expect_err("hung request must be detected");
5086 assert!(
5087 start.elapsed() < Duration::from_secs(5),
5088 "hung headers must be detected promptly, took {:?}",
5089 start.elapsed()
5090 );
5091 assert!(err.is_timeout(), "must be a timeout: {err:?}");
5092 }
5093}
5094
5095#[cfg(test)]
5097mod memory_context_provider_tests {
5098 use super::{memory_context, Runtime};
5099 use std::sync::Arc;
5100
5101 async fn memory_runtime_with_providers(
5109 plugins: &[(&str, &str)],
5110 ) -> (
5111 Runtime,
5112 std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
5113 ) {
5114 let mut manager = crate::extensions::manager::ExtensionManager::new(Arc::new(
5115 crate::extensions::hooks::HookBus::new(),
5116 ));
5117 manager.set_progressive_deferral(true);
5118 for (plugin, provider) in plugins {
5119 let manifest: crate::extensions::manifest::ExtensionManifest =
5120 serde_json::from_value(serde_json::json!({
5121 "runtime": "process",
5122 "command": "/bin/false",
5123 "permissions": ["context_providers.register"],
5124 "deferred": {
5125 "context_providers": [{
5126 "id": provider,
5127 "capability": "project-memory",
5128 "description": "test context provider",
5129 "schema_version": 1
5130 }]
5131 }
5132 }))
5133 .expect("manifest parses");
5134 manager
5135 .load(plugin, &manifest)
5136 .await
5137 .expect("deferred context-provider load succeeds without spawning");
5138 }
5139 let extension_runtime = manager.extension_runtime();
5140 let mut runtime = Runtime::new_headless();
5141 runtime.install_extension_runtime(std::sync::Arc::clone(&extension_runtime));
5142 (runtime, extension_runtime)
5143 }
5144
5145 fn assert_memory_off_no_lease(runtime: &Runtime) {
5146 let status = runtime.memory_context_status();
5147 assert_eq!(
5148 status.durable,
5149 memory_context::DurableStatus::Off,
5150 "state must stay Off"
5151 );
5152 assert_eq!(
5153 status.one_shot,
5154 memory_context::OneShotStatus::Idle,
5155 "no one-shot lease may exist"
5156 );
5157 assert!(
5158 runtime.memory_bound_providers_for_test().is_empty(),
5159 "no provider may be bound"
5160 );
5161 }
5162
5163 #[tokio::test]
5169 async fn memory_enable_unregistered_provider_fails_closed_grants_nothing() {
5170 let (runtime, _ext) =
5172 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
5173 for requested in ["no-such-provider", "bad:id"] {
5174 let err = runtime
5175 .memory_context_enable_resolved(
5176 memory_context::MemoryContextMode::CaptureAndRecall,
5177 memory_context::mint_explicit_command_proof(),
5178 Some(requested),
5179 )
5180 .expect_err("unregistered provider must fail closed");
5181 assert_eq!(
5182 err,
5183 memory_context::MemoryContextError::ProviderNotRegistered
5184 );
5185 assert_memory_off_no_lease(&runtime);
5186 }
5187
5188 let (runtime, _ext) = memory_runtime_with_providers(&[]).await;
5191 let err = runtime
5192 .memory_context_enable(
5193 memory_context::MemoryContextMode::CaptureAndRecall,
5194 memory_context::mint_explicit_command_proof(),
5195 )
5196 .expect_err("empty catalog must fail closed");
5197 assert_eq!(
5198 err,
5199 memory_context::MemoryContextError::ProviderNotRegistered
5200 );
5201 assert_memory_off_no_lease(&runtime);
5202 let err = runtime
5204 .memory_context_recall_once(memory_context::mint_explicit_command_proof())
5205 .expect_err("one-shot grant must validate the provider too");
5206 assert_eq!(
5207 err,
5208 memory_context::MemoryContextError::ProviderNotRegistered
5209 );
5210 assert_memory_off_no_lease(&runtime);
5211 }
5212
5213 #[tokio::test]
5217 async fn memory_enable_exactly_one_declared_provider_records_exact_identity() {
5218 const ADDRESS: &str = "extension:axel-memory-manager:project-memory";
5219 let (runtime, _ext) =
5221 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
5222 let status = runtime
5223 .memory_context_enable(
5224 memory_context::MemoryContextMode::CaptureAndRecall,
5225 memory_context::mint_explicit_command_proof(),
5226 )
5227 .expect("unique declared provider must resolve");
5228 assert!(matches!(
5229 status.durable,
5230 memory_context::DurableStatus::Active { .. }
5231 ));
5232 let bound = runtime.memory_bound_providers_for_test();
5233 assert_eq!(bound.len(), 1);
5234 assert_eq!(bound[0].as_str(), ADDRESS, "exact runtime address recorded");
5235
5236 let (runtime, _ext) =
5238 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
5239 runtime
5240 .memory_context_enable_resolved(
5241 memory_context::MemoryContextMode::RecallEachPrompt,
5242 memory_context::mint_explicit_command_proof(),
5243 Some("project-memory"),
5244 )
5245 .expect("explicit exact id must resolve");
5246 assert_eq!(
5247 runtime.memory_bound_providers_for_test()[0].as_str(),
5248 ADDRESS
5249 );
5250 }
5251
5252 #[tokio::test]
5256 async fn memory_enable_ambiguous_overlapping_declarations_fails_closed() {
5257 let (runtime, _ext) = memory_runtime_with_providers(&[
5258 ("mem-a", "project-memory"),
5259 ("mem-b", "project-memory"),
5260 ])
5261 .await;
5262 let err = runtime
5263 .memory_context_enable(
5264 memory_context::MemoryContextMode::CaptureAndRecall,
5265 memory_context::mint_explicit_command_proof(),
5266 )
5267 .expect_err("overlapping declarations must fail closed");
5268 assert_eq!(err, memory_context::MemoryContextError::ProviderAmbiguous);
5269 assert_memory_off_no_lease(&runtime);
5270
5271 let err = runtime
5273 .memory_context_enable_resolved(
5274 memory_context::MemoryContextMode::CaptureOnly,
5275 memory_context::mint_explicit_command_proof(),
5276 Some("project-memory"),
5277 )
5278 .expect_err("same id declared by two plugins is ambiguous");
5279 assert_eq!(err, memory_context::MemoryContextError::ProviderAmbiguous);
5280 assert_memory_off_no_lease(&runtime);
5281 }
5282
5283 #[tokio::test]
5289 async fn memory_disable_revokes_runtime_lease_without_spawn_no_panic() {
5290 let (runtime, ext) =
5291 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
5292 runtime
5293 .memory_context_enable(
5294 memory_context::MemoryContextMode::CaptureAndRecall,
5295 memory_context::mint_explicit_command_proof(),
5296 )
5297 .expect("enable succeeds");
5298 assert_eq!(
5299 ext.lease_count(),
5300 0,
5301 "granting a memory lease spawns nothing"
5302 );
5303
5304 let status = runtime.memory_context_disable();
5305 assert_eq!(status.durable, memory_context::DurableStatus::Off);
5306 assert_memory_off_no_lease(&runtime);
5307 assert_eq!(
5308 ext.lease_count(),
5309 0,
5310 "revocation of a never-spawned lease is a no-op"
5311 );
5312
5313 let status = runtime.memory_context_disable();
5315 assert_eq!(status.durable, memory_context::DurableStatus::Off);
5316 assert_eq!(ext.lease_count(), 0);
5317 }
5318
5319 #[test]
5324 fn memory_context_enable_disable_emit_metadata_only_events() {
5325 memory_context::drain_captured_memory_events_for_test();
5326
5327 let runtime = Runtime::new_headless();
5328 runtime
5330 .memory_context_enable(
5331 memory_context::MemoryContextMode::Off,
5332 memory_context::mint_explicit_command_proof(),
5333 )
5334 .expect_err("Off is not an enable mode");
5335 assert!(
5336 memory_context::drain_captured_memory_events_for_test().is_empty(),
5337 "failed enable must emit no event"
5338 );
5339
5340 runtime
5341 .memory_context_enable(
5342 memory_context::MemoryContextMode::CaptureAndRecall,
5343 memory_context::mint_explicit_command_proof(),
5344 )
5345 .expect("enable succeeds");
5346 runtime.memory_context_disable();
5347
5348 let events = memory_context::drain_captured_memory_events_for_test();
5349 assert_eq!(
5350 events
5351 .iter()
5352 .map(|event| (event.event, event.outcome))
5353 .collect::<Vec<_>>(),
5354 vec![
5355 ("memory_context.enabled", "enabled"),
5356 ("memory_context.disabled", "disabled"),
5357 ],
5358 );
5359 assert_eq!(events[0].mode, Some("capture_and_recall"));
5360 assert_eq!(events[1].mode, Some("off"));
5361
5362 let cwd = std::env::current_dir()
5365 .expect("cwd")
5366 .to_string_lossy()
5367 .into_owned();
5368 for event in &events {
5369 let json = serde_json::to_string(event).expect("event serializes");
5370 assert!(
5371 json.contains("\"project_digest\":\"p") || json.contains("project-cwd-"),
5372 "digest identity expected; got: {json}"
5373 );
5374 assert!(
5375 !json.contains(&cwd),
5376 "raw project path leaked into event JSON: {json}"
5377 );
5378 }
5379 }
5380
5381 #[tokio::test]
5387 async fn memory_session_end_guard_drop_fresh_construction_is_off_no_lease() {
5388 let (runtime, ext) =
5389 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
5390 runtime
5391 .memory_context_enable(
5392 memory_context::MemoryContextMode::CaptureAndRecall,
5393 memory_context::mint_explicit_command_proof(),
5394 )
5395 .expect("enable succeeds");
5396 drop(runtime);
5398 assert_eq!(
5399 ext.lease_count(),
5400 0,
5401 "session end must leave no runtime lease (never-spawned reap is a no-op)"
5402 );
5403
5404 let mut fresh = Runtime::new_headless();
5406 fresh.install_extension_runtime(std::sync::Arc::clone(&ext));
5407 assert_memory_off_no_lease(&fresh);
5408 assert_eq!(ext.lease_count(), 0);
5409 }
5410}