1use crate::{Result, RuntimeError, ToolRegistry};
2use futures::stream::Stream;
3use reqwest::Client;
4use serde_json::{json, Value};
5use std::path::{Path, PathBuf};
6use std::pin::Pin;
7use std::sync::Arc;
8use std::sync::Mutex;
9use std::time::Duration;
10use tokio::sync::{mpsc, RwLock};
11use tokio_util::sync::CancellationToken;
12
13mod api;
14mod api_sync;
15pub mod attachments;
16mod auth;
17mod axel_context;
18#[cfg(test)]
19mod body_golden;
20pub mod budget;
21pub mod capture_worker;
22pub mod chat_capture;
23pub(crate) mod cloud_invoke;
24pub mod compaction;
25pub mod context;
26pub mod continuation;
27pub mod google_gemini;
28pub mod google_vertex;
29pub(crate) mod helpers;
30mod image_limits;
31mod image_retention;
32pub mod keepwarm;
33pub mod memory_context;
34pub mod memory_history;
35pub mod openai;
36pub mod relay;
37mod request;
38mod sse;
39mod sse_types;
40mod stream;
41pub mod subagent;
42pub mod telemetry;
43pub mod trace;
44pub(crate) mod transport;
45mod types;
46
47use api::ApiMethods;
48use auth::AuthMethods;
49use helpers::HelperMethods;
50use stream::StreamMethods;
51use types::AuthState;
52pub use types::{AgentEvent, CancelCause, LlmEvent, SessionEvent, StreamEvent, TurnCompletion};
53pub use stream::activation_policy;
54
55pub enum BeforeToolCallDecision {
57 Continue { input: Value },
58 Block { reason: String },
59}
60
61pub async fn emit_before_tool_call(
65 hook_bus: &Arc<crate::extensions::hooks::HookBus>,
66 tool_name: &str,
67 runtime_tool_name: Option<&str>,
68 input: Value,
69 session_id: Option<&str>,
70) -> crate::extensions::hooks::events::HookResult {
71 let mut event = crate::extensions::hooks::events::HookEvent::before_tool_call(tool_name, input)
72 .with_session(session_id);
73 if let Some(runtime_tool_name) = runtime_tool_name {
74 event.tool_runtime_name = Some(runtime_tool_name.to_string());
75 }
76 hook_bus.emit(&event).await
77}
78
79pub async fn resolve_before_tool_call_result(
84 hook_result: crate::extensions::hooks::events::HookResult,
85 secret_prompt: Option<&crate::tools::SecretPromptHandle>,
86 auto_approve_confirms: bool,
87) -> crate::extensions::hooks::events::HookResult {
88 match hook_result {
89 crate::extensions::hooks::events::HookResult::Confirm { message } => {
90 if auto_approve_confirms {
91 tracing::info!(message = %message, "confirm auto-approved (auto_approve_confirms=true)");
92 return crate::extensions::hooks::events::HookResult::Continue;
93 }
94
95 let Some(prompt) = secret_prompt else {
96 return crate::extensions::hooks::events::HookResult::Block {
97 reason: format!(
98 "Tool call requires confirmation but no interactive prompt is available: {}",
99 message
100 ),
101 };
102 };
103
104 let response = prompt
105 .prompt(
106 "Confirm tool call".to_string(),
107 format!("{}\n\nType 'yes' or 'y' to allow.", message),
108 )
109 .await;
110
111 match response.as_deref().map(str::trim) {
112 Some(answer)
113 if answer.eq_ignore_ascii_case("yes") || answer.eq_ignore_ascii_case("y") =>
114 {
115 crate::extensions::hooks::events::HookResult::Continue
116 }
117 _ => crate::extensions::hooks::events::HookResult::Block {
118 reason: format!("Tool call confirmation denied: {}", message),
119 },
120 }
121 }
122 other => other,
123 }
124}
125
126pub async fn resolve_before_tool_call_decision(
128 original_input: Value,
129 hook_result: crate::extensions::hooks::events::HookResult,
130 secret_prompt: Option<&crate::tools::SecretPromptHandle>,
131 auto_approve_confirms: bool,
132) -> BeforeToolCallDecision {
133 match resolve_before_tool_call_result(hook_result, secret_prompt, auto_approve_confirms).await {
134 crate::extensions::hooks::events::HookResult::Block { reason } => {
135 BeforeToolCallDecision::Block { reason }
136 }
137 crate::extensions::hooks::events::HookResult::Modify { input } => {
138 BeforeToolCallDecision::Continue { input }
139 }
140 _ => BeforeToolCallDecision::Continue {
141 input: original_input,
142 },
143 }
144}
145
146const MAX_REPLACE_OUTPUT: usize = 1024 * 1024; pub async fn emit_after_tool_call(
171 hook_bus: &Arc<crate::extensions::hooks::HookBus>,
172 tool_name: &str,
173 runtime_tool_name: Option<&str>,
174 input: Value,
175 output: String,
176 max_tool_output: usize,
177 session_id: Option<&str>,
178) -> String {
179 use crate::extensions::hooks::events::HookResult;
180 let original = output.clone();
182 let mut event =
183 crate::extensions::hooks::events::HookEvent::after_tool_call(tool_name, input, output)
184 .with_session(session_id);
185 if let Some(runtime_tool_name) = runtime_tool_name {
186 event.tool_runtime_name = Some(runtime_tool_name.to_string());
187 }
188 let post_hook = match hook_bus.emit(&event).await {
189 HookResult::Replace { mut output } => {
190 if output.len() > MAX_REPLACE_OUTPUT {
191 tracing::warn!(
192 tool = %tool_name,
193 len = output.len(),
194 cap = MAX_REPLACE_OUTPUT,
195 "Extension Replace output exceeds cap — clamping",
196 );
197 let mut boundary = MAX_REPLACE_OUTPUT;
199 while boundary > 0 && !output.is_char_boundary(boundary) {
200 boundary -= 1;
201 }
202 output.truncate(boundary);
203 }
204 output
205 }
206 HookResult::Continue => original,
207 other => {
208 tracing::warn!(
209 tool = %tool_name,
210 ?other,
211 "Unexpected after_tool_call result — using original output",
212 );
213 original
214 }
215 };
216 crate::runtime::helpers::HelperMethods::truncate_tool_result(&post_hook, max_tool_output)
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub struct ReasoningClamp {
226 pub from: agent_core::reasoning::ReasoningLevel,
227 pub to: agent_core::reasoning::ReasoningLevel,
228}
229
230fn validated_single_tool_output(
231 model: &str,
232 output: crate::ToolOutput,
233) -> (String, Option<Vec<Value>>) {
234 let (summary, blocks) = output.into_parts();
235 if let Some(ref blocks) = blocks {
236 if let Err(error) = attachments::validate_tool_blocks(model, blocks) {
237 return (format!("Attachment not sent: {error}"), None);
238 }
239 }
240 (summary, blocks)
241}
242
243fn retain_single_tool_blocks(
246 blocks: Option<Vec<Value>>,
247 original: &str,
248 hooked: &str,
249) -> Option<Vec<Value>> {
250 if original == hooked {
251 blocks
252 } else {
253 None
254 }
255}
256
257fn single_tool_result_content(
258 result: &str,
259 blocks: Option<Vec<Value>>,
260 max_tool_output: usize,
261) -> Value {
262 match blocks {
263 Some(blocks) => Value::Array(blocks),
264 None => Value::String(HelperMethods::truncate_tool_result(result, max_tool_output)),
265 }
266}
267
268#[derive(Clone)]
271struct PromptReloadSource {
272 manifest: PathBuf,
273 context: agent_core::prompt::SelectionContext,
274 user_module: Option<agent_core::prompt::PromptModule>,
275 delegation_policy_digest: Option<String>,
276}
277
278pub struct Runtime {
279 client: Client,
280 auth: Arc<RwLock<AuthState>>,
281 model: String,
282 tools: Arc<RwLock<ToolRegistry>>,
283 system_prompt: Option<String>,
284 effective_prompt: Option<agent_core::prompt::PromptStack>,
286 prompt_generation: u64,
287 prompt_reload_source: Option<PromptReloadSource>,
289 thinking_budget: u32,
290 named_level: Option<agent_core::reasoning::ReasoningLevel>,
294 explicit_reasoning: bool,
299 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole,
302 context_window_override: Option<u64>,
307 pub(crate) continuation: continuation::SharedContinuation,
310 compaction_model: Option<String>,
312 compaction_mode: agent_core::compaction::CompactionMode,
314 compaction_exclusions: Vec<agent_core::compaction::ContentClass>,
316 remote_summarization_attempts: Arc<std::sync::atomic::AtomicU64>,
321 subagent_registry: Arc<Mutex<crate::runtime::subagent::SubagentRegistry>>,
323 orchestration: Option<Arc<crate::orchestration::OrchestrationRuntime>>,
325 event_queue: Arc<crate::events::EventQueue>,
327 pub watcher_exit_path: Option<PathBuf>,
329 max_tool_output: usize,
331 bash_timeout: u64,
332 bash_max_timeout: u64,
333 subagent_timeout: u64,
334 api_retries: u32,
335 refusal_retries: u32,
336 telemetry_level: crate::runtime::telemetry::TelemetryLevel,
338 telemetry_writer: Option<crate::runtime::telemetry::TelemetryWriter>,
344 trace_ctx: trace::TraceContext,
349 trace_controls: std::sync::Arc<trace::TraceControls>,
353 memory_context_state: std::sync::Arc<std::sync::Mutex<memory_context::SessionMemoryState>>,
365 memory_backend: crate::memory_backend::MemoryBinding,
368 memory_backend_config: Option<crate::config::MemoryBackendConfig>,
370 memory_backend_reconfigure_denied: Arc<std::sync::atomic::AtomicBool>,
372 #[cfg(any(test, feature = "testing"))]
376 capture_provider_for_test: std::sync::Arc<
377 std::sync::Mutex<Option<std::sync::Arc<dyn capture_worker::CaptureProvider>>>,
378 >,
379 pending_history_import_preview:
383 std::sync::Arc<std::sync::Mutex<Option<memory_history::HistoryImportPreview>>>,
384 history_import_plan: std::sync::Arc<std::sync::Mutex<Option<memory_history::ImportPlan>>>,
386 pending_memory_segment:
397 std::sync::Arc<std::sync::Mutex<Option<memory_context::ContextSegment>>>,
398 retained_recall_turn:
405 std::sync::Arc<std::sync::Mutex<Option<memory_context::RetainedRecallTurn>>>,
406 one_shot_trace_writer:
414 std::sync::Arc<std::sync::Mutex<Option<crate::runtime::telemetry::TelemetryWriter>>>,
415 capture_dir: std::path::PathBuf,
421 cache_diagnostics: bool,
423 cache_ttl: crate::core::config::CacheTtl,
426 ttl_downgrade_notified: std::sync::Arc<std::sync::atomic::AtomicBool>,
429 saw_1h_honored: std::sync::Arc<std::sync::atomic::AtomicBool>,
433 cache_anchor: std::sync::Arc<keepwarm::CacheAnchorCell>,
437 #[allow(dead_code)]
441 last_msg_id: Arc<Mutex<Option<String>>>,
442 session_manager: std::sync::Arc<crate::tools::shell::SessionManager>,
443 hook_bus: Arc<crate::extensions::hooks::HookBus>,
445 #[allow(dead_code)]
447 reaper_handle: Option<tokio::task::JoinHandle<()>>,
448 #[allow(dead_code)]
449 reaper_cancel: Option<tokio_util::sync::CancellationToken>,
450 credential_source: crate::auth::CredentialSource,
454 token_cache: crate::auth::TokenCache,
457 trusted_worker_models: Vec<agent_core::prompt::QualifiedModelId>,
461 subagent_limits: crate::config::SubagentLimits,
465 progressive_tool_disclosure: bool,
469 deferred_tool_loading: bool,
472 activation_confirm: agent_core::config::ActivationConfirm,
475 delegation_parent: Option<String>,
478 mcp_runtime: Option<std::sync::Arc<crate::mcp::McpRuntimeManager>>,
482 mcp_session_scope: Option<std::sync::Arc<crate::mcp::McpSessionEndGuard>>,
486 extension_runtime: Option<std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>>,
490 extension_session_scope:
494 Option<std::sync::Arc<crate::extensions::lease::ExtensionSessionEndGuard>>,
495 turn_budget: crate::runtime::budget::TurnBudget,
498 host_tool_session: crate::tools::activation::SessionId,
506 tool_catalog_prompt: Arc<std::sync::Mutex<Option<Option<String>>>>,
511 retained_tool_set: crate::tools::activation::RetainedSessionToolSet,
515 session_id: Option<String>,
519 cwd: Option<PathBuf>,
523 env: Option<crate::session::types::SessionEnv>,
527 env_stripped: Vec<String>,
529 env_warned: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
533}
534
535fn fresh_host_tool_session() -> crate::tools::activation::SessionId {
539 crate::tools::activation::SessionId::parse(&format!(
540 "runtime-{}-{}",
541 std::process::id(),
542 uuid::Uuid::new_v4()
543 ))
544 .expect("generated runtime session id is always valid")
545}
546
547fn fresh_memory_context_state(
554) -> std::sync::Arc<std::sync::Mutex<memory_context::SessionMemoryState>> {
555 let session = memory_context::SessionId::parse(&format!(
556 "memctx-{}-{}",
557 std::process::id(),
558 uuid::Uuid::new_v4()
559 ))
560 .expect("generated memory session id is always valid");
561 std::sync::Arc::new(std::sync::Mutex::new(
562 memory_context::SessionMemoryState::new(session),
563 ))
564}
565
566fn memory_project_scope() -> std::result::Result<agent_core::memory::store::ProjectScope, String> {
570 let cwd = std::env::current_dir()
571 .map_err(|error| format!("cannot resolve current project directory: {error}"))?;
572 agent_core::memory::store::ProjectScope::discover(&cwd)
573 .map_err(|error| format!("cannot resolve project scope: {error}"))
574}
575
576fn memory_project_id() -> memory_context::ProjectId {
577 match memory_project_scope() {
578 Ok(scope) => memory_context::ProjectId::parse(scope.key())
579 .expect("host project scope key is always a valid identifier"),
580 Err(_) => memory_context::ProjectId::parse("project-unresolved")
581 .expect("static fallback project id is valid"),
582 }
583}
584
585fn memory_capture_worker() -> &'static capture_worker::CaptureWorker {
594 static WORKER: std::sync::OnceLock<capture_worker::CaptureWorker> = std::sync::OnceLock::new();
595 WORKER.get_or_init(|| capture_worker::CaptureWorker::new(32))
596}
597
598struct ExtensionCaptureProvider {
599 manager: std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
600 session: crate::tools::activation::SessionId,
601 plugin: String,
602 digest: crate::tools::catalog::SchemaDigest,
603 handle: tokio::runtime::Handle,
604}
605
606impl capture_worker::CaptureProvider for ExtensionCaptureProvider {
607 fn capture(
608 &self,
609 capture: chat_capture::ChatTurnCapture,
610 ) -> std::result::Result<(), capture_worker::CaptureFailure> {
611 let params = memory_context::capture_request_wire(&capture);
612 self.handle
613 .block_on(
614 crate::extensions::lease::ExtensionLeaseCapability::new(
615 self.session.clone(),
616 self.manager.clone(),
617 )
618 .call_exact(
619 &self.plugin,
620 memory_context::MEMORY_CAPTURE_TOOL_NAME,
621 &self.digest,
622 params,
623 ),
624 )
625 .map(|_| ())
626 .map_err(|_| capture_worker::CaptureFailure {
627 code: "provider_call_failed",
628 })
629 }
630
631 fn contains_capture(
632 &self,
633 capture_id: &[u8; 32],
634 ) -> std::result::Result<capture_worker::CaptureCommitState, capture_worker::CaptureFailure>
635 {
636 let capture_id = chat_capture::CaptureId::from_bytes(*capture_id);
637 let response = self
638 .handle
639 .block_on(
640 crate::extensions::lease::ExtensionLeaseCapability::new(
641 self.session.clone(),
642 self.manager.clone(),
643 )
644 .call_exact(
645 &self.plugin,
646 memory_context::MEMORY_CAPTURE_TOOL_NAME,
647 &self.digest,
648 memory_context::capture_query_wire(&capture_id),
649 ),
650 )
651 .map_err(|_| capture_worker::CaptureFailure {
652 code: "provider_query_failed",
653 })?;
654 Ok(
655 if response
656 .get("committed")
657 .and_then(serde_json::Value::as_bool)
658 == Some(true)
659 {
660 capture_worker::CaptureCommitState::Committed
661 } else {
662 capture_worker::CaptureCommitState::Absent
663 },
664 )
665 }
666
667 fn capture_summary(
668 &self,
669 capture: chat_capture::ConversationSummaryCapture,
670 ) -> std::result::Result<(), capture_worker::CaptureFailure> {
671 let params = memory_context::summary_capture_request_wire(&capture);
672 self.handle
673 .block_on(
674 crate::extensions::lease::ExtensionLeaseCapability::new(
675 self.session.clone(),
676 self.manager.clone(),
677 )
678 .call_exact(
679 &self.plugin,
680 memory_context::MEMORY_CAPTURE_TOOL_NAME,
681 &self.digest,
682 params,
683 ),
684 )
685 .map(|_| ())
686 .map_err(|_| capture_worker::CaptureFailure {
687 code: "provider_call_failed",
688 })
689 }
690}
691
692fn terminal_capture_history(
693 lease: &memory_context::MemoryContextLease,
694 messages: &[crate::SharedMessage],
695 started_at: std::time::SystemTime,
696) -> chat_capture::TerminalTurnHistory {
697 use agent_core::core::disclosure::DisclosureClass;
698 let mut items = Vec::new();
699 for message in messages {
700 let Some(role) = message.get("role").and_then(serde_json::Value::as_str) else {
701 continue;
702 };
703 let class = match role {
704 "user" => chat_capture::CaptureContentClass::UserMessage,
705 "assistant" => chat_capture::CaptureContentClass::AssistantFinal,
706 _ => continue,
707 };
708 let text = message
709 .get("content")
710 .and_then(serde_json::Value::as_str)
711 .map(str::to_owned)
712 .unwrap_or_else(|| {
713 message
714 .get("content")
715 .map(ToString::to_string)
716 .unwrap_or_default()
717 });
718 items.push(chat_capture::CanonicalCaptureItem {
719 project_id: lease.project_id.clone(),
720 class,
721 disclosure: DisclosureClass::ModelVisible,
722 sensitivity: chat_capture::Sensitivity::Normal,
723 text,
724 tool_name: None,
725 });
726 }
727 chat_capture::TerminalTurnHistory {
728 project_id: lease.project_id.clone(),
729 session_id: lease.session_id.clone(),
730 turn_id: memory_context::TurnId::parse(&format!("turn-{}", uuid::Uuid::new_v4()))
731 .expect("generated turn id"),
732 turn_ordinal: 0,
733 started_at,
734 completed_at: std::time::SystemTime::now(),
735 outcome: agent_core::TurnOutcome::Completed,
736 items,
737 compaction: None,
738 }
739}
740
741fn memory_provider_id() -> memory_context::ContextProviderId {
742 memory_context::ContextProviderId::parse("axel-memory")
743 .expect("static provider id is always valid")
744}
745
746fn terminal_capture_start(messages: &[crate::SharedMessage]) -> Option<crate::SharedMessage> {
747 messages
748 .iter()
749 .rfind(|message| {
750 message["role"] == "user"
751 && (message["content"].is_string()
752 || message["content"].as_array().is_some_and(|blocks| {
753 blocks.iter().any(|block| block["type"] != "tool_result")
754 }))
755 })
756 .cloned()
757}
758
759fn terminal_capture_messages<'a>(
760 messages: &'a [crate::SharedMessage],
761 start: &crate::SharedMessage,
762) -> Option<&'a [crate::SharedMessage]> {
763 let index = messages
767 .iter()
768 .position(|message| Arc::ptr_eq(message, start))?;
769 Some(&messages[index..])
770}
771
772fn dispatch_completed_terminal_capture(
775 completed: bool,
776 final_history: &std::sync::Mutex<Option<Vec<crate::SharedMessage>>>,
777 state: &std::sync::Mutex<memory_context::SessionMemoryState>,
778 selected: Option<(
779 memory_context::MemoryContextLease,
780 Arc<dyn capture_worker::CaptureProvider>,
781 )>,
782 started_at: std::time::SystemTime,
783 turn_start: Option<&crate::SharedMessage>,
784) -> bool {
785 let history = final_history
786 .lock()
787 .unwrap_or_else(std::sync::PoisonError::into_inner)
788 .take();
789 let (Some(messages), Some((lease, provider))) = (history, selected) else {
790 return false;
791 };
792 if !completed {
793 return false;
794 }
795 let Some(messages) = turn_start.and_then(|start| terminal_capture_messages(&messages, start))
796 else {
797 return false;
798 };
799 let state = state
800 .lock()
801 .unwrap_or_else(std::sync::PoisonError::into_inner);
802 if state
803 .capture_lease_at(std::time::SystemTime::now())
804 .as_ref()
805 != Some(&lease)
806 {
807 return false;
808 }
809 memory_capture_worker()
810 .submit_terminal(
811 &lease,
812 terminal_capture_history(&lease, messages, started_at),
813 memory_context::RetentionClass::Standard,
814 provider,
815 )
816 .unwrap_or(false)
817}
818
819const HTTP_READ_TIMEOUT: Duration = Duration::from_secs(90);
824
825fn build_http_client(read_timeout: Duration) -> reqwest::Result<Client> {
838 Client::builder()
839 .tls_built_in_webpki_certs(true)
840 .tls_built_in_native_certs(true)
841 .connect_timeout(Duration::from_secs(10))
842 .read_timeout(read_timeout)
843 .build()
844}
845
846pub fn build_host_http_client() -> Result<Client> {
850 build_http_client(HTTP_READ_TIMEOUT)
851 .map_err(|e| RuntimeError::Config(format!("Failed to build HTTP client: {}", e)))
852}
853
854pub(crate) struct RuntimeParts {
858 pub host: crate::host::HostParts,
859 pub session_manager: Arc<crate::tools::shell::SessionManager>,
860 pub reaper: Option<(tokio::task::JoinHandle<()>, CancellationToken)>,
861}
862
863impl RuntimeParts {
864 pub(crate) fn with_reaper(host: crate::host::HostParts) -> Self {
866 let session_manager = fresh_session_manager();
867 let cancel = CancellationToken::new();
868 let handle =
869 crate::tools::shell::session::start_reaper(session_manager.clone(), cancel.clone());
870 Self {
871 host,
872 session_manager,
873 reaper: Some((handle, cancel)),
874 }
875 }
876
877 #[cfg_attr(not(any(test, feature = "testing")), allow(dead_code))]
879 pub(crate) fn without_reaper(host: crate::host::HostParts) -> Self {
880 Self {
881 host,
882 session_manager: fresh_session_manager(),
883 reaper: None,
884 }
885 }
886}
887
888fn fresh_session_manager() -> Arc<crate::tools::shell::SessionManager> {
889 let config = crate::tools::shell::ShellConfig::default();
890 crate::tools::shell::SessionManager::new(config)
891}
892
893fn fresh_host_parts() -> Result<crate::host::HostParts> {
897 let client = build_host_http_client()?;
898 let capture_dir = trace::default_capture_dir();
903 let _ = trace::sweep_expired_captures(&capture_dir);
904 Ok(crate::host::HostParts {
905 client,
906 tools: Arc::new(RwLock::new(ToolRegistry::new())),
907 hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
908 credential_source: crate::auth::CredentialSource::Local,
909 token_cache: crate::auth::TokenCache::new(),
910 mcp_runtime: None,
911 extension_runtime: None,
912 capture_dir,
913 progressive_tool_disclosure: false,
914 })
915}
916
917fn canonical_trusted_worker_model(model: &str) -> String {
921 let model = model.trim();
922 if let Some(id) = model.strip_prefix("claude/") {
923 format!("anthropic/{id}")
924 } else if model.starts_with("claude-") {
925 format!("anthropic/{model}")
926 } else {
927 model.to_owned()
928 }
929}
930
931impl Drop for Runtime {
935 fn drop(&mut self) {
936 if let Some(c) = &self.reaper_cancel {
937 c.cancel();
938 }
939 }
940}
941
942impl Runtime {
943 pub async fn new() -> Result<Self> {
944 Ok(Self::from_parts(RuntimeParts::with_reaper(
946 fresh_host_parts()?,
947 )))
948 }
949
950 pub(crate) fn from_parts(parts: RuntimeParts) -> Self {
953 let (auth_token, auth_type, refresh_token, token_expires) =
957 (String::new(), "oauth".to_string(), None, Some(0));
958 let RuntimeParts {
959 host,
960 session_manager,
961 reaper,
962 } = parts;
963 let (reaper_handle, reaper_cancel) = match reaper {
964 Some((h, c)) => (Some(h), Some(c)),
965 None => (None, None),
966 };
967 let mcp_runtime = host.mcp_runtime;
968 let extension_runtime = host.extension_runtime;
969
970 let mut runtime = Runtime {
971 client: host.client,
972 auth: Arc::new(RwLock::new(AuthState {
973 auth_token,
974 auth_type,
975 refresh_token,
976 token_expires,
977 })),
978 model: crate::models::default_model().to_string(),
979 tools: host.tools,
980 system_prompt: None,
981 effective_prompt: None,
982 prompt_generation: 0,
983 prompt_reload_source: None,
984 thinking_budget: 4096,
985 named_level: None,
986 explicit_reasoning: false,
987 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole::Foreground,
988 context_window_override: None,
989 compaction_model: None,
990 compaction_mode: agent_core::compaction::CompactionMode::default(),
991 compaction_exclusions: Vec::new(),
992 remote_summarization_attempts: Arc::new(std::sync::atomic::AtomicU64::new(0)),
993 subagent_registry: Arc::new(Mutex::new(
994 crate::runtime::subagent::SubagentRegistry::new(),
995 )),
996 orchestration: None,
997 event_queue: Arc::new(crate::events::EventQueue::new(1000)),
998 watcher_exit_path: None,
999 max_tool_output: 30000,
1000 bash_timeout: 30,
1001 bash_max_timeout: 300,
1002 subagent_timeout: 300,
1003 api_retries: 3,
1004 refusal_retries: 2,
1005 telemetry_level: crate::runtime::telemetry::TelemetryLevel::Off,
1006 telemetry_writer: None,
1007 trace_ctx: trace::TraceContext::disabled(),
1008 trace_controls: std::sync::Arc::new(trace::TraceControls::new()),
1009 memory_context_state: fresh_memory_context_state(),
1012 memory_backend: crate::memory_backend::MemoryBinding::legacy_current(),
1014 memory_backend_config: None,
1015 memory_backend_reconfigure_denied: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1016 continuation: Arc::new(Mutex::new(continuation::ContinuationState::default())),
1017 pending_history_import_preview: std::sync::Arc::new(std::sync::Mutex::new(None)),
1018 history_import_plan: std::sync::Arc::new(std::sync::Mutex::new(None)),
1019 #[cfg(any(test, feature = "testing"))]
1020 capture_provider_for_test: std::sync::Arc::new(std::sync::Mutex::new(None)),
1021 pending_memory_segment: std::sync::Arc::new(std::sync::Mutex::new(None)),
1024 retained_recall_turn: std::sync::Arc::new(std::sync::Mutex::new(None)),
1025 one_shot_trace_writer: std::sync::Arc::new(std::sync::Mutex::new(None)),
1026 capture_dir: host.capture_dir,
1027 cache_diagnostics: false,
1028 cache_ttl: crate::core::config::CacheTtl::default(),
1029 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1030 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1031 cache_anchor: Default::default(),
1032 last_msg_id: Arc::new(Mutex::new(None)),
1033 session_manager,
1034 hook_bus: host.hook_bus,
1035 reaper_handle,
1036 reaper_cancel,
1037 credential_source: host.credential_source,
1038 token_cache: host.token_cache,
1039 trusted_worker_models: Vec::new(),
1040 subagent_limits: crate::config::SubagentLimits::default(),
1041 progressive_tool_disclosure: host.progressive_tool_disclosure,
1042 deferred_tool_loading: true,
1043 activation_confirm: agent_core::config::ActivationConfirm::default(),
1044 delegation_parent: None,
1045 mcp_runtime: None,
1046 mcp_session_scope: None,
1047 extension_runtime: None,
1048 extension_session_scope: None,
1049 turn_budget: crate::runtime::budget::TurnBudget::for_role(
1050 crate::runtime::budget::TurnRole::Foreground,
1051 ),
1052 host_tool_session: fresh_host_tool_session(),
1053 tool_catalog_prompt: Arc::new(std::sync::Mutex::new(None)),
1054 retained_tool_set: Default::default(),
1055 session_id: None,
1056 cwd: None,
1057 env: None,
1058 env_stripped: Vec::new(),
1059 env_warned: Default::default(),
1060 };
1061 if let Some(m) = mcp_runtime {
1065 runtime.install_mcp_runtime(m);
1066 }
1067 if let Some(m) = extension_runtime {
1068 runtime.install_extension_runtime(m);
1069 }
1070 runtime
1071 }
1072
1073
1074 #[cfg(any(test, feature = "testing"))]
1085 pub fn new_headless() -> Self {
1086 let client = build_http_client(HTTP_READ_TIMEOUT)
1087 .expect("reqwest client construction is infallible with built-in roots");
1088 let host = crate::host::HostParts {
1089 client,
1090 tools: Arc::new(RwLock::new(ToolRegistry::new())),
1091 hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
1092 credential_source: crate::auth::CredentialSource::Local,
1093 token_cache: crate::auth::TokenCache::new(),
1094 mcp_runtime: None,
1095 extension_runtime: None,
1096 capture_dir: trace::default_capture_dir(),
1097 progressive_tool_disclosure: false,
1098 };
1099 let mut runtime = Self::from_parts(RuntimeParts::without_reaper(host));
1100 runtime.auth = Arc::new(RwLock::new(AuthState {
1101 auth_token: "test-token".to_string(),
1102 auth_type: "api_key".to_string(),
1103 refresh_token: None,
1104 token_expires: None,
1105 }));
1106 runtime
1107 }
1108
1109 pub fn set_system_prompt(&mut self, prompt: String) {
1110 self.system_prompt = Some(prompt);
1111 }
1112
1113 pub fn system_prompt(&self) -> Option<&str> {
1114 self.system_prompt.as_deref()
1115 }
1116
1117 pub async fn effective_system_prompt(&self) -> Option<String> {
1129 let base = self.orchestrated_system_prompt().await;
1130 if self.effective_prompt.is_some() {
1132 return base;
1133 }
1134 match self.frozen_tool_catalog().await {
1135 Some(catalog) => Some(match base {
1136 Some(base) if !base.is_empty() => format!("{base}\n\n{catalog}"),
1137 _ => catalog,
1138 }),
1139 None => base,
1140 }
1141 }
1142
1143 pub async fn frozen_tool_catalog(&self) -> Option<String> {
1149 if !self.progressive_tool_disclosure
1150 || self.activation_confirm == agent_core::config::ActivationConfirm::Deny
1151 {
1152 return None;
1153 }
1154 if let Some(frozen) = self
1155 .tool_catalog_prompt
1156 .lock()
1157 .unwrap_or_else(std::sync::PoisonError::into_inner)
1158 .clone()
1159 {
1160 return frozen;
1161 }
1162 let rendered = {
1163 let registry = self.tools.read().await;
1164 if registry.get("activate_tools").is_none() {
1165 None
1166 } else {
1167 let core_set = continuation::context_tool_set(
1168 self.host_tool_session.clone(),
1169 registry.catalog(),
1170 true,
1171 self.context_management_enabled(),
1172 );
1173 let core: std::collections::HashSet<String> = core_set
1174 .core_ids()
1175 .map(|id| id.as_str().to_string())
1176 .collect();
1177 crate::tools::prompt_catalog::render(
1178 registry.catalog(),
1179 &core,
1180 crate::tools::activation::is_activatable_record,
1181 )
1182 }
1183 };
1184 let mut slot = self
1185 .tool_catalog_prompt
1186 .lock()
1187 .unwrap_or_else(std::sync::PoisonError::into_inner);
1188 slot.get_or_insert(rendered).clone()
1190 }
1191
1192 async fn orchestrated_system_prompt(&self) -> Option<String> {
1196 if self.effective_prompt.is_some() {
1197 return self.system_prompt.clone();
1198 }
1199 if self.tools.read().await.get("subagent_start").is_none() {
1200 return self.system_prompt.clone();
1201 }
1202 let Ok(model) = crate::orchestration::canonical_foreground_identity(&self.model) else {
1203 return self.system_prompt.clone();
1204 };
1205 let workflow_mode = match self.reasoning_level() {
1206 agent_core::reasoning::ReasoningLevel::UltraCode => {
1207 Some(agent_core::prompt::WorkflowMode::UltraCode)
1208 }
1209 agent_core::reasoning::ReasoningLevel::Max => {
1210 Some(agent_core::prompt::WorkflowMode::Max)
1211 }
1212 agent_core::reasoning::ReasoningLevel::XHigh => {
1213 Some(agent_core::prompt::WorkflowMode::XHigh)
1214 }
1215 _ => None,
1216 };
1217 let Ok(context) = agent_core::prompt::SelectionContext::new(model, None)
1218 .map(|context| context.with_workflow_mode(workflow_mode))
1219 else {
1220 return self.system_prompt.clone();
1221 };
1222 agent_core::prompt::compose_orchestration_prompt(self.system_prompt.as_deref(), &context)
1223 }
1224
1225 pub fn effective_prompt(&self) -> Option<&agent_core::prompt::PromptStack> {
1226 self.effective_prompt.as_ref()
1227 }
1228
1229 pub fn prompt_generation(&self) -> u64 {
1230 self.prompt_generation
1231 }
1232
1233 pub fn retain_prompt_reload_source(
1234 &mut self,
1235 manifest: PathBuf,
1236 context: agent_core::prompt::SelectionContext,
1237 user_module: Option<agent_core::prompt::PromptModule>,
1238 delegation_policy_digest: Option<String>,
1239 ) {
1240 self.prompt_reload_source = Some(PromptReloadSource {
1241 manifest,
1242 context,
1243 user_module,
1244 delegation_policy_digest,
1245 });
1246 }
1247
1248 pub fn reload_prompt(&mut self) -> std::result::Result<u64, agent_core::prompt::PromptError> {
1250 let source = self.prompt_reload_source.clone().ok_or_else(|| {
1251 agent_core::prompt::PromptError::Invalid("no prompt manifest is active".into())
1252 })?;
1253 let raw = std::fs::read_to_string(&source.manifest).map_err(|_| {
1254 agent_core::prompt::PromptError::Invalid("prompt manifest is unavailable".into())
1255 })?;
1256 let manifest = agent_core::prompt::PromptManifest::parse(&raw)?;
1257 let reload_catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
1258 source.context.model(),
1259 manifest.delegation_catalog_candidates(),
1260 )
1261 .map_err(|error| agent_core::prompt::PromptError::Invalid(error.into()))?;
1262 let candidate_policy_digest = manifest
1263 .delegation_policy(source.context.model().clone(), &reload_catalog)?
1264 .map(|policy| policy.digest());
1265 if candidate_policy_digest != source.delegation_policy_digest {
1266 return Err(agent_core::prompt::PromptError::Invalid(
1267 "hot reload cannot safely change delegation policy".into(),
1268 ));
1269 }
1270 let registry = manifest.registry(source.manifest.parent())?;
1271 let candidate = agent_core::prompt::compile_prompt_stack(
1272 &manifest,
1273 ®istry,
1274 &source.context,
1275 source.user_module,
1276 )?;
1277 self.apply_prompt_stack(candidate)
1278 }
1279
1280 pub fn apply_prompt_stack(
1282 &mut self,
1283 candidate: agent_core::prompt::PromptStack,
1284 ) -> std::result::Result<u64, agent_core::prompt::PromptError> {
1285 if let Some(current) = &self.effective_prompt {
1286 current.validate_hot_reload(&candidate)?;
1287 }
1288 let composed = candidate.composed().to_owned();
1289 self.effective_prompt = Some(candidate);
1290 self.system_prompt = Some(composed);
1291 self.prompt_generation = self.prompt_generation.saturating_add(1);
1292 Ok(self.prompt_generation)
1293 }
1294
1295 pub fn prompt_inspection_json(&self) -> Option<String> {
1296 let mode = self
1297 .orchestration
1298 .as_ref()
1299 .map(|runtime| runtime.enforcement_mode())
1300 .unwrap_or(agent_core::orchestration::EnforcementMode::Off);
1301 self.effective_prompt.as_ref().and_then(|stack| {
1302 serde_json::to_string(&serde_json::json!({
1303 "generation": self.prompt_generation,
1304 "effective": stack.inspect(mode),
1305 "token_estimate": stack.composed().len().div_ceil(4),
1306 }))
1307 .ok()
1308 })
1309 }
1310
1311 pub fn install_mcp_runtime(&mut self, manager: std::sync::Arc<crate::mcp::McpRuntimeManager>) {
1316 self.mcp_session_scope = Some(std::sync::Arc::new(crate::mcp::McpSessionEndGuard::new(
1317 self.host_tool_session.clone(),
1318 std::sync::Arc::clone(&manager),
1319 )));
1320 self.mcp_runtime = Some(manager);
1321 }
1322
1323 pub fn set_turn_budget(&mut self, budget: crate::runtime::budget::TurnBudget) {
1327 self.turn_budget = budget;
1328 }
1329
1330 pub fn turn_budget(&self) -> &crate::runtime::budget::TurnBudget {
1332 &self.turn_budget
1333 }
1334
1335 pub fn install_extension_runtime(
1340 &mut self,
1341 manager: std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
1342 ) {
1343 manager.bind_host_scope(self.host_tool_session.clone());
1347 self.extension_session_scope = Some(std::sync::Arc::new(
1348 crate::extensions::lease::ExtensionSessionEndGuard::new(
1349 self.host_tool_session.clone(),
1350 std::sync::Arc::clone(&manager),
1351 ),
1352 ));
1353 self.extension_runtime = Some(manager);
1354 }
1355
1356 pub fn install_orchestration(
1357 &mut self,
1358 runtime: Arc<crate::orchestration::OrchestrationRuntime>,
1359 ) {
1360 for model in &self.trusted_worker_models {
1361 if let Err(error) = runtime.grant_worker_model(model.as_str()) {
1362 tracing::warn!(
1363 model = model.as_str(),
1364 error = %error,
1365 "failed to apply configured worker-model trust"
1366 );
1367 }
1368 }
1369 self.orchestration = Some(runtime);
1370 }
1371
1372 pub fn install_worker_orchestration(
1378 &mut self,
1379 runtime: Arc<crate::orchestration::OrchestrationRuntime>,
1380 ) {
1381 self.orchestration = Some(runtime);
1382 }
1383
1384 pub fn subagent_limits(&self) -> crate::config::SubagentLimits {
1386 self.subagent_limits
1387 }
1388
1389 pub fn set_delegation_parent(&mut self, parent: Option<String>) {
1390 self.delegation_parent = parent;
1391 }
1392
1393 pub fn orchestration(&self) -> Option<&Arc<crate::orchestration::OrchestrationRuntime>> {
1394 self.orchestration.as_ref()
1395 }
1396
1397 pub fn grant_worker_model(&self, model: &str) -> std::result::Result<(), String> {
1402 self.orchestration
1403 .as_ref()
1404 .ok_or_else(|| "delegation policy unavailable".to_string())?
1405 .grant_worker_model(model)
1406 }
1407
1408 pub fn set_model(&mut self, model: String) -> Option<ReasoningClamp> {
1412 self.try_set_model(model).ok().flatten()
1413 }
1414
1415 pub fn try_set_model(
1422 &mut self,
1423 model: String,
1424 ) -> std::result::Result<Option<ReasoningClamp>, String> {
1425 let trimmed = model.trim();
1430 let cleaned = trimmed
1431 .split_once(char::is_whitespace)
1432 .and_then(|(_, rest)| {
1433 let rest = rest.trim_start();
1434 let (latency, candidate) = rest.split_once(char::is_whitespace)?;
1435 let millis = latency.strip_suffix("ms")?;
1436 if !millis.is_empty() && millis.chars().all(|c| c.is_ascii_digit()) {
1437 let candidate = candidate.trim();
1438 if !candidate.is_empty() && !candidate.chars().any(char::is_whitespace) {
1439 return Some(candidate);
1440 }
1441 }
1442 None
1443 })
1444 .unwrap_or(trimmed);
1445 if self.prompt_reload_source.is_none()
1451 && self
1452 .orchestration
1453 .as_ref()
1454 .is_some_and(|current| !current.unreconciled_runtime_handles().is_empty())
1455 {
1456 return Err("model change blocked: workers require collection/reconciliation".into());
1457 }
1458 let orchestration_replacement = if self.prompt_reload_source.is_none()
1459 && self.orchestration.is_some()
1460 {
1461 let foreground = crate::orchestration::canonical_foreground_identity(cleaned)
1462 .map_err(|_| "model change blocked: unresolved foreground model".to_string())?;
1463 let replacement = crate::orchestration::OrchestrationRuntime::from_limits(
1464 foreground,
1465 self.subagent_limits,
1466 )
1467 .map_err(|_| "model change blocked: trusted worker catalog unavailable".to_string())?;
1468 for trusted in &self.trusted_worker_models {
1469 replacement
1470 .grant_worker_model(trusted.as_str())
1471 .map_err(|_| {
1472 "model change blocked: configured worker trust invalid".to_string()
1473 })?;
1474 }
1475 Some(Arc::new(replacement))
1476 } else {
1477 None
1478 };
1479 self.model = cleaned.to_owned();
1480 if let Some(replacement) = orchestration_replacement {
1484 self.orchestration = Some(replacement);
1485 }
1486 if !self.explicit_reasoning {
1492 if let Some(level) =
1493 crate::runtime::openai::catalog::validation::default_level_for_model(cleaned)
1494 {
1495 self.set_reasoning_level(level);
1496 }
1497 }
1498 Ok(self.clamp_reasoning_to_model())
1503 }
1504
1505 fn clamp_reasoning_to_model(&mut self) -> Option<ReasoningClamp> {
1511 use crate::runtime::openai::catalog::validation::{
1512 default_level_for_model, thinking_options_for_model, validate_reasoning_mutation,
1513 };
1514 let model = self.model.as_str();
1515 let from = self.reasoning_level();
1516 if validate_reasoning_mutation(model, from).is_ok() {
1517 return None;
1518 }
1519 let to = default_level_for_model(model)
1520 .filter(|level| validate_reasoning_mutation(model, *level).is_ok())
1521 .or_else(|| {
1522 thinking_options_for_model(model)
1523 .iter()
1524 .filter_map(|opt| agent_core::reasoning::ReasoningLevel::parse(opt))
1525 .find(|level| validate_reasoning_mutation(model, *level).is_ok())
1526 })?;
1527 let explicit = self.explicit_reasoning;
1528 self.set_reasoning_level(to);
1529 self.explicit_reasoning = explicit;
1530 Some(ReasoningClamp { from, to })
1531 }
1532
1533 pub fn set_tools(&mut self, tools: ToolRegistry) {
1534 self.tools = Arc::new(RwLock::new(tools));
1535 }
1536
1537 pub fn subagent_registry(&self) -> &Arc<Mutex<crate::runtime::subagent::SubagentRegistry>> {
1538 &self.subagent_registry
1539 }
1540
1541 pub fn event_queue(&self) -> &Arc<crate::events::EventQueue> {
1542 &self.event_queue
1543 }
1544
1545 pub fn set_event_queue(&mut self, queue: Arc<crate::events::EventQueue>) {
1549 self.event_queue = queue;
1550 }
1551
1552 pub fn hook_bus(&self) -> &Arc<crate::extensions::hooks::HookBus> {
1554 &self.hook_bus
1555 }
1556
1557 pub fn session_manager(&self) -> &Arc<crate::tools::shell::SessionManager> {
1560 &self.session_manager
1561 }
1562
1563 pub fn host_tool_session_id(&self) -> &crate::tools::activation::SessionId {
1567 &self.host_tool_session
1568 }
1569
1570 pub fn set_session_id(&mut self, id: Option<String>) {
1573 self.session_id = id;
1574 }
1575
1576 pub fn session_id(&self) -> Option<&str> {
1577 self.session_id.as_deref()
1578 }
1579
1580 pub fn set_cwd(&mut self, cwd: Option<PathBuf>) {
1583 self.cwd = cwd;
1584 }
1585
1586 pub fn cwd(&self) -> Option<&Path> {
1587 self.cwd.as_deref()
1588 }
1589
1590 pub fn set_env(&mut self, env: Option<crate::session::types::SessionEnv>) {
1593 self.env = env;
1594 }
1595
1596 pub fn set_env_stripped(&mut self, stripped: Vec<String>) {
1597 self.env_stripped = stripped;
1598 }
1599
1600 pub fn env(&self) -> Option<&crate::session::types::SessionEnv> {
1601 self.env.as_ref()
1602 }
1603
1604 pub fn env_stripped(&self) -> &[String] {
1605 &self.env_stripped
1606 }
1607
1608 pub fn tools_shared(&self) -> Arc<RwLock<ToolRegistry>> {
1610 Arc::clone(&self.tools)
1611 }
1612
1613 pub fn model(&self) -> &str {
1614 &self.model
1615 }
1616
1617 pub fn http_client(&self) -> &Client {
1618 &self.client
1619 }
1620
1621 pub fn progressive_tool_disclosure(&self) -> bool {
1623 self.progressive_tool_disclosure
1624 }
1625
1626 pub fn activation_confirm(&self) -> agent_core::config::ActivationConfirm {
1628 self.activation_confirm
1629 }
1630
1631 pub fn set_activation_confirm(&mut self, mode: agent_core::config::ActivationConfirm) {
1634 self.activation_confirm = mode;
1635 }
1636
1637 #[cfg(any(test, feature = "testing"))]
1641 pub fn http_client_pool_id(&self) -> usize {
1642 const _: () = assert!(std::mem::size_of::<Client>() == std::mem::size_of::<usize>());
1643 unsafe { std::mem::transmute_copy::<Client, usize>(&self.client) }
1646 }
1647 pub fn set_thinking_budget(&mut self, budget: u32) {
1648 self.thinking_budget = budget;
1649 self.named_level = Some(agent_core::reasoning::ReasoningLevel::from_legacy_budget(
1651 budget,
1652 ));
1653 self.explicit_reasoning = false;
1655 }
1656
1657 pub fn set_reasoning_level(&mut self, level: agent_core::reasoning::ReasoningLevel) {
1663 self.named_level = Some(level);
1664 if let Some(budget) = level.to_legacy_budget() {
1665 self.thinking_budget = budget;
1666 }
1667 self.explicit_reasoning = false;
1669 }
1670
1671 pub fn set_reasoning_level_explicit(&mut self, level: agent_core::reasoning::ReasoningLevel) {
1676 self.set_reasoning_level(level);
1677 self.explicit_reasoning = true;
1678 }
1679
1680 pub fn restore_session_reasoning(&mut self, thinking_level: &str) -> Option<ReasoningClamp> {
1686 if let Some(level) = agent_core::reasoning::ReasoningLevel::parse(thinking_level) {
1687 self.set_reasoning_level_explicit(level);
1688 } else if let Some(budget) = crate::models::budget_for_thinking_level(thinking_level) {
1689 self.set_thinking_budget_explicit(budget);
1690 } else {
1691 return None;
1692 }
1693 self.clamp_reasoning_to_model()
1694 }
1695
1696 pub fn set_thinking_budget_explicit(&mut self, budget: u32) {
1701 self.thinking_budget = budget;
1702 self.named_level = Some(agent_core::reasoning::ReasoningLevel::from_legacy_budget(
1703 budget,
1704 ));
1705 self.explicit_reasoning = true;
1706 }
1707
1708 pub fn is_reasoning_explicit(&self) -> bool {
1710 self.explicit_reasoning
1711 }
1712
1713 pub fn thinking_budget_raw(&self) -> u32 {
1715 self.thinking_budget
1716 }
1717
1718 pub fn set_reasoning_level_checked(
1723 &mut self,
1724 level: agent_core::reasoning::ReasoningLevel,
1725 ) -> std::result::Result<(), String> {
1726 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
1727 &self.model,
1728 level,
1729 )?;
1730 if self.model.starts_with("anthropic/")
1731 && level == agent_core::reasoning::ReasoningLevel::UltraCode
1732 {
1733 if self.codex_request_role()
1734 != crate::runtime::openai::catalog::ExecutionRole::Foreground
1735 {
1736 return Err("ultracode_requires_foreground".into());
1737 }
1738 if self.effective_prompt.is_some() {
1739 return Err("typed_prompt_manifest_blocks_required_doctrine".into());
1740 }
1741 if self.orchestration.is_none() {
1742 return Err("ultracode_requires_orchestration".into());
1743 }
1744 let tools = self.tools.try_read().map_err(|_| {
1745 "ultracode prerequisite state is busy; refusing mutation".to_string()
1746 })?;
1747 let required = ["subagent_start", "subagent_status", "subagent_collect"];
1748 if !required.iter().all(|name| {
1749 tools
1750 .get(name)
1751 .is_some_and(|tool| tool.extension_id().is_none())
1752 }) {
1753 return Err("ultracode_requires_lifecycle_tools".into());
1754 }
1755 }
1756 self.set_reasoning_level_explicit(level);
1757 Ok(())
1758 }
1759
1760 pub fn set_thinking_budget_checked(&mut self, budget: u32) -> std::result::Result<(), String> {
1767 let level = agent_core::reasoning::ReasoningLevel::from_legacy_budget(budget);
1768 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
1769 &self.model,
1770 level,
1771 )?;
1772 self.set_thinking_budget_explicit(budget);
1773 Ok(())
1774 }
1775
1776 pub fn reasoning_level(&self) -> agent_core::reasoning::ReasoningLevel {
1777 self.named_level.unwrap_or_else(|| {
1778 agent_core::reasoning::ReasoningLevel::from_legacy_budget(self.thinking_budget)
1779 })
1780 }
1781
1782 pub(crate) fn set_codex_request_role(
1783 &mut self,
1784 role: crate::runtime::openai::catalog::CodexRequestRole,
1785 ) {
1786 self.codex_request_role = role;
1787 }
1788
1789 pub(crate) fn codex_request_role(&self) -> crate::runtime::openai::catalog::CodexRequestRole {
1790 self.codex_request_role
1791 }
1792
1793 pub(crate) fn codex_delegation_plan(
1794 model: &str,
1795 level: agent_core::reasoning::ReasoningLevel,
1796 role: crate::runtime::openai::catalog::CodexRequestRole,
1797 ) -> Option<crate::runtime::openai::catalog::CodexExecutionPlan> {
1798 use crate::runtime::openai::catalog::{plan_codex_execution, CodexRequestRole};
1799 if role != CodexRequestRole::Foreground
1800 || level != agent_core::reasoning::ReasoningLevel::Ultra
1801 || !model.starts_with("openai-codex/")
1802 {
1803 return None;
1804 }
1805 plan_codex_execution(model, level, role, None).ok()
1806 }
1807
1808 async fn authorized_anthropic_plan(
1809 &self,
1810 ) -> Result<Option<crate::runtime::openai::catalog::AnthropicExecutionPlan>> {
1811 if !self.model.starts_with("anthropic/") {
1812 return Ok(None);
1813 }
1814 let tools = self.tools.read().await;
1815 let builtin = |name: &str| {
1816 tools
1817 .get(name)
1818 .is_some_and(|tool| tool.extension_id().is_none())
1819 };
1820 let readiness = self
1821 .orchestration
1822 .as_ref()
1823 .and_then(|runtime| runtime.ultracode_readiness(&self.model).ok());
1824 let prerequisites = crate::runtime::openai::catalog::AnthropicPlanPrerequisites {
1825 orchestration_policy: self.orchestration.is_some(),
1826 foreground_worker_authorized: readiness.is_some(),
1827 concurrent_limit: readiness.map_or(0, |limits| limits.0),
1828 total_limit: readiness.map_or(0, |limits| limits.1),
1829 lifecycle_start: builtin("subagent_start"),
1830 lifecycle_status: builtin("subagent_status"),
1831 lifecycle_steer: builtin("subagent_steer"),
1832 lifecycle_collect: builtin("subagent_collect"),
1833 lifecycle_resume: builtin("subagent_resume"),
1834 };
1835 crate::runtime::openai::catalog::plan_anthropic_execution(
1836 &self.model,
1837 self.reasoning_level(),
1838 self.codex_request_role(),
1839 prerequisites,
1840 crate::runtime::openai::catalog::capability_cache::get(&self.model).and_then(|entry| {
1841 match entry.reasoning {
1842 crate::runtime::openai::catalog::ReasoningSupport::AnthropicAdaptive {
1843 adaptive,
1844 } => Some(adaptive),
1845 crate::runtime::openai::catalog::ReasoningSupport::None => Some(false),
1846 _ => None,
1847 }
1848 }),
1849 )
1850 .map(Some)
1851 .map_err(|error| RuntimeError::Config(error.to_string()))
1852 }
1853
1854 async fn validate_request_preflight(&self) -> Result<()> {
1857 self.validate_request_preflight_for(&self.model, self.codex_request_role)
1858 .await
1859 }
1860
1861 pub(crate) async fn validate_session_driver_preflight(&self) -> Result<()> {
1864 self.validate_request_preflight().await
1865 }
1866
1867 async fn validate_request_preflight_for(
1868 &self,
1869 model: &str,
1870 role: crate::runtime::openai::catalog::CodexRequestRole,
1871 ) -> Result<()> {
1872 if self
1876 .memory_backend_reconfigure_denied
1877 .load(std::sync::atomic::Ordering::SeqCst)
1878 {
1879 return Err(RuntimeError::Config(
1880 "memory backend cannot be reconfigured at runtime (including live legacy extension processes); restart required; request denied".into(),
1881 ));
1882 }
1883 let level = self.reasoning_level();
1884 if model.starts_with("anthropic/")
1885 && level == agent_core::reasoning::ReasoningLevel::UltraCode
1886 && self.effective_prompt.is_some()
1887 {
1888 return Err(RuntimeError::Config(
1889 "Anthropic execution plan rejected: typed_prompt_manifest_blocks_required_doctrine"
1890 .into(),
1891 ));
1892 }
1893 if model.starts_with("anthropic/")
1894 && matches!(
1895 level,
1896 agent_core::reasoning::ReasoningLevel::Max
1897 | agent_core::reasoning::ReasoningLevel::UltraCode
1898 | agent_core::reasoning::ReasoningLevel::Ultra
1899 )
1900 {
1901 let tools = self.tools.read().await;
1902 let builtin = |name: &str| {
1903 tools
1904 .get(name)
1905 .is_some_and(|tool| tool.extension_id().is_none())
1906 };
1907 let readiness = self
1908 .orchestration
1909 .as_ref()
1910 .and_then(|runtime| runtime.ultracode_readiness(model).ok());
1911 let prerequisites = crate::runtime::openai::catalog::AnthropicPlanPrerequisites {
1912 orchestration_policy: self.orchestration.is_some(),
1913 foreground_worker_authorized: readiness.is_some(),
1914 concurrent_limit: readiness.map_or(0, |limits| limits.0),
1915 total_limit: readiness.map_or(0, |limits| limits.1),
1916 lifecycle_start: builtin("subagent_start"),
1917 lifecycle_status: builtin("subagent_status"),
1918 lifecycle_steer: builtin("subagent_steer"),
1919 lifecycle_collect: builtin("subagent_collect"),
1920 lifecycle_resume: builtin("subagent_resume"),
1921 };
1922 drop(tools);
1923 let result = crate::runtime::openai::catalog::plan_anthropic_execution(
1924 model,
1925 level,
1926 role,
1927 prerequisites,
1928 crate::runtime::openai::catalog::capability_cache::get(model).and_then(|entry| {
1929 match entry.reasoning {
1930 crate::runtime::openai::catalog::ReasoningSupport::AnthropicAdaptive {
1931 adaptive,
1932 } => Some(adaptive),
1933 crate::runtime::openai::catalog::ReasoningSupport::None => Some(false),
1934 _ => None,
1935 }
1936 }),
1937 );
1938 match result {
1939 Ok(plan) => {
1940 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);
1941 return Ok(());
1942 }
1943 Err(error) => {
1944 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);
1945 return Err(RuntimeError::Config(error.to_string()));
1946 }
1947 }
1948 }
1949 if model.starts_with("openai-codex/") {
1950 let plan =
1951 crate::runtime::openai::catalog::plan_codex_execution(model, level, role, None)
1952 .map_err(|error| {
1953 tracing::debug!(
1954 event = "codex_mode_plan",
1955 qualified_model = %model,
1956 requested_level = %level,
1957 runtime_role = role.as_str(),
1958 decision = "deny",
1959 deny_code = error.code().as_str(),
1960 network_attempted = false,
1961 "Codex request preflight denied"
1962 );
1963 RuntimeError::Config(error.to_string())
1964 })?;
1965
1966 if plan.automatic_delegation() {
1967 if self.orchestration.is_none() {
1968 tracing::debug!(
1969 event = "codex_mode_plan",
1970 qualified_model = %model,
1971 requested_level = %level,
1972 runtime_role = role.as_str(),
1973 decision = "deny",
1974 deny_code = "ultra_requires_orchestration",
1975 network_attempted = false,
1976 "Codex request preflight denied"
1977 );
1978 return Err(RuntimeError::Config(
1979 "Ultra requires an installed orchestration policy".to_string(),
1980 ));
1981 }
1982
1983 let tools = self.tools.read().await;
1984 let required = ["subagent_start", "subagent_status", "subagent_collect"];
1985 let tools_ready = required.iter().all(|name| {
1986 tools
1987 .get(name)
1988 .is_some_and(|tool| tool.extension_id().is_none())
1989 });
1990 if !tools_ready {
1991 tracing::debug!(
1992 event = "codex_mode_plan",
1993 qualified_model = %model,
1994 requested_level = %level,
1995 runtime_role = role.as_str(),
1996 decision = "deny",
1997 deny_code = "ultra_requires_subagent_tools",
1998 network_attempted = false,
1999 "Codex request preflight denied"
2000 );
2001 return Err(RuntimeError::Config(
2002 "Ultra requires built-in subagent_start, subagent_status, and subagent_collect tools"
2003 .to_string(),
2004 ));
2005 }
2006 }
2007 return Ok(());
2008 }
2009
2010 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(model, level)
2011 .map_err(|error| {
2012 tracing::debug!(
2013 event = "codex_mode_plan",
2014 qualified_model = %model,
2015 requested_level = %level,
2016 runtime_role = role.as_str(),
2017 decision = "deny",
2018 deny_code = "unsupported_reasoning_level",
2019 network_attempted = false,
2020 "request preflight denied"
2021 );
2022 RuntimeError::Config(error)
2023 })
2024 }
2025
2026 pub fn set_compaction_model(&mut self, model: Option<String>) {
2027 self.compaction_model = model;
2028 }
2029
2030 pub fn set_compaction_mode(&mut self, mode: agent_core::compaction::CompactionMode) {
2032 self.compaction_mode = mode;
2033 }
2034
2035 pub fn set_compaction_exclusions(
2037 &mut self,
2038 exclude: Vec<agent_core::compaction::ContentClass>,
2039 ) {
2040 self.compaction_exclusions = exclude;
2041 }
2042
2043 pub fn remote_summarization_attempts(&self) -> u64 {
2046 self.remote_summarization_attempts
2047 .load(std::sync::atomic::Ordering::SeqCst)
2048 }
2049
2050 pub fn compaction_policy(&self) -> crate::runtime::compaction::DisclosurePolicy {
2053 crate::runtime::compaction::DisclosurePolicy {
2054 mode: self.compaction_mode,
2055 exclude: self.compaction_exclusions.clone(),
2056 }
2057 }
2058
2059 pub fn set_context_window(&mut self, window: Option<u64>) {
2060 self.context_window_override = window;
2061 }
2062
2063 pub fn compaction_model(&self) -> &str {
2066 self.compaction_model
2067 .as_deref()
2068 .unwrap_or("claude-sonnet-4-6")
2069 }
2070
2071 pub fn context_window(&self) -> u64 {
2072 self.context_window_override
2073 .unwrap_or_else(|| crate::models::context_window_for_model(&self.model))
2074 }
2075
2076 pub async fn assess_context(
2090 &self,
2091 messages: &[crate::SharedMessage],
2092 ) -> context::ContextAssessment {
2093 let system = self.effective_system_prompt().await;
2094 let schema = self.tools.read().await.tools_schema();
2095 let memory_rendered: Option<String> = self
2100 .pending_memory_segment
2101 .lock()
2102 .unwrap_or_else(std::sync::PoisonError::into_inner)
2103 .as_ref()
2104 .map(|segment| segment.rendered_text().to_string());
2105 let memory_contents: Vec<&str> = memory_rendered.as_deref().into_iter().collect();
2106 context::assess(&context::ContextBudgetInputs {
2107 model: &self.model,
2108 provider_window: self.context_window(),
2109 system_prompt: system.as_deref(),
2110 tools_schema: &schema,
2111 messages,
2112 skill_contents: &[],
2113 memory_contents: &memory_contents,
2114 thinking_budget_tokens: self.thinking_budget as u64,
2115 next_tool_result_bytes: self.max_tool_output as u64,
2116 output_reserve_tokens: HelperMethods::max_tokens_for_model(&self.model),
2117 })
2118 }
2119
2120 #[allow(dead_code)] pub(crate) fn hold_memory_contribution(
2138 &self,
2139 contribution: memory_context::MemoryContextContribution,
2140 ) -> std::result::Result<(), memory_context::MemoryContextError> {
2141 let budget = memory_context::memory_budget_tokens(self.context_window())
2142 .ok_or(memory_context::MemoryContextError::RecallBudgetBelowMinimum)?;
2143 memory_context::validate_contribution(
2147 &contribution,
2148 &self.memory_context_project_id(),
2149 budget,
2150 &memory_context::DisclosureGrantSet::model_visible_only(),
2151 )?;
2152 *self
2153 .pending_memory_segment
2154 .lock()
2155 .unwrap_or_else(std::sync::PoisonError::into_inner) =
2156 Some(memory_context::ContextSegment::Memory(contribution));
2157 Ok(())
2158 }
2159
2160 pub(crate) fn clear_memory_contribution(&self) {
2164 *self
2165 .pending_memory_segment
2166 .lock()
2167 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
2168 }
2169
2170 pub fn memory_recall_why(&self) -> Option<memory_context::RecallTurnMetadata> {
2176 self.retained_recall_turn
2177 .lock()
2178 .unwrap_or_else(std::sync::PoisonError::into_inner)
2179 .as_ref()
2180 .map(|retained| retained.why.clone())
2181 }
2182
2183 async fn apply_turn_memory_recall(&self, messages: &mut Vec<crate::SharedMessage>) {
2201 if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
2202 || self
2203 .memory_backend_reconfigure_denied
2204 .load(std::sync::atomic::Ordering::SeqCst)
2205 {
2206 self.clear_memory_contribution();
2207 tracing::debug!("memory recall unavailable for the selected host backend");
2208 return;
2209 }
2210 let extension_runtime = self.extension_runtime.clone();
2211 let binding = self.memory_backend.clone();
2212 let session = self.host_tool_session.clone();
2213 let outcome = memory_context::resolve_turn_recall(
2214 &self.memory_context_state,
2215 &self.retained_recall_turn,
2216 &self.memory_context_project_id(),
2217 self.context_window(),
2218 messages,
2219 memory_context::RECALL_HARD_TIMEOUT,
2220 move |lease: memory_context::MemoryContextLease,
2221 request: memory_context::RecallRequest| async move {
2222 if binding.is_axel() {
2223 return axel_context::recall(binding, lease, request).await;
2224 }
2225 let Some(manager) = extension_runtime else {
2226 return Err(memory_context::RecallCallError::ProviderUnavailable);
2227 };
2228 let mut parts = lease.provider_id.as_str().splitn(3, ':');
2229 let (Some("extension"), Some(plugin), Some(local)) =
2230 (parts.next(), parts.next(), parts.next())
2231 else {
2232 return Err(memory_context::RecallCallError::ProviderUnavailable);
2233 };
2234 let Some(digest) =
2235 manager.declared_tool_digest(plugin, memory_context::MEMORY_RECALL_TOOL_NAME)
2236 else {
2237 return Err(memory_context::RecallCallError::ProviderUnavailable);
2238 };
2239 let mut response =
2240 crate::extensions::lease::ExtensionLeaseCapability::new(session, manager)
2241 .call_exact(
2242 plugin,
2243 memory_context::MEMORY_RECALL_TOOL_NAME,
2244 &digest,
2245 memory_context::recall_request_wire(&request),
2246 )
2247 .await
2248 .map_err(|_| memory_context::RecallCallError::CallFailed)?;
2249 if response["provider_id"] != local
2252 && response["provider_id"] != lease.provider_id.as_str()
2253 {
2254 return Err(memory_context::RecallCallError::CallFailed);
2255 }
2256 response["provider_id"] = json!(lease.provider_id.as_str());
2257 Ok(response)
2258 },
2259 )
2260 .await;
2261 match outcome {
2262 memory_context::TurnRecallOutcome::Injected
2263 | memory_context::TurnRecallOutcome::ReusedRetained => {
2264 let contribution = self
2265 .retained_recall_turn
2266 .lock()
2267 .unwrap_or_else(std::sync::PoisonError::into_inner)
2268 .as_ref()
2269 .map(|retained| retained.contribution.clone());
2270 if let Some(contribution) = contribution {
2271 *self
2272 .pending_memory_segment
2273 .lock()
2274 .unwrap_or_else(std::sync::PoisonError::into_inner) =
2275 Some(memory_context::ContextSegment::Memory(contribution));
2276 }
2277 }
2278 memory_context::TurnRecallOutcome::NotEligible => {
2279 self.clear_memory_contribution();
2280 }
2281 memory_context::TurnRecallOutcome::SkippedOpen(reason) => {
2282 self.clear_memory_contribution();
2283 tracing::debug!(
2286 reason = reason.as_str(),
2287 "memory recall skipped; turn proceeds without memory"
2288 );
2289 }
2290 }
2291 }
2292
2293 #[cfg(any(test, feature = "testing"))]
2303 pub async fn apply_turn_memory_recall_for_harness(
2304 &self,
2305 messages: &mut Vec<crate::SharedMessage>,
2306 ) {
2307 self.apply_turn_memory_recall(messages).await;
2308 }
2309
2310 #[cfg(any(test, feature = "testing"))]
2314 pub fn capture_completed_turn_for_harness(
2315 &self,
2316 messages: Vec<crate::SharedMessage>,
2317 turn_ordinal: u64,
2318 ) -> std::result::Result<(), &'static str> {
2319 let started_at = std::time::SystemTime::now();
2320 let lease = self
2321 .memory_context_lock()
2322 .capture_lease_at(started_at)
2323 .ok_or("capture lease unavailable")?;
2324 let provider = self
2325 .extension_capture_provider(&lease)
2326 .ok_or("capture provider unavailable")?;
2327 let mut history = terminal_capture_history(&lease, &messages, started_at);
2328 history.turn_ordinal = turn_ordinal;
2329 memory_capture_worker()
2330 .submit_terminal(
2331 &lease,
2332 history,
2333 memory_context::RetentionClass::Standard,
2334 provider,
2335 )
2336 .map_err(|_| "capture build failed")?;
2337 Ok(())
2338 }
2339
2340 #[cfg(any(test, feature = "testing"))]
2343 pub fn capture_compaction_summary_for_harness(
2344 &self,
2345 first_turn_ordinal: u64,
2346 last_turn_ordinal: u64,
2347 source_digest: &str,
2348 source_message_count: usize,
2349 summary_text: &str,
2350 ) -> std::result::Result<(), &'static str> {
2351 let summarized_at = std::time::SystemTime::now();
2352 let lease = self
2353 .memory_context_lock()
2354 .capture_lease_at(summarized_at)
2355 .ok_or("capture lease unavailable")?;
2356 let source_digest = chat_capture::MessageRangeDigest::from_hex(source_digest)
2357 .ok_or("invalid source digest")?;
2358 let provider = self
2359 .extension_capture_provider(&lease)
2360 .ok_or("capture provider unavailable")?;
2361 let source = chat_capture::CompactionSource {
2362 schema: chat_capture::CompactionSchemaVersion::V1,
2363 project_id: lease.project_id.clone(),
2364 source_session_id: lease.session_id.clone(),
2365 first_turn_ordinal,
2366 last_turn_ordinal,
2367 source_digest,
2368 summary_origin: chat_capture::CompactionSummaryOrigin::LocalOnly,
2369 prompt_stack_digest: chat_capture::PromptStackDigest::from_bytes([7; 32]),
2370 redaction: chat_capture::RedactionPolicy::HostRedacted,
2371 content_classes: vec![agent_core::compaction::ContentClass::UserText],
2372 summarized_at,
2373 };
2374 memory_capture_worker()
2375 .submit_summary(
2376 &lease,
2377 source,
2378 source_message_count,
2379 summary_text,
2380 agent_core::compaction::RedactionPolicy::PolicyExclusions,
2381 memory_context::RetentionClass::Standard,
2382 provider,
2383 )
2384 .map_err(|_| "summary capture build failed")?;
2385 Ok(())
2386 }
2387
2388 #[cfg(any(test, feature = "testing"))]
2391 pub fn cancel_memory_forwarding_for_harness(&self) {
2392 self.clear_memory_contribution();
2393 self.memory_context_disable();
2394 }
2395
2396 #[cfg(any(test, feature = "testing"))]
2398 pub fn extension_lease_count_for_harness(&self) -> usize {
2399 self.extension_runtime
2400 .as_ref()
2401 .map_or(0, |manager| manager.lease_count())
2402 }
2403
2404 pub(crate) fn inherit_memory_backend(&mut self, binding: crate::memory_backend::MemoryBinding) {
2408 self.memory_backend = binding.fork_for_worker();
2409 }
2410
2411 pub fn apply_memory_backend_config(&mut self, config: &crate::config::MemoryBackendConfig) {
2413 if let Some(selected) = &self.memory_backend_config {
2414 if selected != config {
2415 self.memory_backend_reconfigure_denied
2416 .store(true, std::sync::atomic::Ordering::SeqCst);
2417 tracing::warn!("memory backend change denied: live extension processes cannot be reconfigured at runtime; original binding retained; restart required");
2418 self.memory_context_disable();
2419 self.clear_memory_contribution();
2420 }
2421 return;
2422 }
2423 if config.kind != crate::config::MemoryBackendKind::Legacy {
2424 self.memory_context_disable();
2425 }
2426 self.memory_backend = crate::memory_backend::MemoryBinding::from_config_with_cwd(
2430 config,
2431 self.cwd.clone().or_else(|| std::env::current_dir().ok()),
2432 );
2433 self.memory_backend_config = Some(config.clone());
2434 }
2435
2436 #[cfg(test)]
2437 #[allow(dead_code)] pub(crate) fn memory_backend_for_test(&self) -> crate::memory_backend::MemoryBinding {
2439 self.memory_backend.clone()
2440 }
2441
2442 pub fn memory_backend_is_axel(&self) -> bool {
2447 self.memory_backend.is_axel()
2448 }
2449
2450 pub fn memory_backend_exclusive(&self) -> bool {
2451 self.memory_backend.exclusive()
2452 }
2453
2454 fn memory_context_project_id(&self) -> memory_context::ProjectId {
2455 if self.memory_backend.exclusive() {
2456 self.memory_backend
2457 .scope()
2458 .ok()
2459 .and_then(|scope| memory_context::ProjectId::parse(scope.key()).ok())
2460 .unwrap_or_else(|| {
2461 memory_context::ProjectId::parse("project-unresolved").expect("static id")
2462 })
2463 } else {
2464 memory_project_id()
2465 }
2466 }
2467
2468 pub fn memory_tool_capability(&self) -> Option<memory_context::MemoryContextCapability> {
2469 if !self.memory_backend.exclusive() {
2470 return None;
2471 }
2472 let runtime = self.clone();
2473 Some(memory_context::MemoryContextCapability::control_only(
2474 self.memory_context_state.clone(),
2475 self.memory_context_project_id(),
2476 memory_context::ContextProviderId::parse(axel_context::PROVIDER_ID).expect("static id"),
2477 Arc::new(move || runtime.memory_context_disable()),
2478 ))
2479 }
2480
2481 pub fn apply_config(&mut self, config: &crate::config::SynapsConfig) {
2485 self.apply_config_inner(config, true);
2486 }
2487
2488 pub(crate) fn apply_config_keep_tools(&mut self, config: &crate::config::SynapsConfig) {
2495 self.apply_config_inner(config, false);
2496 }
2497
2498 fn apply_config_inner(&mut self, config: &crate::config::SynapsConfig, disable_tools: bool) {
2499 self.apply_memory_backend_config(&config.memory_backend);
2501 if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
2502 || self
2503 .memory_backend_reconfigure_denied
2504 .load(std::sync::atomic::Ordering::SeqCst)
2505 {
2506 self.memory_context_disable();
2508 self.clear_memory_contribution();
2509 *self
2510 .retained_recall_turn
2511 .lock()
2512 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
2513 }
2514 if let Some(ref model) = config.model {
2515 self.set_model(model.clone());
2516 }
2517 if let Some(level) = config.thinking_level {
2520 self.set_reasoning_level_explicit(level);
2521 } else if let Some(budget) = config.thinking_budget {
2522 self.set_thinking_budget_explicit(budget);
2523 }
2524 if let Some(clamp) = self.clamp_reasoning_to_model() {
2527 tracing::warn!(
2528 from = clamp.from.as_str(),
2529 to = clamp.to.as_str(),
2530 model = %self.model,
2531 "configured reasoning level not supported by model; clamped"
2532 );
2533 }
2534 self.context_window_override = config.context_window;
2535 self.continuation
2536 .lock()
2537 .unwrap_or_else(std::sync::PoisonError::into_inner)
2538 .config = config.context_management;
2539 self.compaction_model = config.compaction_model.clone();
2540 self.compaction_mode = config.compaction_mode;
2541 self.compaction_exclusions = config.compaction_exclude.clone();
2542 self.max_tool_output = config.max_tool_output;
2543 self.bash_timeout = config.bash_timeout;
2544 self.bash_max_timeout = config.bash_max_timeout;
2545 self.subagent_timeout = config.subagent_timeout;
2546 self.api_retries = config.api_retries;
2547 self.refusal_retries = config.refusal_retries;
2548 self.telemetry_level =
2549 crate::runtime::telemetry::TelemetryLevel::from_str_key(&config.telemetry);
2550 self.sync_observability();
2551 self.cache_diagnostics = config.cache_diagnostics;
2552 self.cache_ttl = config.cache_ttl;
2553 self.progressive_tool_disclosure = config.progressive_tool_disclosure;
2554 self.deferred_tool_loading = config.tools_deferred_loading;
2555 self.activation_confirm = config.tools_activation_confirm;
2556 tracing::info!(
2557 mode = config.tools_activation_confirm.as_str(),
2558 auto_approve_confirms = config.server.auto_approve_confirms,
2559 "tools.activation_confirm: model-initiated activate_tools policy"
2560 );
2561 self.trusted_worker_models = config
2562 .favorite_models
2563 .iter()
2564 .filter_map(|model| {
2565 let canonical = canonical_trusted_worker_model(model);
2566 match agent_core::prompt::QualifiedModelId::parse(canonical) {
2567 Ok(model) => Some(model),
2568 Err(_) => {
2569 tracing::warn!(
2570 model = model.as_str(),
2571 "ignoring invalid favorite worker-model identity"
2572 );
2573 None
2574 }
2575 }
2576 })
2577 .collect();
2578 self.trusted_worker_models.sort();
2579 self.trusted_worker_models.dedup();
2580 self.subagent_limits = config.subagent;
2581 if let Some(orchestration) = &self.orchestration {
2582 for model in &self.trusted_worker_models {
2583 if let Err(error) = orchestration.grant_worker_model(model.as_str()) {
2584 tracing::warn!(
2585 model = model.as_str(),
2586 error = %error,
2587 "failed to apply configured worker-model trust"
2588 );
2589 }
2590 }
2591 }
2592 self.apply_auth_config(config);
2593
2594 if disable_tools && !config.disabled_tools.is_empty() {
2598 if let Ok(mut reg) = self.tools.try_write() {
2599 reg.disable(&config.disabled_tools);
2600 }
2601 }
2602 }
2603
2604 pub fn apply_auth_config(&mut self, config: &crate::config::SynapsConfig) {
2612 let new_source = config.auth.credential_source();
2613 if new_source != self.credential_source {
2614 self.token_cache.invalidate("anthropic");
2615 }
2616 self.credential_source = new_source;
2617 if self.credential_source.is_remote() {
2618 if let Ok(mut auth) = self.auth.try_write() {
2619 AuthMethods::scrub_for_remote(&mut auth);
2620 }
2621 }
2622 crate::auth::set_global_broker(crate::auth::broker_from_source(
2627 &self.credential_source,
2628 &self.token_cache,
2629 self.client.clone(),
2630 ));
2631 }
2632
2633 pub fn thinking_budget(&self) -> u32 {
2634 self.thinking_budget
2635 }
2636
2637 pub fn max_tool_output(&self) -> usize {
2638 self.max_tool_output
2639 }
2640
2641 pub fn bash_timeout(&self) -> u64 {
2642 self.bash_timeout
2643 }
2644
2645 pub fn bash_max_timeout(&self) -> u64 {
2646 self.bash_max_timeout
2647 }
2648
2649 pub fn subagent_timeout(&self) -> u64 {
2650 self.subagent_timeout
2651 }
2652
2653 pub fn api_retries(&self) -> u32 {
2654 self.api_retries
2655 }
2656
2657 pub fn set_max_tool_output(&mut self, v: usize) {
2658 self.max_tool_output = v;
2659 }
2660
2661 pub fn set_bash_timeout(&mut self, v: u64) {
2662 self.bash_timeout = v;
2663 }
2664
2665 pub fn set_bash_max_timeout(&mut self, v: u64) {
2666 self.bash_max_timeout = v;
2667 }
2668
2669 pub fn set_subagent_timeout(&mut self, v: u64) {
2670 self.subagent_timeout = v;
2671 }
2672
2673 pub fn set_api_retries(&mut self, v: u32) {
2674 self.api_retries = v;
2675 }
2676
2677 pub fn telemetry_level(&self) -> crate::runtime::telemetry::TelemetryLevel {
2678 self.telemetry_level
2679 }
2680
2681 pub fn set_telemetry_level(&mut self, level: crate::runtime::telemetry::TelemetryLevel) {
2682 self.telemetry_level = level;
2683 self.sync_observability();
2684 }
2685
2686 fn sync_observability(&mut self) {
2693 if self.telemetry_level.enabled() {
2694 if self.telemetry_writer.is_none() {
2695 let writer = crate::runtime::telemetry::TelemetryWriter::new(
2696 crate::runtime::telemetry::WriterOptions::default(),
2697 );
2698 self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
2699 crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
2700 ));
2701 self.telemetry_writer = Some(writer);
2702 }
2703 } else {
2704 self.telemetry_writer = None;
2708 self.trace_ctx = trace::TraceContext::disabled();
2709 }
2710 }
2711
2712 pub fn trace_context(&self) -> trace::TraceContext {
2714 self.trace_ctx.clone()
2715 }
2716
2717 pub(crate) fn effective_trace_context(&self) -> trace::TraceContext {
2738 match self.trace_controls.consume() {
2739 None => self.trace_ctx.clone(),
2740 Some(with_content) => {
2741 let base = if self.trace_ctx.enabled() {
2742 self.trace_ctx.clone()
2743 } else {
2744 let writer = crate::runtime::telemetry::TelemetryWriter::new(
2745 crate::runtime::telemetry::WriterOptions::default(),
2746 );
2747 *self
2751 .one_shot_trace_writer
2752 .lock()
2753 .expect("one-shot trace writer lock poisoned") = Some(writer.clone());
2754 self.trace_ctx
2755 .fork_with_sink(Arc::new(crate::runtime::telemetry::WriterTraceSink::new(
2756 writer,
2757 )))
2758 .with_one_shot_request_gate()
2759 };
2760 if with_content {
2761 base.with_content_capture(Arc::new(trace::ContentCapture::new(
2762 self.capture_dir.clone(),
2763 )))
2764 } else {
2765 base
2766 }
2767 }
2768 }
2769 }
2770
2771 pub fn trace_arm_next(&self, with_content: bool) {
2775 self.trace_controls.arm_next(with_content);
2776 }
2777
2778 pub fn trace_status(&self) -> trace::TraceStatusReport {
2783 let _ = trace::sweep_expired_captures(&self.capture_dir);
2784 trace::TraceStatusReport {
2785 persistent_enabled: self.telemetry_writer.is_some(),
2786 arm: self.trace_controls.peek(),
2787 trace_path: crate::runtime::telemetry::default_trace_log_path(),
2788 writer_stats: self.telemetry_writer.as_ref().map(|w| w.stats()),
2789 degraded_records: self.trace_ctx.degraded_records(),
2790 }
2791 }
2792
2793 fn memory_context_lock(&self) -> std::sync::MutexGuard<'_, memory_context::SessionMemoryState> {
2800 self.memory_context_state
2801 .lock()
2802 .unwrap_or_else(std::sync::PoisonError::into_inner)
2803 }
2804
2805 pub fn memory_context_status(&self) -> memory_context::MemoryContextStatus {
2808 self.memory_context_lock().status()
2809 }
2810
2811 pub fn memory_context_disable(&self) -> memory_context::MemoryContextStatus {
2825 let (status, bound, session) = {
2826 let mut state = self.memory_context_lock();
2827 let session = state.session_id().clone();
2828 let bound = state.bound_provider_ids();
2829 let status = match memory_context::apply_memory_context_action(
2830 &mut state,
2831 memory_context::AuthorizedMemoryAction::Disable {
2832 session: session.clone(),
2833 },
2834 ) {
2835 Ok(status) => status,
2836 Err(_) => state.status(),
2840 };
2841 (status, bound, session)
2842 };
2843 memory_context::emit_memory_observability_event(
2846 &memory_context::MemoryObservabilityEvent::context_disabled(
2847 &session,
2848 &self.memory_context_project_id(),
2849 ),
2850 );
2851 self.revoke_memory_provider_leases(&bound);
2855 status
2856 }
2857
2858 fn revoke_memory_provider_leases(&self, bound: &[memory_context::ContextProviderId]) {
2866 let Some(extension_runtime) = &self.extension_runtime else {
2867 return;
2868 };
2869 for provider in bound {
2870 let mut parts = provider.as_str().splitn(3, ':');
2871 if let (Some("extension"), Some(plugin), Some(_local_id)) =
2872 (parts.next(), parts.next(), parts.next())
2873 {
2874 if !plugin.is_empty() {
2875 extension_runtime.revoke_plugin_lease(&self.host_tool_session, plugin);
2876 }
2877 }
2878 }
2879 }
2880
2881 fn resolve_memory_provider(
2901 &self,
2902 requested: Option<&str>,
2903 ) -> std::result::Result<memory_context::ContextProviderId, memory_context::MemoryContextError>
2904 {
2905 use crate::extensions::context_provider as ext_cp;
2906 if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
2907 || self
2908 .memory_backend_reconfigure_denied
2909 .load(std::sync::atomic::Ordering::SeqCst)
2910 {
2911 return Err(memory_context::MemoryContextError::ProviderNotRegistered);
2913 }
2914 if self.memory_backend.is_axel() {
2915 self.memory_backend
2916 .validate_axel_config()
2917 .map_err(|_| memory_context::MemoryContextError::ProviderNotRegistered)?;
2918 if requested.is_some_and(|id| id != axel_context::PROVIDER_ID) {
2919 return Err(memory_context::MemoryContextError::ProviderNotRegistered);
2920 }
2921 return memory_context::ContextProviderId::parse(axel_context::PROVIDER_ID);
2922 }
2923 let Some(extension_runtime) = &self.extension_runtime else {
2924 return Ok(memory_provider_id());
2925 };
2926 let requested_id = match requested {
2927 None => None,
2928 Some(raw) => Some(
2931 ext_cp::ContextProviderId::parse(raw)
2932 .map_err(|_| memory_context::MemoryContextError::ProviderNotRegistered)?,
2933 ),
2934 };
2935 match extension_runtime.resolve_context_provider(requested_id.as_ref()) {
2936 Ok(descriptor) => {
2937 memory_context::ContextProviderId::parse(&descriptor.runtime_address())
2938 }
2939 Err(ext_cp::ContextProviderLookupError::NotRegistered) => {
2940 Err(memory_context::MemoryContextError::ProviderNotRegistered)
2941 }
2942 Err(ext_cp::ContextProviderLookupError::Ambiguous) => {
2943 Err(memory_context::MemoryContextError::ProviderAmbiguous)
2944 }
2945 }
2946 }
2947
2948 pub(crate) fn memory_context_enable(
2956 &self,
2957 mode: memory_context::MemoryContextMode,
2958 proof: memory_context::UserIntentProof,
2959 ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
2960 {
2961 self.memory_context_enable_resolved(mode, proof, None)
2962 }
2963
2964 pub(crate) fn memory_context_enable_resolved(
2969 &self,
2970 mode: memory_context::MemoryContextMode,
2971 proof: memory_context::UserIntentProof,
2972 requested_provider: Option<&str>,
2973 ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
2974 {
2975 let mut state = self.memory_context_lock();
2976 let lease = self.grant_command_memory_lease(&state, mode, proof, requested_provider)?;
2977 let enabled_event = memory_context::MemoryObservabilityEvent::context_enabled(&lease);
2981 let result = memory_context::apply_memory_context_action(
2982 &mut state,
2983 memory_context::AuthorizedMemoryAction::Enable { lease },
2984 );
2985 if result.is_ok() {
2986 memory_context::emit_memory_observability_event(&enabled_event);
2987 }
2988 result
2989 }
2990
2991 pub(crate) fn memory_context_recall_once(
2996 &self,
2997 proof: memory_context::UserIntentProof,
2998 ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
2999 {
3000 let mut state = self.memory_context_lock();
3001 let lease = self.grant_command_memory_lease(
3002 &state,
3003 memory_context::MemoryContextMode::RecallOnce,
3004 proof,
3005 None,
3006 )?;
3007 let enabled_event = memory_context::MemoryObservabilityEvent::context_enabled(&lease);
3010 let result = memory_context::apply_memory_context_action(
3011 &mut state,
3012 memory_context::AuthorizedMemoryAction::RecallOnce { lease },
3013 );
3014 if result.is_ok() {
3015 memory_context::emit_memory_observability_event(&enabled_event);
3016 }
3017 result
3018 }
3019
3020 fn grant_command_memory_lease(
3027 &self,
3028 state: &memory_context::SessionMemoryState,
3029 mode: memory_context::MemoryContextMode,
3030 proof: memory_context::UserIntentProof,
3031 requested_provider: Option<&str>,
3032 ) -> std::result::Result<memory_context::MemoryContextLease, memory_context::MemoryContextError>
3033 {
3034 let provider_id = self.resolve_memory_provider(requested_provider)?;
3035 memory_context::MemoryContextLease::grant(
3036 memory_context::MemoryLeaseId::parse(&format!("memctx-cmd-{}", uuid::Uuid::new_v4()))?,
3037 state.session_id().clone(),
3038 self.memory_context_project_id(),
3039 provider_id,
3040 mode,
3041 memory_context::CapturePolicy::default(),
3042 memory_context::RecallPolicy::default(),
3043 proof,
3044 std::time::SystemTime::now(),
3045 None,
3046 )
3047 }
3048
3049 #[cfg(test)]
3050 pub(crate) fn set_capture_provider_for_test(
3051 &self,
3052 provider: std::sync::Arc<dyn capture_worker::CaptureProvider>,
3053 ) {
3054 *self
3055 .capture_provider_for_test
3056 .lock()
3057 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(provider);
3058 }
3059
3060 pub(crate) fn compaction_capture_lease_at(
3061 &self,
3062 now: std::time::SystemTime,
3063 ) -> Option<memory_context::MemoryContextLease> {
3064 self.memory_context_lock().capture_lease_at(now)
3065 }
3066
3067 pub(crate) fn submit_compaction_summary_capture(
3068 &self,
3069 capture_lease: Option<memory_context::MemoryContextLease>,
3070 current: &agent_core::session::Session,
3071 api_messages: &[crate::SharedMessage],
3072 outcome: &compaction::CompactionOutcome,
3073 summarized_at: std::time::SystemTime,
3074 ) {
3075 let Some(lease) = capture_lease else {
3076 return;
3077 };
3078 let Ok(source_session_id) = memory_context::SessionId::parse(¤t.id) else {
3079 return;
3080 };
3081 if api_messages.is_empty() {
3086 return;
3087 }
3088 let first_turn_ordinal = 0;
3089 let last_turn_ordinal = (api_messages.len() - 1) as u64;
3090 let Some(source_digest) = chat_capture::MessageRangeDigest::from_hex(
3091 &agent_core::compaction::message_range_digest(api_messages),
3092 ) else {
3093 return;
3094 };
3095 let Some(prompt_stack_digest) =
3096 chat_capture::PromptStackDigest::from_hex(&outcome.prompt_stack_digest)
3097 else {
3098 return;
3099 };
3100 let summary_origin = if outcome.local_only {
3101 chat_capture::CompactionSummaryOrigin::LocalOnly
3102 } else {
3103 chat_capture::CompactionSummaryOrigin::Provider {
3104 provider_id: outcome.summary_provider.clone(),
3105 model_id: outcome.summary_model.clone(),
3106 }
3107 };
3108 let content_classes = outcome.included_classes.clone();
3109 let source = chat_capture::CompactionSource {
3110 schema: chat_capture::CompactionSchemaVersion::V1,
3111 project_id: lease.project_id.clone(),
3112 source_session_id,
3113 first_turn_ordinal,
3114 last_turn_ordinal,
3115 source_digest,
3116 summary_origin,
3117 prompt_stack_digest,
3118 redaction: match outcome.redaction_policy {
3119 agent_core::compaction::RedactionPolicy::TruncationOnly => {
3120 chat_capture::RedactionPolicy::None
3121 }
3122 agent_core::compaction::RedactionPolicy::PolicyExclusions => {
3123 chat_capture::RedactionPolicy::HostRedacted
3124 }
3125 },
3126 content_classes,
3127 summarized_at,
3128 };
3129
3130 #[cfg(any(test, feature = "testing"))]
3131 let test_provider = self
3132 .capture_provider_for_test
3133 .lock()
3134 .unwrap_or_else(std::sync::PoisonError::into_inner)
3135 .clone();
3136 #[cfg(not(any(test, feature = "testing")))]
3137 let test_provider: Option<std::sync::Arc<dyn capture_worker::CaptureProvider>> = None;
3138 let provider = test_provider.or_else(|| self.extension_capture_provider(&lease));
3139 if let Some(provider) = provider {
3140 let _ = memory_capture_worker().submit_summary(
3141 &lease,
3142 source,
3143 api_messages.len(),
3144 &outcome.summary_text,
3145 outcome.redaction_policy,
3146 memory_context::RetentionClass::Standard,
3147 provider,
3148 );
3149 }
3150 }
3151
3152 fn extension_capture_provider(
3153 &self,
3154 lease: &memory_context::MemoryContextLease,
3155 ) -> Option<std::sync::Arc<dyn capture_worker::CaptureProvider>> {
3156 if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
3157 || self
3158 .memory_backend_reconfigure_denied
3159 .load(std::sync::atomic::Ordering::SeqCst)
3160 {
3161 return None;
3162 }
3163 if self.memory_backend.is_axel() {
3164 if lease.provider_id.as_str() != axel_context::PROVIDER_ID
3165 || lease.project_id != self.memory_context_project_id()
3166 {
3167 return None;
3168 }
3169 return Some(Arc::new(axel_context::AxelCaptureProvider {
3170 binding: self.memory_backend.clone(),
3171 state: self.memory_context_state.clone(),
3172 lease: lease.clone(),
3173 }));
3174 }
3175 let manager = self.extension_runtime.clone()?;
3176 let mut parts = lease.provider_id.as_str().splitn(3, ':');
3177 let (Some("extension"), Some(plugin), Some(_)) = (parts.next(), parts.next(), parts.next())
3178 else {
3179 return None;
3180 };
3181 let digest =
3182 manager.declared_tool_digest(plugin, memory_context::MEMORY_CAPTURE_TOOL_NAME)?;
3183 let handle = tokio::runtime::Handle::try_current().ok()?;
3184 Some(std::sync::Arc::new(ExtensionCaptureProvider {
3185 manager,
3186 session: self.host_tool_session.clone(),
3187 plugin: plugin.to_owned(),
3188 digest,
3189 handle,
3190 }))
3191 }
3192
3193 pub fn memory_history_preview(
3197 &self,
3198 ) -> std::result::Result<memory_history::HistoryImportPreview, memory_history::HistoryImportError>
3199 {
3200 let host = memory_history::HistoryImportHostState::from_current_host()?;
3201 let mut io = memory_history::CanonicalHistoryMetadataIo::new();
3202 let preview = memory_history::preview_history_import(&host, &mut io)?;
3203 *self
3204 .pending_history_import_preview
3205 .lock()
3206 .expect("history preview mutex poisoned") = Some(preview.clone());
3207 Ok(preview)
3208 }
3209
3210 pub(crate) fn memory_history_confirm(
3214 &self,
3215 ) -> std::result::Result<memory_history::HistoryImportReport, memory_history::HistoryImportError>
3216 {
3217 let preview = self
3218 .pending_history_import_preview
3219 .lock()
3220 .expect("history preview mutex poisoned")
3221 .take()
3222 .ok_or(memory_history::HistoryImportError::ConsentRequired)?;
3223 let intent = memory_history::ConfirmedUserIntent::from_explicit_command(
3224 memory_context::mint_explicit_command_id(),
3225 );
3226 let mut io = memory_history::CanonicalHistoryMetadataIo::new();
3227 match memory_history::authorize_history_import(
3228 preview,
3229 memory_history::HistoryImportConsent::Confirmed(intent),
3230 memory_history::RequestAuthority::UserFrontend,
3231 &mut io,
3232 )? {
3233 memory_history::HistoryImportOutcome::Ready(plan) => {
3234 *self
3235 .history_import_plan
3236 .lock()
3237 .expect("history plan mutex poisoned") = Some(plan.clone());
3238 let lease = self
3239 .memory_context_lock()
3240 .capture_lease_at(std::time::SystemTime::now())
3241 .ok_or(memory_history::HistoryImportError::CaptureLeaseUnavailable)?;
3242 let provider = self
3245 .extension_capture_provider(&lease)
3246 .ok_or(memory_history::HistoryImportError::CaptureProviderUnavailable)?;
3247 let sessions_dir = agent_core::config::get_active_config_dir().join("sessions");
3248 memory_history::import_history_from_dir(
3249 &plan,
3250 &lease,
3251 &sessions_dir,
3252 memory_history::IMPORT_BATCH_MAX_RECORDS,
3253 memory_capture_worker(),
3254 provider,
3255 )
3256 }
3257 memory_history::HistoryImportOutcome::Declined => {
3258 Err(memory_history::HistoryImportError::ConsentRequired)
3259 }
3260 }
3261 }
3262
3263 pub(crate) fn memory_history_decline(&self) -> bool {
3265 self.pending_history_import_preview
3266 .lock()
3267 .expect("history preview mutex poisoned")
3268 .take()
3269 .is_some()
3270 }
3271
3272 #[cfg(test)]
3274 pub(crate) fn memory_durable_proof_for_test(&self) -> Option<memory_context::UserIntentProof> {
3275 self.memory_context_lock().durable_proof().cloned()
3276 }
3277
3278 #[cfg(test)]
3280 pub(crate) fn memory_one_shot_proof_for_test(&self) -> Option<memory_context::UserIntentProof> {
3281 self.memory_context_lock().one_shot_pending_proof().cloned()
3282 }
3283
3284 #[cfg(test)]
3287 pub(crate) fn memory_bound_providers_for_test(&self) -> Vec<memory_context::ContextProviderId> {
3288 self.memory_context_lock().bound_provider_ids()
3289 }
3290
3291 pub fn context_report(&self, history: Option<&[crate::SharedMessage]>) -> trace::ContextReport {
3300 use trace::ReportValue;
3301 let (history_messages, history_bytes) = match history {
3302 Some(msgs) => (
3303 ReportValue::Count(msgs.len() as u64),
3304 ReportValue::Count(
3305 msgs.iter()
3306 .map(|m| {
3307 serde_json::to_vec(&**m)
3308 .map(|v| v.len() as u64)
3309 .unwrap_or(0)
3310 })
3311 .sum(),
3312 ),
3313 ),
3314 None => (ReportValue::Unavailable, ReportValue::Unavailable),
3315 };
3316 let tool_count = match self.tools.try_read() {
3319 Ok(tools) => ReportValue::Count(tools.iter_tools_sorted().len() as u64),
3320 Err(_) => ReportValue::Unavailable,
3321 };
3322 trace::ContextReport {
3323 model: self.model.clone(),
3324 system_prompt_bytes: ReportValue::Count(
3325 self.system_prompt().map(|s| s.len() as u64).unwrap_or(0),
3326 ),
3327 tool_count,
3328 history_messages,
3329 history_bytes,
3330 loaded_skills: ReportValue::Unavailable,
3331 loaded_memories: ReportValue::Unavailable,
3332 cache: self.trace_ctx.cache_snapshots().last_activity(),
3333 trace_enabled: self.trace_ctx.enabled(),
3334 writer_stats: self.telemetry_writer.as_ref().map(|w| w.stats()),
3335 degraded_records: self.trace_ctx.degraded_records(),
3336 }
3337 }
3338
3339 pub fn telemetry_writer(&self) -> Option<crate::runtime::telemetry::TelemetryWriter> {
3341 self.telemetry_writer.clone()
3342 }
3343
3344 pub fn shutdown_observability(
3358 &self,
3359 timeout: std::time::Duration,
3360 ) -> Option<crate::runtime::telemetry::ShutdownOutcome> {
3361 let _ = trace::sweep_expired_captures(&self.capture_dir);
3366 let ephemeral = self
3372 .one_shot_trace_writer
3373 .lock()
3374 .expect("one-shot trace writer lock poisoned")
3375 .take();
3376 let ephemeral_outcome = ephemeral.map(|w| w.shutdown(timeout));
3377 self.telemetry_writer
3378 .as_ref()
3379 .map(|w| w.shutdown(timeout))
3380 .or(ephemeral_outcome)
3381 }
3382
3383 pub async fn shutdown_observability_async(
3396 &self,
3397 timeout: std::time::Duration,
3398 ) -> Option<crate::runtime::telemetry::ShutdownOutcome> {
3399 let capture_dir = self.capture_dir.clone();
3403 let _ =
3404 tokio::task::spawn_blocking(move || trace::sweep_expired_captures(&capture_dir)).await;
3405 let ephemeral = self
3407 .one_shot_trace_writer
3408 .lock()
3409 .expect("one-shot trace writer lock poisoned")
3410 .take();
3411 let ephemeral_outcome = match ephemeral {
3412 Some(writer) => Some(writer.shutdown_async(timeout).await),
3413 None => None,
3414 };
3415 match self.telemetry_writer.clone() {
3416 Some(writer) => Some(writer.shutdown_async(timeout).await),
3417 None => ephemeral_outcome,
3418 }
3419 }
3420
3421 #[cfg(any(test, feature = "testing"))]
3425 pub fn install_observability_for_tests(
3426 &mut self,
3427 writer: crate::runtime::telemetry::TelemetryWriter,
3428 ) {
3429 self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
3430 crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
3431 ));
3432 self.telemetry_writer = Some(writer);
3433 }
3434
3435 pub fn cache_diagnostics(&self) -> bool {
3436 self.cache_diagnostics
3437 }
3438
3439 pub fn set_cache_diagnostics(&mut self, v: bool) {
3440 self.cache_diagnostics = v;
3441 }
3442
3443 pub fn cache_ttl(&self) -> crate::core::config::CacheTtl {
3444 self.cache_ttl
3445 }
3446
3447 pub fn share_ttl_latches(&mut self, parent: &Runtime) {
3451 self.ttl_downgrade_notified = parent.ttl_downgrade_notified.clone();
3452 self.saw_1h_honored = parent.saw_1h_honored.clone();
3453 self.cache_anchor = parent.cache_anchor.clone();
3454 }
3455
3456 pub fn set_cache_ttl(&mut self, ttl: crate::core::config::CacheTtl) {
3460 self.cache_ttl = ttl;
3461 }
3462
3463 pub fn thinking_level(&self) -> &str {
3464 match self.named_level {
3467 Some(level) => level.as_str(),
3468 None => crate::core::models::thinking_level_for_budget(self.thinking_budget),
3469 }
3470 }
3471
3472 pub async fn refresh_if_needed(&self) -> Result<()> {
3474 self.refresh_if_needed_for_model(&self.model).await
3475 }
3476
3477 async fn refresh_if_needed_for_model(&self, model: &str) -> Result<()> {
3478 if !crate::runtime::auth::model_is_anthropic(model) {
3481 return Ok(());
3482 }
3483 AuthMethods::refresh_if_needed(
3484 Arc::clone(&self.auth),
3485 &self.client,
3486 &self.credential_source,
3487 &self.token_cache,
3488 )
3489 .await
3490 }
3491
3492 pub async fn compact_call(&self, messages: Vec<crate::SharedMessage>) -> Result<String> {
3498 self.remote_summarization_attempts
3501 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3502 let model = self.compaction_model();
3503 self.validate_request_preflight_for(
3504 model,
3505 crate::runtime::openai::catalog::CodexRequestRole::Internal,
3506 )
3507 .await?;
3508 self.refresh_if_needed_for_model(model).await?;
3509
3510 use crate::runtime::compaction::COMPACTION_SYSTEM_PROMPT;
3511
3512 ApiMethods::call_api_simple(
3513 &self.auth,
3514 &self.client,
3515 model,
3516 COMPACTION_SYSTEM_PROMPT,
3517 self.thinking_budget,
3518 self.reasoning_level(),
3519 &messages,
3520 self.api_retries,
3521 &api::ApiOptions {
3527 trace: self.trace_ctx.clone(),
3528 telemetry: self.telemetry_writer.clone(),
3529 ..api::ApiOptions::default()
3530 },
3531 )
3532 .await
3533 }
3534
3535 pub async fn run_single(&self, prompt: &str) -> Result<String> {
3538 if self
3539 .continuation
3540 .lock()
3541 .unwrap_or_else(std::sync::PoisonError::into_inner)
3542 .durability_blocked
3543 {
3544 return Err(RuntimeError::Session(
3545 "context head save is unresolved; reload the session before further inference"
3546 .into(),
3547 ));
3548 }
3549 self.validate_request_preflight().await?;
3550 let anthropic_execution_plan = self.authorized_anthropic_plan().await?;
3551 self.refresh_if_needed().await?;
3553
3554 let mut messages: Vec<crate::SharedMessage> = vec![std::sync::Arc::new(
3555 json!({"role": "user", "content": prompt}),
3556 )];
3557 let system_prompt = self.effective_system_prompt().await;
3558
3559 loop {
3560 let response = ApiMethods::call_api(
3561 &self.auth,
3562 &self.client,
3563 &self.model,
3564 &*self.tools.read().await,
3565 &system_prompt,
3566 self.thinking_budget,
3567 self.reasoning_level(),
3568 &messages,
3569 self.api_retries,
3570 &api::ApiOptions {
3571 memory_backend: None,
3572 use_1m_context: self.context_window_override == Some(1_000_000),
3573 cache_ttl: self.cache_ttl,
3574 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
3575 saw_1h_honored: self.saw_1h_honored.clone(),
3576 credential_source: self.credential_source.clone(),
3577 token_cache: self.token_cache.clone(),
3578 anthropic_base_url: None,
3579 anthropic_execution_plan: anthropic_execution_plan.clone(),
3580 codex_request_role: self.codex_request_role(),
3581 trace: self.effective_trace_context(),
3586 request_correlation: None,
3587 suppress_stream_deltas: true,
3588 telemetry: self.telemetry_writer.clone(),
3589 tool_session_id: Some(self.host_tool_session.clone()),
3596 session_tool_set: None,
3600 request_tools_schema: None,
3601 anthropic_tool_split: None,
3602 usage_counters: None,
3603 cache_anchor: None,
3604 keepwarm: None,
3605 },
3606 )
3607 .await?;
3608
3609 if let Some(content) = response["content"].as_array() {
3611 let mut response_text = String::new();
3612 let mut tool_uses = Vec::new();
3613
3614 for item in content {
3616 match item["type"].as_str() {
3617 Some("text") => {
3618 if let Some(text) = item["text"].as_str() {
3619 response_text.push_str(text);
3620 }
3621 }
3622 Some("tool_use") => {
3623 tool_uses.push(item.clone());
3624 }
3625 _ => {}
3626 }
3627 }
3628
3629 if tool_uses.is_empty() {
3631 if let Some(orchestration) = &self.orchestration {
3632 if let agent_core::orchestration::CompletionGate::Blocked { workers } =
3633 orchestration.completion_gate()
3634 {
3635 return Err(crate::RuntimeError::Tool(format!(
3636 "completion blocked: {} worker(s) require collection/reconciliation: {} (call subagent_collect with reconciled=true after inspecting each result)",
3637 workers.len(),
3638 workers.join(", ")
3639 )));
3640 }
3641 }
3642 return Ok(response_text);
3643 }
3644
3645 messages.push(std::sync::Arc::new(json!({
3647 "role": "assistant",
3648 "content": content
3649 })));
3650
3651 let mut tool_results = Vec::new();
3653
3654 if tool_uses.len() == 1 {
3655 let tool_use = &tool_uses[0];
3657 if let (Some(tool_name), Some(tool_id)) =
3658 (tool_use["name"].as_str(), tool_use["id"].as_str())
3659 {
3660 let input = &tool_use["input"];
3661 let (result, rich_blocks) = match self.tools.read().await.get(tool_name).cloned() {
3662 Some(tool) => {
3663 let input = self
3664 .tools
3665 .read()
3666 .await
3667 .translate_input_for_api_tool(tool_name, input.clone());
3668 let runtime_name = self
3669 .tools
3670 .read()
3671 .await
3672 .runtime_name_for_api(tool_name)
3673 .to_string();
3674 let ctx = crate::ToolContext {
3675 channels: crate::tools::ToolChannels {
3676 tx_delta: None,
3677 tx_events: None,
3678 },
3679 capabilities: crate::tools::ToolCapabilities {
3680 launch_cancel: None,
3681 memory_backend: Some(self.memory_backend.clone()),
3682 watcher_exit_path: self.watcher_exit_path.clone(),
3683 tool_register_tx: None,
3684 session_manager: Some(self.session_manager.clone()),
3685 subagent_registry: Some(self.subagent_registry.clone()),
3686 delegation_parent: None,
3687 codex_parent_plan: Self::codex_delegation_plan(
3688 &self.model,
3689 self.reasoning_level(),
3690 self.codex_request_role(),
3691 ),
3692 event_queue: Some(self.event_queue.clone()),
3693 secret_prompt: None,
3694 orchestration: self.orchestration.clone(),
3695 tool_activation: None,
3696 mcp_leases: None,
3697 extension_leases: None,
3698 memory_context: self.memory_tool_capability(),
3699 cwd: self.cwd.clone(),
3700 env: self.env.clone(),
3701 env_stripped: self.env_stripped.clone(),
3702 env_warned: self.env_warned.clone(),
3703 },
3704 limits: crate::tools::ToolLimits {
3705 max_tool_output: self.max_tool_output,
3706 max_tool_buffer: 256 * 1024,
3707 bash_timeout: self.bash_timeout,
3708 bash_max_timeout: self.bash_max_timeout,
3709 subagent_timeout: self.subagent_timeout,
3710 },
3711 };
3712 let decision = resolve_before_tool_call_decision(
3713 input.clone(),
3714 emit_before_tool_call(
3715 &self.hook_bus,
3716 tool_name,
3717 Some(&runtime_name),
3718 input.clone(),
3719 self.session_id.as_deref(),
3720 )
3721 .await,
3722 None,
3723 false,
3724 )
3725 .await;
3726 if let BeforeToolCallDecision::Block { reason } = decision {
3727 (format!("Tool call blocked by extension: {}", reason), None)
3728 } else {
3729 let BeforeToolCallDecision::Continue { input } = decision
3730 else {
3731 unreachable!()
3732 };
3733 let input_for_hook = input.clone();
3734 let (output, rich_blocks) = match tool.execute_rich(input, ctx).await {
3735 Ok(output) => validated_single_tool_output(&self.model, output),
3736 Err(e) => (e.to_string(), None),
3737 };
3738 let hooked_output = emit_after_tool_call(
3739 &self.hook_bus,
3740 tool_name,
3741 Some(&runtime_name),
3742 input_for_hook,
3743 output.clone(),
3744 self.max_tool_output,
3745 self.session_id.as_deref(),
3746 )
3747 .await;
3748 let rich_blocks = retain_single_tool_blocks(rich_blocks, &output, &hooked_output);
3749 (hooked_output, rich_blocks)
3750 }
3751 }
3752 None => (format!("Unknown tool: {}", tool_name), None),
3753 };
3754 tool_results.push(json!({
3755 "type": "tool_result",
3756 "tool_use_id": tool_id,
3757 "content": single_tool_result_content(&result, rich_blocks, self.max_tool_output)
3758 }));
3759 }
3760 } else {
3761 let mut join_set = tokio::task::JoinSet::new();
3763
3764 let cfg_max_tool_output = self.max_tool_output;
3766 let cfg_bash_timeout = self.bash_timeout;
3767 let cfg_bash_max_timeout = self.bash_max_timeout;
3768 let cfg_subagent_timeout = self.subagent_timeout;
3769 let session_mgr = self.session_manager.clone();
3770 let cfg_subagent_registry = self.subagent_registry.clone();
3771 let cfg_event_queue = self.event_queue.clone();
3772 let cfg_hook_bus = self.hook_bus.clone();
3773 let cfg_orchestration = self.orchestration.clone();
3774 let codex_parent_plan = Self::codex_delegation_plan(
3775 &self.model,
3776 self.reasoning_level(),
3777 self.codex_request_role(),
3778 );
3779 let cfg_cwd = self.cwd.clone();
3780 let cfg_env = self.env.clone();
3781 let cfg_session_id = self.session_id.clone();
3782
3783 for tool_use in &tool_uses {
3784 if let (Some(tool_name), Some(tool_id)) = (
3785 tool_use["name"].as_str().map(|s| s.to_string()),
3786 tool_use["id"].as_str().map(|s| s.to_string()),
3787 ) {
3788 let input = tool_use["input"].clone();
3789 let tools_snapshot = self.tools.read().await;
3790 let input =
3791 tools_snapshot.translate_input_for_api_tool(&tool_name, input);
3792 let runtime_name =
3793 tools_snapshot.runtime_name_for_api(&tool_name).to_string();
3794 let tool = tools_snapshot.get(&tool_name).cloned();
3795 drop(tools_snapshot);
3796 let exit_path = self.watcher_exit_path.clone();
3797 let session_mgr_inner = session_mgr.clone();
3798 let registry_inner = cfg_subagent_registry.clone();
3799 let event_queue_inner = cfg_event_queue.clone();
3800 let hook_bus_inner = cfg_hook_bus.clone();
3801 let orchestration_inner = cfg_orchestration.clone();
3802 let memory_backend_inner = self.memory_backend.clone();
3803 let memory_context_inner = self.memory_tool_capability();
3804 let codex_parent_plan_inner = codex_parent_plan.clone();
3805 let cwd_inner = cfg_cwd.clone();
3806 let env_inner = cfg_env.clone();
3807 let env_stripped_inner = self.env_stripped.clone();
3808 let env_warned_inner = self.env_warned.clone();
3809 let session_id_inner = cfg_session_id.clone();
3810 let attachment_model = self.model.clone();
3811 let tool_name_for_hook = tool_name.clone();
3812 let runtime_name_for_hook = runtime_name.clone();
3813
3814 join_set.spawn(async move {
3815 let result = match tool {
3816 Some(t) => {
3817 let decision =
3818 crate::runtime::resolve_before_tool_call_decision(
3819 input.clone(),
3820 crate::runtime::emit_before_tool_call(
3821 &hook_bus_inner,
3822 &tool_name_for_hook,
3823 Some(&runtime_name_for_hook),
3824 input.clone(),
3825 session_id_inner.as_deref(),
3826 )
3827 .await,
3828 None,
3829 false,
3830 )
3831 .await;
3832 if let crate::runtime::BeforeToolCallDecision::Block {
3833 reason,
3834 } = decision
3835 {
3836 (format!("Tool call blocked by extension: {}", reason), None)
3837 } else {
3838 let crate::runtime::BeforeToolCallDecision::Continue {
3839 input,
3840 } = decision
3841 else {
3842 unreachable!()
3843 };
3844 let ctx = crate::ToolContext {
3845 channels: crate::tools::ToolChannels {
3846 tx_delta: None,
3847 tx_events: None,
3848 },
3849 capabilities: crate::tools::ToolCapabilities {
3850 launch_cancel: None,
3851 memory_backend: Some(memory_backend_inner),
3852 watcher_exit_path: exit_path,
3853 tool_register_tx: None,
3854 session_manager: Some(session_mgr_inner),
3855 subagent_registry: Some(registry_inner),
3856 delegation_parent: None,
3857 codex_parent_plan: codex_parent_plan_inner,
3858 event_queue: Some(event_queue_inner),
3859 secret_prompt: None,
3860 orchestration: orchestration_inner,
3861 tool_activation: None,
3862 mcp_leases: None,
3863 extension_leases: None,
3864 memory_context: memory_context_inner,
3865 cwd: cwd_inner,
3866 env: env_inner,
3867 env_stripped: env_stripped_inner,
3868 env_warned: env_warned_inner,
3869 },
3870 limits: crate::tools::ToolLimits {
3871 max_tool_output: cfg_max_tool_output,
3872 max_tool_buffer: 256 * 1024,
3873 bash_timeout: cfg_bash_timeout,
3874 bash_max_timeout: cfg_bash_max_timeout,
3875 subagent_timeout: cfg_subagent_timeout,
3876 },
3877 };
3878 let input_for_hook = input.clone();
3879 let (output, rich_blocks) =
3880 match t.execute_rich(input, ctx).await {
3881 Ok(output) => validated_single_tool_output(
3882 &attachment_model,
3883 output,
3884 ),
3885 Err(e) => (e.to_string(), None),
3886 };
3887 let hooked_output =
3888 crate::runtime::emit_after_tool_call(
3889 &hook_bus_inner,
3890 &tool_name_for_hook,
3891 Some(&runtime_name_for_hook),
3892 input_for_hook,
3893 output.clone(),
3894 cfg_max_tool_output,
3895 session_id_inner.as_deref(),
3896 )
3897 .await;
3898 let rich_blocks = retain_single_tool_blocks(
3899 rich_blocks,
3900 &output,
3901 &hooked_output,
3902 );
3903 (hooked_output, rich_blocks)
3904 }
3905 }
3906 None => (format!("Unknown tool: {}", tool_name), None),
3907 };
3908 (tool_id, result)
3909 });
3910 }
3911 }
3912
3913 let mut results_map = std::collections::HashMap::new();
3915 while let Some(res) = join_set.join_next().await {
3916 match res {
3917 Ok((tool_id, result)) => {
3918 results_map.insert(tool_id, result);
3919 }
3920 Err(e) => {
3921 tracing::error!("Parallel tool task panicked: {}", e);
3923 }
3924 }
3925 }
3926
3927 for tool_use in &tool_uses {
3929 if let Some(tool_id) = tool_use["id"].as_str() {
3930 let (result, rich_blocks) = results_map.remove(tool_id).unwrap_or_else(|| {
3931 ("Tool execution failed: task panicked".to_string(), None)
3932 });
3933 tool_results.push(json!({
3934 "type": "tool_result",
3935 "tool_use_id": tool_id,
3936 "content": single_tool_result_content(&result, rich_blocks, self.max_tool_output)
3937 }));
3938 }
3939 }
3940 }
3941
3942 messages.push(std::sync::Arc::new(json!({
3944 "role": "user",
3945 "content": tool_results
3946 })));
3947
3948 } else {
3950 return Err(RuntimeError::Tool("Invalid response format".to_string()));
3951 }
3952 }
3953 }
3954
3955 pub async fn run_stream(
3958 &self,
3959 prompt: String,
3960 cancel: CancellationToken,
3961 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
3962 self.run_stream_with_messages(
3963 vec![std::sync::Arc::new(
3964 json!({"role": "user", "content": prompt}),
3965 )],
3966 cancel,
3967 None,
3968 None,
3969 false,
3970 )
3971 .await
3972 }
3973
3974 pub async fn run_stream_with_messages(
3978 &self,
3979 messages: Vec<crate::SharedMessage>,
3980 cancel: CancellationToken,
3981 steering_rx: Option<mpsc::UnboundedReceiver<String>>,
3982 secret_prompt: Option<crate::tools::SecretPromptHandle>,
3983 auto_approve_confirms: bool,
3984 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
3985 self.run_stream_tracked(
3986 messages,
3987 cancel,
3988 steering_rx,
3989 secret_prompt,
3990 auto_approve_confirms,
3991 TurnCompletion::new(),
3992 )
3993 .await
3994 }
3995
3996 pub async fn run_stream_tracked(
4000 &self,
4001 messages: Vec<crate::SharedMessage>,
4002 cancel: CancellationToken,
4003 steering_rx: Option<mpsc::UnboundedReceiver<String>>,
4004 secret_prompt: Option<crate::tools::SecretPromptHandle>,
4005 auto_approve_confirms: bool,
4006 completion: TurnCompletion,
4007 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
4008 let (tx, internal_rx) = mpsc::unbounded_channel();
4014 let bounded_rx =
4015 crate::runtime::relay::spawn_bounded_stream_relay(internal_rx, cancel.clone());
4016
4017 let turn_correlation_id = agent_core::next_turn_correlation_id();
4020
4021 if self
4022 .continuation
4023 .lock()
4024 .unwrap_or_else(std::sync::PoisonError::into_inner)
4025 .durability_blocked
4026 {
4027 let error = RuntimeError::Session(
4028 "context head save is unresolved; reload the session before further inference"
4029 .into(),
4030 );
4031 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
4032 helpers::turn_error_for(&error, &turn_correlation_id),
4033 )));
4034 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
4035 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
4036 }
4037
4038 if let Err(error) = self.validate_request_preflight().await {
4039 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
4040 helpers::turn_error_for(&error, &turn_correlation_id),
4041 )));
4042 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
4043 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
4044 }
4045
4046 let anthropic_execution_plan = match self.authorized_anthropic_plan().await {
4047 Ok(plan) => plan,
4048 Err(error) => {
4049 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
4050 helpers::turn_error_for(&error, &turn_correlation_id),
4051 )));
4052 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
4053 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
4054 }
4055 };
4056
4057 if let Err(e) = self.refresh_if_needed().await {
4059 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
4060 helpers::turn_error_for(&e, &turn_correlation_id),
4061 )));
4062 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
4063 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
4064 }
4065
4066 let capture_started_at = std::time::SystemTime::now();
4076 let capture_turn_start = terminal_capture_start(&messages);
4077 let capture_lease = self
4080 .memory_context_lock()
4081 .capture_lease_at(capture_started_at);
4082 let mut messages = messages;
4083 self.apply_turn_memory_recall(&mut messages).await;
4084 let messages = messages;
4085
4086 let auth = Arc::clone(&self.auth);
4089 let client = self.client.clone();
4090 let credential_source = self.credential_source.clone();
4091 let token_cache = self.token_cache.clone();
4092 let model = self.model.clone();
4093 let tools = self.tools.clone();
4094 let system_prompt = self.effective_system_prompt().await;
4095 let thinking_budget = self.thinking_budget;
4096 let reasoning_level = self.reasoning_level();
4097 let watcher_exit_path = self.watcher_exit_path.clone();
4098 let max_tool_output = self.max_tool_output;
4099 let bash_timeout = self.bash_timeout;
4100 let bash_max_timeout = self.bash_max_timeout;
4101 let subagent_timeout = self.subagent_timeout;
4102 let api_retries = self.api_retries;
4103 let refusal_retries = self.refusal_retries;
4104 let session_manager = self.session_manager.clone();
4105 let subagent_registry = self.subagent_registry.clone();
4109 let reaper_registry = Arc::clone(&subagent_registry);
4112 let reaper_orchestration = self.orchestration.clone();
4113 let capture_provider = capture_lease
4114 .as_ref()
4115 .and_then(|lease| self.extension_capture_provider(lease));
4116 let capture_state = self.memory_context_state.clone();
4117 let event_queue = self.event_queue.clone();
4118 let options = api::ApiOptions {
4119 memory_backend: None,
4120 use_1m_context: self.context_window_override == Some(1_000_000),
4121 cache_ttl: self.cache_ttl,
4122 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
4123 saw_1h_honored: self.saw_1h_honored.clone(),
4124 credential_source: self.credential_source.clone(),
4125 token_cache: self.token_cache.clone(),
4126 anthropic_base_url: None,
4127 anthropic_execution_plan,
4128 codex_request_role: self.codex_request_role(),
4129 trace: self.effective_trace_context(),
4130 request_correlation: None,
4131 suppress_stream_deltas: false,
4132 telemetry: self.telemetry_writer.clone(),
4133 tool_session_id: Some(self.host_tool_session.clone()),
4136 session_tool_set: None,
4140 request_tools_schema: None,
4141 anthropic_tool_split: None,
4142 usage_counters: None,
4143 cache_anchor: Some(self.cache_anchor.clone()),
4146 keepwarm: None,
4147 };
4148
4149 let final_capture_history: Arc<Mutex<Option<Vec<crate::SharedMessage>>>> = Arc::new(Mutex::new(None));
4150 let session = crate::runtime::stream::StreamSession {
4151 memory_backend: self.memory_backend.clone(),
4152 memory_context: self.memory_tool_capability(),
4153 final_capture_history: final_capture_history.clone(),
4154 turn_completion: completion,
4155 context_window: self.context_window(),
4156 continuation: self.continuation.clone(),
4157 auth,
4158 client,
4159 credential_source,
4160 token_cache,
4161 options,
4162 api_retries,
4163 refusal_retries,
4164 model,
4165 tools,
4166 system_prompt,
4167 thinking_budget,
4168 reasoning_level,
4169 tx: tx.clone(),
4170 cancel,
4171 steering_rx,
4172 watcher_exit_path,
4173 max_tool_output,
4174 bash_timeout,
4175 bash_max_timeout,
4176 subagent_timeout,
4177 session_manager,
4178 subagent_registry,
4179 event_queue,
4180 secret_prompt,
4181 hook_bus: self.hook_bus.clone(),
4182 session_id: self.session_id.clone(),
4183 cwd: self.cwd.clone(),
4184 env: self.env.clone(),
4185 env_stripped: self.env_stripped.clone(),
4186 env_warned: self.env_warned.clone(),
4187 auto_approve_confirms,
4188 telemetry_level: self.telemetry_level,
4189 orchestration: self.orchestration.clone(),
4190 delegation_parent: self.delegation_parent.clone(),
4191 turn_correlation_id: turn_correlation_id.clone(),
4192 progressive_tool_disclosure: self.progressive_tool_disclosure,
4193 deferred_tool_loading: self.deferred_tool_loading,
4194 activation_confirm: self.activation_confirm,
4195 tool_session_id: self.host_tool_session.clone(),
4196 retained_tool_set: std::sync::Arc::clone(&self.retained_tool_set),
4197 mcp_runtime: self.mcp_runtime.clone(),
4198 mcp_session_scope: self.mcp_session_scope.clone(),
4199 extension_runtime: self.extension_runtime.clone(),
4200 extension_session_scope: self.extension_session_scope.clone(),
4201 turn_budget: self.turn_budget.clone(),
4202 };
4203
4204 tokio::spawn(async move {
4205 let completed = match StreamMethods::run_stream_internal(session, messages).await {
4206 Ok(()) => true,
4207 Err(e) => {
4208 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
4209 helpers::turn_error_for(&e, &turn_correlation_id),
4210 )));
4211 false
4212 }
4213 };
4214 dispatch_completed_terminal_capture(
4217 completed,
4218 &final_capture_history,
4219 &capture_state,
4220 capture_lease.zip(capture_provider),
4221 capture_started_at,
4222 capture_turn_start.as_ref(),
4223 );
4224 crate::runtime::subagent::reap_finished(
4228 &reaper_registry,
4229 reaper_orchestration.as_deref(),
4230 );
4231 agent_core::core::memstat::log_turn_memory();
4232 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
4233 });
4234
4235 Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx))
4236 }
4237}
4238
4239impl Clone for Runtime {
4240 fn clone(&self) -> Self {
4241 Self {
4242 client: self.client.clone(),
4243 auth: Arc::clone(&self.auth),
4244 model: self.model.clone(),
4245 tools: self.tools.clone(),
4246 system_prompt: self.system_prompt.clone(),
4247 effective_prompt: self.effective_prompt.clone(),
4248 prompt_generation: self.prompt_generation,
4249 prompt_reload_source: self.prompt_reload_source.clone(),
4250 thinking_budget: self.thinking_budget,
4251 named_level: self.named_level,
4252 explicit_reasoning: self.explicit_reasoning,
4253 codex_request_role: self.codex_request_role,
4254 context_window_override: self.context_window_override,
4255 continuation: Arc::clone(&self.continuation),
4256 compaction_model: self.compaction_model.clone(),
4257 compaction_mode: self.compaction_mode,
4258 compaction_exclusions: self.compaction_exclusions.clone(),
4259 remote_summarization_attempts: Arc::clone(&self.remote_summarization_attempts),
4260 subagent_registry: self.subagent_registry.clone(),
4261 orchestration: self.orchestration.clone(),
4262 event_queue: self.event_queue.clone(),
4263 watcher_exit_path: self.watcher_exit_path.clone(),
4264 max_tool_output: self.max_tool_output,
4265 bash_timeout: self.bash_timeout,
4266 bash_max_timeout: self.bash_max_timeout,
4267 subagent_timeout: self.subagent_timeout,
4268 api_retries: self.api_retries,
4269 refusal_retries: self.refusal_retries,
4270 telemetry_level: self.telemetry_level,
4271 telemetry_writer: self.telemetry_writer.clone(),
4275 trace_ctx: self.trace_ctx.clone(),
4276 trace_controls: Arc::clone(&self.trace_controls),
4277 memory_context_state: std::sync::Arc::clone(&self.memory_context_state),
4283 memory_backend: self.memory_backend.clone(),
4284 memory_backend_config: self.memory_backend_config.clone(),
4285 memory_backend_reconfigure_denied: Arc::clone(&self.memory_backend_reconfigure_denied),
4286 pending_history_import_preview: std::sync::Arc::clone(
4287 &self.pending_history_import_preview,
4288 ),
4289 history_import_plan: std::sync::Arc::clone(&self.history_import_plan),
4290 #[cfg(any(test, feature = "testing"))]
4291 capture_provider_for_test: std::sync::Arc::clone(&self.capture_provider_for_test),
4292 pending_memory_segment: std::sync::Arc::clone(&self.pending_memory_segment),
4295 retained_recall_turn: std::sync::Arc::clone(&self.retained_recall_turn),
4298 one_shot_trace_writer: Arc::clone(&self.one_shot_trace_writer),
4299 capture_dir: self.capture_dir.clone(),
4300 cache_diagnostics: self.cache_diagnostics,
4301 cache_ttl: self.cache_ttl,
4302 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
4306 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
4307 cache_anchor: Default::default(),
4308 last_msg_id: Arc::new(Mutex::new(None)),
4311 session_manager: self.session_manager.clone(),
4312 hook_bus: self.hook_bus.clone(),
4313 reaper_handle: None, reaper_cancel: None, credential_source: self.credential_source.clone(),
4316 token_cache: self.token_cache.clone(), trusted_worker_models: self.trusted_worker_models.clone(),
4318 subagent_limits: self.subagent_limits,
4319 progressive_tool_disclosure: self.progressive_tool_disclosure,
4320 deferred_tool_loading: self.deferred_tool_loading,
4321 activation_confirm: self.activation_confirm,
4322 delegation_parent: self.delegation_parent.clone(),
4323 mcp_runtime: self.mcp_runtime.clone(),
4324 mcp_session_scope: self.mcp_session_scope.clone(),
4327 extension_runtime: self.extension_runtime.clone(),
4328 extension_session_scope: self.extension_session_scope.clone(),
4330 turn_budget: self.turn_budget.clone(),
4331 host_tool_session: self.host_tool_session.clone(),
4336 tool_catalog_prompt: Arc::clone(&self.tool_catalog_prompt),
4337 retained_tool_set: std::sync::Arc::clone(&self.retained_tool_set),
4338 session_id: self.session_id.clone(),
4340 cwd: self.cwd.clone(),
4341 env: self.env.clone(),
4342 env_stripped: self.env_stripped.clone(),
4343 env_warned: self.env_warned.clone(),
4344 }
4345 }
4346}
4347
4348#[cfg(test)]
4349mod rich_output_validation_tests {
4350 use super::*;
4351
4352 const MODEL: &str = "anthropic/claude-sonnet-4-6";
4353
4354 fn rich_output(summary: &str) -> crate::ToolOutput {
4355 crate::ToolOutput::Blocks {
4356 summary: summary.to_string(),
4357 blocks: vec![json!({
4359 "type": "image",
4360 "source": {
4361 "type": "base64",
4362 "media_type": "image/png",
4363 "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l+QAAAAASUVORK5CYII="
4364 }
4365 })],
4366 }
4367 }
4368
4369 #[test]
4370 fn unchanged_summary_retains_rich_array_without_text_truncation() {
4371 let (summary, blocks) = validated_single_tool_output(MODEL, rich_output("image"));
4372 assert_eq!(summary, "image");
4373 let expected = blocks.clone().expect("supported image");
4374 assert_eq!(expected[0], json!({"type": "text", "text": summary}));
4375 assert_eq!(expected[1]["type"], "image");
4376 let retained = retain_single_tool_blocks(blocks, &summary, &summary);
4377 assert_eq!(
4378 single_tool_result_content(&summary, retained, summary.len()),
4379 Value::Array(expected)
4380 );
4381 }
4382
4383 #[test]
4384 fn rewritten_or_truncated_summary_drops_rich_blocks() {
4385 let (summary, blocks) = validated_single_tool_output(MODEL, rich_output("image summary"));
4386 assert!(blocks.is_some());
4387 for hooked in [
4388 "redacted".to_string(),
4389 String::new(),
4390 helpers::HelperMethods::truncate_tool_result(&summary, 5),
4391 ] {
4392 let retained = retain_single_tool_blocks(blocks.clone(), &summary, &hooked);
4393 assert!(retained.is_none());
4394 assert_eq!(
4395 single_tool_result_content(&hooked, retained, 5),
4396 Value::String(helpers::HelperMethods::truncate_tool_result(&hooked, 5))
4397 );
4398 }
4399 }
4400
4401 #[test]
4402 fn unsupported_or_malformed_media_becomes_explicit_text() {
4403 let mut malformed = rich_output("private summary");
4404 if let crate::ToolOutput::Blocks { blocks, .. } = &mut malformed {
4405 blocks[0]["source"]["data"] = json!("private invalid payload");
4406 }
4407 for (model, output) in [
4408 (
4409 "google-gemini/unsupported-rich-test",
4410 rich_output("private summary"),
4411 ),
4412 (MODEL, malformed),
4413 ] {
4414 let (summary, blocks) = validated_single_tool_output(model, output);
4415 assert!(summary.starts_with("Attachment not sent: "), "{summary}");
4416 assert!(!summary.contains("private"));
4417 assert!(blocks.is_none());
4418 assert_eq!(
4419 single_tool_result_content(&summary, blocks, 1024),
4420 Value::String(summary)
4421 );
4422 }
4423 }
4424
4425 #[test]
4426 fn plain_text_and_errors_keep_legacy_truncation() {
4427 for text in ["plain output", "tool error", "Unknown tool: missing"] {
4428 let (summary, blocks) = validated_single_tool_output(
4429 "google-gemini/unsupported-rich-test",
4430 crate::ToolOutput::Text(text.to_string()),
4431 );
4432 assert_eq!(summary, text);
4433 assert!(blocks.is_none());
4434 assert_eq!(
4435 single_tool_result_content(&summary, blocks, 5),
4436 Value::String(helpers::HelperMethods::truncate_tool_result(text, 5))
4437 );
4438 }
4439 }
4440}
4441
4442#[cfg(test)]
4443mod tests {
4444 use super::*;
4445
4446 #[test]
4451 fn host_tool_session_shared_by_clones_fresh_per_runtime() {
4452 let rt = Runtime::new_headless();
4453 let clone = rt.clone();
4454 assert_eq!(
4455 rt.host_tool_session_id(),
4456 clone.host_tool_session_id(),
4457 "clones share the live registry and therefore the host session"
4458 );
4459
4460 let other = Runtime::new_headless();
4461 assert_ne!(
4462 rt.host_tool_session_id(),
4463 other.host_tool_session_id(),
4464 "independently constructed runtimes must never share a session"
4465 );
4466 }
4467
4468 #[test]
4473 fn telemetry_level_gates_shared_observability_sink() {
4474 let mut rt = Runtime::new_headless();
4475 assert!(!rt.trace_context().enabled(), "off by default");
4476 assert!(rt.telemetry_writer().is_none());
4477
4478 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Basic);
4479 assert!(rt.trace_context().enabled(), "basic enables the trace sink");
4480 assert!(rt.telemetry_writer().is_some());
4481
4482 let ctx_before = format!("{:?}", rt.trace_context());
4484 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Full);
4485 assert_eq!(format!("{:?}", rt.trace_context()), ctx_before);
4486
4487 let clone = rt.clone();
4489 assert!(clone.trace_context().enabled());
4490 assert_eq!(format!("{:?}", clone.trace_context()), ctx_before);
4491 assert!(clone.telemetry_writer().is_some());
4492
4493 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Off);
4494 assert!(!rt.trace_context().enabled(), "off disables both");
4495 assert!(rt.telemetry_writer().is_none());
4496 assert!(rt
4497 .shutdown_observability(std::time::Duration::ZERO)
4498 .is_none());
4499 }
4500
4501 #[tokio::test]
4507 async fn shutdown_observability_async_epilogue_contract() {
4508 let rt = Runtime::new_headless();
4510 let budget = crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT;
4511 assert!(rt.shutdown_observability_async(budget).await.is_none());
4512 assert!(rt.shutdown_observability_async(budget).await.is_none());
4513
4514 let tmp = tempfile::tempdir().unwrap();
4516 let telemetry_path = tmp.path().join("synaps/api-log.jsonl");
4517 let mut rt = Runtime::new_headless();
4518 let writer = crate::runtime::telemetry::TelemetryWriter::new(
4519 crate::runtime::telemetry::WriterOptions {
4520 telemetry_path: Some(telemetry_path.clone()),
4521 trace_path: Some(tmp.path().join("synaps/request-trace.jsonl")),
4522 ..Default::default()
4523 },
4524 );
4525 rt.install_observability_for_tests(writer.clone());
4526 writer.enqueue_telemetry(crate::runtime::telemetry::TelemetryRecord {
4527 ts: 7,
4528 model: "claude-sonnet-4-6".to_string(),
4529 ..Default::default()
4530 });
4531 let outcome = rt
4532 .shutdown_observability_async(budget)
4533 .await
4534 .expect("writer installed");
4535 assert!(outcome.is_flushed());
4536 assert_eq!(outcome.stats().written, 1);
4537 assert_eq!(
4538 std::fs::read_to_string(&telemetry_path)
4539 .unwrap()
4540 .lines()
4541 .count(),
4542 1,
4543 "queued final record must be on disk after the epilogue flush"
4544 );
4545 assert!(rt
4547 .shutdown_observability_async(budget)
4548 .await
4549 .expect("writer still installed")
4550 .is_flushed());
4551
4552 let mut rt = Runtime::new_headless();
4554 let slow = crate::runtime::telemetry::TelemetryWriter::new(
4555 crate::runtime::telemetry::WriterOptions {
4556 telemetry_path: Some(tmp.path().join("slow/api-log.jsonl")),
4557 trace_path: Some(tmp.path().join("slow/request-trace.jsonl")),
4558 write_delay: Some(std::time::Duration::from_millis(300)),
4559 ..Default::default()
4560 },
4561 );
4562 rt.install_observability_for_tests(slow.clone());
4563 for _ in 0..10 {
4564 slow.enqueue_telemetry(crate::runtime::telemetry::TelemetryRecord::default());
4565 }
4566 let start = std::time::Instant::now();
4567 let outcome = rt
4568 .shutdown_observability_async(std::time::Duration::from_millis(200))
4569 .await
4570 .expect("writer installed");
4571 assert!(!outcome.is_flushed(), "10×300ms cannot drain in 200ms");
4572 assert!(
4575 start.elapsed() < std::time::Duration::from_millis(1500),
4576 "bounded epilogue must not block exit: {:?}",
4577 start.elapsed()
4578 );
4579 }
4580
4581 #[tokio::test]
4586 async fn one_shot_ephemeral_writer_is_retained_and_drained_at_shutdown() {
4587 let rt = Runtime::new_headless();
4588 assert!(!rt.trace_context().enabled(), "telemetry off");
4589 rt.trace_arm_next(false);
4590 let armed = rt.effective_trace_context();
4591 assert!(armed.enabled(), "armed ephemeral context traces");
4592 let budget = crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT;
4595 let outcome = rt
4596 .shutdown_observability_async(budget)
4597 .await
4598 .expect("retained one-shot writer must be drained by the epilogue");
4599 assert!(outcome.is_flushed());
4600 assert!(rt.shutdown_observability_async(budget).await.is_none());
4602 }
4603
4604 #[tokio::test]
4610 #[serial_test::serial(synaps_base_dir)]
4611 async fn startup_and_shutdown_paths_sweep_expired_captures() {
4612 struct BaseDirGuard(Option<String>);
4613 impl Drop for BaseDirGuard {
4614 fn drop(&mut self) {
4615 match self.0.take() {
4616 Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
4617 None => std::env::remove_var("SYNAPS_BASE_DIR"),
4618 }
4619 }
4620 }
4621 let tmp = tempfile::tempdir().unwrap();
4622 let guard = BaseDirGuard(std::env::var("SYNAPS_BASE_DIR").ok());
4623 std::env::set_var("SYNAPS_BASE_DIR", tmp.path());
4624
4625 let cap_dir = trace::default_capture_dir();
4626 assert!(
4627 cap_dir.starts_with(tmp.path()),
4628 "capture dir must be private to the test"
4629 );
4630 let stale_id = trace::TraceId::new("req-stale").unwrap();
4631 let plant_stale = || {
4632 agent_core::core::private_fs::ensure_private_dir(&cap_dir).unwrap();
4633 let stale = trace::controls::ContentCaptureBundle {
4634 schema: trace::CONTENT_CAPTURE_SCHEMA.to_string(),
4635 request_id: stale_id.clone(),
4636 created_unix_ms: 1_000,
4637 expires_unix_ms: 2_000, redacted: true,
4639 over_budget: false,
4640 body: Some(serde_json::json!({"old": true})),
4641 };
4642 let path = trace::controls::capture_path(&cap_dir, &stale_id);
4643 std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap();
4644 path
4645 };
4646
4647 let stale_path = plant_stale();
4649 let rt = Runtime::new().await.expect("runtime constructs");
4650 assert!(
4651 !stale_path.exists(),
4652 "Runtime startup must physically remove expired capture bundles"
4653 );
4654
4655 let stale_path = plant_stale();
4657 let _ = rt.shutdown_observability(std::time::Duration::ZERO);
4658 assert!(
4659 !stale_path.exists(),
4660 "sync shutdown epilogue must remove expired capture bundles"
4661 );
4662
4663 let stale_path = plant_stale();
4665 let _ = rt
4666 .shutdown_observability_async(std::time::Duration::from_millis(100))
4667 .await;
4668 assert!(
4669 !stale_path.exists(),
4670 "async shutdown epilogue must remove expired capture bundles"
4671 );
4672 drop(guard);
4673 }
4674
4675 #[tokio::test]
4680 #[serial_test::serial(synaps_base_dir)]
4681 async fn capture_root_binds_at_construction_and_survives_env_churn() {
4682 let base_a = tempfile::tempdir().unwrap();
4683 let base_b = tempfile::tempdir().unwrap();
4684 let old = std::env::var("SYNAPS_BASE_DIR").ok();
4685
4686 std::env::set_var("SYNAPS_BASE_DIR", base_a.path());
4687 let rt_a = Runtime::new().await.expect("runtime A");
4688 std::env::set_var("SYNAPS_BASE_DIR", base_b.path());
4689 let rt_b = Runtime::new().await.expect("runtime B");
4690
4691 let cap_a = base_a.path().join("trace").join("capture");
4692 let cap_b = base_b.path().join("trace").join("capture");
4693 let plant = |dir: &std::path::Path, name: &str| {
4694 agent_core::core::private_fs::ensure_private_dir(dir).unwrap();
4695 let id = trace::TraceId::new(name).unwrap();
4696 let stale = trace::controls::ContentCaptureBundle {
4697 schema: trace::CONTENT_CAPTURE_SCHEMA.to_string(),
4698 request_id: id.clone(),
4699 created_unix_ms: 1_000,
4700 expires_unix_ms: 2_000,
4701 redacted: true,
4702 over_budget: false,
4703 body: Some(serde_json::json!({"old": true})),
4704 };
4705 let path = trace::controls::capture_path(dir, &id);
4706 std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap();
4707 path
4708 };
4709
4710 let decoy = tempfile::tempdir().unwrap();
4712 std::env::set_var("SYNAPS_BASE_DIR", decoy.path());
4713
4714 let stale_a = plant(&cap_a, "req-stale-a");
4715 let stale_b = plant(&cap_b, "req-stale-b");
4716 let _ = rt_a.shutdown_observability(std::time::Duration::ZERO);
4717 assert!(
4718 !stale_a.exists(),
4719 "runtime A must sweep the root bound at ITS construction"
4720 );
4721 assert!(
4722 stale_b.exists(),
4723 "runtime A must never sweep another runtime's root"
4724 );
4725 let _ = rt_b
4726 .shutdown_observability_async(std::time::Duration::from_millis(100))
4727 .await;
4728 assert!(
4729 !stale_b.exists(),
4730 "runtime B must sweep the root bound at ITS construction"
4731 );
4732
4733 match old {
4734 Some(v) => std::env::set_var("SYNAPS_BASE_DIR", v),
4735 None => std::env::remove_var("SYNAPS_BASE_DIR"),
4736 }
4737 }
4738
4739 #[test]
4740 fn reload_rejects_policy_change_without_partial_apply() {
4741 let dir = tempfile::tempdir().unwrap();
4742 let path = dir.path().join("prompt.yaml");
4743 let manifest = |total: &str, content: &str| {
4744 format!(
4745 "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"
4746 )
4747 };
4748 std::fs::write(&path, manifest("1", "before")).unwrap();
4749 let parsed =
4750 agent_core::prompt::PromptManifest::parse(&std::fs::read_to_string(&path).unwrap())
4751 .unwrap();
4752 let model =
4753 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6").unwrap();
4754 let context = agent_core::prompt::SelectionContext::new(model.clone(), None).unwrap();
4755 let stack = agent_core::prompt::compile_prompt_stack(
4756 &parsed,
4757 &parsed.registry(path.parent()).unwrap(),
4758 &context,
4759 None,
4760 )
4761 .unwrap();
4762 let catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
4763 &model,
4764 parsed.delegation_catalog_candidates(),
4765 )
4766 .unwrap();
4767 let digest = parsed
4768 .delegation_policy(model, &catalog)
4769 .unwrap()
4770 .map(|p| p.digest());
4771 let mut runtime = Runtime::new_headless();
4772 runtime.apply_prompt_stack(stack).unwrap();
4773 runtime.retain_prompt_reload_source(path.clone(), context, None, digest.clone());
4774 let generation = runtime.prompt_generation();
4775 let composed = runtime.effective_prompt().unwrap().composed().to_owned();
4776
4777 std::fs::write(&path, manifest("2", "after")).unwrap();
4778 let error = runtime.reload_prompt().unwrap_err().to_string();
4779 assert!(error.contains("cannot safely change delegation policy"));
4780 assert_eq!(runtime.prompt_generation(), generation);
4781 assert_eq!(runtime.effective_prompt().unwrap().composed(), composed);
4782 assert_eq!(
4783 runtime
4784 .prompt_reload_source
4785 .as_ref()
4786 .unwrap()
4787 .delegation_policy_digest,
4788 digest
4789 );
4790 }
4791
4792 #[test]
4793 fn worker_install_does_not_replay_process_global_favorite_grants() {
4794 let foreground =
4795 crate::orchestration::canonical_foreground_identity("anthropic/claude-fable-5")
4796 .unwrap();
4797 let session = Arc::new(
4798 crate::orchestration::OrchestrationRuntime::baseline(foreground, 4, 8).unwrap(),
4799 );
4800 let favorite = "openai-codex/gpt-5.6-sol";
4801 assert!(
4802 session.preflight(favorite).is_err(),
4803 "precondition: the parent session did not grant this identity"
4804 );
4805
4806 let mut worker = Runtime::new_headless();
4807 worker.apply_config(&crate::config::SynapsConfig {
4808 favorite_models: vec![favorite.to_owned()],
4809 ..Default::default()
4810 });
4811 worker.install_worker_orchestration(Arc::clone(&session));
4812
4813 assert!(
4814 session.preflight(favorite).is_err(),
4815 "constructing a child must not mint/replay a favorite-model grant"
4816 );
4817 assert!(!session.effective_choices().contains(&favorite.to_owned()));
4818 }
4819
4820 #[test]
4821 fn host_install_may_apply_explicit_operator_favorites() {
4822 let mut runtime = Runtime::new_headless();
4823 let config = crate::config::SynapsConfig {
4824 favorite_models: vec![
4825 "openai-codex/gpt-5.6-luna".to_owned(),
4826 "anthropic/claude-opus-4-6".to_owned(),
4827 "claude/claude-fable-5".to_owned(),
4829 "not-qualified".to_owned(),
4831 ],
4832 ..Default::default()
4833 };
4834 runtime.apply_config(&config);
4835 let foreground =
4836 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
4837 runtime.install_orchestration(std::sync::Arc::new(
4838 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap(),
4839 ));
4840
4841 let orchestration = runtime.orchestration().unwrap();
4842 for trusted in [
4843 "openai-codex/gpt-5.6-sol",
4844 "openai-codex/gpt-5.6-luna",
4845 "anthropic/claude-opus-4-6",
4846 "anthropic/claude-fable-5",
4847 ] {
4848 assert!(
4849 orchestration
4850 .effective_choices()
4851 .contains(&trusted.to_owned()),
4852 "missing configured worker choice {trusted}: {:?}",
4853 orchestration.effective_choices()
4854 );
4855 }
4856 let authorized = orchestration
4857 .resolve_and_authorize("sa_cross_provider", Some("anthropic/claude-opus-4-6"))
4858 .expect("an explicitly favorited cross-provider model must be authorized");
4859 assert_eq!(authorized.model.as_str(), "anthropic/claude-opus-4-6");
4860 assert!(!orchestration
4861 .effective_choices()
4862 .contains(&"not-qualified".to_owned()));
4863 }
4864
4865 #[test]
4866 fn configured_worker_choices_survive_manifestless_foreground_changes() {
4867 let mut runtime = Runtime::new_headless();
4868 runtime.apply_config(&crate::config::SynapsConfig {
4869 favorite_models: vec!["anthropic/claude-opus-4-6".to_owned()],
4870 ..Default::default()
4871 });
4872 let foreground =
4873 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
4874 runtime.install_orchestration(std::sync::Arc::new(
4875 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap(),
4876 ));
4877
4878 runtime
4879 .try_set_model("xai-auth/grok-4.5-latest".to_owned())
4880 .unwrap();
4881
4882 let orchestration = runtime.orchestration().unwrap();
4883 assert_eq!(orchestration.foreground_model(), "xai-auth/grok-4.5-latest");
4884 assert!(orchestration
4885 .effective_choices()
4886 .contains(&"anthropic/claude-opus-4-6".to_owned()));
4887 orchestration
4888 .resolve_and_authorize("sa_after_switch", Some("anthropic/claude-opus-4-6"))
4889 .expect("configured worker trust must survive policy replacement");
4890 }
4891
4892 #[test]
4893 fn configured_subagent_limits_size_the_policy_rebuilt_on_model_change() {
4894 let mut runtime = Runtime::new_headless();
4895 assert_eq!(
4896 runtime.subagent_limits(),
4897 crate::config::SubagentLimits::default(),
4898 "defaults before any config"
4899 );
4900 runtime.apply_config(&crate::config::SynapsConfig {
4901 subagent: crate::config::SubagentLimits {
4902 max_concurrent: 24,
4903 max_total: 96,
4904 max_depth: 2,
4905 },
4906 ..Default::default()
4907 });
4908 let foreground =
4909 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
4910 runtime.install_orchestration(std::sync::Arc::new(
4911 crate::orchestration::OrchestrationRuntime::from_limits(
4912 foreground,
4913 runtime.subagent_limits(),
4914 )
4915 .unwrap(),
4916 ));
4917 runtime
4918 .try_set_model("xai-auth/grok-4.5-latest".to_owned())
4919 .unwrap();
4920
4921 let orchestration = runtime.orchestration().unwrap();
4922 assert_eq!(orchestration.foreground_model(), "xai-auth/grok-4.5-latest");
4923 assert_eq!(
4924 orchestration.ultracode_readiness("xai-auth/grok-4.5-latest"),
4925 Ok((24, 96)),
4926 "the replacement policy uses the configured limits, not a hardcoded 8/64"
4927 );
4928 assert_eq!(orchestration.reserve_delegation("a", None), Ok(1));
4929 assert_eq!(orchestration.reserve_delegation("b", Some("a")), Ok(2));
4930 assert_eq!(
4931 orchestration.reserve_delegation("c", Some("b")),
4932 Err(crate::orchestration::DelegationTreeDenied::DepthLimit { max_depth: 2 })
4933 );
4934 }
4935
4936 #[test]
4937 fn set_model_replaces_manifestless_orchestration_foreground_snapshot() {
4938 let mut runtime = Runtime::new_headless();
4939 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4940 runtime.install_orchestration(std::sync::Arc::new(
4941 crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
4942 ));
4943
4944 runtime.set_model("openrouter/deepseek/deepseek-v4-pro".to_owned());
4945
4946 let orchestration = runtime.orchestration().unwrap();
4947 assert_eq!(
4948 orchestration.foreground_model(),
4949 "openrouter/deepseek/deepseek-v4-pro"
4950 );
4951 assert!(orchestration
4952 .effective_choices()
4953 .contains(&"openrouter/moonshotai/kimi-k2.7-code".to_owned()));
4954 assert!(orchestration
4955 .resolve_and_authorize("sa_kimi", Some("openrouter/moonshotai/kimi-k2.7-code"))
4956 .is_ok());
4957 }
4958
4959 #[test]
4960 fn set_model_refuses_to_orphan_unreconciled_policy_workers() {
4961 let mut runtime = Runtime::new_headless();
4962 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4963 let orchestration = std::sync::Arc::new(
4964 crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
4965 );
4966 orchestration
4967 .resolve_and_authorize("sa_active", None)
4968 .unwrap();
4969 runtime.install_orchestration(orchestration.clone());
4970 runtime.model = "openrouter/z-ai/glm-5.2".to_owned();
4971
4972 let error = runtime
4973 .try_set_model("openrouter/deepseek/deepseek-v4-pro".to_owned())
4974 .unwrap_err();
4975
4976 assert!(error.contains("collection/reconciliation"));
4977 assert_eq!(runtime.model(), "openrouter/z-ai/glm-5.2");
4978 assert!(std::sync::Arc::ptr_eq(
4979 runtime.orchestration().unwrap(),
4980 &orchestration
4981 ));
4982 assert!(orchestration.is_unreconciled("sa_active"));
4983 }
4984
4985 #[test]
4986 fn set_model_keeps_active_typed_manifest_orchestration_unchanged() {
4987 let dir = tempfile::tempdir().unwrap();
4988 let manifest_path = dir.path().join("prompt.yaml");
4989 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4990 let mut runtime = Runtime::new_headless();
4991 let orchestration = std::sync::Arc::new(
4992 crate::orchestration::OrchestrationRuntime::baseline(glm.clone(), 3, 8).unwrap(),
4993 );
4994 runtime.install_orchestration(orchestration.clone());
4995 runtime.retain_prompt_reload_source(
4996 manifest_path,
4997 agent_core::prompt::SelectionContext::new(glm, None).unwrap(),
4998 None,
4999 None,
5000 );
5001
5002 runtime.set_model("openrouter/deepseek/deepseek-v4-pro".to_owned());
5003
5004 assert!(std::sync::Arc::ptr_eq(
5005 runtime.orchestration().unwrap(),
5006 &orchestration
5007 ));
5008 assert_eq!(
5009 runtime.orchestration().unwrap().foreground_model(),
5010 "openrouter/z-ai/glm-5.2"
5011 );
5012 }
5013
5014 #[test]
5015 fn set_model_preserves_qualified_provider_ids_and_bare_claude() {
5016 let cases = [
5017 (
5018 "github-copilot/claude-opus-4.8",
5019 "github-copilot/claude-opus-4.8",
5020 ),
5021 ("anthropic/claude-sonnet-4-6", "anthropic/claude-sonnet-4-6"),
5022 ("google/gemini-2.5-pro", "google/gemini-2.5-pro"),
5023 ("openai/gpt-5", "openai/gpt-5"),
5024 ("claude-opus-4-8", "claude-opus-4-8"),
5025 ];
5026
5027 for (input, expected) in cases {
5028 let mut runtime = Runtime::new_headless();
5029 runtime.set_model(input.to_owned());
5030 assert_eq!(runtime.model(), expected, "input: {input}");
5031 }
5032 }
5033
5034 #[test]
5035 fn set_model_strips_only_legacy_health_status_decoration() {
5036 let cases = [
5037 ("✅ 339ms groq/llama-3.3-70b", "groq/llama-3.3-70b"),
5038 (
5039 "✅ 339ms github-copilot/claude-opus-4.8",
5040 "github-copilot/claude-opus-4.8",
5041 ),
5042 (
5043 "⚠️ 1200ms anthropic/claude-sonnet-4-6",
5044 "anthropic/claude-sonnet-4-6",
5045 ),
5046 ];
5047
5048 for (input, expected) in cases {
5049 let mut runtime = Runtime::new_headless();
5050 runtime.set_model(input.to_owned());
5051 assert_eq!(runtime.model(), expected, "input: {input}");
5052 }
5053 }
5054
5055 #[tokio::test]
5056 async fn confirm_without_prompt_fails_closed() {
5057 let result = resolve_before_tool_call_result(
5058 crate::extensions::hooks::events::HookResult::Confirm {
5059 message: "Run deploy?".into(),
5060 },
5061 None,
5062 false,
5063 )
5064 .await;
5065
5066 assert!(matches!(
5067 result,
5068 crate::extensions::hooks::events::HookResult::Block { reason }
5069 if reason.contains("requires confirmation") && reason.contains("Run deploy?")
5070 ));
5071 }
5072
5073 #[tokio::test]
5074 async fn modify_result_replaces_tool_input() {
5075 let result = resolve_before_tool_call_decision(
5076 serde_json::json!({"command":"rm -rf /"}),
5077 crate::extensions::hooks::events::HookResult::Modify {
5078 input: serde_json::json!({"command":"echo safe"}),
5079 },
5080 None,
5081 false,
5082 )
5083 .await;
5084
5085 match result {
5086 BeforeToolCallDecision::Continue { input } => {
5087 assert_eq!(input, serde_json::json!({"command":"echo safe"}));
5088 }
5089 BeforeToolCallDecision::Block { reason } => panic!("unexpected block: {reason}"),
5090 }
5091 }
5092
5093 #[tokio::test]
5094 async fn confirm_prompt_yes_continues() {
5095 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5096 let handle = crate::tools::SecretPromptHandle::new(tx);
5097
5098 let task = tokio::spawn(async move {
5099 let request = rx.recv().await.expect("confirm prompt request");
5100 assert_eq!(request.title, "Confirm tool call");
5101 assert!(request.prompt.contains("Run deploy?"));
5102 let _ = request.response_tx.send(Some("yes".to_string()));
5103 });
5104
5105 let result = resolve_before_tool_call_result(
5106 crate::extensions::hooks::events::HookResult::Confirm {
5107 message: "Run deploy?".into(),
5108 },
5109 Some(&handle),
5110 false,
5111 )
5112 .await;
5113
5114 task.await.unwrap();
5115 assert!(matches!(
5116 result,
5117 crate::extensions::hooks::events::HookResult::Continue
5118 ));
5119 }
5120
5121 #[tokio::test]
5122 async fn confirm_prompt_non_yes_blocks() {
5123 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5124 let handle = crate::tools::SecretPromptHandle::new(tx);
5125
5126 let task = tokio::spawn(async move {
5127 let request = rx.recv().await.expect("confirm prompt request");
5128 let _ = request.response_tx.send(Some("no".to_string()));
5129 });
5130
5131 let result = resolve_before_tool_call_result(
5132 crate::extensions::hooks::events::HookResult::Confirm {
5133 message: "Run deploy?".into(),
5134 },
5135 Some(&handle),
5136 false,
5137 )
5138 .await;
5139
5140 task.await.unwrap();
5141 assert!(matches!(
5142 result,
5143 crate::extensions::hooks::events::HookResult::Block { reason }
5144 if reason.contains("confirmation denied")
5145 ));
5146 }
5147
5148 #[test]
5149 fn test_max_tokens_for_model() {
5150 assert_eq!(
5152 HelperMethods::max_tokens_for_model("claude-opus-4-6"),
5153 128000
5154 );
5155 assert_eq!(
5156 HelperMethods::max_tokens_for_model("opus-something"),
5157 128000
5158 );
5159
5160 assert_eq!(
5162 HelperMethods::max_tokens_for_model("claude-sonnet-4-20250514"),
5163 64000
5164 );
5165 assert_eq!(HelperMethods::max_tokens_for_model("haiku"), 64000);
5166 assert_eq!(HelperMethods::max_tokens_for_model("claude-3-haiku"), 64000);
5167 assert_eq!(
5168 HelperMethods::max_tokens_for_model("some-other-model"),
5169 64000
5170 );
5171
5172 assert_eq!(HelperMethods::max_tokens_for_model(""), 64000);
5174 assert_eq!(HelperMethods::max_tokens_for_model("OPUS"), 64000); assert_eq!(
5176 HelperMethods::max_tokens_for_model("model-opus-end"),
5177 128000
5178 ); }
5180
5181 #[test]
5182 fn test_truncate_tool_result() {
5183 let default_max = 30000;
5184
5185 let short = "This is a short string.";
5187 assert_eq!(
5188 HelperMethods::truncate_tool_result(short, default_max),
5189 short
5190 );
5191
5192 let exact = "x".repeat(30000);
5194 assert_eq!(
5195 HelperMethods::truncate_tool_result(&exact, default_max),
5196 exact
5197 );
5198
5199 let too_long = "x".repeat(30001);
5201 let truncated = HelperMethods::truncate_tool_result(&too_long, default_max);
5202
5203 assert!(truncated.starts_with(&"x".repeat(30000)));
5205
5206 assert!(truncated.contains("[truncated — 30001 total bytes, showing first 30000]"));
5208
5209 assert!(truncated.len() > 30000);
5211
5212 let very_long = "a".repeat(50000);
5214 let truncated_very_long = HelperMethods::truncate_tool_result(&very_long, default_max);
5215 assert!(
5216 truncated_very_long.contains("[truncated — 50000 total bytes, showing first 30000]")
5217 );
5218 assert!(truncated_very_long.starts_with(&"a".repeat(30000)));
5219
5220 let custom_truncated = HelperMethods::truncate_tool_result(&very_long, 100);
5222 assert!(custom_truncated.starts_with(&"a".repeat(100)));
5223 assert!(custom_truncated.contains("[truncated — 50000 total bytes, showing first 100]"));
5224 }
5225
5226 #[test]
5230 fn truncate_tool_result_enforces_byte_budget() {
5231 let multibyte = "é".repeat(100); let out = HelperMethods::truncate_tool_result(&multibyte, 50);
5233 let body = out.split("\n\n[truncated").next().unwrap();
5234 assert!(
5235 body.len() <= 50,
5236 "retained body must fit the byte budget (got {} bytes)",
5237 body.len()
5238 );
5239 assert!(std::str::from_utf8(body.as_bytes()).is_ok());
5240 assert!(
5241 out.contains("total bytes"),
5242 "marker must report byte counts, got: {out}"
5243 );
5244 }
5245
5246 #[test]
5247 fn test_thinking_level_ranges() {
5248 use crate::core::models::thinking_level_for_budget;
5249
5250 assert_eq!(thinking_level_for_budget(0), "adaptive");
5252
5253 assert_eq!(thinking_level_for_budget(1), "low");
5255 assert_eq!(thinking_level_for_budget(1024), "low");
5256 assert_eq!(thinking_level_for_budget(2048), "low");
5257
5258 assert_eq!(thinking_level_for_budget(2049), "medium");
5260 assert_eq!(thinking_level_for_budget(3000), "medium");
5261 assert_eq!(thinking_level_for_budget(4096), "medium");
5262
5263 assert_eq!(thinking_level_for_budget(4097), "high");
5265 assert_eq!(thinking_level_for_budget(8192), "high");
5266 assert_eq!(thinking_level_for_budget(16384), "high");
5267
5268 assert_eq!(thinking_level_for_budget(16385), "xhigh");
5270 assert_eq!(thinking_level_for_budget(32768), "xhigh");
5271 assert_eq!(thinking_level_for_budget(100000), "xhigh");
5272 }
5273}
5274
5275#[cfg(test)]
5276mod effective_prompt_tests {
5277 use super::*;
5278
5279 struct LateTool;
5282 #[async_trait::async_trait]
5283 impl crate::Tool for LateTool {
5284 fn name(&self) -> &str {
5285 "late_tool"
5286 }
5287 fn description(&self) -> &str {
5288 "Registered after the catalog froze."
5289 }
5290 fn parameters(&self) -> Value {
5291 json!({"type": "object", "properties": {}})
5292 }
5293 async fn execute(&self, _p: Value, _c: crate::ToolContext) -> crate::Result<String> {
5294 Ok(String::new())
5295 }
5296 }
5297
5298 #[tokio::test]
5299 async fn tool_catalog_lists_activatable_tools_outside_the_core() {
5300 let mut runtime = headless("anthropic/claude-sonnet-5-5", "BASE.");
5301 assert!(
5302 runtime.frozen_tool_catalog().await.is_none(),
5303 "flag off: no catalog"
5304 );
5305 runtime.progressive_tool_disclosure = true;
5306 let catalog = runtime.frozen_tool_catalog().await.expect("catalog");
5307 assert!(catalog.starts_with("## Activatable tools\n"));
5308 assert!(
5309 catalog.contains("not instructions"),
5310 "third-party text is framed as data"
5311 );
5312 assert!(catalog.contains("- builtin:subagent_start"), "{catalog}");
5313 for core in [
5314 "builtin:bash",
5315 "builtin:read",
5316 "builtin:activate_tools",
5317 "builtin:search_tools",
5318 ] {
5319 assert!(
5320 !catalog.contains(&format!("- {core}")),
5321 "core {core} listed"
5322 );
5323 }
5324 let prompt = runtime.effective_system_prompt().await.unwrap();
5325 assert!(prompt.starts_with("BASE."), "base stays first");
5326 assert!(prompt.ends_with(&catalog), "catalog appended last");
5327 }
5328
5329 #[tokio::test]
5330 async fn tool_catalog_is_frozen_per_session() {
5331 let mut runtime = headless("anthropic/claude-sonnet-5-5", "BASE.");
5332 runtime.progressive_tool_disclosure = true;
5333 let first = runtime.effective_system_prompt().await.unwrap();
5334 runtime.tools.write().await.register(Arc::new(LateTool));
5335 let clone = runtime.clone();
5336 assert_eq!(runtime.effective_system_prompt().await.unwrap(), first);
5337 assert_eq!(clone.effective_system_prompt().await.unwrap(), first);
5338 assert!(
5339 !first.contains("late_tool"),
5340 "late registration does not rewrite the prefix"
5341 );
5342 }
5343
5344 #[tokio::test]
5345 async fn tool_catalog_absent_when_activation_is_denied_or_impossible() {
5346 let mut denied = headless("anthropic/claude-sonnet-5-5", "BASE.");
5347 denied.progressive_tool_disclosure = true;
5348 denied.set_activation_confirm(agent_core::config::ActivationConfirm::Deny);
5349 assert!(denied.frozen_tool_catalog().await.is_none());
5350 assert_eq!(
5351 denied.effective_system_prompt().await.as_deref(),
5352 Some("BASE.")
5353 );
5354
5355 let mut worker = headless("anthropic/claude-sonnet-5-5", "BASE.");
5356 worker.progressive_tool_disclosure = true;
5357 worker.set_tools(crate::ToolRegistry::without_subagent());
5358 assert!(
5359 worker.frozen_tool_catalog().await.is_none(),
5360 "no activate_tools, no catalog"
5361 );
5362 }
5363
5364 fn headless(model: &str, base: &str) -> Runtime {
5365 let mut runtime = Runtime::new_headless();
5366 runtime.set_model(model.to_string());
5367 runtime.set_system_prompt(base.to_string());
5368 runtime
5369 }
5370
5371 #[tokio::test]
5372 async fn anthropic_foreground_ultracode_composes_standing_doctrine_once() {
5373 let mut runtime = headless("anthropic/claude-fable-5", "BASE.");
5374 runtime.named_level = Some(agent_core::reasoning::ReasoningLevel::UltraCode);
5375 let prompt = runtime.effective_system_prompt().await.expect("doctrine");
5376 assert_eq!(prompt.matches("<anthropic-ultracode-workflow>").count(), 1);
5377 assert!(prompt.contains("subagent_start"));
5378 assert!(prompt.contains("subagent_resume"));
5379 }
5380
5381 #[tokio::test]
5382 async fn codex_foreground_with_subagent_tools_composes_doctrine() {
5383 let runtime = headless("openai-codex/gpt-5.6-sol", "BASE.");
5384 let prompt = runtime
5385 .effective_system_prompt()
5386 .await
5387 .expect("prompt must compose");
5388 assert!(prompt.starts_with("BASE."), "base must lead");
5389 assert!(prompt.contains("## Subagent supervision"));
5390 assert!(prompt.contains("NEVER end your turn"));
5391 }
5392
5393 #[tokio::test]
5394 async fn worker_runtime_without_subagent_tools_stays_clean() {
5395 let mut runtime = headless("openai-codex/gpt-5.6-sol", "WORKER.");
5396 runtime.set_tools(ToolRegistry::without_subagent());
5397 assert_eq!(
5398 runtime.effective_system_prompt().await.as_deref(),
5399 Some("WORKER."),
5400 "workers have no subagent tools; doctrine would be unactionable noise"
5401 );
5402 }
5403
5404 #[tokio::test]
5405 async fn non_codex_foreground_stays_clean() {
5406 let runtime = headless("xai-auth/grok-4.5-latest", "BASE.");
5407 assert_eq!(
5408 runtime.effective_system_prompt().await.as_deref(),
5409 Some("BASE.")
5410 );
5411 }
5412
5413 #[tokio::test]
5414 async fn manifest_prompt_is_returned_verbatim() {
5415 let mut runtime = headless("openai-codex/gpt-5.6-sol", "IGNORED.");
5416 let module = agent_core::prompt::PromptModule::new(
5417 agent_core::prompt::PromptModuleId::parse("kernel.test").unwrap(),
5418 "1.0.0",
5419 agent_core::prompt::PromptModuleSource::User,
5420 0,
5421 agent_core::prompt::PromptSelectors::default(),
5422 agent_core::prompt::ModuleMutability::MutableGuidance,
5423 "KERNEL.",
5424 )
5425 .unwrap();
5426 let context = agent_core::prompt::SelectionContext::new(
5427 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap(),
5428 None,
5429 )
5430 .unwrap();
5431 let stack = agent_core::prompt::PromptStack::new(vec![module], context).unwrap();
5432 runtime.apply_prompt_stack(stack).unwrap();
5433 assert_eq!(
5434 runtime.effective_system_prompt().await.as_deref(),
5435 Some("KERNEL."),
5436 "manifest authors own the full stack; no builtin injection on top"
5437 );
5438 }
5439
5440 #[tokio::test]
5441 async fn unresolvable_model_identity_fails_closed_to_base() {
5442 let runtime = headless("definitely-not-a-model", "BASE.");
5443 assert_eq!(
5444 runtime.effective_system_prompt().await.as_deref(),
5445 Some("BASE.")
5446 );
5447 }
5448}
5449
5450#[cfg(test)]
5451mod set_reasoning_level_checked_tests {
5452 use super::*;
5453 use agent_core::reasoning::ReasoningLevel;
5454
5455 fn codex_runtime(model_id: &str) -> Runtime {
5456 let mut rt = Runtime::new_headless();
5457 rt.set_model(format!("openai-codex/{model_id}"));
5458 rt.set_reasoning_level(ReasoningLevel::Low);
5459 rt
5460 }
5461
5462 fn provisioned_fable_runtime() -> Runtime {
5463 let mut rt = Runtime::new_headless();
5464 rt.set_model("anthropic/claude-fable-5".into());
5465 rt.set_reasoning_level(ReasoningLevel::Low);
5466 let foreground =
5467 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-fable-5").unwrap();
5468 let orchestration =
5469 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap();
5470 rt.install_orchestration(Arc::new(orchestration));
5471 rt
5472 }
5473
5474 fn install_typed_manifest(rt: &mut Runtime) {
5475 let module = agent_core::prompt::PromptModule::new(
5476 agent_core::prompt::PromptModuleId::parse("kernel.test").unwrap(),
5477 "1.0.0",
5478 agent_core::prompt::PromptModuleSource::User,
5479 0,
5480 agent_core::prompt::PromptSelectors::default(),
5481 agent_core::prompt::ModuleMutability::MutableGuidance,
5482 "KERNEL.",
5483 )
5484 .unwrap();
5485 let context = agent_core::prompt::SelectionContext::new(
5486 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-fable-5").unwrap(),
5487 None,
5488 )
5489 .unwrap();
5490 rt.apply_prompt_stack(agent_core::prompt::PromptStack::new(vec![module], context).unwrap())
5491 .unwrap();
5492 }
5493
5494 #[test]
5495 fn provisioned_fable_accepts_max_and_ultracode() {
5496 for level in [ReasoningLevel::Max, ReasoningLevel::UltraCode] {
5497 let mut rt = provisioned_fable_runtime();
5498 rt.set_reasoning_level_checked(level).unwrap();
5499 assert_eq!(rt.reasoning_level(), level);
5500 }
5501 }
5502
5503 #[test]
5504 fn fable_ultracode_prerequisites_fail_without_mutation() {
5505 let mut missing_orchestration = Runtime::new_headless();
5506 missing_orchestration.set_model("anthropic/claude-fable-5".into());
5507 missing_orchestration.set_reasoning_level(ReasoningLevel::Low);
5508 assert!(missing_orchestration
5509 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5510 .is_err());
5511 assert_eq!(missing_orchestration.reasoning_level(), ReasoningLevel::Low);
5512
5513 let mut missing_tools = provisioned_fable_runtime();
5514 missing_tools.set_tools(ToolRegistry::without_subagent());
5515 assert!(missing_tools
5516 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5517 .is_err());
5518 assert_eq!(missing_tools.reasoning_level(), ReasoningLevel::Low);
5519
5520 let mut worker = provisioned_fable_runtime();
5521 worker.set_codex_request_role(crate::runtime::openai::catalog::ExecutionRole::Worker);
5522 assert!(worker
5523 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5524 .is_err());
5525 assert_eq!(worker.reasoning_level(), ReasoningLevel::Low);
5526
5527 let mut internal = provisioned_fable_runtime();
5528 internal.set_codex_request_role(crate::runtime::openai::catalog::ExecutionRole::Internal);
5529 assert!(internal
5530 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5531 .is_err());
5532 assert_eq!(internal.reasoning_level(), ReasoningLevel::Low);
5533
5534 let mut manifest = provisioned_fable_runtime();
5535 install_typed_manifest(&mut manifest);
5536 assert!(manifest
5537 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5538 .is_err());
5539 assert_eq!(manifest.reasoning_level(), ReasoningLevel::Low);
5540 }
5541
5542 #[test]
5543 fn checked_accepts_client_omission_modes_for_codex() {
5544 for level in [ReasoningLevel::Off, ReasoningLevel::Adaptive] {
5545 let mut rt = codex_runtime("gpt-5.6-sol");
5546 rt.set_reasoning_level_checked(level).unwrap();
5547 assert_eq!(rt.reasoning_level(), level);
5548 }
5549 }
5550
5551 #[test]
5553 fn checked_rejects_ultra_for_luna_no_mutation() {
5554 let mut rt = codex_runtime("gpt-5.6-luna");
5555 let before = rt.reasoning_level();
5556 let err = rt
5557 .set_reasoning_level_checked(ReasoningLevel::Ultra)
5558 .unwrap_err();
5559 assert!(
5560 err.contains("ultra"),
5561 "error must name the rejected level; got: {err}"
5562 );
5563 assert_eq!(
5564 rt.reasoning_level(),
5565 before,
5566 "runtime must not be mutated when validation fails"
5567 );
5568 }
5569
5570 #[test]
5572 fn checked_rejects_max_for_gpt55_no_mutation() {
5573 let mut rt = codex_runtime("gpt-5.5");
5574 rt.set_reasoning_level(ReasoningLevel::Medium);
5575 let err = rt
5576 .set_reasoning_level_checked(ReasoningLevel::Max)
5577 .unwrap_err();
5578 assert!(
5579 err.contains("max"),
5580 "error must name the rejected level; got: {err}"
5581 );
5582 assert_eq!(rt.reasoning_level(), ReasoningLevel::Medium);
5583 }
5584
5585 #[test]
5587 fn checked_accepts_ultra_for_sol_and_mutates() {
5588 let mut rt = codex_runtime("gpt-5.6-sol");
5589 rt.set_reasoning_level_checked(ReasoningLevel::Ultra)
5590 .expect("sol must accept ultra");
5591 assert_eq!(rt.reasoning_level(), ReasoningLevel::Ultra);
5592 }
5593
5594 #[test]
5596 fn checked_rejects_extended_levels_without_metadata() {
5597 let mut rt = Runtime::new_headless();
5598 rt.set_model("claude-opus-4-7".to_string());
5599 rt.set_reasoning_level(ReasoningLevel::Low);
5600 for level in [ReasoningLevel::Max, ReasoningLevel::Ultra] {
5601 assert!(rt.set_reasoning_level_checked(level).is_err());
5602 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5603 }
5604 }
5605
5606 #[test]
5609 fn checked_rejects_max_ultra_for_unknown_codex_model() {
5610 let mut rt = codex_runtime("gpt-unknown-future");
5611 rt.set_reasoning_level(ReasoningLevel::Low);
5612 for level in [ReasoningLevel::Max, ReasoningLevel::Ultra] {
5613 let err = rt.set_reasoning_level_checked(level).unwrap_err();
5614 assert!(err.contains("no capability metadata"), "{err}");
5615 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5616 }
5617 }
5618
5619 #[test]
5622 fn checked_enforces_xai_capability_matrix_no_mutation_on_err() {
5623 let mut rt = Runtime::new_headless();
5624 rt.set_model("xai-auth/grok-4.5".to_string());
5625 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5627 for level in [
5628 ReasoningLevel::Off,
5629 ReasoningLevel::XHigh,
5630 ReasoningLevel::Ultra,
5631 ] {
5632 let before = rt.reasoning_level();
5633 assert!(rt.set_reasoning_level_checked(level).is_err(), "{level}");
5634 assert_eq!(rt.reasoning_level(), before);
5635 }
5636 rt.set_reasoning_level_checked(ReasoningLevel::Low)
5637 .expect("grok-4.5 supports low");
5638 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5639 }
5640
5641 #[test]
5644 fn checked_rejects_named_on_intrinsic_xai_model_and_defaults_adaptive() {
5645 let mut rt = Runtime::new_headless();
5646 rt.set_model("xai-auth/grok-4.3".to_string());
5647 assert_eq!(rt.reasoning_level(), ReasoningLevel::Adaptive);
5648 assert!(rt
5649 .set_reasoning_level_checked(ReasoningLevel::Medium)
5650 .is_err());
5651 assert_eq!(rt.reasoning_level(), ReasoningLevel::Adaptive);
5652 rt.set_reasoning_level_checked(ReasoningLevel::Adaptive)
5653 .unwrap();
5654 }
5655
5656 #[test]
5658 fn explicit_level_survives_xai_model_switch() {
5659 let mut rt = Runtime::new_headless();
5660 rt.set_model("xai-auth/grok-4.5".to_string());
5661 rt.set_reasoning_level_checked(ReasoningLevel::Low).unwrap();
5662 rt.set_model("xai-auth/grok-4.5-latest".to_string());
5663 assert_eq!(
5664 rt.reasoning_level(),
5665 ReasoningLevel::Low,
5666 "explicit level must not be overwritten by the model default"
5667 );
5668 }
5669
5670 #[test]
5673 fn explicit_unsupported_level_is_clamped_to_model_default_on_switch() {
5674 let mut rt = Runtime::new_headless();
5675 rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
5676 let clamp = rt
5677 .try_set_model("xai-auth/grok-4.6".to_string())
5678 .expect("model switch succeeds");
5679 assert_eq!(
5680 clamp,
5681 Some(ReasoningClamp {
5682 from: ReasoningLevel::XHigh,
5683 to: ReasoningLevel::High,
5684 })
5685 );
5686 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5687 assert!(
5688 rt.is_reasoning_explicit(),
5689 "clamp keeps explicit provenance"
5690 );
5691 assert_eq!(rt.model(), "xai-auth/grok-4.6");
5692 }
5693
5694 #[test]
5696 fn explicit_supported_level_is_not_clamped_on_switch() {
5697 let mut rt = Runtime::new_headless();
5698 rt.set_reasoning_level_explicit(ReasoningLevel::High);
5699 let clamp = rt.try_set_model("xai-auth/grok-4.6".to_string()).unwrap();
5700 assert_eq!(clamp, None);
5701 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5702 assert!(rt.is_reasoning_explicit());
5703 }
5704
5705 #[test]
5708 fn non_explicit_default_application_reports_no_clamp() {
5709 let mut rt = Runtime::new_headless();
5710 rt.set_reasoning_level(ReasoningLevel::XHigh);
5711 let clamp = rt.try_set_model("xai-auth/grok-4.6".to_string()).unwrap();
5712 assert_eq!(clamp, None);
5713 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5714 assert!(!rt.is_reasoning_explicit());
5715 }
5716
5717 #[test]
5720 fn explicit_max_clamps_to_validating_option_on_multi_agent_model() {
5721 let model = "xai-auth/grok-4.20-multi-agent-0309";
5722 let mut rt = Runtime::new_headless();
5723 rt.set_reasoning_level_explicit(ReasoningLevel::Max);
5724 let clamp = rt
5725 .try_set_model(model.to_string())
5726 .unwrap()
5727 .expect("Max is not supported: must clamp");
5728 assert_eq!(clamp.from, ReasoningLevel::Max);
5729 assert!(
5730 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
5731 model, clamp.to
5732 )
5733 .is_ok(),
5734 "clamped level {} must validate",
5735 clamp.to
5736 );
5737 assert_eq!(rt.reasoning_level(), clamp.to);
5738 assert!(rt.is_reasoning_explicit());
5739 }
5740
5741 #[test]
5743 fn set_model_returns_clamp() {
5744 let mut rt = Runtime::new_headless();
5745 rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
5746 let clamp = rt.set_model("xai-auth/grok-4.6".to_string());
5747 assert_eq!(clamp.map(|c| c.to), Some(ReasoningLevel::High));
5748 }
5749
5750 #[test]
5753 fn restore_session_reasoning_clamps_after_reapply() {
5754 let mut rt = Runtime::new_headless();
5755 rt.set_model("xai-auth/grok-4.6".to_string());
5756 let clamp = rt.restore_session_reasoning("xhigh");
5757 assert_eq!(
5758 clamp,
5759 Some(ReasoningClamp {
5760 from: ReasoningLevel::XHigh,
5761 to: ReasoningLevel::High,
5762 })
5763 );
5764 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5765 assert!(rt.is_reasoning_explicit());
5766 assert!(rt.set_reasoning_level_checked(rt.reasoning_level()).is_ok());
5767 }
5768
5769 #[test]
5771 fn restore_session_reasoning_keeps_supported_level() {
5772 let mut rt = Runtime::new_headless();
5773 rt.set_model("xai-auth/grok-4.6".to_string());
5774 assert_eq!(rt.restore_session_reasoning("low"), None);
5775 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5776 assert!(rt.is_reasoning_explicit());
5777 }
5778
5779 #[test]
5782 fn apply_config_clamps_unsupported_explicit_level_against_config_model() {
5783 let mut rt = Runtime::new_headless();
5784 rt.apply_config(&crate::config::SynapsConfig {
5785 model: Some("xai-auth/grok-4.6".to_string()),
5786 thinking_level: Some(ReasoningLevel::XHigh),
5787 ..Default::default()
5788 });
5789 assert_eq!(rt.model(), "xai-auth/grok-4.6");
5790 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5791 assert!(rt.is_reasoning_explicit());
5792 assert!(rt.set_reasoning_level_checked(rt.reasoning_level()).is_ok());
5793 }
5794}
5795
5796#[cfg(test)]
5797mod codex_execution_preflight_tests {
5798 use super::*;
5799 use crate::runtime::openai::catalog::CodexRequestRole;
5800 use agent_core::prompt::QualifiedModelId;
5801 use agent_core::reasoning::ReasoningLevel;
5802
5803 fn ultra_runtime() -> Runtime {
5804 let mut runtime = Runtime::new_headless();
5805 runtime.set_model("openai-codex/gpt-5.6-sol".to_string());
5806 runtime.set_reasoning_level_explicit(ReasoningLevel::Ultra);
5807 runtime
5808 }
5809
5810 fn install_baseline(runtime: &mut Runtime) {
5811 let foreground =
5812 QualifiedModelId::parse("openai-codex/gpt-5.6-sol").expect("qualified model");
5813 let orchestration = crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8)
5814 .expect("baseline orchestration");
5815 runtime.install_orchestration(Arc::new(orchestration));
5816 }
5817
5818 #[tokio::test]
5819 async fn foreground_ultra_preflight_requires_orchestration() {
5820 let runtime = ultra_runtime();
5821 let error = runtime
5822 .validate_request_preflight()
5823 .await
5824 .expect_err("Ultra without orchestration must fail closed");
5825 assert!(error.to_string().contains("orchestration"), "{error}");
5826 }
5827
5828 #[tokio::test]
5829 async fn foreground_ultra_preflight_requires_actionable_subagent_tools() {
5830 let mut runtime = ultra_runtime();
5831 install_baseline(&mut runtime);
5832 runtime.set_tools(ToolRegistry::without_subagent());
5833
5834 let error = runtime
5835 .validate_request_preflight()
5836 .await
5837 .expect_err("Ultra without subagent tools must fail closed");
5838 assert!(error.to_string().contains("subagent"), "{error}");
5839 }
5840
5841 #[tokio::test]
5842 async fn foreground_ultra_preflight_accepts_exact_model_policy_and_tools() {
5843 let mut runtime = ultra_runtime();
5844 install_baseline(&mut runtime);
5845 runtime
5846 .validate_request_preflight()
5847 .await
5848 .expect("fully provisioned Ultra must pass preflight");
5849 }
5850
5851 #[tokio::test]
5852 async fn worker_ultra_preflight_never_requires_or_enables_orchestration() {
5853 let mut runtime = ultra_runtime();
5854 runtime.set_codex_request_role(CodexRequestRole::Worker);
5855 runtime.set_tools(ToolRegistry::without_subagent());
5856 runtime
5857 .validate_request_preflight()
5858 .await
5859 .expect("worker Ultra is underlying max without proactive orchestration");
5860 assert_eq!(runtime.codex_request_role(), CodexRequestRole::Worker);
5861 }
5862
5863 #[tokio::test]
5864 async fn non_codex_ultra_preflight_fails_before_provider_work() {
5865 let mut runtime = Runtime::new_headless();
5866 runtime.set_model("anthropic/claude-opus-4-7".to_string());
5867 runtime.set_reasoning_level_explicit(ReasoningLevel::Ultra);
5868 let error = runtime
5869 .validate_request_preflight()
5870 .await
5871 .expect_err("non-Codex Ultra must fail closed");
5872 assert!(
5873 error.to_string().contains("unsupported_reasoning_level"),
5874 "{error}"
5875 );
5876 }
5877
5878 #[tokio::test]
5879 async fn compaction_preflight_validates_target_before_credentials() {
5880 let mut runtime = ultra_runtime();
5881 runtime.set_compaction_model(Some("claude-sonnet-4-6".to_string()));
5882 {
5883 let mut auth = runtime.auth.write().await;
5884 auth.auth_token.clear();
5885 auth.auth_type = "none".to_string();
5886 auth.refresh_token = None;
5887 auth.token_expires = None;
5888 }
5889
5890 let error = runtime
5891 .compact_call(Vec::new())
5892 .await
5893 .expect_err("Codex Ultra cannot silently cross into Anthropic compaction");
5894 assert!(
5895 error.to_string().contains("authoritative exact-model"),
5896 "preflight must reject the target before auth access: {error}"
5897 );
5898 }
5899}
5900
5901#[cfg(test)]
5902mod http_client_timeout_tests {
5903 use futures::StreamExt;
5917 use std::time::Duration;
5918 use tokio::io::AsyncWriteExt;
5919
5920 const CHUNKED_HEAD: &[u8] =
5921 b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\n\r\n";
5922
5923 fn chunk(data: &str) -> Vec<u8> {
5924 format!("{:x}\r\n{}\r\n", data.len(), data).into_bytes()
5925 }
5926
5927 async fn spawn_chunked_server(n_chunks: usize, gap: Duration, terminate: bool) -> String {
5930 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5931 let addr = listener.local_addr().unwrap();
5932 tokio::spawn(async move {
5933 let (mut sock, _) = listener.accept().await.unwrap();
5934 let mut buf = [0u8; 4096];
5936 use tokio::io::AsyncReadExt;
5937 let _ = sock.read(&mut buf).await;
5938 sock.write_all(CHUNKED_HEAD).await.unwrap();
5939 for i in 0..n_chunks {
5940 sock.write_all(&chunk(&format!("data {i}\n")))
5941 .await
5942 .unwrap();
5943 sock.flush().await.unwrap();
5944 tokio::time::sleep(gap).await;
5945 }
5946 if terminate {
5947 sock.write_all(b"0\r\n\r\n").await.unwrap();
5948 } else {
5949 tokio::time::sleep(Duration::from_secs(60)).await;
5951 }
5952 });
5953 format!("http://{addr}/stream")
5954 }
5955
5956 async fn drain(
5957 client: &reqwest::Client,
5958 url: &str,
5959 ) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
5960 let resp = client.get(url).send().await?;
5961 let mut stream = resp.bytes_stream();
5962 let mut total = 0usize;
5963 while let Some(c) = stream.next().await {
5964 total += c?.len();
5965 }
5966 Ok(total)
5967 }
5968
5969 #[tokio::test]
5974 async fn active_stream_outliving_read_timeout_survives() {
5975 let url = spawn_chunked_server(15, Duration::from_millis(100), true).await;
5977 let client = super::build_http_client(Duration::from_millis(400)).expect("client builds");
5978 let total = drain(&client, &url)
5979 .await
5980 .expect("active stream must never be killed by the idle timeout");
5981 assert!(total > 0);
5982 }
5983
5984 #[tokio::test]
5987 async fn stalled_stream_is_killed_by_read_timeout() {
5988 let url = spawn_chunked_server(2, Duration::from_millis(10), false).await;
5990 let client = super::build_http_client(Duration::from_millis(300)).expect("client builds");
5991 let start = std::time::Instant::now();
5992 let err = drain(&client, &url)
5993 .await
5994 .expect_err("stalled stream must be detected");
5995 assert!(
5996 start.elapsed() < Duration::from_secs(5),
5997 "stall must be detected promptly, took {:?}",
5998 start.elapsed()
5999 );
6000 let msg = crate::core::error::error_chain_string(err.as_ref());
6001 assert!(
6002 msg.contains("timed out") || msg.contains("timeout"),
6003 "must be a timeout error: {msg}"
6004 );
6005 }
6006
6007 #[tokio::test]
6011 async fn hung_headers_are_killed_by_read_timeout() {
6012 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6013 let addr = listener.local_addr().unwrap();
6014 tokio::spawn(async move {
6015 let (_sock, _) = listener.accept().await.unwrap();
6016 tokio::time::sleep(Duration::from_secs(60)).await;
6017 });
6018 let client = super::build_http_client(Duration::from_millis(300)).expect("client builds");
6019 let start = std::time::Instant::now();
6020 let err = client
6021 .get(format!("http://{addr}/codex/responses"))
6022 .send()
6023 .await
6024 .expect_err("hung request must be detected");
6025 assert!(
6026 start.elapsed() < Duration::from_secs(5),
6027 "hung headers must be detected promptly, took {:?}",
6028 start.elapsed()
6029 );
6030 assert!(err.is_timeout(), "must be a timeout: {err:?}");
6031 }
6032}
6033
6034#[cfg(test)]
6036mod memory_context_provider_tests {
6037 use super::{memory_context, Runtime};
6038 use std::sync::Arc;
6039
6040 async fn memory_runtime_with_providers(
6048 plugins: &[(&str, &str)],
6049 ) -> (
6050 Runtime,
6051 std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
6052 ) {
6053 let mut manager = crate::extensions::manager::ExtensionManager::new(Arc::new(
6054 crate::extensions::hooks::HookBus::new(),
6055 ));
6056 manager.set_progressive_deferral(true);
6057 for (plugin, provider) in plugins {
6058 let manifest: crate::extensions::manifest::ExtensionManifest =
6059 serde_json::from_value(serde_json::json!({
6060 "runtime": "process",
6061 "command": "/bin/false",
6062 "permissions": ["context_providers.register"],
6063 "deferred": {
6064 "context_providers": [{
6065 "id": provider,
6066 "capability": "project-memory",
6067 "description": "test context provider",
6068 "schema_version": 1
6069 }]
6070 }
6071 }))
6072 .expect("manifest parses");
6073 manager
6074 .load(plugin, &manifest)
6075 .await
6076 .expect("deferred context-provider load succeeds without spawning");
6077 }
6078 let extension_runtime = manager.extension_runtime();
6079 let mut runtime = Runtime::new_headless();
6080 runtime.install_extension_runtime(std::sync::Arc::clone(&extension_runtime));
6081 (runtime, extension_runtime)
6082 }
6083
6084 fn assert_memory_off_no_lease(runtime: &Runtime) {
6085 let status = runtime.memory_context_status();
6086 assert_eq!(
6087 status.durable,
6088 memory_context::DurableStatus::Off,
6089 "state must stay Off"
6090 );
6091 assert_eq!(
6092 status.one_shot,
6093 memory_context::OneShotStatus::Idle,
6094 "no one-shot lease may exist"
6095 );
6096 assert!(
6097 runtime.memory_bound_providers_for_test().is_empty(),
6098 "no provider may be bound"
6099 );
6100 }
6101
6102 #[tokio::test]
6105 async fn memory_backend_exclusive_rejects_extension_recall_capture_and_history() {
6106 for selector in ["axel", "invalid-selector"] {
6107 let (mut runtime, _manager) =
6108 memory_runtime_with_providers(&[("memory-test", "notes")]).await;
6109 let provider = runtime.resolve_memory_provider(None).unwrap();
6110 runtime.apply_config(&agent_core::config::load_config_from_str(&format!(
6111 "memory.backend = {selector}\n"
6112 )));
6113 assert!(runtime.memory_backend.exclusive());
6114 assert!(matches!(
6115 runtime.resolve_memory_provider(None),
6116 Err(memory_context::MemoryContextError::ProviderNotRegistered)
6117 ));
6118 assert!(matches!(
6119 runtime.resolve_memory_provider(Some(provider.as_str())),
6120 Err(memory_context::MemoryContextError::ProviderNotRegistered)
6121 ));
6122 assert!(matches!(
6123 runtime.memory_history_confirm(),
6124 Err(
6125 super::memory_history::HistoryImportError::CaptureProviderUnavailable
6126 | super::memory_history::HistoryImportError::ConsentRequired
6127 )
6128 ));
6129 assert_memory_off_no_lease(&runtime);
6130 let mut messages = vec![Arc::new(
6131 serde_json::json!({"role": "user", "content": "test"}),
6132 )];
6133 let original = messages.clone();
6134 runtime.apply_turn_memory_recall(&mut messages).await;
6135 assert_eq!(messages, original);
6136 let clone = runtime.clone();
6137 assert!(clone.memory_backend.exclusive());
6138 assert_eq!(clone.memory_backend.base(), runtime.memory_backend.base());
6139 }
6140 }
6141
6142 #[tokio::test]
6143 async fn memory_backend_first_config_is_immutable_and_changes_require_restart() {
6144 for (first, different) in [("legacy", "axel"), ("axel", "legacy")] {
6145 let mut runtime = Runtime::new_headless();
6146 let config =
6147 agent_core::config::load_config_from_str(&format!("memory.backend = {first}\n"));
6148 runtime.apply_config(&config);
6149 let binding = runtime.memory_backend.clone();
6150 runtime.apply_config(&config);
6151 assert!(!runtime
6152 .memory_backend_reconfigure_denied
6153 .load(std::sync::atomic::Ordering::SeqCst));
6154 if let (Ok(a), Ok(b)) = (binding.scope(), runtime.memory_backend.scope()) {
6155 assert!(std::ptr::eq(a, b));
6156 }
6157 let clone = runtime.clone();
6158 runtime.apply_config(&agent_core::config::load_config_from_str(&format!(
6159 "memory.backend = {different}\n"
6160 )));
6161 assert_eq!(runtime.memory_backend.exclusive(), binding.exclusive());
6162 assert_eq!(
6163 runtime.memory_backend_config.as_ref(),
6164 Some(&config.memory_backend)
6165 );
6166 for denied in [&runtime, &clone] {
6167 let error = denied
6168 .validate_request_preflight()
6169 .await
6170 .unwrap_err()
6171 .to_string();
6172 assert!(error.contains("restart required"), "{error}");
6173 assert!(denied.resolve_memory_provider(None).is_err());
6174 }
6175 runtime.apply_config(&config);
6176 assert!(runtime.validate_request_preflight().await.is_err());
6177 }
6178 }
6179
6180 #[tokio::test]
6181 async fn memory_backend_path_changes_also_require_restart() {
6182 for field in ["executable", "brain"] {
6183 let mut runtime = Runtime::new_headless();
6184 let config = crate::config::MemoryBackendConfig::default();
6185 runtime.apply_memory_backend_config(&config);
6186 let mut different = config.clone();
6187 let path = std::env::temp_dir().join("synaps-memory-config-test");
6188 if field == "executable" {
6189 different.executable = Some(path);
6190 } else {
6191 different.brain = Some(path);
6192 }
6193 runtime.apply_memory_backend_config(&different);
6194 assert_eq!(runtime.memory_backend_config.as_ref(), Some(&config));
6195 assert!(!runtime.memory_backend.exclusive());
6196 let error = runtime
6197 .validate_request_preflight()
6198 .await
6199 .unwrap_err()
6200 .to_string();
6201 assert!(error.contains("restart required"), "{error}");
6202 assert!(runtime.resolve_memory_provider(None).is_err());
6203 }
6204 }
6205
6206 #[test]
6207 fn memory_backend_apply_config_revokes_preexisting_legacy_lease() {
6208 let mut runtime = Runtime::new_headless();
6209 runtime
6210 .memory_context_enable(
6211 memory_context::MemoryContextMode::CaptureAndRecall,
6212 memory_context::UserIntentProof::ExplicitCommand {
6213 command_id: memory_context::mint_explicit_command_id(),
6214 },
6215 )
6216 .unwrap();
6217 let lease = runtime
6218 .memory_context_lock()
6219 .capture_lease_at(std::time::SystemTime::now())
6220 .unwrap();
6221 runtime.apply_config(&agent_core::config::load_config_from_str(
6222 "memory.backend = axel\n",
6223 ));
6224 assert_memory_off_no_lease(&runtime);
6225 assert!(runtime.extension_capture_provider(&lease).is_none());
6226 }
6227
6228 #[test]
6229 fn axel_invalid_config_cannot_grant_consent_or_fallback() {
6230 let mut runtime = Runtime::new_headless();
6231 runtime.apply_config(&agent_core::config::load_config_from_str(
6232 "memory.backend = invalid-not-axel-not-legacy\n",
6233 ));
6234 assert!(runtime.memory_backend.exclusive());
6235 assert!(runtime.resolve_memory_provider(None).is_err());
6236 assert_memory_off_no_lease(&runtime);
6237 }
6238
6239 #[tokio::test]
6245 async fn memory_enable_unregistered_provider_fails_closed_grants_nothing() {
6246 let (runtime, _ext) =
6248 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
6249 for requested in ["no-such-provider", "bad:id"] {
6250 let err = runtime
6251 .memory_context_enable_resolved(
6252 memory_context::MemoryContextMode::CaptureAndRecall,
6253 memory_context::mint_explicit_command_proof(),
6254 Some(requested),
6255 )
6256 .expect_err("unregistered provider must fail closed");
6257 assert_eq!(
6258 err,
6259 memory_context::MemoryContextError::ProviderNotRegistered
6260 );
6261 assert_memory_off_no_lease(&runtime);
6262 }
6263
6264 let (runtime, _ext) = memory_runtime_with_providers(&[]).await;
6267 let err = runtime
6268 .memory_context_enable(
6269 memory_context::MemoryContextMode::CaptureAndRecall,
6270 memory_context::mint_explicit_command_proof(),
6271 )
6272 .expect_err("empty catalog must fail closed");
6273 assert_eq!(
6274 err,
6275 memory_context::MemoryContextError::ProviderNotRegistered
6276 );
6277 assert_memory_off_no_lease(&runtime);
6278 let err = runtime
6280 .memory_context_recall_once(memory_context::mint_explicit_command_proof())
6281 .expect_err("one-shot grant must validate the provider too");
6282 assert_eq!(
6283 err,
6284 memory_context::MemoryContextError::ProviderNotRegistered
6285 );
6286 assert_memory_off_no_lease(&runtime);
6287 }
6288
6289 #[tokio::test]
6293 async fn memory_enable_exactly_one_declared_provider_records_exact_identity() {
6294 const ADDRESS: &str = "extension:axel-memory-manager:project-memory";
6295 let (runtime, _ext) =
6297 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
6298 let status = runtime
6299 .memory_context_enable(
6300 memory_context::MemoryContextMode::CaptureAndRecall,
6301 memory_context::mint_explicit_command_proof(),
6302 )
6303 .expect("unique declared provider must resolve");
6304 assert!(matches!(
6305 status.durable,
6306 memory_context::DurableStatus::Active { .. }
6307 ));
6308 let bound = runtime.memory_bound_providers_for_test();
6309 assert_eq!(bound.len(), 1);
6310 assert_eq!(bound[0].as_str(), ADDRESS, "exact runtime address recorded");
6311
6312 let (runtime, _ext) =
6314 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
6315 runtime
6316 .memory_context_enable_resolved(
6317 memory_context::MemoryContextMode::RecallEachPrompt,
6318 memory_context::mint_explicit_command_proof(),
6319 Some("project-memory"),
6320 )
6321 .expect("explicit exact id must resolve");
6322 assert_eq!(
6323 runtime.memory_bound_providers_for_test()[0].as_str(),
6324 ADDRESS
6325 );
6326 }
6327
6328 #[tokio::test]
6332 async fn memory_enable_ambiguous_overlapping_declarations_fails_closed() {
6333 let (runtime, _ext) = memory_runtime_with_providers(&[
6334 ("mem-a", "project-memory"),
6335 ("mem-b", "project-memory"),
6336 ])
6337 .await;
6338 let err = runtime
6339 .memory_context_enable(
6340 memory_context::MemoryContextMode::CaptureAndRecall,
6341 memory_context::mint_explicit_command_proof(),
6342 )
6343 .expect_err("overlapping declarations must fail closed");
6344 assert_eq!(err, memory_context::MemoryContextError::ProviderAmbiguous);
6345 assert_memory_off_no_lease(&runtime);
6346
6347 let err = runtime
6349 .memory_context_enable_resolved(
6350 memory_context::MemoryContextMode::CaptureOnly,
6351 memory_context::mint_explicit_command_proof(),
6352 Some("project-memory"),
6353 )
6354 .expect_err("same id declared by two plugins is ambiguous");
6355 assert_eq!(err, memory_context::MemoryContextError::ProviderAmbiguous);
6356 assert_memory_off_no_lease(&runtime);
6357 }
6358
6359 #[tokio::test]
6365 async fn memory_disable_revokes_runtime_lease_without_spawn_no_panic() {
6366 let (runtime, ext) =
6367 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
6368 runtime
6369 .memory_context_enable(
6370 memory_context::MemoryContextMode::CaptureAndRecall,
6371 memory_context::mint_explicit_command_proof(),
6372 )
6373 .expect("enable succeeds");
6374 assert_eq!(
6375 ext.lease_count(),
6376 0,
6377 "granting a memory lease spawns nothing"
6378 );
6379
6380 let status = runtime.memory_context_disable();
6381 assert_eq!(status.durable, memory_context::DurableStatus::Off);
6382 assert_memory_off_no_lease(&runtime);
6383 assert_eq!(
6384 ext.lease_count(),
6385 0,
6386 "revocation of a never-spawned lease is a no-op"
6387 );
6388
6389 let status = runtime.memory_context_disable();
6391 assert_eq!(status.durable, memory_context::DurableStatus::Off);
6392 assert_eq!(ext.lease_count(), 0);
6393 }
6394
6395 #[test]
6400 fn memory_context_enable_disable_emit_metadata_only_events() {
6401 memory_context::drain_captured_memory_events_for_test();
6402
6403 let runtime = Runtime::new_headless();
6404 runtime
6406 .memory_context_enable(
6407 memory_context::MemoryContextMode::Off,
6408 memory_context::mint_explicit_command_proof(),
6409 )
6410 .expect_err("Off is not an enable mode");
6411 assert!(
6412 memory_context::drain_captured_memory_events_for_test().is_empty(),
6413 "failed enable must emit no event"
6414 );
6415
6416 runtime
6417 .memory_context_enable(
6418 memory_context::MemoryContextMode::CaptureAndRecall,
6419 memory_context::mint_explicit_command_proof(),
6420 )
6421 .expect("enable succeeds");
6422 runtime.memory_context_disable();
6423
6424 let events = memory_context::drain_captured_memory_events_for_test();
6425 assert_eq!(
6426 events
6427 .iter()
6428 .map(|event| (event.event, event.outcome))
6429 .collect::<Vec<_>>(),
6430 vec![
6431 ("memory_context.enabled", "enabled"),
6432 ("memory_context.disabled", "disabled"),
6433 ],
6434 );
6435 assert_eq!(events[0].mode, Some("capture_and_recall"));
6436 assert_eq!(events[1].mode, Some("off"));
6437
6438 let cwd = std::env::current_dir()
6441 .expect("cwd")
6442 .to_string_lossy()
6443 .into_owned();
6444 for event in &events {
6445 let json = serde_json::to_string(event).expect("event serializes");
6446 assert!(
6447 json.contains("\"project_digest\":\"p") || json.contains("project-cwd-"),
6448 "digest identity expected; got: {json}"
6449 );
6450 assert!(
6451 !json.contains(&cwd),
6452 "raw project path leaked into event JSON: {json}"
6453 );
6454 }
6455 }
6456
6457 #[tokio::test]
6463 async fn memory_session_end_guard_drop_fresh_construction_is_off_no_lease() {
6464 let (runtime, ext) =
6465 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
6466 runtime
6467 .memory_context_enable(
6468 memory_context::MemoryContextMode::CaptureAndRecall,
6469 memory_context::mint_explicit_command_proof(),
6470 )
6471 .expect("enable succeeds");
6472 drop(runtime);
6474 assert_eq!(
6475 ext.lease_count(),
6476 0,
6477 "session end must leave no runtime lease (never-spawned reap is a no-op)"
6478 );
6479
6480 let mut fresh = Runtime::new_headless();
6482 fresh.install_extension_runtime(std::sync::Arc::clone(&ext));
6483 assert_memory_off_no_lease(&fresh);
6484 assert_eq!(ext.lease_count(), 0);
6485 }
6486}