1use crate::{Result, RuntimeError, ToolRegistry};
2use futures::stream::Stream;
3use reqwest::Client;
4use serde_json::{json, Value};
5use std::path::PathBuf;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::sync::Mutex;
9use std::time::Duration;
10use tokio::sync::{mpsc, RwLock};
11use tokio_util::sync::CancellationToken;
12
13mod api;
14mod api_sync;
15mod auth;
16#[cfg(test)]
17mod body_golden;
18pub mod budget;
19pub mod capture_worker;
20pub mod chat_capture;
21pub(crate) mod cloud_invoke;
22pub mod compaction;
23pub mod context;
24pub mod google_gemini;
25pub mod google_vertex;
26pub(crate) mod helpers;
27pub mod memory_context;
28pub mod memory_history;
29pub mod openai;
30pub mod relay;
31mod request;
32mod sse;
33mod sse_types;
34mod stream;
35pub mod subagent;
36pub mod telemetry;
37pub mod trace;
38pub(crate) mod transport;
39mod types;
40
41use api::ApiMethods;
42use auth::AuthMethods;
43use helpers::HelperMethods;
44use stream::StreamMethods;
45use types::AuthState;
46pub use types::{AgentEvent, LlmEvent, SessionEvent, StreamEvent};
47
48pub enum BeforeToolCallDecision {
50 Continue { input: Value },
51 Block { reason: String },
52}
53
54pub async fn emit_before_tool_call(
57 hook_bus: &Arc<crate::extensions::hooks::HookBus>,
58 tool_name: &str,
59 runtime_tool_name: Option<&str>,
60 input: Value,
61) -> crate::extensions::hooks::events::HookResult {
62 let mut event = crate::extensions::hooks::events::HookEvent::before_tool_call(tool_name, input);
63 if let Some(runtime_tool_name) = runtime_tool_name {
64 event.tool_runtime_name = Some(runtime_tool_name.to_string());
65 }
66 hook_bus.emit(&event).await
67}
68
69pub async fn resolve_before_tool_call_result(
74 hook_result: crate::extensions::hooks::events::HookResult,
75 secret_prompt: Option<&crate::tools::SecretPromptHandle>,
76 auto_approve_confirms: bool,
77) -> crate::extensions::hooks::events::HookResult {
78 match hook_result {
79 crate::extensions::hooks::events::HookResult::Confirm { message } => {
80 if auto_approve_confirms {
81 tracing::info!(message = %message, "confirm auto-approved (auto_approve_confirms=true)");
82 return crate::extensions::hooks::events::HookResult::Continue;
83 }
84
85 let Some(prompt) = secret_prompt else {
86 return crate::extensions::hooks::events::HookResult::Block {
87 reason: format!(
88 "Tool call requires confirmation but no interactive prompt is available: {}",
89 message
90 ),
91 };
92 };
93
94 let response = prompt
95 .prompt(
96 "Confirm tool call".to_string(),
97 format!("{}\n\nType 'yes' or 'y' to allow.", message),
98 )
99 .await;
100
101 match response.as_deref().map(str::trim) {
102 Some(answer)
103 if answer.eq_ignore_ascii_case("yes") || answer.eq_ignore_ascii_case("y") =>
104 {
105 crate::extensions::hooks::events::HookResult::Continue
106 }
107 _ => crate::extensions::hooks::events::HookResult::Block {
108 reason: format!("Tool call confirmation denied: {}", message),
109 },
110 }
111 }
112 other => other,
113 }
114}
115
116pub async fn resolve_before_tool_call_decision(
118 original_input: Value,
119 hook_result: crate::extensions::hooks::events::HookResult,
120 secret_prompt: Option<&crate::tools::SecretPromptHandle>,
121 auto_approve_confirms: bool,
122) -> BeforeToolCallDecision {
123 match resolve_before_tool_call_result(hook_result, secret_prompt, auto_approve_confirms).await {
124 crate::extensions::hooks::events::HookResult::Block { reason } => {
125 BeforeToolCallDecision::Block { reason }
126 }
127 crate::extensions::hooks::events::HookResult::Modify { input } => {
128 BeforeToolCallDecision::Continue { input }
129 }
130 _ => BeforeToolCallDecision::Continue {
131 input: original_input,
132 },
133 }
134}
135
136const MAX_REPLACE_OUTPUT: usize = 1024 * 1024; pub async fn emit_after_tool_call(
161 hook_bus: &Arc<crate::extensions::hooks::HookBus>,
162 tool_name: &str,
163 runtime_tool_name: Option<&str>,
164 input: Value,
165 output: String,
166 max_tool_output: usize,
167) -> String {
168 use crate::extensions::hooks::events::HookResult;
169 let original = output.clone();
171 let mut event =
172 crate::extensions::hooks::events::HookEvent::after_tool_call(tool_name, input, output);
173 if let Some(runtime_tool_name) = runtime_tool_name {
174 event.tool_runtime_name = Some(runtime_tool_name.to_string());
175 }
176 let post_hook = match hook_bus.emit(&event).await {
177 HookResult::Replace { mut output } => {
178 if output.len() > MAX_REPLACE_OUTPUT {
179 tracing::warn!(
180 tool = %tool_name,
181 len = output.len(),
182 cap = MAX_REPLACE_OUTPUT,
183 "Extension Replace output exceeds cap — clamping",
184 );
185 let mut boundary = MAX_REPLACE_OUTPUT;
187 while boundary > 0 && !output.is_char_boundary(boundary) {
188 boundary -= 1;
189 }
190 output.truncate(boundary);
191 }
192 output
193 }
194 HookResult::Continue => original,
195 other => {
196 tracing::warn!(
197 tool = %tool_name,
198 ?other,
199 "Unexpected after_tool_call result — using original output",
200 );
201 original
202 }
203 };
204 crate::runtime::helpers::HelperMethods::truncate_tool_result(&post_hook, max_tool_output)
208}
209
210#[derive(Clone)]
213struct PromptReloadSource {
214 manifest: PathBuf,
215 context: agent_core::prompt::SelectionContext,
216 user_module: Option<agent_core::prompt::PromptModule>,
217 delegation_policy_digest: Option<String>,
218}
219
220pub struct Runtime {
221 client: Client,
222 auth: Arc<RwLock<AuthState>>,
223 model: String,
224 tools: Arc<RwLock<ToolRegistry>>,
225 system_prompt: Option<String>,
226 effective_prompt: Option<agent_core::prompt::PromptStack>,
228 prompt_generation: u64,
229 prompt_reload_source: Option<PromptReloadSource>,
231 thinking_budget: u32,
232 named_level: Option<agent_core::reasoning::ReasoningLevel>,
236 explicit_reasoning: bool,
241 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole,
244 context_window_override: Option<u64>,
249 compaction_model: Option<String>,
251 compaction_mode: agent_core::compaction::CompactionMode,
253 compaction_exclusions: Vec<agent_core::compaction::ContentClass>,
255 remote_summarization_attempts: Arc<std::sync::atomic::AtomicU64>,
260 subagent_registry: Arc<Mutex<crate::runtime::subagent::SubagentRegistry>>,
262 orchestration: Option<Arc<crate::orchestration::OrchestrationRuntime>>,
264 event_queue: Arc<crate::events::EventQueue>,
266 pub watcher_exit_path: Option<PathBuf>,
268 max_tool_output: usize,
270 bash_timeout: u64,
271 bash_max_timeout: u64,
272 subagent_timeout: u64,
273 api_retries: u32,
274 refusal_retries: u32,
275 telemetry_level: crate::runtime::telemetry::TelemetryLevel,
277 telemetry_writer: Option<crate::runtime::telemetry::TelemetryWriter>,
283 trace_ctx: trace::TraceContext,
288 trace_controls: std::sync::Arc<trace::TraceControls>,
292 memory_context_state: std::sync::Arc<std::sync::Mutex<memory_context::SessionMemoryState>>,
304 #[cfg(any(test, feature = "testing"))]
308 capture_provider_for_test: std::sync::Arc<
309 std::sync::Mutex<Option<std::sync::Arc<dyn capture_worker::CaptureProvider>>>,
310 >,
311 pending_history_import_preview:
315 std::sync::Arc<std::sync::Mutex<Option<memory_history::HistoryImportPreview>>>,
316 history_import_plan: std::sync::Arc<std::sync::Mutex<Option<memory_history::ImportPlan>>>,
318 pending_memory_segment:
329 std::sync::Arc<std::sync::Mutex<Option<memory_context::ContextSegment>>>,
330 retained_recall_turn:
337 std::sync::Arc<std::sync::Mutex<Option<memory_context::RetainedRecallTurn>>>,
338 one_shot_trace_writer:
346 std::sync::Arc<std::sync::Mutex<Option<crate::runtime::telemetry::TelemetryWriter>>>,
347 capture_dir: std::path::PathBuf,
353 cache_diagnostics: bool,
355 cache_ttl: crate::core::config::CacheTtl,
358 ttl_downgrade_notified: std::sync::Arc<std::sync::atomic::AtomicBool>,
361 saw_1h_honored: std::sync::Arc<std::sync::atomic::AtomicBool>,
365 #[allow(dead_code)]
369 last_msg_id: Arc<Mutex<Option<String>>>,
370 session_manager: std::sync::Arc<crate::tools::shell::SessionManager>,
371 hook_bus: Arc<crate::extensions::hooks::HookBus>,
373 #[allow(dead_code)]
375 reaper_handle: Option<tokio::task::JoinHandle<()>>,
376 #[allow(dead_code)]
377 reaper_cancel: Option<tokio_util::sync::CancellationToken>,
378 credential_source: crate::auth::CredentialSource,
382 token_cache: crate::auth::TokenCache,
385 trusted_worker_models: Vec<agent_core::prompt::QualifiedModelId>,
389 progressive_tool_disclosure: bool,
393 delegation_parent: Option<String>,
396 mcp_runtime: Option<std::sync::Arc<crate::mcp::McpRuntimeManager>>,
400 mcp_session_scope: Option<std::sync::Arc<crate::mcp::McpSessionEndGuard>>,
404 extension_runtime: Option<std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>>,
408 extension_session_scope:
412 Option<std::sync::Arc<crate::extensions::lease::ExtensionSessionEndGuard>>,
413 turn_budget: crate::runtime::budget::TurnBudget,
416 host_tool_session: crate::tools::activation::SessionId,
424}
425
426fn fresh_host_tool_session() -> crate::tools::activation::SessionId {
430 crate::tools::activation::SessionId::parse(&format!(
431 "runtime-{}-{}",
432 std::process::id(),
433 uuid::Uuid::new_v4()
434 ))
435 .expect("generated runtime session id is always valid")
436}
437
438fn fresh_memory_context_state(
445) -> std::sync::Arc<std::sync::Mutex<memory_context::SessionMemoryState>> {
446 let session = memory_context::SessionId::parse(&format!(
447 "memctx-{}-{}",
448 std::process::id(),
449 uuid::Uuid::new_v4()
450 ))
451 .expect("generated memory session id is always valid");
452 std::sync::Arc::new(std::sync::Mutex::new(
453 memory_context::SessionMemoryState::new(session),
454 ))
455}
456
457fn memory_project_scope() -> std::result::Result<agent_core::memory::store::ProjectScope, String> {
461 let cwd = std::env::current_dir()
462 .map_err(|error| format!("cannot resolve current project directory: {error}"))?;
463 agent_core::memory::store::ProjectScope::discover(&cwd)
464 .map_err(|error| format!("cannot resolve project scope: {error}"))
465}
466
467fn memory_project_id() -> memory_context::ProjectId {
468 match memory_project_scope() {
469 Ok(scope) => memory_context::ProjectId::parse(scope.key())
470 .expect("host project scope key is always a valid identifier"),
471 Err(_) => memory_context::ProjectId::parse("project-unresolved")
472 .expect("static fallback project id is valid"),
473 }
474}
475
476fn memory_capture_worker() -> &'static capture_worker::CaptureWorker {
485 static WORKER: std::sync::OnceLock<capture_worker::CaptureWorker> = std::sync::OnceLock::new();
486 WORKER.get_or_init(|| capture_worker::CaptureWorker::new(32))
487}
488
489struct ExtensionCaptureProvider {
490 manager: std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
491 session: crate::tools::activation::SessionId,
492 plugin: String,
493 digest: crate::tools::catalog::SchemaDigest,
494 handle: tokio::runtime::Handle,
495}
496
497impl capture_worker::CaptureProvider for ExtensionCaptureProvider {
498 fn capture(
499 &self,
500 capture: chat_capture::ChatTurnCapture,
501 ) -> std::result::Result<(), capture_worker::CaptureFailure> {
502 let params = memory_context::capture_request_wire(&capture);
503 self.handle
504 .block_on(
505 crate::extensions::lease::ExtensionLeaseCapability::new(
506 self.session.clone(),
507 self.manager.clone(),
508 )
509 .call_exact(
510 &self.plugin,
511 memory_context::MEMORY_CAPTURE_TOOL_NAME,
512 &self.digest,
513 params,
514 ),
515 )
516 .map(|_| ())
517 .map_err(|_| capture_worker::CaptureFailure {
518 code: "provider_call_failed",
519 })
520 }
521
522 fn contains_capture(
523 &self,
524 capture_id: &[u8; 32],
525 ) -> std::result::Result<capture_worker::CaptureCommitState, capture_worker::CaptureFailure>
526 {
527 let capture_id = chat_capture::CaptureId::from_bytes(*capture_id);
528 let response = self
529 .handle
530 .block_on(
531 crate::extensions::lease::ExtensionLeaseCapability::new(
532 self.session.clone(),
533 self.manager.clone(),
534 )
535 .call_exact(
536 &self.plugin,
537 memory_context::MEMORY_CAPTURE_TOOL_NAME,
538 &self.digest,
539 memory_context::capture_query_wire(&capture_id),
540 ),
541 )
542 .map_err(|_| capture_worker::CaptureFailure {
543 code: "provider_query_failed",
544 })?;
545 Ok(
546 if response
547 .get("committed")
548 .and_then(serde_json::Value::as_bool)
549 == Some(true)
550 {
551 capture_worker::CaptureCommitState::Committed
552 } else {
553 capture_worker::CaptureCommitState::Absent
554 },
555 )
556 }
557
558 fn capture_summary(
559 &self,
560 capture: chat_capture::ConversationSummaryCapture,
561 ) -> std::result::Result<(), capture_worker::CaptureFailure> {
562 let params = memory_context::summary_capture_request_wire(&capture);
563 self.handle
564 .block_on(
565 crate::extensions::lease::ExtensionLeaseCapability::new(
566 self.session.clone(),
567 self.manager.clone(),
568 )
569 .call_exact(
570 &self.plugin,
571 memory_context::MEMORY_CAPTURE_TOOL_NAME,
572 &self.digest,
573 params,
574 ),
575 )
576 .map(|_| ())
577 .map_err(|_| capture_worker::CaptureFailure {
578 code: "provider_call_failed",
579 })
580 }
581}
582
583fn terminal_capture_history(
584 lease: &memory_context::MemoryContextLease,
585 messages: &[crate::SharedMessage],
586 started_at: std::time::SystemTime,
587) -> chat_capture::TerminalTurnHistory {
588 use agent_core::core::disclosure::DisclosureClass;
589 let mut items = Vec::new();
590 for message in messages {
591 let Some(role) = message.get("role").and_then(serde_json::Value::as_str) else {
592 continue;
593 };
594 let class = match role {
595 "user" => chat_capture::CaptureContentClass::UserMessage,
596 "assistant" => chat_capture::CaptureContentClass::AssistantFinal,
597 _ => continue,
598 };
599 let text = message
600 .get("content")
601 .and_then(serde_json::Value::as_str)
602 .map(str::to_owned)
603 .unwrap_or_else(|| {
604 message
605 .get("content")
606 .map(ToString::to_string)
607 .unwrap_or_default()
608 });
609 items.push(chat_capture::CanonicalCaptureItem {
610 project_id: lease.project_id.clone(),
611 class,
612 disclosure: DisclosureClass::ModelVisible,
613 sensitivity: chat_capture::Sensitivity::Normal,
614 text,
615 tool_name: None,
616 });
617 }
618 chat_capture::TerminalTurnHistory {
619 project_id: lease.project_id.clone(),
620 session_id: lease.session_id.clone(),
621 turn_id: memory_context::TurnId::parse(&format!("turn-{}", uuid::Uuid::new_v4()))
622 .expect("generated turn id"),
623 turn_ordinal: 0,
624 started_at,
625 completed_at: std::time::SystemTime::now(),
626 outcome: agent_core::TurnOutcome::Completed,
627 items,
628 compaction: None,
629 }
630}
631
632fn memory_provider_id() -> memory_context::ContextProviderId {
633 memory_context::ContextProviderId::parse("axel-memory")
634 .expect("static provider id is always valid")
635}
636
637const HTTP_READ_TIMEOUT: Duration = Duration::from_secs(90);
642
643fn build_http_client(read_timeout: Duration) -> reqwest::Result<Client> {
656 Client::builder()
657 .tls_built_in_webpki_certs(true)
658 .tls_built_in_native_certs(true)
659 .connect_timeout(Duration::from_secs(10))
660 .read_timeout(read_timeout)
661 .build()
662}
663
664fn canonical_trusted_worker_model(model: &str) -> String {
668 let model = model.trim();
669 if let Some(id) = model.strip_prefix("claude/") {
670 format!("anthropic/{id}")
671 } else if model.starts_with("claude-") {
672 format!("anthropic/{model}")
673 } else {
674 model.to_owned()
675 }
676}
677
678impl Runtime {
679 pub async fn new() -> Result<Self> {
680 let (auth_token, auth_type, refresh_token, token_expires) =
684 (String::new(), "oauth".to_string(), None, Some(0));
685
686 let client = build_http_client(HTTP_READ_TIMEOUT)
687 .map_err(|e| RuntimeError::Config(format!("Failed to build HTTP client: {}", e)))?;
688
689 let capture_dir = trace::default_capture_dir();
694 let _ = trace::sweep_expired_captures(&capture_dir);
695
696 let session_manager = {
697 let config = crate::tools::shell::ShellConfig::default();
698 crate::tools::shell::SessionManager::new(config)
699 };
700
701 let mgr = session_manager.clone();
703 let cancel = tokio_util::sync::CancellationToken::new();
704 let reaper_handle = crate::tools::shell::session::start_reaper(mgr, cancel.clone());
705
706 Ok(Runtime {
707 client,
708 auth: Arc::new(RwLock::new(AuthState {
709 auth_token,
710 auth_type,
711 refresh_token,
712 token_expires,
713 })),
714 model: crate::models::default_model().to_string(),
715 tools: Arc::new(RwLock::new(ToolRegistry::new())),
716 system_prompt: None,
717 effective_prompt: None,
718 prompt_generation: 0,
719 prompt_reload_source: None,
720 thinking_budget: 4096,
721 named_level: None,
722 explicit_reasoning: false,
723 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole::Foreground,
724 context_window_override: None,
725 compaction_model: None,
726 compaction_mode: agent_core::compaction::CompactionMode::default(),
727 compaction_exclusions: Vec::new(),
728 remote_summarization_attempts: Arc::new(std::sync::atomic::AtomicU64::new(0)),
729 subagent_registry: Arc::new(Mutex::new(
730 crate::runtime::subagent::SubagentRegistry::new(),
731 )),
732 orchestration: None,
733 event_queue: Arc::new(crate::events::EventQueue::new(1000)),
734 watcher_exit_path: None,
735 max_tool_output: 30000,
736 bash_timeout: 30,
737 bash_max_timeout: 300,
738 subagent_timeout: 300,
739 api_retries: 3,
740 refusal_retries: 2,
741 telemetry_level: crate::runtime::telemetry::TelemetryLevel::Off,
742 telemetry_writer: None,
743 trace_ctx: trace::TraceContext::disabled(),
744 trace_controls: std::sync::Arc::new(trace::TraceControls::new()),
745 memory_context_state: fresh_memory_context_state(),
748 pending_history_import_preview: std::sync::Arc::new(std::sync::Mutex::new(None)),
749 history_import_plan: std::sync::Arc::new(std::sync::Mutex::new(None)),
750 #[cfg(any(test, feature = "testing"))]
751 capture_provider_for_test: std::sync::Arc::new(std::sync::Mutex::new(None)),
752 pending_memory_segment: std::sync::Arc::new(std::sync::Mutex::new(None)),
755 retained_recall_turn: std::sync::Arc::new(std::sync::Mutex::new(None)),
756 one_shot_trace_writer: std::sync::Arc::new(std::sync::Mutex::new(None)),
757 capture_dir,
758 cache_diagnostics: false,
759 cache_ttl: crate::core::config::CacheTtl::default(),
760 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
761 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
762 last_msg_id: Arc::new(Mutex::new(None)),
763 session_manager,
764 hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
765 reaper_handle: Some(reaper_handle),
766 reaper_cancel: Some(cancel),
767 credential_source: crate::auth::CredentialSource::Local,
768 token_cache: crate::auth::TokenCache::new(),
769 trusted_worker_models: Vec::new(),
770 progressive_tool_disclosure: false,
771 delegation_parent: None,
772 mcp_runtime: None,
773 mcp_session_scope: None,
774 extension_runtime: None,
775 extension_session_scope: None,
776 turn_budget: crate::runtime::budget::TurnBudget::for_role(
777 crate::runtime::budget::TurnRole::Foreground,
778 ),
779 host_tool_session: fresh_host_tool_session(),
780 })
781 }
782
783 #[cfg(any(test, feature = "testing"))]
794 pub fn new_headless() -> Self {
795 let client = build_http_client(HTTP_READ_TIMEOUT)
796 .expect("reqwest client construction is infallible with built-in roots");
797
798 let session_manager = {
799 let config = crate::tools::shell::ShellConfig::default();
800 crate::tools::shell::SessionManager::new(config)
801 };
802
803 Runtime {
804 client,
805 auth: Arc::new(RwLock::new(AuthState {
806 auth_token: "test-token".to_string(),
807 auth_type: "api_key".to_string(),
808 refresh_token: None,
809 token_expires: None,
810 })),
811 model: crate::models::default_model().to_string(),
812 tools: Arc::new(RwLock::new(ToolRegistry::new())),
813 system_prompt: None,
814 effective_prompt: None,
815 prompt_generation: 0,
816 prompt_reload_source: None,
817 thinking_budget: 4096,
818 named_level: None,
819 explicit_reasoning: false,
820 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole::Foreground,
821 context_window_override: None,
822 compaction_model: None,
823 compaction_mode: agent_core::compaction::CompactionMode::default(),
824 compaction_exclusions: Vec::new(),
825 remote_summarization_attempts: Arc::new(std::sync::atomic::AtomicU64::new(0)),
826 subagent_registry: Arc::new(Mutex::new(
827 crate::runtime::subagent::SubagentRegistry::new(),
828 )),
829 orchestration: None,
830 event_queue: Arc::new(crate::events::EventQueue::new(1000)),
831 watcher_exit_path: None,
832 max_tool_output: 30000,
833 bash_timeout: 30,
834 bash_max_timeout: 300,
835 subagent_timeout: 300,
836 api_retries: 3,
837 refusal_retries: 2,
838 telemetry_level: crate::runtime::telemetry::TelemetryLevel::Off,
839 telemetry_writer: None,
840 trace_ctx: trace::TraceContext::disabled(),
841 trace_controls: std::sync::Arc::new(trace::TraceControls::new()),
842 memory_context_state: fresh_memory_context_state(),
845 pending_history_import_preview: std::sync::Arc::new(std::sync::Mutex::new(None)),
846 history_import_plan: std::sync::Arc::new(std::sync::Mutex::new(None)),
847 #[cfg(any(test, feature = "testing"))]
848 capture_provider_for_test: std::sync::Arc::new(std::sync::Mutex::new(None)),
849 pending_memory_segment: std::sync::Arc::new(std::sync::Mutex::new(None)),
852 retained_recall_turn: std::sync::Arc::new(std::sync::Mutex::new(None)),
853 one_shot_trace_writer: std::sync::Arc::new(std::sync::Mutex::new(None)),
854 capture_dir: trace::default_capture_dir(),
855 cache_diagnostics: false,
856 cache_ttl: crate::core::config::CacheTtl::default(),
857 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
858 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
859 last_msg_id: Arc::new(Mutex::new(None)),
860 session_manager,
861 hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
862 reaper_handle: None,
863 reaper_cancel: None,
864 credential_source: crate::auth::CredentialSource::Local,
865 token_cache: crate::auth::TokenCache::new(),
866 trusted_worker_models: Vec::new(),
867 progressive_tool_disclosure: false,
868 delegation_parent: None,
869 mcp_runtime: None,
870 mcp_session_scope: None,
871 extension_runtime: None,
872 extension_session_scope: None,
873 turn_budget: crate::runtime::budget::TurnBudget::for_role(
874 crate::runtime::budget::TurnRole::Foreground,
875 ),
876 host_tool_session: fresh_host_tool_session(),
877 }
878 }
879
880 pub fn set_system_prompt(&mut self, prompt: String) {
881 self.system_prompt = Some(prompt);
882 }
883
884 pub fn system_prompt(&self) -> Option<&str> {
885 self.system_prompt.as_deref()
886 }
887
888 pub async fn effective_system_prompt(&self) -> Option<String> {
900 if self.effective_prompt.is_some() {
901 return self.system_prompt.clone();
902 }
903 if self.tools.read().await.get("subagent_start").is_none() {
904 return self.system_prompt.clone();
905 }
906 let Ok(model) = crate::orchestration::canonical_foreground_identity(&self.model) else {
907 return self.system_prompt.clone();
908 };
909 let workflow_mode = match self.reasoning_level() {
910 agent_core::reasoning::ReasoningLevel::UltraCode => {
911 Some(agent_core::prompt::WorkflowMode::UltraCode)
912 }
913 agent_core::reasoning::ReasoningLevel::Max => {
914 Some(agent_core::prompt::WorkflowMode::Max)
915 }
916 agent_core::reasoning::ReasoningLevel::XHigh => {
917 Some(agent_core::prompt::WorkflowMode::XHigh)
918 }
919 _ => None,
920 };
921 let Ok(context) = agent_core::prompt::SelectionContext::new(model, None)
922 .map(|context| context.with_workflow_mode(workflow_mode))
923 else {
924 return self.system_prompt.clone();
925 };
926 agent_core::prompt::compose_orchestration_prompt(self.system_prompt.as_deref(), &context)
927 }
928
929 pub fn effective_prompt(&self) -> Option<&agent_core::prompt::PromptStack> {
930 self.effective_prompt.as_ref()
931 }
932
933 pub fn prompt_generation(&self) -> u64 {
934 self.prompt_generation
935 }
936
937 pub fn retain_prompt_reload_source(
938 &mut self,
939 manifest: PathBuf,
940 context: agent_core::prompt::SelectionContext,
941 user_module: Option<agent_core::prompt::PromptModule>,
942 delegation_policy_digest: Option<String>,
943 ) {
944 self.prompt_reload_source = Some(PromptReloadSource {
945 manifest,
946 context,
947 user_module,
948 delegation_policy_digest,
949 });
950 }
951
952 pub fn reload_prompt(&mut self) -> std::result::Result<u64, agent_core::prompt::PromptError> {
954 let source = self.prompt_reload_source.clone().ok_or_else(|| {
955 agent_core::prompt::PromptError::Invalid("no prompt manifest is active".into())
956 })?;
957 let raw = std::fs::read_to_string(&source.manifest).map_err(|_| {
958 agent_core::prompt::PromptError::Invalid("prompt manifest is unavailable".into())
959 })?;
960 let manifest = agent_core::prompt::PromptManifest::parse(&raw)?;
961 let reload_catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
962 source.context.model(),
963 manifest.delegation_catalog_candidates(),
964 )
965 .map_err(|error| agent_core::prompt::PromptError::Invalid(error.into()))?;
966 let candidate_policy_digest = manifest
967 .delegation_policy(source.context.model().clone(), &reload_catalog)?
968 .map(|policy| policy.digest());
969 if candidate_policy_digest != source.delegation_policy_digest {
970 return Err(agent_core::prompt::PromptError::Invalid(
971 "hot reload cannot safely change delegation policy".into(),
972 ));
973 }
974 let registry = manifest.registry(source.manifest.parent())?;
975 let candidate = agent_core::prompt::compile_prompt_stack(
976 &manifest,
977 ®istry,
978 &source.context,
979 source.user_module,
980 )?;
981 self.apply_prompt_stack(candidate)
982 }
983
984 pub fn apply_prompt_stack(
986 &mut self,
987 candidate: agent_core::prompt::PromptStack,
988 ) -> std::result::Result<u64, agent_core::prompt::PromptError> {
989 if let Some(current) = &self.effective_prompt {
990 current.validate_hot_reload(&candidate)?;
991 }
992 let composed = candidate.composed().to_owned();
993 self.effective_prompt = Some(candidate);
994 self.system_prompt = Some(composed);
995 self.prompt_generation = self.prompt_generation.saturating_add(1);
996 Ok(self.prompt_generation)
997 }
998
999 pub fn prompt_inspection_json(&self) -> Option<String> {
1000 let mode = self
1001 .orchestration
1002 .as_ref()
1003 .map(|runtime| runtime.enforcement_mode())
1004 .unwrap_or(agent_core::orchestration::EnforcementMode::Off);
1005 self.effective_prompt.as_ref().and_then(|stack| {
1006 serde_json::to_string(&serde_json::json!({
1007 "generation": self.prompt_generation,
1008 "effective": stack.inspect(mode),
1009 "token_estimate": stack.composed().len().div_ceil(4),
1010 }))
1011 .ok()
1012 })
1013 }
1014
1015 pub fn install_mcp_runtime(&mut self, manager: std::sync::Arc<crate::mcp::McpRuntimeManager>) {
1020 self.mcp_session_scope = Some(std::sync::Arc::new(crate::mcp::McpSessionEndGuard::new(
1021 self.host_tool_session.clone(),
1022 std::sync::Arc::clone(&manager),
1023 )));
1024 self.mcp_runtime = Some(manager);
1025 }
1026
1027 pub fn set_turn_budget(&mut self, budget: crate::runtime::budget::TurnBudget) {
1031 self.turn_budget = budget;
1032 }
1033
1034 pub fn turn_budget(&self) -> &crate::runtime::budget::TurnBudget {
1036 &self.turn_budget
1037 }
1038
1039 pub fn install_extension_runtime(
1044 &mut self,
1045 manager: std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
1046 ) {
1047 manager.bind_host_scope(self.host_tool_session.clone());
1051 self.extension_session_scope = Some(std::sync::Arc::new(
1052 crate::extensions::lease::ExtensionSessionEndGuard::new(
1053 self.host_tool_session.clone(),
1054 std::sync::Arc::clone(&manager),
1055 ),
1056 ));
1057 self.extension_runtime = Some(manager);
1058 }
1059
1060 pub fn install_orchestration(
1061 &mut self,
1062 runtime: Arc<crate::orchestration::OrchestrationRuntime>,
1063 ) {
1064 for model in &self.trusted_worker_models {
1065 if let Err(error) = runtime.grant_worker_model(model.as_str()) {
1066 tracing::warn!(
1067 model = model.as_str(),
1068 error = %error,
1069 "failed to apply configured worker-model trust"
1070 );
1071 }
1072 }
1073 self.orchestration = Some(runtime);
1074 }
1075
1076 pub fn install_worker_orchestration(
1082 &mut self,
1083 runtime: Arc<crate::orchestration::OrchestrationRuntime>,
1084 ) {
1085 self.orchestration = Some(runtime);
1086 }
1087
1088 pub fn set_delegation_parent(&mut self, parent: Option<String>) {
1089 self.delegation_parent = parent;
1090 }
1091
1092 pub fn orchestration(&self) -> Option<&Arc<crate::orchestration::OrchestrationRuntime>> {
1093 self.orchestration.as_ref()
1094 }
1095
1096 pub fn grant_worker_model(&self, model: &str) -> std::result::Result<(), String> {
1101 self.orchestration
1102 .as_ref()
1103 .ok_or_else(|| "delegation policy unavailable".to_string())?
1104 .grant_worker_model(model)
1105 }
1106
1107 pub fn set_model(&mut self, model: String) {
1108 let _ = self.try_set_model(model);
1109 }
1110
1111 pub fn try_set_model(&mut self, model: String) -> std::result::Result<(), String> {
1116 let trimmed = model.trim();
1121 let cleaned = trimmed
1122 .split_once(char::is_whitespace)
1123 .and_then(|(_, rest)| {
1124 let rest = rest.trim_start();
1125 let (latency, candidate) = rest.split_once(char::is_whitespace)?;
1126 let millis = latency.strip_suffix("ms")?;
1127 if !millis.is_empty() && millis.chars().all(|c| c.is_ascii_digit()) {
1128 let candidate = candidate.trim();
1129 if !candidate.is_empty() && !candidate.chars().any(char::is_whitespace) {
1130 return Some(candidate);
1131 }
1132 }
1133 None
1134 })
1135 .unwrap_or(trimmed);
1136 if self.prompt_reload_source.is_none()
1142 && self
1143 .orchestration
1144 .as_ref()
1145 .is_some_and(|current| !current.unreconciled_runtime_handles().is_empty())
1146 {
1147 return Err("model change blocked: workers require collection/reconciliation".into());
1148 }
1149 let orchestration_replacement = if self.prompt_reload_source.is_none()
1150 && self.orchestration.is_some()
1151 {
1152 let foreground = crate::orchestration::canonical_foreground_identity(cleaned)
1153 .map_err(|_| "model change blocked: unresolved foreground model".to_string())?;
1154 let replacement = crate::orchestration::OrchestrationRuntime::baseline(
1155 foreground, 8, 64,
1156 )
1157 .map_err(|_| "model change blocked: trusted worker catalog unavailable".to_string())?;
1158 for trusted in &self.trusted_worker_models {
1159 replacement
1160 .grant_worker_model(trusted.as_str())
1161 .map_err(|_| {
1162 "model change blocked: configured worker trust invalid".to_string()
1163 })?;
1164 }
1165 Some(Arc::new(replacement))
1166 } else {
1167 None
1168 };
1169 self.model = cleaned.to_owned();
1170 if let Some(replacement) = orchestration_replacement {
1174 self.orchestration = Some(replacement);
1175 }
1176 if !self.explicit_reasoning {
1182 if let Some(level) =
1183 crate::runtime::openai::catalog::validation::default_level_for_model(cleaned)
1184 {
1185 self.set_reasoning_level(level);
1186 }
1187 }
1188 Ok(())
1189 }
1190
1191 pub fn set_tools(&mut self, tools: ToolRegistry) {
1192 self.tools = Arc::new(RwLock::new(tools));
1193 }
1194
1195 pub fn subagent_registry(&self) -> &Arc<Mutex<crate::runtime::subagent::SubagentRegistry>> {
1196 &self.subagent_registry
1197 }
1198
1199 pub fn event_queue(&self) -> &Arc<crate::events::EventQueue> {
1200 &self.event_queue
1201 }
1202
1203 pub fn hook_bus(&self) -> &Arc<crate::extensions::hooks::HookBus> {
1205 &self.hook_bus
1206 }
1207
1208 pub fn host_tool_session_id(&self) -> &crate::tools::activation::SessionId {
1212 &self.host_tool_session
1213 }
1214
1215 pub fn tools_shared(&self) -> Arc<RwLock<ToolRegistry>> {
1217 Arc::clone(&self.tools)
1218 }
1219
1220 pub fn model(&self) -> &str {
1221 &self.model
1222 }
1223
1224 pub fn http_client(&self) -> &Client {
1225 &self.client
1226 }
1227 pub fn set_thinking_budget(&mut self, budget: u32) {
1228 self.thinking_budget = budget;
1229 self.named_level = Some(agent_core::reasoning::ReasoningLevel::from_legacy_budget(
1231 budget,
1232 ));
1233 self.explicit_reasoning = false;
1235 }
1236
1237 pub fn set_reasoning_level(&mut self, level: agent_core::reasoning::ReasoningLevel) {
1243 self.named_level = Some(level);
1244 if let Some(budget) = level.to_legacy_budget() {
1245 self.thinking_budget = budget;
1246 }
1247 self.explicit_reasoning = false;
1249 }
1250
1251 pub fn set_reasoning_level_explicit(&mut self, level: agent_core::reasoning::ReasoningLevel) {
1256 self.set_reasoning_level(level);
1257 self.explicit_reasoning = true;
1258 }
1259
1260 pub fn set_thinking_budget_explicit(&mut self, budget: u32) {
1265 self.thinking_budget = budget;
1266 self.named_level = Some(agent_core::reasoning::ReasoningLevel::from_legacy_budget(
1267 budget,
1268 ));
1269 self.explicit_reasoning = true;
1270 }
1271
1272 pub fn is_reasoning_explicit(&self) -> bool {
1274 self.explicit_reasoning
1275 }
1276
1277 pub fn thinking_budget_raw(&self) -> u32 {
1279 self.thinking_budget
1280 }
1281
1282 pub fn set_reasoning_level_checked(
1287 &mut self,
1288 level: agent_core::reasoning::ReasoningLevel,
1289 ) -> std::result::Result<(), String> {
1290 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
1291 &self.model,
1292 level,
1293 )?;
1294 if self.model.starts_with("anthropic/")
1295 && level == agent_core::reasoning::ReasoningLevel::UltraCode
1296 {
1297 if self.codex_request_role()
1298 != crate::runtime::openai::catalog::ExecutionRole::Foreground
1299 {
1300 return Err("ultracode_requires_foreground".into());
1301 }
1302 if self.effective_prompt.is_some() {
1303 return Err("typed_prompt_manifest_blocks_required_doctrine".into());
1304 }
1305 if self.orchestration.is_none() {
1306 return Err("ultracode_requires_orchestration".into());
1307 }
1308 let tools = self.tools.try_read().map_err(|_| {
1309 "ultracode prerequisite state is busy; refusing mutation".to_string()
1310 })?;
1311 let required = ["subagent_start", "subagent_status", "subagent_collect"];
1312 if !required.iter().all(|name| {
1313 tools
1314 .get(name)
1315 .is_some_and(|tool| tool.extension_id().is_none())
1316 }) {
1317 return Err("ultracode_requires_lifecycle_tools".into());
1318 }
1319 }
1320 self.set_reasoning_level_explicit(level);
1321 Ok(())
1322 }
1323
1324 pub fn set_thinking_budget_checked(&mut self, budget: u32) -> std::result::Result<(), String> {
1331 let level = agent_core::reasoning::ReasoningLevel::from_legacy_budget(budget);
1332 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
1333 &self.model,
1334 level,
1335 )?;
1336 self.set_thinking_budget_explicit(budget);
1337 Ok(())
1338 }
1339
1340 pub fn reasoning_level(&self) -> agent_core::reasoning::ReasoningLevel {
1341 self.named_level.unwrap_or_else(|| {
1342 agent_core::reasoning::ReasoningLevel::from_legacy_budget(self.thinking_budget)
1343 })
1344 }
1345
1346 pub(crate) fn set_codex_request_role(
1347 &mut self,
1348 role: crate::runtime::openai::catalog::CodexRequestRole,
1349 ) {
1350 self.codex_request_role = role;
1351 }
1352
1353 pub(crate) fn codex_request_role(&self) -> crate::runtime::openai::catalog::CodexRequestRole {
1354 self.codex_request_role
1355 }
1356
1357 async fn authorized_anthropic_plan(
1358 &self,
1359 ) -> Result<Option<crate::runtime::openai::catalog::AnthropicExecutionPlan>> {
1360 if !self.model.starts_with("anthropic/") {
1361 return Ok(None);
1362 }
1363 let tools = self.tools.read().await;
1364 let builtin = |name: &str| {
1365 tools
1366 .get(name)
1367 .is_some_and(|tool| tool.extension_id().is_none())
1368 };
1369 let readiness = self
1370 .orchestration
1371 .as_ref()
1372 .and_then(|runtime| runtime.ultracode_readiness(&self.model).ok());
1373 let prerequisites = crate::runtime::openai::catalog::AnthropicPlanPrerequisites {
1374 orchestration_policy: self.orchestration.is_some(),
1375 foreground_worker_authorized: readiness.is_some(),
1376 concurrent_limit: readiness.map_or(0, |limits| limits.0),
1377 total_limit: readiness.map_or(0, |limits| limits.1),
1378 lifecycle_start: builtin("subagent_start"),
1379 lifecycle_status: builtin("subagent_status"),
1380 lifecycle_steer: builtin("subagent_steer"),
1381 lifecycle_collect: builtin("subagent_collect"),
1382 lifecycle_resume: builtin("subagent_resume"),
1383 };
1384 crate::runtime::openai::catalog::plan_anthropic_execution(
1385 &self.model,
1386 self.reasoning_level(),
1387 self.codex_request_role(),
1388 prerequisites,
1389 crate::runtime::openai::catalog::capability_cache::get(&self.model).and_then(|entry| {
1390 match entry.reasoning {
1391 crate::runtime::openai::catalog::ReasoningSupport::AnthropicAdaptive {
1392 adaptive,
1393 } => Some(adaptive),
1394 crate::runtime::openai::catalog::ReasoningSupport::None => Some(false),
1395 _ => None,
1396 }
1397 }),
1398 )
1399 .map(Some)
1400 .map_err(|error| RuntimeError::Config(error.to_string()))
1401 }
1402
1403 async fn validate_request_preflight(&self) -> Result<()> {
1406 self.validate_request_preflight_for(&self.model, self.codex_request_role)
1407 .await
1408 }
1409
1410 async fn validate_request_preflight_for(
1411 &self,
1412 model: &str,
1413 role: crate::runtime::openai::catalog::CodexRequestRole,
1414 ) -> Result<()> {
1415 let level = self.reasoning_level();
1416 if model.starts_with("anthropic/")
1417 && level == agent_core::reasoning::ReasoningLevel::UltraCode
1418 && self.effective_prompt.is_some()
1419 {
1420 return Err(RuntimeError::Config(
1421 "Anthropic execution plan rejected: typed_prompt_manifest_blocks_required_doctrine"
1422 .into(),
1423 ));
1424 }
1425 if model.starts_with("anthropic/")
1426 && matches!(
1427 level,
1428 agent_core::reasoning::ReasoningLevel::Max
1429 | agent_core::reasoning::ReasoningLevel::UltraCode
1430 | agent_core::reasoning::ReasoningLevel::Ultra
1431 )
1432 {
1433 let tools = self.tools.read().await;
1434 let builtin = |name: &str| {
1435 tools
1436 .get(name)
1437 .is_some_and(|tool| tool.extension_id().is_none())
1438 };
1439 let readiness = self
1440 .orchestration
1441 .as_ref()
1442 .and_then(|runtime| runtime.ultracode_readiness(model).ok());
1443 let prerequisites = crate::runtime::openai::catalog::AnthropicPlanPrerequisites {
1444 orchestration_policy: self.orchestration.is_some(),
1445 foreground_worker_authorized: readiness.is_some(),
1446 concurrent_limit: readiness.map_or(0, |limits| limits.0),
1447 total_limit: readiness.map_or(0, |limits| limits.1),
1448 lifecycle_start: builtin("subagent_start"),
1449 lifecycle_status: builtin("subagent_status"),
1450 lifecycle_steer: builtin("subagent_steer"),
1451 lifecycle_collect: builtin("subagent_collect"),
1452 lifecycle_resume: builtin("subagent_resume"),
1453 };
1454 drop(tools);
1455 let result = crate::runtime::openai::catalog::plan_anthropic_execution(
1456 model,
1457 level,
1458 role,
1459 prerequisites,
1460 crate::runtime::openai::catalog::capability_cache::get(model).and_then(|entry| {
1461 match entry.reasoning {
1462 crate::runtime::openai::catalog::ReasoningSupport::AnthropicAdaptive {
1463 adaptive,
1464 } => Some(adaptive),
1465 crate::runtime::openai::catalog::ReasoningSupport::None => Some(false),
1466 _ => None,
1467 }
1468 }),
1469 );
1470 match result {
1471 Ok(plan) => {
1472 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);
1473 return Ok(());
1474 }
1475 Err(error) => {
1476 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);
1477 return Err(RuntimeError::Config(error.to_string()));
1478 }
1479 }
1480 }
1481 if model.starts_with("openai-codex/") {
1482 let plan =
1483 crate::runtime::openai::catalog::plan_codex_execution(model, level, role, None)
1484 .map_err(|error| {
1485 tracing::debug!(
1486 event = "codex_mode_plan",
1487 qualified_model = %model,
1488 requested_level = %level,
1489 runtime_role = role.as_str(),
1490 decision = "deny",
1491 deny_code = error.code().as_str(),
1492 network_attempted = false,
1493 "Codex request preflight denied"
1494 );
1495 RuntimeError::Config(error.to_string())
1496 })?;
1497
1498 if plan.automatic_delegation() {
1499 if self.orchestration.is_none() {
1500 tracing::debug!(
1501 event = "codex_mode_plan",
1502 qualified_model = %model,
1503 requested_level = %level,
1504 runtime_role = role.as_str(),
1505 decision = "deny",
1506 deny_code = "ultra_requires_orchestration",
1507 network_attempted = false,
1508 "Codex request preflight denied"
1509 );
1510 return Err(RuntimeError::Config(
1511 "Ultra requires an installed orchestration policy".to_string(),
1512 ));
1513 }
1514
1515 let tools = self.tools.read().await;
1516 let required = ["subagent_start", "subagent_status", "subagent_collect"];
1517 let tools_ready = required.iter().all(|name| {
1518 tools
1519 .get(name)
1520 .is_some_and(|tool| tool.extension_id().is_none())
1521 });
1522 if !tools_ready {
1523 tracing::debug!(
1524 event = "codex_mode_plan",
1525 qualified_model = %model,
1526 requested_level = %level,
1527 runtime_role = role.as_str(),
1528 decision = "deny",
1529 deny_code = "ultra_requires_subagent_tools",
1530 network_attempted = false,
1531 "Codex request preflight denied"
1532 );
1533 return Err(RuntimeError::Config(
1534 "Ultra requires built-in subagent_start, subagent_status, and subagent_collect tools"
1535 .to_string(),
1536 ));
1537 }
1538 }
1539 return Ok(());
1540 }
1541
1542 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(model, level)
1543 .map_err(|error| {
1544 tracing::debug!(
1545 event = "codex_mode_plan",
1546 qualified_model = %model,
1547 requested_level = %level,
1548 runtime_role = role.as_str(),
1549 decision = "deny",
1550 deny_code = "unsupported_reasoning_level",
1551 network_attempted = false,
1552 "request preflight denied"
1553 );
1554 RuntimeError::Config(error)
1555 })
1556 }
1557
1558 pub fn set_compaction_model(&mut self, model: Option<String>) {
1559 self.compaction_model = model;
1560 }
1561
1562 pub fn set_compaction_mode(&mut self, mode: agent_core::compaction::CompactionMode) {
1564 self.compaction_mode = mode;
1565 }
1566
1567 pub fn set_compaction_exclusions(
1569 &mut self,
1570 exclude: Vec<agent_core::compaction::ContentClass>,
1571 ) {
1572 self.compaction_exclusions = exclude;
1573 }
1574
1575 pub fn remote_summarization_attempts(&self) -> u64 {
1578 self.remote_summarization_attempts
1579 .load(std::sync::atomic::Ordering::SeqCst)
1580 }
1581
1582 pub fn compaction_policy(&self) -> crate::runtime::compaction::DisclosurePolicy {
1585 crate::runtime::compaction::DisclosurePolicy {
1586 mode: self.compaction_mode,
1587 exclude: self.compaction_exclusions.clone(),
1588 }
1589 }
1590
1591 pub fn set_context_window(&mut self, window: Option<u64>) {
1592 self.context_window_override = window;
1593 }
1594
1595 pub fn compaction_model(&self) -> &str {
1598 self.compaction_model
1599 .as_deref()
1600 .unwrap_or("claude-sonnet-4-6")
1601 }
1602
1603 pub fn context_window(&self) -> u64 {
1604 self.context_window_override
1605 .unwrap_or_else(|| crate::models::context_window_for_model(&self.model))
1606 }
1607
1608 pub async fn assess_context(
1622 &self,
1623 messages: &[crate::SharedMessage],
1624 ) -> context::ContextAssessment {
1625 let system = self.effective_system_prompt().await;
1626 let schema = self.tools.read().await.tools_schema();
1627 let memory_rendered: Option<String> = self
1632 .pending_memory_segment
1633 .lock()
1634 .unwrap_or_else(std::sync::PoisonError::into_inner)
1635 .as_ref()
1636 .map(|segment| segment.rendered_text().to_string());
1637 let memory_contents: Vec<&str> = memory_rendered.as_deref().into_iter().collect();
1638 context::assess(&context::ContextBudgetInputs {
1639 model: &self.model,
1640 provider_window: self.context_window(),
1641 system_prompt: system.as_deref(),
1642 tools_schema: &schema,
1643 messages,
1644 skill_contents: &[],
1645 memory_contents: &memory_contents,
1646 thinking_budget_tokens: self.thinking_budget as u64,
1647 next_tool_result_bytes: self.max_tool_output as u64,
1648 output_reserve_tokens: HelperMethods::max_tokens_for_model(&self.model),
1649 })
1650 }
1651
1652 #[allow(dead_code)] pub(crate) fn hold_memory_contribution(
1670 &self,
1671 contribution: memory_context::MemoryContextContribution,
1672 ) -> std::result::Result<(), memory_context::MemoryContextError> {
1673 let budget = memory_context::memory_budget_tokens(self.context_window())
1674 .ok_or(memory_context::MemoryContextError::RecallBudgetBelowMinimum)?;
1675 memory_context::validate_contribution(
1679 &contribution,
1680 &memory_project_id(),
1681 budget,
1682 &memory_context::DisclosureGrantSet::model_visible_only(),
1683 )?;
1684 *self
1685 .pending_memory_segment
1686 .lock()
1687 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1688 Some(memory_context::ContextSegment::Memory(contribution));
1689 Ok(())
1690 }
1691
1692 pub(crate) fn clear_memory_contribution(&self) {
1696 *self
1697 .pending_memory_segment
1698 .lock()
1699 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1700 }
1701
1702 pub fn memory_recall_why(&self) -> Option<memory_context::RecallTurnMetadata> {
1708 self.retained_recall_turn
1709 .lock()
1710 .unwrap_or_else(std::sync::PoisonError::into_inner)
1711 .as_ref()
1712 .map(|retained| retained.why.clone())
1713 }
1714
1715 async fn apply_turn_memory_recall(&self, messages: &mut Vec<crate::SharedMessage>) {
1733 let extension_runtime = self.extension_runtime.clone();
1734 let session = self.host_tool_session.clone();
1735 let outcome = memory_context::resolve_turn_recall(
1736 &self.memory_context_state,
1737 &self.retained_recall_turn,
1738 &memory_project_id(),
1739 self.context_window(),
1740 messages,
1741 memory_context::RECALL_HARD_TIMEOUT,
1742 move |lease: memory_context::MemoryContextLease,
1743 request: memory_context::RecallRequest| async move {
1744 let Some(manager) = extension_runtime else {
1745 return Err(memory_context::RecallCallError::ProviderUnavailable);
1746 };
1747 let mut parts = lease.provider_id.as_str().splitn(3, ':');
1748 let (Some("extension"), Some(plugin), Some(_local)) =
1749 (parts.next(), parts.next(), parts.next())
1750 else {
1751 return Err(memory_context::RecallCallError::ProviderUnavailable);
1752 };
1753 let Some(digest) =
1754 manager.declared_tool_digest(plugin, memory_context::MEMORY_RECALL_TOOL_NAME)
1755 else {
1756 return Err(memory_context::RecallCallError::ProviderUnavailable);
1757 };
1758 crate::extensions::lease::ExtensionLeaseCapability::new(session, manager)
1759 .call_exact(
1760 plugin,
1761 memory_context::MEMORY_RECALL_TOOL_NAME,
1762 &digest,
1763 memory_context::recall_request_wire(&request),
1764 )
1765 .await
1766 .map_err(|_| memory_context::RecallCallError::CallFailed)
1767 },
1768 )
1769 .await;
1770 match outcome {
1771 memory_context::TurnRecallOutcome::Injected
1772 | memory_context::TurnRecallOutcome::ReusedRetained => {
1773 let contribution = self
1774 .retained_recall_turn
1775 .lock()
1776 .unwrap_or_else(std::sync::PoisonError::into_inner)
1777 .as_ref()
1778 .map(|retained| retained.contribution.clone());
1779 if let Some(contribution) = contribution {
1780 *self
1781 .pending_memory_segment
1782 .lock()
1783 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1784 Some(memory_context::ContextSegment::Memory(contribution));
1785 }
1786 }
1787 memory_context::TurnRecallOutcome::NotEligible => {
1788 self.clear_memory_contribution();
1789 }
1790 memory_context::TurnRecallOutcome::SkippedOpen(reason) => {
1791 self.clear_memory_contribution();
1792 tracing::debug!(
1795 reason = reason.as_str(),
1796 "memory recall skipped; turn proceeds without memory"
1797 );
1798 }
1799 }
1800 }
1801
1802 #[cfg(any(test, feature = "testing"))]
1812 pub async fn apply_turn_memory_recall_for_harness(
1813 &self,
1814 messages: &mut Vec<crate::SharedMessage>,
1815 ) {
1816 self.apply_turn_memory_recall(messages).await;
1817 }
1818
1819 #[cfg(any(test, feature = "testing"))]
1823 pub fn capture_completed_turn_for_harness(
1824 &self,
1825 messages: Vec<crate::SharedMessage>,
1826 turn_ordinal: u64,
1827 ) -> std::result::Result<(), &'static str> {
1828 let started_at = std::time::SystemTime::now();
1829 let lease = self
1830 .memory_context_lock()
1831 .capture_lease_at(started_at)
1832 .ok_or("capture lease unavailable")?;
1833 let provider = self
1834 .extension_capture_provider(&lease)
1835 .ok_or("capture provider unavailable")?;
1836 let mut history = terminal_capture_history(&lease, &messages, started_at);
1837 history.turn_ordinal = turn_ordinal;
1838 memory_capture_worker()
1839 .submit_terminal(
1840 &lease,
1841 history,
1842 memory_context::RetentionClass::Standard,
1843 provider,
1844 )
1845 .map_err(|_| "capture build failed")?;
1846 Ok(())
1847 }
1848
1849 #[cfg(any(test, feature = "testing"))]
1852 pub fn capture_compaction_summary_for_harness(
1853 &self,
1854 first_turn_ordinal: u64,
1855 last_turn_ordinal: u64,
1856 source_digest: &str,
1857 source_message_count: usize,
1858 summary_text: &str,
1859 ) -> std::result::Result<(), &'static str> {
1860 let summarized_at = std::time::SystemTime::now();
1861 let lease = self
1862 .memory_context_lock()
1863 .capture_lease_at(summarized_at)
1864 .ok_or("capture lease unavailable")?;
1865 let source_digest = chat_capture::MessageRangeDigest::from_hex(source_digest)
1866 .ok_or("invalid source digest")?;
1867 let provider = self
1868 .extension_capture_provider(&lease)
1869 .ok_or("capture provider unavailable")?;
1870 let source = chat_capture::CompactionSource {
1871 schema: chat_capture::CompactionSchemaVersion::V1,
1872 project_id: lease.project_id.clone(),
1873 source_session_id: lease.session_id.clone(),
1874 first_turn_ordinal,
1875 last_turn_ordinal,
1876 source_digest,
1877 summary_origin: chat_capture::CompactionSummaryOrigin::LocalOnly,
1878 prompt_stack_digest: chat_capture::PromptStackDigest::from_bytes([7; 32]),
1879 redaction: chat_capture::RedactionPolicy::HostRedacted,
1880 content_classes: vec![agent_core::compaction::ContentClass::UserText],
1881 summarized_at,
1882 };
1883 memory_capture_worker()
1884 .submit_summary(
1885 &lease,
1886 source,
1887 source_message_count,
1888 summary_text,
1889 agent_core::compaction::RedactionPolicy::PolicyExclusions,
1890 memory_context::RetentionClass::Standard,
1891 provider,
1892 )
1893 .map_err(|_| "summary capture build failed")?;
1894 Ok(())
1895 }
1896
1897 #[cfg(any(test, feature = "testing"))]
1900 pub fn cancel_memory_forwarding_for_harness(&self) {
1901 self.clear_memory_contribution();
1902 self.memory_context_disable();
1903 }
1904
1905 #[cfg(any(test, feature = "testing"))]
1907 pub fn extension_lease_count_for_harness(&self) -> usize {
1908 self.extension_runtime
1909 .as_ref()
1910 .map_or(0, |manager| manager.lease_count())
1911 }
1912
1913 pub fn apply_config(&mut self, config: &crate::config::SynapsConfig) {
1915 if let Some(ref model) = config.model {
1916 self.set_model(model.clone());
1917 }
1918 if let Some(level) = config.thinking_level {
1921 self.set_reasoning_level_explicit(level);
1922 } else if let Some(budget) = config.thinking_budget {
1923 self.set_thinking_budget_explicit(budget);
1924 }
1925 self.context_window_override = config.context_window;
1926 self.compaction_model = config.compaction_model.clone();
1927 self.compaction_mode = config.compaction_mode;
1928 self.compaction_exclusions = config.compaction_exclude.clone();
1929 self.max_tool_output = config.max_tool_output;
1930 self.bash_timeout = config.bash_timeout;
1931 self.bash_max_timeout = config.bash_max_timeout;
1932 self.subagent_timeout = config.subagent_timeout;
1933 self.api_retries = config.api_retries;
1934 self.refusal_retries = config.refusal_retries;
1935 self.telemetry_level =
1936 crate::runtime::telemetry::TelemetryLevel::from_str_key(&config.telemetry);
1937 self.sync_observability();
1938 self.cache_diagnostics = config.cache_diagnostics;
1939 self.cache_ttl = config.cache_ttl;
1940 self.progressive_tool_disclosure = config.progressive_tool_disclosure;
1941 self.trusted_worker_models = config
1942 .favorite_models
1943 .iter()
1944 .filter_map(|model| {
1945 let canonical = canonical_trusted_worker_model(model);
1946 match agent_core::prompt::QualifiedModelId::parse(canonical) {
1947 Ok(model) => Some(model),
1948 Err(_) => {
1949 tracing::warn!(
1950 model = model.as_str(),
1951 "ignoring invalid favorite worker-model identity"
1952 );
1953 None
1954 }
1955 }
1956 })
1957 .collect();
1958 self.trusted_worker_models.sort();
1959 self.trusted_worker_models.dedup();
1960 if let Some(orchestration) = &self.orchestration {
1961 for model in &self.trusted_worker_models {
1962 if let Err(error) = orchestration.grant_worker_model(model.as_str()) {
1963 tracing::warn!(
1964 model = model.as_str(),
1965 error = %error,
1966 "failed to apply configured worker-model trust"
1967 );
1968 }
1969 }
1970 }
1971 self.apply_auth_config(config);
1972
1973 if !config.disabled_tools.is_empty() {
1977 if let Ok(mut reg) = self.tools.try_write() {
1978 reg.disable(&config.disabled_tools);
1979 }
1980 }
1981 }
1982
1983 pub fn apply_auth_config(&mut self, config: &crate::config::SynapsConfig) {
1991 let new_source = config.auth.credential_source();
1992 if new_source != self.credential_source {
1993 self.token_cache.invalidate("anthropic");
1994 }
1995 self.credential_source = new_source;
1996 if self.credential_source.is_remote() {
1997 if let Ok(mut auth) = self.auth.try_write() {
1998 AuthMethods::scrub_for_remote(&mut auth);
1999 }
2000 }
2001 crate::auth::set_global_broker(crate::auth::broker_from_source(
2006 &self.credential_source,
2007 &self.token_cache,
2008 self.client.clone(),
2009 ));
2010 }
2011
2012 pub fn thinking_budget(&self) -> u32 {
2013 self.thinking_budget
2014 }
2015
2016 pub fn max_tool_output(&self) -> usize {
2017 self.max_tool_output
2018 }
2019
2020 pub fn bash_timeout(&self) -> u64 {
2021 self.bash_timeout
2022 }
2023
2024 pub fn bash_max_timeout(&self) -> u64 {
2025 self.bash_max_timeout
2026 }
2027
2028 pub fn subagent_timeout(&self) -> u64 {
2029 self.subagent_timeout
2030 }
2031
2032 pub fn api_retries(&self) -> u32 {
2033 self.api_retries
2034 }
2035
2036 pub fn set_max_tool_output(&mut self, v: usize) {
2037 self.max_tool_output = v;
2038 }
2039
2040 pub fn set_bash_timeout(&mut self, v: u64) {
2041 self.bash_timeout = v;
2042 }
2043
2044 pub fn set_bash_max_timeout(&mut self, v: u64) {
2045 self.bash_max_timeout = v;
2046 }
2047
2048 pub fn set_subagent_timeout(&mut self, v: u64) {
2049 self.subagent_timeout = v;
2050 }
2051
2052 pub fn set_api_retries(&mut self, v: u32) {
2053 self.api_retries = v;
2054 }
2055
2056 pub fn telemetry_level(&self) -> crate::runtime::telemetry::TelemetryLevel {
2057 self.telemetry_level
2058 }
2059
2060 pub fn set_telemetry_level(&mut self, level: crate::runtime::telemetry::TelemetryLevel) {
2061 self.telemetry_level = level;
2062 self.sync_observability();
2063 }
2064
2065 fn sync_observability(&mut self) {
2072 if self.telemetry_level.enabled() {
2073 if self.telemetry_writer.is_none() {
2074 let writer = crate::runtime::telemetry::TelemetryWriter::new(
2075 crate::runtime::telemetry::WriterOptions::default(),
2076 );
2077 self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
2078 crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
2079 ));
2080 self.telemetry_writer = Some(writer);
2081 }
2082 } else {
2083 self.telemetry_writer = None;
2087 self.trace_ctx = trace::TraceContext::disabled();
2088 }
2089 }
2090
2091 pub fn trace_context(&self) -> trace::TraceContext {
2093 self.trace_ctx.clone()
2094 }
2095
2096 pub(crate) fn effective_trace_context(&self) -> trace::TraceContext {
2117 match self.trace_controls.consume() {
2118 None => self.trace_ctx.clone(),
2119 Some(with_content) => {
2120 let base = if self.trace_ctx.enabled() {
2121 self.trace_ctx.clone()
2122 } else {
2123 let writer = crate::runtime::telemetry::TelemetryWriter::new(
2124 crate::runtime::telemetry::WriterOptions::default(),
2125 );
2126 *self
2130 .one_shot_trace_writer
2131 .lock()
2132 .expect("one-shot trace writer lock poisoned") = Some(writer.clone());
2133 self.trace_ctx
2134 .fork_with_sink(Arc::new(crate::runtime::telemetry::WriterTraceSink::new(
2135 writer,
2136 )))
2137 .with_one_shot_request_gate()
2138 };
2139 if with_content {
2140 base.with_content_capture(Arc::new(trace::ContentCapture::new(
2141 self.capture_dir.clone(),
2142 )))
2143 } else {
2144 base
2145 }
2146 }
2147 }
2148 }
2149
2150 pub fn trace_arm_next(&self, with_content: bool) {
2154 self.trace_controls.arm_next(with_content);
2155 }
2156
2157 pub fn trace_status(&self) -> trace::TraceStatusReport {
2162 let _ = trace::sweep_expired_captures(&self.capture_dir);
2163 trace::TraceStatusReport {
2164 persistent_enabled: self.telemetry_writer.is_some(),
2165 arm: self.trace_controls.peek(),
2166 trace_path: crate::runtime::telemetry::default_trace_log_path(),
2167 writer_stats: self.telemetry_writer.as_ref().map(|w| w.stats()),
2168 degraded_records: self.trace_ctx.degraded_records(),
2169 }
2170 }
2171
2172 fn memory_context_lock(&self) -> std::sync::MutexGuard<'_, memory_context::SessionMemoryState> {
2179 self.memory_context_state
2180 .lock()
2181 .unwrap_or_else(std::sync::PoisonError::into_inner)
2182 }
2183
2184 pub fn memory_context_status(&self) -> memory_context::MemoryContextStatus {
2187 self.memory_context_lock().status()
2188 }
2189
2190 pub fn memory_context_disable(&self) -> memory_context::MemoryContextStatus {
2204 let (status, bound, session) = {
2205 let mut state = self.memory_context_lock();
2206 let session = state.session_id().clone();
2207 let bound = state.bound_provider_ids();
2208 let status = match memory_context::apply_memory_context_action(
2209 &mut state,
2210 memory_context::AuthorizedMemoryAction::Disable {
2211 session: session.clone(),
2212 },
2213 ) {
2214 Ok(status) => status,
2215 Err(_) => state.status(),
2219 };
2220 (status, bound, session)
2221 };
2222 memory_context::emit_memory_observability_event(
2225 &memory_context::MemoryObservabilityEvent::context_disabled(
2226 &session,
2227 &memory_project_id(),
2228 ),
2229 );
2230 self.revoke_memory_provider_leases(&bound);
2234 status
2235 }
2236
2237 fn revoke_memory_provider_leases(&self, bound: &[memory_context::ContextProviderId]) {
2245 let Some(extension_runtime) = &self.extension_runtime else {
2246 return;
2247 };
2248 for provider in bound {
2249 let mut parts = provider.as_str().splitn(3, ':');
2250 if let (Some("extension"), Some(plugin), Some(_local_id)) =
2251 (parts.next(), parts.next(), parts.next())
2252 {
2253 if !plugin.is_empty() {
2254 extension_runtime.revoke_plugin_lease(&self.host_tool_session, plugin);
2255 }
2256 }
2257 }
2258 }
2259
2260 fn resolve_memory_provider(
2280 &self,
2281 requested: Option<&str>,
2282 ) -> std::result::Result<memory_context::ContextProviderId, memory_context::MemoryContextError>
2283 {
2284 use crate::extensions::context_provider as ext_cp;
2285 let Some(extension_runtime) = &self.extension_runtime else {
2286 return Ok(memory_provider_id());
2287 };
2288 let requested_id = match requested {
2289 None => None,
2290 Some(raw) => Some(
2293 ext_cp::ContextProviderId::parse(raw)
2294 .map_err(|_| memory_context::MemoryContextError::ProviderNotRegistered)?,
2295 ),
2296 };
2297 match extension_runtime.resolve_context_provider(requested_id.as_ref()) {
2298 Ok(descriptor) => {
2299 memory_context::ContextProviderId::parse(&descriptor.runtime_address())
2300 }
2301 Err(ext_cp::ContextProviderLookupError::NotRegistered) => {
2302 Err(memory_context::MemoryContextError::ProviderNotRegistered)
2303 }
2304 Err(ext_cp::ContextProviderLookupError::Ambiguous) => {
2305 Err(memory_context::MemoryContextError::ProviderAmbiguous)
2306 }
2307 }
2308 }
2309
2310 pub(crate) fn memory_context_enable(
2318 &self,
2319 mode: memory_context::MemoryContextMode,
2320 proof: memory_context::UserIntentProof,
2321 ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
2322 {
2323 self.memory_context_enable_resolved(mode, proof, None)
2324 }
2325
2326 pub(crate) fn memory_context_enable_resolved(
2331 &self,
2332 mode: memory_context::MemoryContextMode,
2333 proof: memory_context::UserIntentProof,
2334 requested_provider: Option<&str>,
2335 ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
2336 {
2337 let mut state = self.memory_context_lock();
2338 let lease = self.grant_command_memory_lease(&state, mode, proof, requested_provider)?;
2339 let enabled_event = memory_context::MemoryObservabilityEvent::context_enabled(&lease);
2343 let result = memory_context::apply_memory_context_action(
2344 &mut state,
2345 memory_context::AuthorizedMemoryAction::Enable { lease },
2346 );
2347 if result.is_ok() {
2348 memory_context::emit_memory_observability_event(&enabled_event);
2349 }
2350 result
2351 }
2352
2353 pub(crate) fn memory_context_recall_once(
2358 &self,
2359 proof: memory_context::UserIntentProof,
2360 ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
2361 {
2362 let mut state = self.memory_context_lock();
2363 let lease = self.grant_command_memory_lease(
2364 &state,
2365 memory_context::MemoryContextMode::RecallOnce,
2366 proof,
2367 None,
2368 )?;
2369 let enabled_event = memory_context::MemoryObservabilityEvent::context_enabled(&lease);
2372 let result = memory_context::apply_memory_context_action(
2373 &mut state,
2374 memory_context::AuthorizedMemoryAction::RecallOnce { lease },
2375 );
2376 if result.is_ok() {
2377 memory_context::emit_memory_observability_event(&enabled_event);
2378 }
2379 result
2380 }
2381
2382 fn grant_command_memory_lease(
2389 &self,
2390 state: &memory_context::SessionMemoryState,
2391 mode: memory_context::MemoryContextMode,
2392 proof: memory_context::UserIntentProof,
2393 requested_provider: Option<&str>,
2394 ) -> std::result::Result<memory_context::MemoryContextLease, memory_context::MemoryContextError>
2395 {
2396 let provider_id = self.resolve_memory_provider(requested_provider)?;
2397 memory_context::MemoryContextLease::grant(
2398 memory_context::MemoryLeaseId::parse(&format!("memctx-cmd-{}", uuid::Uuid::new_v4()))?,
2399 state.session_id().clone(),
2400 memory_project_id(),
2401 provider_id,
2402 mode,
2403 memory_context::CapturePolicy::default(),
2404 memory_context::RecallPolicy::default(),
2405 proof,
2406 std::time::SystemTime::now(),
2407 None,
2408 )
2409 }
2410
2411 #[cfg(test)]
2412 pub(crate) fn set_capture_provider_for_test(
2413 &self,
2414 provider: std::sync::Arc<dyn capture_worker::CaptureProvider>,
2415 ) {
2416 *self
2417 .capture_provider_for_test
2418 .lock()
2419 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(provider);
2420 }
2421
2422 pub(crate) fn compaction_capture_lease_at(
2423 &self,
2424 now: std::time::SystemTime,
2425 ) -> Option<memory_context::MemoryContextLease> {
2426 self.memory_context_lock().capture_lease_at(now)
2427 }
2428
2429 pub(crate) fn submit_compaction_summary_capture(
2430 &self,
2431 capture_lease: Option<memory_context::MemoryContextLease>,
2432 current: &agent_core::session::Session,
2433 api_messages: &[crate::SharedMessage],
2434 outcome: &compaction::CompactionOutcome,
2435 summarized_at: std::time::SystemTime,
2436 ) {
2437 let Some(lease) = capture_lease else {
2438 return;
2439 };
2440 let Ok(source_session_id) = memory_context::SessionId::parse(¤t.id) else {
2441 return;
2442 };
2443 if api_messages.is_empty() {
2448 return;
2449 }
2450 let first_turn_ordinal = 0;
2451 let last_turn_ordinal = (api_messages.len() - 1) as u64;
2452 let Some(source_digest) = chat_capture::MessageRangeDigest::from_hex(
2453 &agent_core::compaction::message_range_digest(api_messages),
2454 ) else {
2455 return;
2456 };
2457 let Some(prompt_stack_digest) =
2458 chat_capture::PromptStackDigest::from_hex(&outcome.prompt_stack_digest)
2459 else {
2460 return;
2461 };
2462 let summary_origin = if outcome.local_only {
2463 chat_capture::CompactionSummaryOrigin::LocalOnly
2464 } else {
2465 chat_capture::CompactionSummaryOrigin::Provider {
2466 provider_id: outcome.summary_provider.clone(),
2467 model_id: outcome.summary_model.clone(),
2468 }
2469 };
2470 let content_classes = outcome.included_classes.clone();
2471 let source = chat_capture::CompactionSource {
2472 schema: chat_capture::CompactionSchemaVersion::V1,
2473 project_id: lease.project_id.clone(),
2474 source_session_id,
2475 first_turn_ordinal,
2476 last_turn_ordinal,
2477 source_digest,
2478 summary_origin,
2479 prompt_stack_digest,
2480 redaction: match outcome.redaction_policy {
2481 agent_core::compaction::RedactionPolicy::TruncationOnly => {
2482 chat_capture::RedactionPolicy::None
2483 }
2484 agent_core::compaction::RedactionPolicy::PolicyExclusions => {
2485 chat_capture::RedactionPolicy::HostRedacted
2486 }
2487 },
2488 content_classes,
2489 summarized_at,
2490 };
2491
2492 #[cfg(any(test, feature = "testing"))]
2493 let test_provider = self
2494 .capture_provider_for_test
2495 .lock()
2496 .unwrap_or_else(std::sync::PoisonError::into_inner)
2497 .clone();
2498 #[cfg(not(any(test, feature = "testing")))]
2499 let test_provider: Option<std::sync::Arc<dyn capture_worker::CaptureProvider>> = None;
2500 let provider = test_provider.or_else(|| self.extension_capture_provider(&lease));
2501 if let Some(provider) = provider {
2502 let _ = memory_capture_worker().submit_summary(
2503 &lease,
2504 source,
2505 api_messages.len(),
2506 &outcome.summary_text,
2507 outcome.redaction_policy,
2508 memory_context::RetentionClass::Standard,
2509 provider,
2510 );
2511 }
2512 }
2513
2514 fn extension_capture_provider(
2515 &self,
2516 lease: &memory_context::MemoryContextLease,
2517 ) -> Option<std::sync::Arc<dyn capture_worker::CaptureProvider>> {
2518 let manager = self.extension_runtime.clone()?;
2519 let mut parts = lease.provider_id.as_str().splitn(3, ':');
2520 let (Some("extension"), Some(plugin), Some(_)) = (parts.next(), parts.next(), parts.next())
2521 else {
2522 return None;
2523 };
2524 let digest =
2525 manager.declared_tool_digest(plugin, memory_context::MEMORY_CAPTURE_TOOL_NAME)?;
2526 let handle = tokio::runtime::Handle::try_current().ok()?;
2527 Some(std::sync::Arc::new(ExtensionCaptureProvider {
2528 manager,
2529 session: self.host_tool_session.clone(),
2530 plugin: plugin.to_owned(),
2531 digest,
2532 handle,
2533 }))
2534 }
2535
2536 pub fn memory_history_preview(
2540 &self,
2541 ) -> std::result::Result<memory_history::HistoryImportPreview, memory_history::HistoryImportError>
2542 {
2543 let host = memory_history::HistoryImportHostState::from_current_host()?;
2544 let mut io = memory_history::CanonicalHistoryMetadataIo::new();
2545 let preview = memory_history::preview_history_import(&host, &mut io)?;
2546 *self
2547 .pending_history_import_preview
2548 .lock()
2549 .expect("history preview mutex poisoned") = Some(preview.clone());
2550 Ok(preview)
2551 }
2552
2553 pub(crate) fn memory_history_confirm(
2557 &self,
2558 ) -> std::result::Result<memory_history::HistoryImportReport, memory_history::HistoryImportError>
2559 {
2560 let preview = self
2561 .pending_history_import_preview
2562 .lock()
2563 .expect("history preview mutex poisoned")
2564 .take()
2565 .ok_or(memory_history::HistoryImportError::ConsentRequired)?;
2566 let intent = memory_history::ConfirmedUserIntent::from_explicit_command(
2567 memory_context::mint_explicit_command_id(),
2568 );
2569 let mut io = memory_history::CanonicalHistoryMetadataIo::new();
2570 match memory_history::authorize_history_import(
2571 preview,
2572 memory_history::HistoryImportConsent::Confirmed(intent),
2573 memory_history::RequestAuthority::UserFrontend,
2574 &mut io,
2575 )? {
2576 memory_history::HistoryImportOutcome::Ready(plan) => {
2577 *self
2578 .history_import_plan
2579 .lock()
2580 .expect("history plan mutex poisoned") = Some(plan.clone());
2581 let lease = self
2582 .memory_context_lock()
2583 .capture_lease_at(std::time::SystemTime::now())
2584 .ok_or(memory_history::HistoryImportError::CaptureLeaseUnavailable)?;
2585 let provider = self
2588 .extension_capture_provider(&lease)
2589 .ok_or(memory_history::HistoryImportError::CaptureProviderUnavailable)?;
2590 let sessions_dir = agent_core::config::get_active_config_dir().join("sessions");
2591 memory_history::import_history_from_dir(
2592 &plan,
2593 &lease,
2594 &sessions_dir,
2595 memory_history::IMPORT_BATCH_MAX_RECORDS,
2596 memory_capture_worker(),
2597 provider,
2598 )
2599 }
2600 memory_history::HistoryImportOutcome::Declined => {
2601 Err(memory_history::HistoryImportError::ConsentRequired)
2602 }
2603 }
2604 }
2605
2606 pub(crate) fn memory_history_decline(&self) -> bool {
2608 self.pending_history_import_preview
2609 .lock()
2610 .expect("history preview mutex poisoned")
2611 .take()
2612 .is_some()
2613 }
2614
2615 #[cfg(test)]
2617 pub(crate) fn memory_durable_proof_for_test(&self) -> Option<memory_context::UserIntentProof> {
2618 self.memory_context_lock().durable_proof().cloned()
2619 }
2620
2621 #[cfg(test)]
2623 pub(crate) fn memory_one_shot_proof_for_test(&self) -> Option<memory_context::UserIntentProof> {
2624 self.memory_context_lock().one_shot_pending_proof().cloned()
2625 }
2626
2627 #[cfg(test)]
2630 pub(crate) fn memory_bound_providers_for_test(&self) -> Vec<memory_context::ContextProviderId> {
2631 self.memory_context_lock().bound_provider_ids()
2632 }
2633
2634 pub fn context_report(&self, history: Option<&[crate::SharedMessage]>) -> trace::ContextReport {
2643 use trace::ReportValue;
2644 let (history_messages, history_bytes) = match history {
2645 Some(msgs) => (
2646 ReportValue::Count(msgs.len() as u64),
2647 ReportValue::Count(
2648 msgs.iter()
2649 .map(|m| {
2650 serde_json::to_vec(&**m)
2651 .map(|v| v.len() as u64)
2652 .unwrap_or(0)
2653 })
2654 .sum(),
2655 ),
2656 ),
2657 None => (ReportValue::Unavailable, ReportValue::Unavailable),
2658 };
2659 let tool_count = match self.tools.try_read() {
2662 Ok(tools) => ReportValue::Count(tools.iter_tools_sorted().len() as u64),
2663 Err(_) => ReportValue::Unavailable,
2664 };
2665 trace::ContextReport {
2666 model: self.model.clone(),
2667 system_prompt_bytes: ReportValue::Count(
2668 self.system_prompt().map(|s| s.len() as u64).unwrap_or(0),
2669 ),
2670 tool_count,
2671 history_messages,
2672 history_bytes,
2673 loaded_skills: ReportValue::Unavailable,
2674 loaded_memories: ReportValue::Unavailable,
2675 cache: self.trace_ctx.cache_snapshots().last_activity(),
2676 trace_enabled: self.trace_ctx.enabled(),
2677 writer_stats: self.telemetry_writer.as_ref().map(|w| w.stats()),
2678 degraded_records: self.trace_ctx.degraded_records(),
2679 }
2680 }
2681
2682 pub fn telemetry_writer(&self) -> Option<crate::runtime::telemetry::TelemetryWriter> {
2684 self.telemetry_writer.clone()
2685 }
2686
2687 pub fn shutdown_observability(
2701 &self,
2702 timeout: std::time::Duration,
2703 ) -> Option<crate::runtime::telemetry::ShutdownOutcome> {
2704 let _ = trace::sweep_expired_captures(&self.capture_dir);
2709 let ephemeral = self
2715 .one_shot_trace_writer
2716 .lock()
2717 .expect("one-shot trace writer lock poisoned")
2718 .take();
2719 let ephemeral_outcome = ephemeral.map(|w| w.shutdown(timeout));
2720 self.telemetry_writer
2721 .as_ref()
2722 .map(|w| w.shutdown(timeout))
2723 .or(ephemeral_outcome)
2724 }
2725
2726 pub async fn shutdown_observability_async(
2739 &self,
2740 timeout: std::time::Duration,
2741 ) -> Option<crate::runtime::telemetry::ShutdownOutcome> {
2742 let capture_dir = self.capture_dir.clone();
2746 let _ =
2747 tokio::task::spawn_blocking(move || trace::sweep_expired_captures(&capture_dir)).await;
2748 let ephemeral = self
2750 .one_shot_trace_writer
2751 .lock()
2752 .expect("one-shot trace writer lock poisoned")
2753 .take();
2754 let ephemeral_outcome = match ephemeral {
2755 Some(writer) => Some(writer.shutdown_async(timeout).await),
2756 None => None,
2757 };
2758 match self.telemetry_writer.clone() {
2759 Some(writer) => Some(writer.shutdown_async(timeout).await),
2760 None => ephemeral_outcome,
2761 }
2762 }
2763
2764 #[cfg(any(test, feature = "testing"))]
2768 pub fn install_observability_for_tests(
2769 &mut self,
2770 writer: crate::runtime::telemetry::TelemetryWriter,
2771 ) {
2772 self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
2773 crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
2774 ));
2775 self.telemetry_writer = Some(writer);
2776 }
2777
2778 pub fn cache_diagnostics(&self) -> bool {
2779 self.cache_diagnostics
2780 }
2781
2782 pub fn set_cache_diagnostics(&mut self, v: bool) {
2783 self.cache_diagnostics = v;
2784 }
2785
2786 pub fn cache_ttl(&self) -> crate::core::config::CacheTtl {
2787 self.cache_ttl
2788 }
2789
2790 pub fn set_cache_ttl(&mut self, ttl: crate::core::config::CacheTtl) {
2794 self.cache_ttl = ttl;
2795 }
2796
2797 pub fn thinking_level(&self) -> &str {
2798 match self.named_level {
2801 Some(level) => level.as_str(),
2802 None => crate::core::models::thinking_level_for_budget(self.thinking_budget),
2803 }
2804 }
2805
2806 pub async fn refresh_if_needed(&self) -> Result<()> {
2808 self.refresh_if_needed_for_model(&self.model).await
2809 }
2810
2811 async fn refresh_if_needed_for_model(&self, model: &str) -> Result<()> {
2812 if !crate::runtime::auth::model_is_anthropic(model) {
2815 return Ok(());
2816 }
2817 AuthMethods::refresh_if_needed(
2818 Arc::clone(&self.auth),
2819 &self.client,
2820 &self.credential_source,
2821 &self.token_cache,
2822 )
2823 .await
2824 }
2825
2826 pub async fn compact_call(&self, messages: Vec<crate::SharedMessage>) -> Result<String> {
2832 self.remote_summarization_attempts
2835 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2836 let model = self.compaction_model();
2837 self.validate_request_preflight_for(
2838 model,
2839 crate::runtime::openai::catalog::CodexRequestRole::Internal,
2840 )
2841 .await?;
2842 self.refresh_if_needed_for_model(model).await?;
2843
2844 use crate::runtime::compaction::COMPACTION_SYSTEM_PROMPT;
2845
2846 ApiMethods::call_api_simple(
2847 &self.auth,
2848 &self.client,
2849 model,
2850 COMPACTION_SYSTEM_PROMPT,
2851 self.thinking_budget,
2852 self.reasoning_level(),
2853 &messages,
2854 self.api_retries,
2855 &api::ApiOptions {
2861 trace: self.trace_ctx.clone(),
2862 telemetry: self.telemetry_writer.clone(),
2863 ..api::ApiOptions::default()
2864 },
2865 )
2866 .await
2867 }
2868
2869 pub async fn run_single(&self, prompt: &str) -> Result<String> {
2872 self.validate_request_preflight().await?;
2873 let anthropic_execution_plan = self.authorized_anthropic_plan().await?;
2874 self.refresh_if_needed().await?;
2876
2877 let mut messages: Vec<crate::SharedMessage> = vec![std::sync::Arc::new(
2878 json!({"role": "user", "content": prompt}),
2879 )];
2880 let system_prompt = self.effective_system_prompt().await;
2881
2882 loop {
2883 let response = ApiMethods::call_api(
2884 &self.auth,
2885 &self.client,
2886 &self.model,
2887 &*self.tools.read().await,
2888 &system_prompt,
2889 self.thinking_budget,
2890 self.reasoning_level(),
2891 &messages,
2892 self.api_retries,
2893 &api::ApiOptions {
2894 use_1m_context: self.context_window_override == Some(1_000_000),
2895 cache_ttl: self.cache_ttl,
2896 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
2897 saw_1h_honored: self.saw_1h_honored.clone(),
2898 credential_source: self.credential_source.clone(),
2899 token_cache: self.token_cache.clone(),
2900 anthropic_base_url: None,
2901 anthropic_execution_plan: anthropic_execution_plan.clone(),
2902 codex_request_role: self.codex_request_role(),
2903 trace: self.effective_trace_context(),
2908 request_correlation: None,
2909 suppress_stream_deltas: true,
2910 telemetry: self.telemetry_writer.clone(),
2911 tool_session_id: Some(self.host_tool_session.clone()),
2918 session_tool_set: None,
2922 request_tools_schema: None,
2923 usage_counters: None,
2924 },
2925 )
2926 .await?;
2927
2928 if let Some(content) = response["content"].as_array() {
2930 let mut response_text = String::new();
2931 let mut tool_uses = Vec::new();
2932
2933 for item in content {
2935 match item["type"].as_str() {
2936 Some("text") => {
2937 if let Some(text) = item["text"].as_str() {
2938 response_text.push_str(text);
2939 }
2940 }
2941 Some("tool_use") => {
2942 tool_uses.push(item.clone());
2943 }
2944 _ => {}
2945 }
2946 }
2947
2948 if tool_uses.is_empty() {
2950 if let Some(orchestration) = &self.orchestration {
2951 if let agent_core::orchestration::CompletionGate::Blocked { workers } =
2952 orchestration.completion_gate()
2953 {
2954 return Err(crate::RuntimeError::Tool(format!(
2955 "completion blocked: {} worker(s) require collection/reconciliation: {} (call subagent_collect with reconciled=true after inspecting each result)",
2956 workers.len(),
2957 workers.join(", ")
2958 )));
2959 }
2960 }
2961 return Ok(response_text);
2962 }
2963
2964 messages.push(std::sync::Arc::new(json!({
2966 "role": "assistant",
2967 "content": content
2968 })));
2969
2970 let mut tool_results = Vec::new();
2972
2973 if tool_uses.len() == 1 {
2974 let tool_use = &tool_uses[0];
2976 if let (Some(tool_name), Some(tool_id)) =
2977 (tool_use["name"].as_str(), tool_use["id"].as_str())
2978 {
2979 let input = &tool_use["input"];
2980 let result = match self.tools.read().await.get(tool_name).cloned() {
2981 Some(tool) => {
2982 let input = self
2983 .tools
2984 .read()
2985 .await
2986 .translate_input_for_api_tool(tool_name, input.clone());
2987 let runtime_name = self
2988 .tools
2989 .read()
2990 .await
2991 .runtime_name_for_api(tool_name)
2992 .to_string();
2993 let ctx = crate::ToolContext {
2994 channels: crate::tools::ToolChannels {
2995 tx_delta: None,
2996 tx_events: None,
2997 },
2998 capabilities: crate::tools::ToolCapabilities {
2999 watcher_exit_path: self.watcher_exit_path.clone(),
3000 tool_register_tx: None,
3001 session_manager: Some(self.session_manager.clone()),
3002 subagent_registry: Some(self.subagent_registry.clone()),
3003 delegation_parent: None,
3004 event_queue: Some(self.event_queue.clone()),
3005 secret_prompt: None,
3006 orchestration: self.orchestration.clone(),
3007 tool_activation: None,
3008 mcp_leases: None,
3009 extension_leases: None,
3010 memory_context: None,
3011 },
3012 limits: crate::tools::ToolLimits {
3013 max_tool_output: self.max_tool_output,
3014 max_tool_buffer: 256 * 1024,
3015 bash_timeout: self.bash_timeout,
3016 bash_max_timeout: self.bash_max_timeout,
3017 subagent_timeout: self.subagent_timeout,
3018 },
3019 };
3020 let decision = resolve_before_tool_call_decision(
3021 input.clone(),
3022 emit_before_tool_call(
3023 &self.hook_bus,
3024 tool_name,
3025 Some(&runtime_name),
3026 input.clone(),
3027 )
3028 .await,
3029 None,
3030 false,
3031 )
3032 .await;
3033 if let BeforeToolCallDecision::Block { reason } = decision {
3034 format!("Tool call blocked by extension: {}", reason)
3035 } else {
3036 let BeforeToolCallDecision::Continue { input } = decision
3037 else {
3038 unreachable!()
3039 };
3040 let input_for_hook = input.clone();
3041 let output = match tool.execute(input, ctx).await {
3042 Ok(output) => output,
3043 Err(e) => e.to_string(),
3044 };
3045 let output = emit_after_tool_call(
3046 &self.hook_bus,
3047 tool_name,
3048 Some(&runtime_name),
3049 input_for_hook,
3050 output,
3051 self.max_tool_output,
3052 )
3053 .await;
3054 output
3055 }
3056 }
3057 None => format!("Unknown tool: {}", tool_name),
3058 };
3059 tool_results.push(json!({
3060 "type": "tool_result",
3061 "tool_use_id": tool_id,
3062 "content": HelperMethods::truncate_tool_result(&result, self.max_tool_output)
3063 }));
3064 }
3065 } else {
3066 let mut join_set = tokio::task::JoinSet::new();
3068
3069 let cfg_max_tool_output = self.max_tool_output;
3071 let cfg_bash_timeout = self.bash_timeout;
3072 let cfg_bash_max_timeout = self.bash_max_timeout;
3073 let cfg_subagent_timeout = self.subagent_timeout;
3074 let session_mgr = self.session_manager.clone();
3075 let cfg_subagent_registry = self.subagent_registry.clone();
3076 let cfg_event_queue = self.event_queue.clone();
3077 let cfg_hook_bus = self.hook_bus.clone();
3078 let cfg_orchestration = self.orchestration.clone();
3079
3080 for tool_use in &tool_uses {
3081 if let (Some(tool_name), Some(tool_id)) = (
3082 tool_use["name"].as_str().map(|s| s.to_string()),
3083 tool_use["id"].as_str().map(|s| s.to_string()),
3084 ) {
3085 let input = tool_use["input"].clone();
3086 let tools_snapshot = self.tools.read().await;
3087 let input =
3088 tools_snapshot.translate_input_for_api_tool(&tool_name, input);
3089 let runtime_name =
3090 tools_snapshot.runtime_name_for_api(&tool_name).to_string();
3091 let tool = tools_snapshot.get(&tool_name).cloned();
3092 drop(tools_snapshot);
3093 let exit_path = self.watcher_exit_path.clone();
3094 let session_mgr_inner = session_mgr.clone();
3095 let registry_inner = cfg_subagent_registry.clone();
3096 let event_queue_inner = cfg_event_queue.clone();
3097 let hook_bus_inner = cfg_hook_bus.clone();
3098 let orchestration_inner = cfg_orchestration.clone();
3099 let tool_name_for_hook = tool_name.clone();
3100 let runtime_name_for_hook = runtime_name.clone();
3101
3102 join_set.spawn(async move {
3103 let result = match tool {
3104 Some(t) => {
3105 let decision =
3106 crate::runtime::resolve_before_tool_call_decision(
3107 input.clone(),
3108 crate::runtime::emit_before_tool_call(
3109 &hook_bus_inner,
3110 &tool_name_for_hook,
3111 Some(&runtime_name_for_hook),
3112 input.clone(),
3113 )
3114 .await,
3115 None,
3116 false,
3117 )
3118 .await;
3119 if let crate::runtime::BeforeToolCallDecision::Block {
3120 reason,
3121 } = decision
3122 {
3123 format!("Tool call blocked by extension: {}", reason)
3124 } else {
3125 let crate::runtime::BeforeToolCallDecision::Continue {
3126 input,
3127 } = decision
3128 else {
3129 unreachable!()
3130 };
3131 let ctx = crate::ToolContext {
3132 channels: crate::tools::ToolChannels {
3133 tx_delta: None,
3134 tx_events: None,
3135 },
3136 capabilities: crate::tools::ToolCapabilities {
3137 watcher_exit_path: exit_path,
3138 tool_register_tx: None,
3139 session_manager: Some(session_mgr_inner),
3140 subagent_registry: Some(registry_inner),
3141 delegation_parent: None,
3142 event_queue: Some(event_queue_inner),
3143 secret_prompt: None,
3144 orchestration: orchestration_inner,
3145 tool_activation: None,
3146 mcp_leases: None,
3147 extension_leases: None,
3148 memory_context: None,
3149 },
3150 limits: crate::tools::ToolLimits {
3151 max_tool_output: cfg_max_tool_output,
3152 max_tool_buffer: 256 * 1024,
3153 bash_timeout: cfg_bash_timeout,
3154 bash_max_timeout: cfg_bash_max_timeout,
3155 subagent_timeout: cfg_subagent_timeout,
3156 },
3157 };
3158 let input_for_hook = input.clone();
3159 let output = match t.execute(input, ctx).await {
3160 Ok(output) => output,
3161 Err(e) => e.to_string(),
3162 };
3163 let output = crate::runtime::emit_after_tool_call(
3164 &hook_bus_inner,
3165 &tool_name_for_hook,
3166 Some(&runtime_name_for_hook),
3167 input_for_hook,
3168 output,
3169 cfg_max_tool_output,
3170 )
3171 .await;
3172 output
3173 }
3174 }
3175 None => format!("Unknown tool: {}", tool_name),
3176 };
3177 (tool_id, result)
3178 });
3179 }
3180 }
3181
3182 let mut results_map = std::collections::HashMap::new();
3184 while let Some(res) = join_set.join_next().await {
3185 match res {
3186 Ok((tool_id, result)) => {
3187 results_map.insert(tool_id, result);
3188 }
3189 Err(e) => {
3190 tracing::error!("Parallel tool task panicked: {}", e);
3192 }
3193 }
3194 }
3195
3196 for tool_use in &tool_uses {
3198 if let Some(tool_id) = tool_use["id"].as_str() {
3199 let result = results_map.remove(tool_id).unwrap_or_else(|| {
3200 "Tool execution failed: task panicked".to_string()
3201 });
3202 tool_results.push(json!({
3203 "type": "tool_result",
3204 "tool_use_id": tool_id,
3205 "content": HelperMethods::truncate_tool_result(&result, self.max_tool_output)
3206 }));
3207 }
3208 }
3209 }
3210
3211 messages.push(std::sync::Arc::new(json!({
3213 "role": "user",
3214 "content": tool_results
3215 })));
3216
3217 } else {
3219 return Err(RuntimeError::Tool("Invalid response format".to_string()));
3220 }
3221 }
3222 }
3223
3224 pub async fn run_stream(
3227 &self,
3228 prompt: String,
3229 cancel: CancellationToken,
3230 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
3231 self.run_stream_with_messages(
3232 vec![std::sync::Arc::new(
3233 json!({"role": "user", "content": prompt}),
3234 )],
3235 cancel,
3236 None,
3237 None,
3238 false,
3239 )
3240 .await
3241 }
3242
3243 pub async fn run_stream_with_messages(
3247 &self,
3248 messages: Vec<crate::SharedMessage>,
3249 cancel: CancellationToken,
3250 steering_rx: Option<mpsc::UnboundedReceiver<String>>,
3251 secret_prompt: Option<crate::tools::SecretPromptHandle>,
3252 auto_approve_confirms: bool,
3253 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
3254 let (tx, internal_rx) = mpsc::unbounded_channel();
3260 let bounded_rx =
3261 crate::runtime::relay::spawn_bounded_stream_relay(internal_rx, cancel.clone());
3262
3263 let turn_correlation_id = agent_core::next_turn_correlation_id();
3266
3267 if let Err(error) = self.validate_request_preflight().await {
3268 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3269 helpers::turn_error_for(&error, &turn_correlation_id),
3270 )));
3271 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3272 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3273 }
3274
3275 let anthropic_execution_plan = match self.authorized_anthropic_plan().await {
3276 Ok(plan) => plan,
3277 Err(error) => {
3278 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3279 helpers::turn_error_for(&error, &turn_correlation_id),
3280 )));
3281 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3282 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3283 }
3284 };
3285
3286 if let Err(e) = self.refresh_if_needed().await {
3288 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3289 helpers::turn_error_for(&e, &turn_correlation_id),
3290 )));
3291 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3292 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3293 }
3294
3295 let capture_started_at = std::time::SystemTime::now();
3305 let capture_lease = self
3308 .memory_context_lock()
3309 .capture_lease_at(capture_started_at);
3310 let mut messages = messages;
3311 self.apply_turn_memory_recall(&mut messages).await;
3312 let messages = messages;
3313
3314 let auth = Arc::clone(&self.auth);
3317 let client = self.client.clone();
3318 let credential_source = self.credential_source.clone();
3319 let token_cache = self.token_cache.clone();
3320 let model = self.model.clone();
3321 let tools = self.tools.clone();
3322 let system_prompt = self.effective_system_prompt().await;
3323 let thinking_budget = self.thinking_budget;
3324 let reasoning_level = self.reasoning_level();
3325 let watcher_exit_path = self.watcher_exit_path.clone();
3326 let max_tool_output = self.max_tool_output;
3327 let bash_timeout = self.bash_timeout;
3328 let bash_max_timeout = self.bash_max_timeout;
3329 let subagent_timeout = self.subagent_timeout;
3330 let api_retries = self.api_retries;
3331 let refusal_retries = self.refusal_retries;
3332 let session_manager = self.session_manager.clone();
3333 let subagent_registry = self.subagent_registry.clone();
3337 let reaper_registry = Arc::clone(&subagent_registry);
3340 let reaper_orchestration = self.orchestration.clone();
3341 let capture_runtime = self.extension_runtime.clone();
3342 let capture_session = self.host_tool_session.clone();
3343 let event_queue = self.event_queue.clone();
3344 let options = api::ApiOptions {
3345 use_1m_context: self.context_window_override == Some(1_000_000),
3346 cache_ttl: self.cache_ttl,
3347 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
3348 saw_1h_honored: self.saw_1h_honored.clone(),
3349 credential_source: self.credential_source.clone(),
3350 token_cache: self.token_cache.clone(),
3351 anthropic_base_url: None,
3352 anthropic_execution_plan,
3353 codex_request_role: self.codex_request_role(),
3354 trace: self.effective_trace_context(),
3355 request_correlation: None,
3356 suppress_stream_deltas: false,
3357 telemetry: self.telemetry_writer.clone(),
3358 tool_session_id: Some(self.host_tool_session.clone()),
3361 session_tool_set: None,
3365 request_tools_schema: None,
3366 usage_counters: None,
3367 };
3368
3369 let session = crate::runtime::stream::StreamSession {
3370 auth,
3371 client,
3372 credential_source,
3373 token_cache,
3374 options,
3375 api_retries,
3376 refusal_retries,
3377 model,
3378 tools,
3379 system_prompt,
3380 thinking_budget,
3381 reasoning_level,
3382 tx: tx.clone(),
3383 cancel,
3384 steering_rx,
3385 watcher_exit_path,
3386 max_tool_output,
3387 bash_timeout,
3388 bash_max_timeout,
3389 subagent_timeout,
3390 session_manager,
3391 subagent_registry,
3392 event_queue,
3393 secret_prompt,
3394 hook_bus: self.hook_bus.clone(),
3395 auto_approve_confirms,
3396 telemetry_level: self.telemetry_level,
3397 orchestration: self.orchestration.clone(),
3398 delegation_parent: self.delegation_parent.clone(),
3399 turn_correlation_id: turn_correlation_id.clone(),
3400 progressive_tool_disclosure: self.progressive_tool_disclosure,
3401 tool_session_id: self.host_tool_session.clone(),
3402 mcp_runtime: self.mcp_runtime.clone(),
3403 mcp_session_scope: self.mcp_session_scope.clone(),
3404 extension_runtime: self.extension_runtime.clone(),
3405 extension_session_scope: self.extension_session_scope.clone(),
3406 turn_budget: self.turn_budget.clone(),
3407 };
3408
3409 tokio::spawn(async move {
3410 let capture_messages = messages.clone();
3411 let completed = match StreamMethods::run_stream_internal(session, messages).await {
3412 Ok(()) => true,
3413 Err(e) => {
3414 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3415 helpers::turn_error_for(&e, &turn_correlation_id),
3416 )));
3417 false
3418 }
3419 };
3420 if completed {
3421 if let (Some(lease), Some(manager)) = (capture_lease, capture_runtime) {
3422 let mut parts = lease.provider_id.as_str().splitn(3, ':');
3423 if let (Some("extension"), Some(plugin), Some(_)) =
3424 (parts.next(), parts.next(), parts.next())
3425 {
3426 if let Some(digest) = manager
3427 .declared_tool_digest(plugin, memory_context::MEMORY_CAPTURE_TOOL_NAME)
3428 {
3429 let provider = std::sync::Arc::new(ExtensionCaptureProvider {
3430 manager,
3431 session: capture_session,
3432 plugin: plugin.to_owned(),
3433 digest,
3434 handle: tokio::runtime::Handle::current(),
3435 });
3436 let history = terminal_capture_history(
3437 &lease,
3438 &capture_messages,
3439 capture_started_at,
3440 );
3441 let _ = memory_capture_worker().submit_terminal(
3442 &lease,
3443 history,
3444 memory_context::RetentionClass::Standard,
3445 provider,
3446 );
3447 }
3448 }
3449 }
3450 }
3451 crate::runtime::subagent::reap_finished(
3455 &reaper_registry,
3456 reaper_orchestration.as_deref(),
3457 );
3458 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3459 });
3460
3461 Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx))
3462 }
3463}
3464
3465impl Clone for Runtime {
3466 fn clone(&self) -> Self {
3467 Self {
3468 client: self.client.clone(),
3469 auth: Arc::clone(&self.auth),
3470 model: self.model.clone(),
3471 tools: self.tools.clone(),
3472 system_prompt: self.system_prompt.clone(),
3473 effective_prompt: self.effective_prompt.clone(),
3474 prompt_generation: self.prompt_generation,
3475 prompt_reload_source: self.prompt_reload_source.clone(),
3476 thinking_budget: self.thinking_budget,
3477 named_level: self.named_level,
3478 explicit_reasoning: self.explicit_reasoning,
3479 codex_request_role: self.codex_request_role,
3480 context_window_override: self.context_window_override,
3481 compaction_model: self.compaction_model.clone(),
3482 compaction_mode: self.compaction_mode,
3483 compaction_exclusions: self.compaction_exclusions.clone(),
3484 remote_summarization_attempts: Arc::clone(&self.remote_summarization_attempts),
3485 subagent_registry: self.subagent_registry.clone(),
3486 orchestration: self.orchestration.clone(),
3487 event_queue: self.event_queue.clone(),
3488 watcher_exit_path: self.watcher_exit_path.clone(),
3489 max_tool_output: self.max_tool_output,
3490 bash_timeout: self.bash_timeout,
3491 bash_max_timeout: self.bash_max_timeout,
3492 subagent_timeout: self.subagent_timeout,
3493 api_retries: self.api_retries,
3494 refusal_retries: self.refusal_retries,
3495 telemetry_level: self.telemetry_level,
3496 telemetry_writer: self.telemetry_writer.clone(),
3500 trace_ctx: self.trace_ctx.clone(),
3501 trace_controls: Arc::clone(&self.trace_controls),
3502 memory_context_state: std::sync::Arc::clone(&self.memory_context_state),
3508 pending_history_import_preview: std::sync::Arc::clone(
3509 &self.pending_history_import_preview,
3510 ),
3511 history_import_plan: std::sync::Arc::clone(&self.history_import_plan),
3512 #[cfg(any(test, feature = "testing"))]
3513 capture_provider_for_test: std::sync::Arc::clone(&self.capture_provider_for_test),
3514 pending_memory_segment: std::sync::Arc::clone(&self.pending_memory_segment),
3517 retained_recall_turn: std::sync::Arc::clone(&self.retained_recall_turn),
3520 one_shot_trace_writer: Arc::clone(&self.one_shot_trace_writer),
3521 capture_dir: self.capture_dir.clone(),
3522 cache_diagnostics: self.cache_diagnostics,
3523 cache_ttl: self.cache_ttl,
3524 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
3528 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
3529 last_msg_id: Arc::new(Mutex::new(None)),
3532 session_manager: self.session_manager.clone(),
3533 hook_bus: self.hook_bus.clone(),
3534 reaper_handle: None, reaper_cancel: None, credential_source: self.credential_source.clone(),
3537 token_cache: self.token_cache.clone(), trusted_worker_models: self.trusted_worker_models.clone(),
3539 progressive_tool_disclosure: self.progressive_tool_disclosure,
3540 delegation_parent: self.delegation_parent.clone(),
3541 mcp_runtime: self.mcp_runtime.clone(),
3542 mcp_session_scope: self.mcp_session_scope.clone(),
3545 extension_runtime: self.extension_runtime.clone(),
3546 extension_session_scope: self.extension_session_scope.clone(),
3548 turn_budget: self.turn_budget.clone(),
3549 host_tool_session: self.host_tool_session.clone(),
3554 }
3555 }
3556}
3557
3558#[cfg(test)]
3559mod tests {
3560 use super::*;
3561
3562 #[test]
3567 fn host_tool_session_shared_by_clones_fresh_per_runtime() {
3568 let rt = Runtime::new_headless();
3569 let clone = rt.clone();
3570 assert_eq!(
3571 rt.host_tool_session_id(),
3572 clone.host_tool_session_id(),
3573 "clones share the live registry and therefore the host session"
3574 );
3575
3576 let other = Runtime::new_headless();
3577 assert_ne!(
3578 rt.host_tool_session_id(),
3579 other.host_tool_session_id(),
3580 "independently constructed runtimes must never share a session"
3581 );
3582 }
3583
3584 #[test]
3589 fn telemetry_level_gates_shared_observability_sink() {
3590 let mut rt = Runtime::new_headless();
3591 assert!(!rt.trace_context().enabled(), "off by default");
3592 assert!(rt.telemetry_writer().is_none());
3593
3594 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Basic);
3595 assert!(rt.trace_context().enabled(), "basic enables the trace sink");
3596 assert!(rt.telemetry_writer().is_some());
3597
3598 let ctx_before = format!("{:?}", rt.trace_context());
3600 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Full);
3601 assert_eq!(format!("{:?}", rt.trace_context()), ctx_before);
3602
3603 let clone = rt.clone();
3605 assert!(clone.trace_context().enabled());
3606 assert_eq!(format!("{:?}", clone.trace_context()), ctx_before);
3607 assert!(clone.telemetry_writer().is_some());
3608
3609 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Off);
3610 assert!(!rt.trace_context().enabled(), "off disables both");
3611 assert!(rt.telemetry_writer().is_none());
3612 assert!(rt
3613 .shutdown_observability(std::time::Duration::ZERO)
3614 .is_none());
3615 }
3616
3617 #[tokio::test]
3623 async fn shutdown_observability_async_epilogue_contract() {
3624 let rt = Runtime::new_headless();
3626 let budget = crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT;
3627 assert!(rt.shutdown_observability_async(budget).await.is_none());
3628 assert!(rt.shutdown_observability_async(budget).await.is_none());
3629
3630 let tmp = tempfile::tempdir().unwrap();
3632 let telemetry_path = tmp.path().join("synaps/api-log.jsonl");
3633 let mut rt = Runtime::new_headless();
3634 let writer = crate::runtime::telemetry::TelemetryWriter::new(
3635 crate::runtime::telemetry::WriterOptions {
3636 telemetry_path: Some(telemetry_path.clone()),
3637 trace_path: Some(tmp.path().join("synaps/request-trace.jsonl")),
3638 ..Default::default()
3639 },
3640 );
3641 rt.install_observability_for_tests(writer.clone());
3642 writer.enqueue_telemetry(crate::runtime::telemetry::TelemetryRecord {
3643 ts: 7,
3644 model: "claude-sonnet-4-6".to_string(),
3645 ..Default::default()
3646 });
3647 let outcome = rt
3648 .shutdown_observability_async(budget)
3649 .await
3650 .expect("writer installed");
3651 assert!(outcome.is_flushed());
3652 assert_eq!(outcome.stats().written, 1);
3653 assert_eq!(
3654 std::fs::read_to_string(&telemetry_path)
3655 .unwrap()
3656 .lines()
3657 .count(),
3658 1,
3659 "queued final record must be on disk after the epilogue flush"
3660 );
3661 assert!(rt
3663 .shutdown_observability_async(budget)
3664 .await
3665 .expect("writer still installed")
3666 .is_flushed());
3667
3668 let mut rt = Runtime::new_headless();
3670 let slow = crate::runtime::telemetry::TelemetryWriter::new(
3671 crate::runtime::telemetry::WriterOptions {
3672 telemetry_path: Some(tmp.path().join("slow/api-log.jsonl")),
3673 trace_path: Some(tmp.path().join("slow/request-trace.jsonl")),
3674 write_delay: Some(std::time::Duration::from_millis(300)),
3675 ..Default::default()
3676 },
3677 );
3678 rt.install_observability_for_tests(slow.clone());
3679 for _ in 0..10 {
3680 slow.enqueue_telemetry(crate::runtime::telemetry::TelemetryRecord::default());
3681 }
3682 let start = std::time::Instant::now();
3683 let outcome = rt
3684 .shutdown_observability_async(std::time::Duration::from_millis(200))
3685 .await
3686 .expect("writer installed");
3687 assert!(!outcome.is_flushed(), "10×300ms cannot drain in 200ms");
3688 assert!(
3691 start.elapsed() < std::time::Duration::from_millis(1500),
3692 "bounded epilogue must not block exit: {:?}",
3693 start.elapsed()
3694 );
3695 }
3696
3697 #[tokio::test]
3702 async fn one_shot_ephemeral_writer_is_retained_and_drained_at_shutdown() {
3703 let rt = Runtime::new_headless();
3704 assert!(!rt.trace_context().enabled(), "telemetry off");
3705 rt.trace_arm_next(false);
3706 let armed = rt.effective_trace_context();
3707 assert!(armed.enabled(), "armed ephemeral context traces");
3708 let budget = crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT;
3711 let outcome = rt
3712 .shutdown_observability_async(budget)
3713 .await
3714 .expect("retained one-shot writer must be drained by the epilogue");
3715 assert!(outcome.is_flushed());
3716 assert!(rt.shutdown_observability_async(budget).await.is_none());
3718 }
3719
3720 #[tokio::test]
3726 #[serial_test::serial(synaps_base_dir)]
3727 async fn startup_and_shutdown_paths_sweep_expired_captures() {
3728 struct BaseDirGuard(Option<String>);
3729 impl Drop for BaseDirGuard {
3730 fn drop(&mut self) {
3731 match self.0.take() {
3732 Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
3733 None => std::env::remove_var("SYNAPS_BASE_DIR"),
3734 }
3735 }
3736 }
3737 let tmp = tempfile::tempdir().unwrap();
3738 let guard = BaseDirGuard(std::env::var("SYNAPS_BASE_DIR").ok());
3739 std::env::set_var("SYNAPS_BASE_DIR", tmp.path());
3740
3741 let cap_dir = trace::default_capture_dir();
3742 assert!(
3743 cap_dir.starts_with(tmp.path()),
3744 "capture dir must be private to the test"
3745 );
3746 let stale_id = trace::TraceId::new("req-stale").unwrap();
3747 let plant_stale = || {
3748 agent_core::core::private_fs::ensure_private_dir(&cap_dir).unwrap();
3749 let stale = trace::controls::ContentCaptureBundle {
3750 schema: trace::CONTENT_CAPTURE_SCHEMA.to_string(),
3751 request_id: stale_id.clone(),
3752 created_unix_ms: 1_000,
3753 expires_unix_ms: 2_000, redacted: true,
3755 over_budget: false,
3756 body: Some(serde_json::json!({"old": true})),
3757 };
3758 let path = trace::controls::capture_path(&cap_dir, &stale_id);
3759 std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap();
3760 path
3761 };
3762
3763 let stale_path = plant_stale();
3765 let rt = Runtime::new().await.expect("runtime constructs");
3766 assert!(
3767 !stale_path.exists(),
3768 "Runtime startup must physically remove expired capture bundles"
3769 );
3770
3771 let stale_path = plant_stale();
3773 let _ = rt.shutdown_observability(std::time::Duration::ZERO);
3774 assert!(
3775 !stale_path.exists(),
3776 "sync shutdown epilogue must remove expired capture bundles"
3777 );
3778
3779 let stale_path = plant_stale();
3781 let _ = rt
3782 .shutdown_observability_async(std::time::Duration::from_millis(100))
3783 .await;
3784 assert!(
3785 !stale_path.exists(),
3786 "async shutdown epilogue must remove expired capture bundles"
3787 );
3788 drop(guard);
3789 }
3790
3791 #[tokio::test]
3796 #[serial_test::serial(synaps_base_dir)]
3797 async fn capture_root_binds_at_construction_and_survives_env_churn() {
3798 let base_a = tempfile::tempdir().unwrap();
3799 let base_b = tempfile::tempdir().unwrap();
3800 let old = std::env::var("SYNAPS_BASE_DIR").ok();
3801
3802 std::env::set_var("SYNAPS_BASE_DIR", base_a.path());
3803 let rt_a = Runtime::new().await.expect("runtime A");
3804 std::env::set_var("SYNAPS_BASE_DIR", base_b.path());
3805 let rt_b = Runtime::new().await.expect("runtime B");
3806
3807 let cap_a = base_a.path().join("trace").join("capture");
3808 let cap_b = base_b.path().join("trace").join("capture");
3809 let plant = |dir: &std::path::Path, name: &str| {
3810 agent_core::core::private_fs::ensure_private_dir(dir).unwrap();
3811 let id = trace::TraceId::new(name).unwrap();
3812 let stale = trace::controls::ContentCaptureBundle {
3813 schema: trace::CONTENT_CAPTURE_SCHEMA.to_string(),
3814 request_id: id.clone(),
3815 created_unix_ms: 1_000,
3816 expires_unix_ms: 2_000,
3817 redacted: true,
3818 over_budget: false,
3819 body: Some(serde_json::json!({"old": true})),
3820 };
3821 let path = trace::controls::capture_path(dir, &id);
3822 std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap();
3823 path
3824 };
3825
3826 let decoy = tempfile::tempdir().unwrap();
3828 std::env::set_var("SYNAPS_BASE_DIR", decoy.path());
3829
3830 let stale_a = plant(&cap_a, "req-stale-a");
3831 let stale_b = plant(&cap_b, "req-stale-b");
3832 let _ = rt_a.shutdown_observability(std::time::Duration::ZERO);
3833 assert!(
3834 !stale_a.exists(),
3835 "runtime A must sweep the root bound at ITS construction"
3836 );
3837 assert!(
3838 stale_b.exists(),
3839 "runtime A must never sweep another runtime's root"
3840 );
3841 let _ = rt_b
3842 .shutdown_observability_async(std::time::Duration::from_millis(100))
3843 .await;
3844 assert!(
3845 !stale_b.exists(),
3846 "runtime B must sweep the root bound at ITS construction"
3847 );
3848
3849 match old {
3850 Some(v) => std::env::set_var("SYNAPS_BASE_DIR", v),
3851 None => std::env::remove_var("SYNAPS_BASE_DIR"),
3852 }
3853 }
3854
3855 #[test]
3856 fn reload_rejects_policy_change_without_partial_apply() {
3857 let dir = tempfile::tempdir().unwrap();
3858 let path = dir.path().join("prompt.yaml");
3859 let manifest = |total: &str, content: &str| {
3860 format!(
3861 "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"
3862 )
3863 };
3864 std::fs::write(&path, manifest("1", "before")).unwrap();
3865 let parsed =
3866 agent_core::prompt::PromptManifest::parse(&std::fs::read_to_string(&path).unwrap())
3867 .unwrap();
3868 let model =
3869 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6").unwrap();
3870 let context = agent_core::prompt::SelectionContext::new(model.clone(), None).unwrap();
3871 let stack = agent_core::prompt::compile_prompt_stack(
3872 &parsed,
3873 &parsed.registry(path.parent()).unwrap(),
3874 &context,
3875 None,
3876 )
3877 .unwrap();
3878 let catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
3879 &model,
3880 parsed.delegation_catalog_candidates(),
3881 )
3882 .unwrap();
3883 let digest = parsed
3884 .delegation_policy(model, &catalog)
3885 .unwrap()
3886 .map(|p| p.digest());
3887 let mut runtime = Runtime::new_headless();
3888 runtime.apply_prompt_stack(stack).unwrap();
3889 runtime.retain_prompt_reload_source(path.clone(), context, None, digest.clone());
3890 let generation = runtime.prompt_generation();
3891 let composed = runtime.effective_prompt().unwrap().composed().to_owned();
3892
3893 std::fs::write(&path, manifest("2", "after")).unwrap();
3894 let error = runtime.reload_prompt().unwrap_err().to_string();
3895 assert!(error.contains("cannot safely change delegation policy"));
3896 assert_eq!(runtime.prompt_generation(), generation);
3897 assert_eq!(runtime.effective_prompt().unwrap().composed(), composed);
3898 assert_eq!(
3899 runtime
3900 .prompt_reload_source
3901 .as_ref()
3902 .unwrap()
3903 .delegation_policy_digest,
3904 digest
3905 );
3906 }
3907
3908 #[test]
3909 fn worker_install_does_not_replay_process_global_favorite_grants() {
3910 let foreground =
3911 crate::orchestration::canonical_foreground_identity("anthropic/claude-fable-5")
3912 .unwrap();
3913 let session = Arc::new(
3914 crate::orchestration::OrchestrationRuntime::baseline(foreground, 4, 8).unwrap(),
3915 );
3916 let favorite = "openai-codex/gpt-5.6-sol";
3917 assert!(
3918 session.preflight(favorite).is_err(),
3919 "precondition: the parent session did not grant this identity"
3920 );
3921
3922 let mut worker = Runtime::new_headless();
3923 worker.apply_config(&crate::config::SynapsConfig {
3924 favorite_models: vec![favorite.to_owned()],
3925 ..Default::default()
3926 });
3927 worker.install_worker_orchestration(Arc::clone(&session));
3928
3929 assert!(
3930 session.preflight(favorite).is_err(),
3931 "constructing a child must not mint/replay a favorite-model grant"
3932 );
3933 assert!(!session.effective_choices().contains(&favorite.to_owned()));
3934 }
3935
3936 #[test]
3937 fn host_install_may_apply_explicit_operator_favorites() {
3938 let mut runtime = Runtime::new_headless();
3939 let config = crate::config::SynapsConfig {
3940 favorite_models: vec![
3941 "openai-codex/gpt-5.6-luna".to_owned(),
3942 "anthropic/claude-opus-4-6".to_owned(),
3943 "claude/claude-fable-5".to_owned(),
3945 "not-qualified".to_owned(),
3947 ],
3948 ..Default::default()
3949 };
3950 runtime.apply_config(&config);
3951 let foreground =
3952 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
3953 runtime.install_orchestration(std::sync::Arc::new(
3954 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap(),
3955 ));
3956
3957 let orchestration = runtime.orchestration().unwrap();
3958 for trusted in [
3959 "openai-codex/gpt-5.6-sol",
3960 "openai-codex/gpt-5.6-luna",
3961 "anthropic/claude-opus-4-6",
3962 "anthropic/claude-fable-5",
3963 ] {
3964 assert!(
3965 orchestration
3966 .effective_choices()
3967 .contains(&trusted.to_owned()),
3968 "missing configured worker choice {trusted}: {:?}",
3969 orchestration.effective_choices()
3970 );
3971 }
3972 let authorized = orchestration
3973 .resolve_and_authorize("sa_cross_provider", Some("anthropic/claude-opus-4-6"))
3974 .expect("an explicitly favorited cross-provider model must be authorized");
3975 assert_eq!(authorized.model.as_str(), "anthropic/claude-opus-4-6");
3976 assert!(!orchestration
3977 .effective_choices()
3978 .contains(&"not-qualified".to_owned()));
3979 }
3980
3981 #[test]
3982 fn configured_worker_choices_survive_manifestless_foreground_changes() {
3983 let mut runtime = Runtime::new_headless();
3984 runtime.apply_config(&crate::config::SynapsConfig {
3985 favorite_models: vec!["anthropic/claude-opus-4-6".to_owned()],
3986 ..Default::default()
3987 });
3988 let foreground =
3989 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
3990 runtime.install_orchestration(std::sync::Arc::new(
3991 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap(),
3992 ));
3993
3994 runtime
3995 .try_set_model("xai-auth/grok-4.5-latest".to_owned())
3996 .unwrap();
3997
3998 let orchestration = runtime.orchestration().unwrap();
3999 assert_eq!(orchestration.foreground_model(), "xai-auth/grok-4.5-latest");
4000 assert!(orchestration
4001 .effective_choices()
4002 .contains(&"anthropic/claude-opus-4-6".to_owned()));
4003 orchestration
4004 .resolve_and_authorize("sa_after_switch", Some("anthropic/claude-opus-4-6"))
4005 .expect("configured worker trust must survive policy replacement");
4006 }
4007
4008 #[test]
4009 fn set_model_replaces_manifestless_orchestration_foreground_snapshot() {
4010 let mut runtime = Runtime::new_headless();
4011 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4012 runtime.install_orchestration(std::sync::Arc::new(
4013 crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
4014 ));
4015
4016 runtime.set_model("openrouter/deepseek/deepseek-v4-pro".to_owned());
4017
4018 let orchestration = runtime.orchestration().unwrap();
4019 assert_eq!(
4020 orchestration.foreground_model(),
4021 "openrouter/deepseek/deepseek-v4-pro"
4022 );
4023 assert!(orchestration
4024 .effective_choices()
4025 .contains(&"openrouter/moonshotai/kimi-k2.7-code".to_owned()));
4026 assert!(orchestration
4027 .resolve_and_authorize("sa_kimi", Some("openrouter/moonshotai/kimi-k2.7-code"))
4028 .is_ok());
4029 }
4030
4031 #[test]
4032 fn set_model_refuses_to_orphan_unreconciled_policy_workers() {
4033 let mut runtime = Runtime::new_headless();
4034 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4035 let orchestration = std::sync::Arc::new(
4036 crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
4037 );
4038 orchestration
4039 .resolve_and_authorize("sa_active", None)
4040 .unwrap();
4041 runtime.install_orchestration(orchestration.clone());
4042 runtime.model = "openrouter/z-ai/glm-5.2".to_owned();
4043
4044 let error = runtime
4045 .try_set_model("openrouter/deepseek/deepseek-v4-pro".to_owned())
4046 .unwrap_err();
4047
4048 assert!(error.contains("collection/reconciliation"));
4049 assert_eq!(runtime.model(), "openrouter/z-ai/glm-5.2");
4050 assert!(std::sync::Arc::ptr_eq(
4051 runtime.orchestration().unwrap(),
4052 &orchestration
4053 ));
4054 assert!(orchestration.is_unreconciled("sa_active"));
4055 }
4056
4057 #[test]
4058 fn set_model_keeps_active_typed_manifest_orchestration_unchanged() {
4059 let dir = tempfile::tempdir().unwrap();
4060 let manifest_path = dir.path().join("prompt.yaml");
4061 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4062 let mut runtime = Runtime::new_headless();
4063 let orchestration = std::sync::Arc::new(
4064 crate::orchestration::OrchestrationRuntime::baseline(glm.clone(), 3, 8).unwrap(),
4065 );
4066 runtime.install_orchestration(orchestration.clone());
4067 runtime.retain_prompt_reload_source(
4068 manifest_path,
4069 agent_core::prompt::SelectionContext::new(glm, None).unwrap(),
4070 None,
4071 None,
4072 );
4073
4074 runtime.set_model("openrouter/deepseek/deepseek-v4-pro".to_owned());
4075
4076 assert!(std::sync::Arc::ptr_eq(
4077 runtime.orchestration().unwrap(),
4078 &orchestration
4079 ));
4080 assert_eq!(
4081 runtime.orchestration().unwrap().foreground_model(),
4082 "openrouter/z-ai/glm-5.2"
4083 );
4084 }
4085
4086 #[test]
4087 fn set_model_preserves_qualified_provider_ids_and_bare_claude() {
4088 let cases = [
4089 (
4090 "github-copilot/claude-opus-4.8",
4091 "github-copilot/claude-opus-4.8",
4092 ),
4093 ("anthropic/claude-sonnet-4-6", "anthropic/claude-sonnet-4-6"),
4094 ("google/gemini-2.5-pro", "google/gemini-2.5-pro"),
4095 ("openai/gpt-5", "openai/gpt-5"),
4096 ("claude-opus-4-8", "claude-opus-4-8"),
4097 ];
4098
4099 for (input, expected) in cases {
4100 let mut runtime = Runtime::new_headless();
4101 runtime.set_model(input.to_owned());
4102 assert_eq!(runtime.model(), expected, "input: {input}");
4103 }
4104 }
4105
4106 #[test]
4107 fn set_model_strips_only_legacy_health_status_decoration() {
4108 let cases = [
4109 ("✅ 339ms groq/llama-3.3-70b", "groq/llama-3.3-70b"),
4110 (
4111 "✅ 339ms github-copilot/claude-opus-4.8",
4112 "github-copilot/claude-opus-4.8",
4113 ),
4114 (
4115 "⚠️ 1200ms anthropic/claude-sonnet-4-6",
4116 "anthropic/claude-sonnet-4-6",
4117 ),
4118 ];
4119
4120 for (input, expected) in cases {
4121 let mut runtime = Runtime::new_headless();
4122 runtime.set_model(input.to_owned());
4123 assert_eq!(runtime.model(), expected, "input: {input}");
4124 }
4125 }
4126
4127 #[tokio::test]
4128 async fn confirm_without_prompt_fails_closed() {
4129 let result = resolve_before_tool_call_result(
4130 crate::extensions::hooks::events::HookResult::Confirm {
4131 message: "Run deploy?".into(),
4132 },
4133 None,
4134 false,
4135 )
4136 .await;
4137
4138 assert!(matches!(
4139 result,
4140 crate::extensions::hooks::events::HookResult::Block { reason }
4141 if reason.contains("requires confirmation") && reason.contains("Run deploy?")
4142 ));
4143 }
4144
4145 #[tokio::test]
4146 async fn modify_result_replaces_tool_input() {
4147 let result = resolve_before_tool_call_decision(
4148 serde_json::json!({"command":"rm -rf /"}),
4149 crate::extensions::hooks::events::HookResult::Modify {
4150 input: serde_json::json!({"command":"echo safe"}),
4151 },
4152 None,
4153 false,
4154 )
4155 .await;
4156
4157 match result {
4158 BeforeToolCallDecision::Continue { input } => {
4159 assert_eq!(input, serde_json::json!({"command":"echo safe"}));
4160 }
4161 BeforeToolCallDecision::Block { reason } => panic!("unexpected block: {reason}"),
4162 }
4163 }
4164
4165 #[tokio::test]
4166 async fn confirm_prompt_yes_continues() {
4167 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4168 let handle = crate::tools::SecretPromptHandle::new(tx);
4169
4170 let task = tokio::spawn(async move {
4171 let request = rx.recv().await.expect("confirm prompt request");
4172 assert_eq!(request.title, "Confirm tool call");
4173 assert!(request.prompt.contains("Run deploy?"));
4174 let _ = request.response_tx.send(Some("yes".to_string()));
4175 });
4176
4177 let result = resolve_before_tool_call_result(
4178 crate::extensions::hooks::events::HookResult::Confirm {
4179 message: "Run deploy?".into(),
4180 },
4181 Some(&handle),
4182 false,
4183 )
4184 .await;
4185
4186 task.await.unwrap();
4187 assert!(matches!(
4188 result,
4189 crate::extensions::hooks::events::HookResult::Continue
4190 ));
4191 }
4192
4193 #[tokio::test]
4194 async fn confirm_prompt_non_yes_blocks() {
4195 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4196 let handle = crate::tools::SecretPromptHandle::new(tx);
4197
4198 let task = tokio::spawn(async move {
4199 let request = rx.recv().await.expect("confirm prompt request");
4200 let _ = request.response_tx.send(Some("no".to_string()));
4201 });
4202
4203 let result = resolve_before_tool_call_result(
4204 crate::extensions::hooks::events::HookResult::Confirm {
4205 message: "Run deploy?".into(),
4206 },
4207 Some(&handle),
4208 false,
4209 )
4210 .await;
4211
4212 task.await.unwrap();
4213 assert!(matches!(
4214 result,
4215 crate::extensions::hooks::events::HookResult::Block { reason }
4216 if reason.contains("confirmation denied")
4217 ));
4218 }
4219
4220 #[test]
4221 fn test_max_tokens_for_model() {
4222 assert_eq!(
4224 HelperMethods::max_tokens_for_model("claude-opus-4-6"),
4225 128000
4226 );
4227 assert_eq!(
4228 HelperMethods::max_tokens_for_model("opus-something"),
4229 128000
4230 );
4231
4232 assert_eq!(
4234 HelperMethods::max_tokens_for_model("claude-sonnet-4-20250514"),
4235 64000
4236 );
4237 assert_eq!(HelperMethods::max_tokens_for_model("haiku"), 64000);
4238 assert_eq!(HelperMethods::max_tokens_for_model("claude-3-haiku"), 64000);
4239 assert_eq!(
4240 HelperMethods::max_tokens_for_model("some-other-model"),
4241 64000
4242 );
4243
4244 assert_eq!(HelperMethods::max_tokens_for_model(""), 64000);
4246 assert_eq!(HelperMethods::max_tokens_for_model("OPUS"), 64000); assert_eq!(
4248 HelperMethods::max_tokens_for_model("model-opus-end"),
4249 128000
4250 ); }
4252
4253 #[test]
4254 fn test_truncate_tool_result() {
4255 let default_max = 30000;
4256
4257 let short = "This is a short string.";
4259 assert_eq!(
4260 HelperMethods::truncate_tool_result(short, default_max),
4261 short
4262 );
4263
4264 let exact = "x".repeat(30000);
4266 assert_eq!(
4267 HelperMethods::truncate_tool_result(&exact, default_max),
4268 exact
4269 );
4270
4271 let too_long = "x".repeat(30001);
4273 let truncated = HelperMethods::truncate_tool_result(&too_long, default_max);
4274
4275 assert!(truncated.starts_with(&"x".repeat(30000)));
4277
4278 assert!(truncated.contains("[truncated — 30001 total bytes, showing first 30000]"));
4280
4281 assert!(truncated.len() > 30000);
4283
4284 let very_long = "a".repeat(50000);
4286 let truncated_very_long = HelperMethods::truncate_tool_result(&very_long, default_max);
4287 assert!(
4288 truncated_very_long.contains("[truncated — 50000 total bytes, showing first 30000]")
4289 );
4290 assert!(truncated_very_long.starts_with(&"a".repeat(30000)));
4291
4292 let custom_truncated = HelperMethods::truncate_tool_result(&very_long, 100);
4294 assert!(custom_truncated.starts_with(&"a".repeat(100)));
4295 assert!(custom_truncated.contains("[truncated — 50000 total bytes, showing first 100]"));
4296 }
4297
4298 #[test]
4302 fn truncate_tool_result_enforces_byte_budget() {
4303 let multibyte = "é".repeat(100); let out = HelperMethods::truncate_tool_result(&multibyte, 50);
4305 let body = out.split("\n\n[truncated").next().unwrap();
4306 assert!(
4307 body.len() <= 50,
4308 "retained body must fit the byte budget (got {} bytes)",
4309 body.len()
4310 );
4311 assert!(std::str::from_utf8(body.as_bytes()).is_ok());
4312 assert!(
4313 out.contains("total bytes"),
4314 "marker must report byte counts, got: {out}"
4315 );
4316 }
4317
4318 #[test]
4319 fn test_thinking_level_ranges() {
4320 use crate::core::models::thinking_level_for_budget;
4321
4322 assert_eq!(thinking_level_for_budget(0), "adaptive");
4324
4325 assert_eq!(thinking_level_for_budget(1), "low");
4327 assert_eq!(thinking_level_for_budget(1024), "low");
4328 assert_eq!(thinking_level_for_budget(2048), "low");
4329
4330 assert_eq!(thinking_level_for_budget(2049), "medium");
4332 assert_eq!(thinking_level_for_budget(3000), "medium");
4333 assert_eq!(thinking_level_for_budget(4096), "medium");
4334
4335 assert_eq!(thinking_level_for_budget(4097), "high");
4337 assert_eq!(thinking_level_for_budget(8192), "high");
4338 assert_eq!(thinking_level_for_budget(16384), "high");
4339
4340 assert_eq!(thinking_level_for_budget(16385), "xhigh");
4342 assert_eq!(thinking_level_for_budget(32768), "xhigh");
4343 assert_eq!(thinking_level_for_budget(100000), "xhigh");
4344 }
4345}
4346
4347#[cfg(test)]
4348mod effective_prompt_tests {
4349 use super::*;
4350
4351 fn headless(model: &str, base: &str) -> Runtime {
4352 let mut runtime = Runtime::new_headless();
4353 runtime.set_model(model.to_string());
4354 runtime.set_system_prompt(base.to_string());
4355 runtime
4356 }
4357
4358 #[tokio::test]
4359 async fn anthropic_foreground_ultracode_composes_standing_doctrine_once() {
4360 let mut runtime = headless("anthropic/claude-fable-5", "BASE.");
4361 runtime.named_level = Some(agent_core::reasoning::ReasoningLevel::UltraCode);
4362 let prompt = runtime.effective_system_prompt().await.expect("doctrine");
4363 assert_eq!(prompt.matches("<anthropic-ultracode-workflow>").count(), 1);
4364 assert!(prompt.contains("subagent_start"));
4365 assert!(prompt.contains("subagent_resume"));
4366 }
4367
4368 #[tokio::test]
4369 async fn codex_foreground_with_subagent_tools_composes_doctrine() {
4370 let runtime = headless("openai-codex/gpt-5.6-sol", "BASE.");
4371 let prompt = runtime
4372 .effective_system_prompt()
4373 .await
4374 .expect("prompt must compose");
4375 assert!(prompt.starts_with("BASE."), "base must lead");
4376 assert!(prompt.contains("## Subagent supervision"));
4377 assert!(prompt.contains("NEVER end your turn"));
4378 }
4379
4380 #[tokio::test]
4381 async fn worker_runtime_without_subagent_tools_stays_clean() {
4382 let mut runtime = headless("openai-codex/gpt-5.6-sol", "WORKER.");
4383 runtime.set_tools(ToolRegistry::without_subagent());
4384 assert_eq!(
4385 runtime.effective_system_prompt().await.as_deref(),
4386 Some("WORKER."),
4387 "workers have no subagent tools; doctrine would be unactionable noise"
4388 );
4389 }
4390
4391 #[tokio::test]
4392 async fn non_codex_foreground_stays_clean() {
4393 let runtime = headless("xai-auth/grok-4.5-latest", "BASE.");
4394 assert_eq!(
4395 runtime.effective_system_prompt().await.as_deref(),
4396 Some("BASE.")
4397 );
4398 }
4399
4400 #[tokio::test]
4401 async fn manifest_prompt_is_returned_verbatim() {
4402 let mut runtime = headless("openai-codex/gpt-5.6-sol", "IGNORED.");
4403 let module = agent_core::prompt::PromptModule::new(
4404 agent_core::prompt::PromptModuleId::parse("kernel.test").unwrap(),
4405 "1.0.0",
4406 agent_core::prompt::PromptModuleSource::User,
4407 0,
4408 agent_core::prompt::PromptSelectors::default(),
4409 agent_core::prompt::ModuleMutability::MutableGuidance,
4410 "KERNEL.",
4411 )
4412 .unwrap();
4413 let context = agent_core::prompt::SelectionContext::new(
4414 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap(),
4415 None,
4416 )
4417 .unwrap();
4418 let stack = agent_core::prompt::PromptStack::new(vec![module], context).unwrap();
4419 runtime.apply_prompt_stack(stack).unwrap();
4420 assert_eq!(
4421 runtime.effective_system_prompt().await.as_deref(),
4422 Some("KERNEL."),
4423 "manifest authors own the full stack; no builtin injection on top"
4424 );
4425 }
4426
4427 #[tokio::test]
4428 async fn unresolvable_model_identity_fails_closed_to_base() {
4429 let runtime = headless("definitely-not-a-model", "BASE.");
4430 assert_eq!(
4431 runtime.effective_system_prompt().await.as_deref(),
4432 Some("BASE.")
4433 );
4434 }
4435}
4436
4437#[cfg(test)]
4438mod set_reasoning_level_checked_tests {
4439 use super::*;
4440 use agent_core::reasoning::ReasoningLevel;
4441
4442 fn codex_runtime(model_id: &str) -> Runtime {
4443 let mut rt = Runtime::new_headless();
4444 rt.set_model(format!("openai-codex/{model_id}"));
4445 rt.set_reasoning_level(ReasoningLevel::Low);
4446 rt
4447 }
4448
4449 fn provisioned_fable_runtime() -> Runtime {
4450 let mut rt = Runtime::new_headless();
4451 rt.set_model("anthropic/claude-fable-5".into());
4452 rt.set_reasoning_level(ReasoningLevel::Low);
4453 let foreground =
4454 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-fable-5").unwrap();
4455 let orchestration =
4456 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap();
4457 rt.install_orchestration(Arc::new(orchestration));
4458 rt
4459 }
4460
4461 fn install_typed_manifest(rt: &mut Runtime) {
4462 let module = agent_core::prompt::PromptModule::new(
4463 agent_core::prompt::PromptModuleId::parse("kernel.test").unwrap(),
4464 "1.0.0",
4465 agent_core::prompt::PromptModuleSource::User,
4466 0,
4467 agent_core::prompt::PromptSelectors::default(),
4468 agent_core::prompt::ModuleMutability::MutableGuidance,
4469 "KERNEL.",
4470 )
4471 .unwrap();
4472 let context = agent_core::prompt::SelectionContext::new(
4473 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-fable-5").unwrap(),
4474 None,
4475 )
4476 .unwrap();
4477 rt.apply_prompt_stack(agent_core::prompt::PromptStack::new(vec![module], context).unwrap())
4478 .unwrap();
4479 }
4480
4481 #[test]
4482 fn provisioned_fable_accepts_max_and_ultracode() {
4483 for level in [ReasoningLevel::Max, ReasoningLevel::UltraCode] {
4484 let mut rt = provisioned_fable_runtime();
4485 rt.set_reasoning_level_checked(level).unwrap();
4486 assert_eq!(rt.reasoning_level(), level);
4487 }
4488 }
4489
4490 #[test]
4491 fn fable_ultracode_prerequisites_fail_without_mutation() {
4492 let mut missing_orchestration = Runtime::new_headless();
4493 missing_orchestration.set_model("anthropic/claude-fable-5".into());
4494 missing_orchestration.set_reasoning_level(ReasoningLevel::Low);
4495 assert!(missing_orchestration
4496 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
4497 .is_err());
4498 assert_eq!(missing_orchestration.reasoning_level(), ReasoningLevel::Low);
4499
4500 let mut missing_tools = provisioned_fable_runtime();
4501 missing_tools.set_tools(ToolRegistry::without_subagent());
4502 assert!(missing_tools
4503 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
4504 .is_err());
4505 assert_eq!(missing_tools.reasoning_level(), ReasoningLevel::Low);
4506
4507 let mut worker = provisioned_fable_runtime();
4508 worker.set_codex_request_role(crate::runtime::openai::catalog::ExecutionRole::Worker);
4509 assert!(worker
4510 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
4511 .is_err());
4512 assert_eq!(worker.reasoning_level(), ReasoningLevel::Low);
4513
4514 let mut internal = provisioned_fable_runtime();
4515 internal.set_codex_request_role(crate::runtime::openai::catalog::ExecutionRole::Internal);
4516 assert!(internal
4517 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
4518 .is_err());
4519 assert_eq!(internal.reasoning_level(), ReasoningLevel::Low);
4520
4521 let mut manifest = provisioned_fable_runtime();
4522 install_typed_manifest(&mut manifest);
4523 assert!(manifest
4524 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
4525 .is_err());
4526 assert_eq!(manifest.reasoning_level(), ReasoningLevel::Low);
4527 }
4528
4529 #[test]
4530 fn checked_accepts_client_omission_modes_for_codex() {
4531 for level in [ReasoningLevel::Off, ReasoningLevel::Adaptive] {
4532 let mut rt = codex_runtime("gpt-5.6-sol");
4533 rt.set_reasoning_level_checked(level).unwrap();
4534 assert_eq!(rt.reasoning_level(), level);
4535 }
4536 }
4537
4538 #[test]
4540 fn checked_rejects_ultra_for_luna_no_mutation() {
4541 let mut rt = codex_runtime("gpt-5.6-luna");
4542 let before = rt.reasoning_level();
4543 let err = rt
4544 .set_reasoning_level_checked(ReasoningLevel::Ultra)
4545 .unwrap_err();
4546 assert!(
4547 err.contains("ultra"),
4548 "error must name the rejected level; got: {err}"
4549 );
4550 assert_eq!(
4551 rt.reasoning_level(),
4552 before,
4553 "runtime must not be mutated when validation fails"
4554 );
4555 }
4556
4557 #[test]
4559 fn checked_rejects_max_for_gpt55_no_mutation() {
4560 let mut rt = codex_runtime("gpt-5.5");
4561 rt.set_reasoning_level(ReasoningLevel::Medium);
4562 let err = rt
4563 .set_reasoning_level_checked(ReasoningLevel::Max)
4564 .unwrap_err();
4565 assert!(
4566 err.contains("max"),
4567 "error must name the rejected level; got: {err}"
4568 );
4569 assert_eq!(rt.reasoning_level(), ReasoningLevel::Medium);
4570 }
4571
4572 #[test]
4574 fn checked_accepts_ultra_for_sol_and_mutates() {
4575 let mut rt = codex_runtime("gpt-5.6-sol");
4576 rt.set_reasoning_level_checked(ReasoningLevel::Ultra)
4577 .expect("sol must accept ultra");
4578 assert_eq!(rt.reasoning_level(), ReasoningLevel::Ultra);
4579 }
4580
4581 #[test]
4583 fn checked_rejects_extended_levels_without_metadata() {
4584 let mut rt = Runtime::new_headless();
4585 rt.set_model("claude-opus-4-7".to_string());
4586 rt.set_reasoning_level(ReasoningLevel::Low);
4587 for level in [ReasoningLevel::Max, ReasoningLevel::Ultra] {
4588 assert!(rt.set_reasoning_level_checked(level).is_err());
4589 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
4590 }
4591 }
4592
4593 #[test]
4596 fn checked_rejects_max_ultra_for_unknown_codex_model() {
4597 let mut rt = codex_runtime("gpt-unknown-future");
4598 rt.set_reasoning_level(ReasoningLevel::Low);
4599 for level in [ReasoningLevel::Max, ReasoningLevel::Ultra] {
4600 let err = rt.set_reasoning_level_checked(level).unwrap_err();
4601 assert!(err.contains("no capability metadata"), "{err}");
4602 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
4603 }
4604 }
4605
4606 #[test]
4609 fn checked_enforces_xai_capability_matrix_no_mutation_on_err() {
4610 let mut rt = Runtime::new_headless();
4611 rt.set_model("xai-auth/grok-4.5".to_string());
4612 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
4614 for level in [
4615 ReasoningLevel::Off,
4616 ReasoningLevel::XHigh,
4617 ReasoningLevel::Ultra,
4618 ] {
4619 let before = rt.reasoning_level();
4620 assert!(rt.set_reasoning_level_checked(level).is_err(), "{level}");
4621 assert_eq!(rt.reasoning_level(), before);
4622 }
4623 rt.set_reasoning_level_checked(ReasoningLevel::Low)
4624 .expect("grok-4.5 supports low");
4625 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
4626 }
4627
4628 #[test]
4631 fn checked_rejects_named_on_intrinsic_xai_model_and_defaults_adaptive() {
4632 let mut rt = Runtime::new_headless();
4633 rt.set_model("xai-auth/grok-4.3".to_string());
4634 assert_eq!(rt.reasoning_level(), ReasoningLevel::Adaptive);
4635 assert!(rt
4636 .set_reasoning_level_checked(ReasoningLevel::Medium)
4637 .is_err());
4638 assert_eq!(rt.reasoning_level(), ReasoningLevel::Adaptive);
4639 rt.set_reasoning_level_checked(ReasoningLevel::Adaptive)
4640 .unwrap();
4641 }
4642
4643 #[test]
4645 fn explicit_level_survives_xai_model_switch() {
4646 let mut rt = Runtime::new_headless();
4647 rt.set_model("xai-auth/grok-4.5".to_string());
4648 rt.set_reasoning_level_checked(ReasoningLevel::Low).unwrap();
4649 rt.set_model("xai-auth/grok-4.5-latest".to_string());
4650 assert_eq!(
4651 rt.reasoning_level(),
4652 ReasoningLevel::Low,
4653 "explicit level must not be overwritten by the model default"
4654 );
4655 }
4656}
4657
4658#[cfg(test)]
4659mod codex_execution_preflight_tests {
4660 use super::*;
4661 use crate::runtime::openai::catalog::CodexRequestRole;
4662 use agent_core::prompt::QualifiedModelId;
4663 use agent_core::reasoning::ReasoningLevel;
4664
4665 fn ultra_runtime() -> Runtime {
4666 let mut runtime = Runtime::new_headless();
4667 runtime.set_model("openai-codex/gpt-5.6-sol".to_string());
4668 runtime.set_reasoning_level_explicit(ReasoningLevel::Ultra);
4669 runtime
4670 }
4671
4672 fn install_baseline(runtime: &mut Runtime) {
4673 let foreground =
4674 QualifiedModelId::parse("openai-codex/gpt-5.6-sol").expect("qualified model");
4675 let orchestration = crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8)
4676 .expect("baseline orchestration");
4677 runtime.install_orchestration(Arc::new(orchestration));
4678 }
4679
4680 #[tokio::test]
4681 async fn foreground_ultra_preflight_requires_orchestration() {
4682 let runtime = ultra_runtime();
4683 let error = runtime
4684 .validate_request_preflight()
4685 .await
4686 .expect_err("Ultra without orchestration must fail closed");
4687 assert!(error.to_string().contains("orchestration"), "{error}");
4688 }
4689
4690 #[tokio::test]
4691 async fn foreground_ultra_preflight_requires_actionable_subagent_tools() {
4692 let mut runtime = ultra_runtime();
4693 install_baseline(&mut runtime);
4694 runtime.set_tools(ToolRegistry::without_subagent());
4695
4696 let error = runtime
4697 .validate_request_preflight()
4698 .await
4699 .expect_err("Ultra without subagent tools must fail closed");
4700 assert!(error.to_string().contains("subagent"), "{error}");
4701 }
4702
4703 #[tokio::test]
4704 async fn foreground_ultra_preflight_accepts_exact_model_policy_and_tools() {
4705 let mut runtime = ultra_runtime();
4706 install_baseline(&mut runtime);
4707 runtime
4708 .validate_request_preflight()
4709 .await
4710 .expect("fully provisioned Ultra must pass preflight");
4711 }
4712
4713 #[tokio::test]
4714 async fn worker_ultra_preflight_never_requires_or_enables_orchestration() {
4715 let mut runtime = ultra_runtime();
4716 runtime.set_codex_request_role(CodexRequestRole::Worker);
4717 runtime.set_tools(ToolRegistry::without_subagent());
4718 runtime
4719 .validate_request_preflight()
4720 .await
4721 .expect("worker Ultra is underlying max without proactive orchestration");
4722 assert_eq!(runtime.codex_request_role(), CodexRequestRole::Worker);
4723 }
4724
4725 #[tokio::test]
4726 async fn non_codex_ultra_preflight_fails_before_provider_work() {
4727 let mut runtime = Runtime::new_headless();
4728 runtime.set_model("anthropic/claude-opus-4-7".to_string());
4729 runtime.set_reasoning_level_explicit(ReasoningLevel::Ultra);
4730 let error = runtime
4731 .validate_request_preflight()
4732 .await
4733 .expect_err("non-Codex Ultra must fail closed");
4734 assert!(
4735 error.to_string().contains("unsupported_reasoning_level"),
4736 "{error}"
4737 );
4738 }
4739
4740 #[tokio::test]
4741 async fn compaction_preflight_validates_target_before_credentials() {
4742 let mut runtime = ultra_runtime();
4743 runtime.set_compaction_model(Some("claude-sonnet-4-6".to_string()));
4744 {
4745 let mut auth = runtime.auth.write().await;
4746 auth.auth_token.clear();
4747 auth.auth_type = "none".to_string();
4748 auth.refresh_token = None;
4749 auth.token_expires = None;
4750 }
4751
4752 let error = runtime
4753 .compact_call(Vec::new())
4754 .await
4755 .expect_err("Codex Ultra cannot silently cross into Anthropic compaction");
4756 assert!(
4757 error.to_string().contains("authoritative exact-model"),
4758 "preflight must reject the target before auth access: {error}"
4759 );
4760 }
4761}
4762
4763#[cfg(test)]
4764mod http_client_timeout_tests {
4765 use futures::StreamExt;
4779 use std::time::Duration;
4780 use tokio::io::AsyncWriteExt;
4781
4782 const CHUNKED_HEAD: &[u8] =
4783 b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\n\r\n";
4784
4785 fn chunk(data: &str) -> Vec<u8> {
4786 format!("{:x}\r\n{}\r\n", data.len(), data).into_bytes()
4787 }
4788
4789 async fn spawn_chunked_server(n_chunks: usize, gap: Duration, terminate: bool) -> String {
4792 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4793 let addr = listener.local_addr().unwrap();
4794 tokio::spawn(async move {
4795 let (mut sock, _) = listener.accept().await.unwrap();
4796 let mut buf = [0u8; 4096];
4798 use tokio::io::AsyncReadExt;
4799 let _ = sock.read(&mut buf).await;
4800 sock.write_all(CHUNKED_HEAD).await.unwrap();
4801 for i in 0..n_chunks {
4802 sock.write_all(&chunk(&format!("data {i}\n")))
4803 .await
4804 .unwrap();
4805 sock.flush().await.unwrap();
4806 tokio::time::sleep(gap).await;
4807 }
4808 if terminate {
4809 sock.write_all(b"0\r\n\r\n").await.unwrap();
4810 } else {
4811 tokio::time::sleep(Duration::from_secs(60)).await;
4813 }
4814 });
4815 format!("http://{addr}/stream")
4816 }
4817
4818 async fn drain(
4819 client: &reqwest::Client,
4820 url: &str,
4821 ) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
4822 let resp = client.get(url).send().await?;
4823 let mut stream = resp.bytes_stream();
4824 let mut total = 0usize;
4825 while let Some(c) = stream.next().await {
4826 total += c?.len();
4827 }
4828 Ok(total)
4829 }
4830
4831 #[tokio::test]
4836 async fn active_stream_outliving_read_timeout_survives() {
4837 let url = spawn_chunked_server(15, Duration::from_millis(100), true).await;
4839 let client = super::build_http_client(Duration::from_millis(400)).expect("client builds");
4840 let total = drain(&client, &url)
4841 .await
4842 .expect("active stream must never be killed by the idle timeout");
4843 assert!(total > 0);
4844 }
4845
4846 #[tokio::test]
4849 async fn stalled_stream_is_killed_by_read_timeout() {
4850 let url = spawn_chunked_server(2, Duration::from_millis(10), false).await;
4852 let client = super::build_http_client(Duration::from_millis(300)).expect("client builds");
4853 let start = std::time::Instant::now();
4854 let err = drain(&client, &url)
4855 .await
4856 .expect_err("stalled stream must be detected");
4857 assert!(
4858 start.elapsed() < Duration::from_secs(5),
4859 "stall must be detected promptly, took {:?}",
4860 start.elapsed()
4861 );
4862 let msg = crate::core::error::error_chain_string(err.as_ref());
4863 assert!(
4864 msg.contains("timed out") || msg.contains("timeout"),
4865 "must be a timeout error: {msg}"
4866 );
4867 }
4868
4869 #[tokio::test]
4873 async fn hung_headers_are_killed_by_read_timeout() {
4874 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4875 let addr = listener.local_addr().unwrap();
4876 tokio::spawn(async move {
4877 let (_sock, _) = listener.accept().await.unwrap();
4878 tokio::time::sleep(Duration::from_secs(60)).await;
4879 });
4880 let client = super::build_http_client(Duration::from_millis(300)).expect("client builds");
4881 let start = std::time::Instant::now();
4882 let err = client
4883 .get(format!("http://{addr}/codex/responses"))
4884 .send()
4885 .await
4886 .expect_err("hung request must be detected");
4887 assert!(
4888 start.elapsed() < Duration::from_secs(5),
4889 "hung headers must be detected promptly, took {:?}",
4890 start.elapsed()
4891 );
4892 assert!(err.is_timeout(), "must be a timeout: {err:?}");
4893 }
4894}
4895
4896#[cfg(test)]
4898mod memory_context_provider_tests {
4899 use super::{memory_context, Runtime};
4900 use std::sync::Arc;
4901
4902 async fn memory_runtime_with_providers(
4910 plugins: &[(&str, &str)],
4911 ) -> (
4912 Runtime,
4913 std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
4914 ) {
4915 let mut manager = crate::extensions::manager::ExtensionManager::new(Arc::new(
4916 crate::extensions::hooks::HookBus::new(),
4917 ));
4918 manager.set_progressive_deferral(true);
4919 for (plugin, provider) in plugins {
4920 let manifest: crate::extensions::manifest::ExtensionManifest =
4921 serde_json::from_value(serde_json::json!({
4922 "runtime": "process",
4923 "command": "/bin/false",
4924 "permissions": ["context_providers.register"],
4925 "deferred": {
4926 "context_providers": [{
4927 "id": provider,
4928 "capability": "project-memory",
4929 "description": "test context provider",
4930 "schema_version": 1
4931 }]
4932 }
4933 }))
4934 .expect("manifest parses");
4935 manager
4936 .load(plugin, &manifest)
4937 .await
4938 .expect("deferred context-provider load succeeds without spawning");
4939 }
4940 let extension_runtime = manager.extension_runtime();
4941 let mut runtime = Runtime::new_headless();
4942 runtime.install_extension_runtime(std::sync::Arc::clone(&extension_runtime));
4943 (runtime, extension_runtime)
4944 }
4945
4946 fn assert_memory_off_no_lease(runtime: &Runtime) {
4947 let status = runtime.memory_context_status();
4948 assert_eq!(
4949 status.durable,
4950 memory_context::DurableStatus::Off,
4951 "state must stay Off"
4952 );
4953 assert_eq!(
4954 status.one_shot,
4955 memory_context::OneShotStatus::Idle,
4956 "no one-shot lease may exist"
4957 );
4958 assert!(
4959 runtime.memory_bound_providers_for_test().is_empty(),
4960 "no provider may be bound"
4961 );
4962 }
4963
4964 #[tokio::test]
4970 async fn memory_enable_unregistered_provider_fails_closed_grants_nothing() {
4971 let (runtime, _ext) =
4973 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
4974 for requested in ["no-such-provider", "bad:id"] {
4975 let err = runtime
4976 .memory_context_enable_resolved(
4977 memory_context::MemoryContextMode::CaptureAndRecall,
4978 memory_context::mint_explicit_command_proof(),
4979 Some(requested),
4980 )
4981 .expect_err("unregistered provider must fail closed");
4982 assert_eq!(
4983 err,
4984 memory_context::MemoryContextError::ProviderNotRegistered
4985 );
4986 assert_memory_off_no_lease(&runtime);
4987 }
4988
4989 let (runtime, _ext) = memory_runtime_with_providers(&[]).await;
4992 let err = runtime
4993 .memory_context_enable(
4994 memory_context::MemoryContextMode::CaptureAndRecall,
4995 memory_context::mint_explicit_command_proof(),
4996 )
4997 .expect_err("empty catalog must fail closed");
4998 assert_eq!(
4999 err,
5000 memory_context::MemoryContextError::ProviderNotRegistered
5001 );
5002 assert_memory_off_no_lease(&runtime);
5003 let err = runtime
5005 .memory_context_recall_once(memory_context::mint_explicit_command_proof())
5006 .expect_err("one-shot grant must validate the provider too");
5007 assert_eq!(
5008 err,
5009 memory_context::MemoryContextError::ProviderNotRegistered
5010 );
5011 assert_memory_off_no_lease(&runtime);
5012 }
5013
5014 #[tokio::test]
5018 async fn memory_enable_exactly_one_declared_provider_records_exact_identity() {
5019 const ADDRESS: &str = "extension:axel-memory-manager:project-memory";
5020 let (runtime, _ext) =
5022 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
5023 let status = runtime
5024 .memory_context_enable(
5025 memory_context::MemoryContextMode::CaptureAndRecall,
5026 memory_context::mint_explicit_command_proof(),
5027 )
5028 .expect("unique declared provider must resolve");
5029 assert!(matches!(
5030 status.durable,
5031 memory_context::DurableStatus::Active { .. }
5032 ));
5033 let bound = runtime.memory_bound_providers_for_test();
5034 assert_eq!(bound.len(), 1);
5035 assert_eq!(bound[0].as_str(), ADDRESS, "exact runtime address recorded");
5036
5037 let (runtime, _ext) =
5039 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
5040 runtime
5041 .memory_context_enable_resolved(
5042 memory_context::MemoryContextMode::RecallEachPrompt,
5043 memory_context::mint_explicit_command_proof(),
5044 Some("project-memory"),
5045 )
5046 .expect("explicit exact id must resolve");
5047 assert_eq!(
5048 runtime.memory_bound_providers_for_test()[0].as_str(),
5049 ADDRESS
5050 );
5051 }
5052
5053 #[tokio::test]
5057 async fn memory_enable_ambiguous_overlapping_declarations_fails_closed() {
5058 let (runtime, _ext) = memory_runtime_with_providers(&[
5059 ("mem-a", "project-memory"),
5060 ("mem-b", "project-memory"),
5061 ])
5062 .await;
5063 let err = runtime
5064 .memory_context_enable(
5065 memory_context::MemoryContextMode::CaptureAndRecall,
5066 memory_context::mint_explicit_command_proof(),
5067 )
5068 .expect_err("overlapping declarations must fail closed");
5069 assert_eq!(err, memory_context::MemoryContextError::ProviderAmbiguous);
5070 assert_memory_off_no_lease(&runtime);
5071
5072 let err = runtime
5074 .memory_context_enable_resolved(
5075 memory_context::MemoryContextMode::CaptureOnly,
5076 memory_context::mint_explicit_command_proof(),
5077 Some("project-memory"),
5078 )
5079 .expect_err("same id declared by two plugins is ambiguous");
5080 assert_eq!(err, memory_context::MemoryContextError::ProviderAmbiguous);
5081 assert_memory_off_no_lease(&runtime);
5082 }
5083
5084 #[tokio::test]
5090 async fn memory_disable_revokes_runtime_lease_without_spawn_no_panic() {
5091 let (runtime, ext) =
5092 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
5093 runtime
5094 .memory_context_enable(
5095 memory_context::MemoryContextMode::CaptureAndRecall,
5096 memory_context::mint_explicit_command_proof(),
5097 )
5098 .expect("enable succeeds");
5099 assert_eq!(
5100 ext.lease_count(),
5101 0,
5102 "granting a memory lease spawns nothing"
5103 );
5104
5105 let status = runtime.memory_context_disable();
5106 assert_eq!(status.durable, memory_context::DurableStatus::Off);
5107 assert_memory_off_no_lease(&runtime);
5108 assert_eq!(
5109 ext.lease_count(),
5110 0,
5111 "revocation of a never-spawned lease is a no-op"
5112 );
5113
5114 let status = runtime.memory_context_disable();
5116 assert_eq!(status.durable, memory_context::DurableStatus::Off);
5117 assert_eq!(ext.lease_count(), 0);
5118 }
5119
5120 #[test]
5125 fn memory_context_enable_disable_emit_metadata_only_events() {
5126 memory_context::drain_captured_memory_events_for_test();
5127
5128 let runtime = Runtime::new_headless();
5129 runtime
5131 .memory_context_enable(
5132 memory_context::MemoryContextMode::Off,
5133 memory_context::mint_explicit_command_proof(),
5134 )
5135 .expect_err("Off is not an enable mode");
5136 assert!(
5137 memory_context::drain_captured_memory_events_for_test().is_empty(),
5138 "failed enable must emit no event"
5139 );
5140
5141 runtime
5142 .memory_context_enable(
5143 memory_context::MemoryContextMode::CaptureAndRecall,
5144 memory_context::mint_explicit_command_proof(),
5145 )
5146 .expect("enable succeeds");
5147 runtime.memory_context_disable();
5148
5149 let events = memory_context::drain_captured_memory_events_for_test();
5150 assert_eq!(
5151 events
5152 .iter()
5153 .map(|event| (event.event, event.outcome))
5154 .collect::<Vec<_>>(),
5155 vec![
5156 ("memory_context.enabled", "enabled"),
5157 ("memory_context.disabled", "disabled"),
5158 ],
5159 );
5160 assert_eq!(events[0].mode, Some("capture_and_recall"));
5161 assert_eq!(events[1].mode, Some("off"));
5162
5163 let cwd = std::env::current_dir()
5166 .expect("cwd")
5167 .to_string_lossy()
5168 .into_owned();
5169 for event in &events {
5170 let json = serde_json::to_string(event).expect("event serializes");
5171 assert!(
5172 json.contains("\"project_digest\":\"p") || json.contains("project-cwd-"),
5173 "digest identity expected; got: {json}"
5174 );
5175 assert!(
5176 !json.contains(&cwd),
5177 "raw project path leaked into event JSON: {json}"
5178 );
5179 }
5180 }
5181
5182 #[tokio::test]
5188 async fn memory_session_end_guard_drop_fresh_construction_is_off_no_lease() {
5189 let (runtime, ext) =
5190 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
5191 runtime
5192 .memory_context_enable(
5193 memory_context::MemoryContextMode::CaptureAndRecall,
5194 memory_context::mint_explicit_command_proof(),
5195 )
5196 .expect("enable succeeds");
5197 drop(runtime);
5199 assert_eq!(
5200 ext.lease_count(),
5201 0,
5202 "session end must leave no runtime lease (never-spawned reap is a no-op)"
5203 );
5204
5205 let mut fresh = Runtime::new_headless();
5207 fresh.install_extension_runtime(std::sync::Arc::clone(&ext));
5208 assert_memory_off_no_lease(&fresh);
5209 assert_eq!(ext.lease_count(), 0);
5210 }
5211}