1use crate::{Result, RuntimeError, ToolRegistry};
2use futures::stream::Stream;
3use reqwest::Client;
4use serde_json::{json, Value};
5use std::path::{Path, PathBuf};
6use std::pin::Pin;
7use std::sync::Arc;
8use std::sync::Mutex;
9use std::time::Duration;
10use tokio::sync::{mpsc, RwLock};
11use tokio_util::sync::CancellationToken;
12
13mod anthropic_quota;
14mod api;
15mod api_sync;
16pub mod attachments;
17mod auth;
18mod axel_context;
19#[cfg(test)]
20mod body_golden;
21pub mod budget;
22mod cache_diagnosis;
23pub mod capture_worker;
24pub mod chat_capture;
25pub(crate) mod cloud_invoke;
26pub mod compaction;
27pub mod context;
28pub mod continuation;
29pub mod google_gemini;
30pub mod google_vertex;
31pub(crate) mod helpers;
32mod http_phases;
33mod image_limits;
34mod image_retention;
35pub mod keepwarm;
36pub mod memory_context;
37pub mod memory_history;
38pub mod openai;
39pub mod relay;
40mod request;
41mod sse;
42mod sse_types;
43mod stream;
44pub mod subagent;
45pub mod subagent_watch;
46pub mod telemetry;
47pub mod trace;
48pub(crate) mod transport;
49mod types;
50
51use api::ApiMethods;
52use auth::AuthMethods;
53use helpers::HelperMethods;
54use stream::StreamMethods;
55use types::AuthState;
56pub use types::{AgentEvent, CancelCause, LlmEvent, SessionEvent, StreamEvent, TurnCompletion};
57pub use stream::activation_policy;
58
59pub enum BeforeToolCallDecision {
61 Continue { input: Value },
62 Block { reason: String },
63}
64
65pub async fn emit_before_tool_call(
69 hook_bus: &Arc<crate::extensions::hooks::HookBus>,
70 tool_name: &str,
71 runtime_tool_name: Option<&str>,
72 input: Value,
73 session_id: Option<&str>,
74) -> crate::extensions::hooks::events::HookResult {
75 let mut event = crate::extensions::hooks::events::HookEvent::before_tool_call(tool_name, input)
76 .with_session(session_id);
77 if let Some(runtime_tool_name) = runtime_tool_name {
78 event.tool_runtime_name = Some(runtime_tool_name.to_string());
79 }
80 hook_bus.emit(&event).await
81}
82
83pub use crate::tools::CONFIRM_ANSWER_ALLOW_ALL;
89
90const MAX_CONFIRM_MESSAGE_CHARS: usize = 1200;
94const MAX_CONFIRM_MESSAGE_LINES: usize = 12;
95
96fn cap_confirm_message(message: &str) -> String {
102 let message = redact_secret_text(&crate::session::sanitize_prompt_text(message));
103 let lines: Vec<&str> = message.lines().collect();
104 let over_lines = lines.len() > MAX_CONFIRM_MESSAGE_LINES;
105 let over_chars = message.chars().count() > MAX_CONFIRM_MESSAGE_CHARS;
106 if !over_lines && !over_chars {
107 return message;
108 }
109 let kept = lines[..lines.len().min(MAX_CONFIRM_MESSAGE_LINES)].join("\n");
110 let kept: String = kept.chars().take(MAX_CONFIRM_MESSAGE_CHARS).collect();
111 format!("{kept}\n…[message truncated by host]")
112}
113
114const MAX_REWRITE_PREVIEW_LINES: usize = 6;
117const MAX_REWRITE_PREVIEW_CHARS: usize = 400;
118
119fn redact_secret_text(text: &str) -> String {
128 crate::runtime::trace::export::redact_command_text(text)
129}
130
131fn redact_preview_value(value: &mut Value) {
134 crate::runtime::trace::export::redact_value(value);
135 match value {
136 Value::Object(map) => map.values_mut().for_each(redact_preview_value),
137 Value::Array(items) => items.iter_mut().for_each(redact_preview_value),
138 Value::String(s) => *s = redact_secret_text(s),
139 _ => {}
140 }
141}
142
143fn final_input_preview(input: &Value) -> String {
146 let mut input = input.clone();
147 redact_preview_value(&mut input);
148 let full = match input.get("command").and_then(Value::as_str) {
149 Some(cmd) => cmd.to_string(),
150 None => serde_json::to_string_pretty(&input).unwrap_or_else(|_| input.to_string()),
151 };
152 let lines: Vec<&str> = full.lines().collect();
153 let mut out: String = lines[..lines.len().min(MAX_REWRITE_PREVIEW_LINES)]
154 .iter()
155 .map(|l| format!(" {l}"))
156 .collect::<Vec<_>>()
157 .join("\n");
158 let mut cut = lines.len() > MAX_REWRITE_PREVIEW_LINES;
159 if out.chars().count() > MAX_REWRITE_PREVIEW_CHARS {
160 out = out.chars().take(MAX_REWRITE_PREVIEW_CHARS).collect();
161 cut = true;
162 }
163 if cut {
164 out.push_str("\n …[preview truncated]");
165 }
166 out
167}
168
169fn confirm_prompt_body(
175 message: &str,
176 sources: &[String],
177 tool: Option<&str>,
178 rewrite: Option<(&[String], &Value)>,
179) -> String {
180 let source = if sources.is_empty() {
181 "(unknown)".to_string()
182 } else {
183 sources.join(", ")
184 };
185 let rewritten = match rewrite {
186 Some((by, input)) => format!(
187 "Input rewritten by: {}\nInput that will run:\n{}\n",
188 if by.is_empty() { "(unknown)".to_string() } else { by.join(", ") },
189 final_input_preview(input)
190 ),
191 None => String::new(),
192 };
193 crate::session::sanitize_prompt_text(&format!(
197 "Extension: {source}\nTool: {}\n{rewritten}\nMessage from {source}:\n{}\n\nAllow this tool call?",
198 tool.unwrap_or("(unknown)"),
199 cap_confirm_message(message)
200 ))
201}
202
203fn approved(
206 modified_input: Option<Value>,
207) -> crate::extensions::hooks::events::HookResult {
208 match modified_input {
209 Some(input) => crate::extensions::hooks::events::HookResult::Modify { input },
210 None => crate::extensions::hooks::events::HookResult::Continue,
211 }
212}
213
214pub async fn resolve_before_tool_call_result(
223 hook_result: crate::extensions::hooks::events::HookResult,
224 secret_prompt: Option<&crate::tools::SecretPromptHandle>,
225 auto_approve_confirms: bool,
226 session_allow_all: Option<&crate::tools::SessionConfirmLatch>,
227) -> crate::extensions::hooks::events::HookResult {
228 match hook_result {
229 crate::extensions::hooks::events::HookResult::Confirm {
230 message,
231 sources,
232 generations,
233 tool,
234 modified_input,
235 modified_by,
236 } => {
237 let keys = crate::tools::latch_keys(&sources, &generations);
238 if auto_approve_confirms {
239 tracing::info!(message = %message, "confirm auto-approved (auto_approve_confirms=true)");
240 return approved(modified_input);
241 }
242 if session_allow_all.is_some_and(|latch| latch.allows_all(&keys)) {
243 tracing::info!(
244 message = %message,
245 extensions = ?sources,
246 "confirm auto-approved (extension allow-all latched)"
247 );
248 return approved(modified_input);
249 }
250
251 let Some(prompt) = secret_prompt else {
252 return crate::extensions::hooks::events::HookResult::Block {
253 reason: format!(
254 "Tool call requires confirmation but no interactive prompt is available: {}",
255 redact_secret_text(&crate::session::sanitize_prompt_text(&message))
256 ),
257 };
258 };
259
260 let scope = (session_allow_all.is_some() && !sources.is_empty()).then(|| {
263 crate::session::ConfirmScope {
264 extensions: sources.clone(),
265 tool: tool.clone(),
266 rewritten_by: if modified_input.is_some() {
267 modified_by.clone()
268 } else {
269 Vec::new()
270 },
271 }
272 });
273 let response = prompt
274 .prompt_scoped(
275 crate::session::CONFIRM_PROMPT_TITLE.to_string(),
276 confirm_prompt_body(
277 &message,
278 &sources,
279 tool.as_deref(),
280 modified_input.as_ref().map(|i| (modified_by.as_slice(), i)),
281 ),
282 scope,
283 )
284 .await;
285
286 match response.as_deref().map(str::trim) {
287 Some(answer)
288 if answer.eq_ignore_ascii_case("yes") || answer.eq_ignore_ascii_case("y") =>
289 {
290 approved(modified_input)
291 }
292 Some(answer) if answer.eq_ignore_ascii_case(CONFIRM_ANSWER_ALLOW_ALL) => {
293 match session_allow_all {
294 Some(latch) if latch.allow(&keys) => tracing::warn!(
295 extensions = ?sources,
296 "user chose Allow all: confirms from these extensions are auto-approved for the rest of this conversation"
297 ),
298 Some(_) => tracing::warn!(
299 "user chose Allow all on an unattributed or revoked confirm; approving this call only"
300 ),
301 None => tracing::warn!(
302 "user chose Allow all but this path has no session latch; approving this call only"
303 ),
304 }
305 approved(modified_input)
306 }
307 _ => crate::extensions::hooks::events::HookResult::Block {
308 reason: format!(
309 "Tool call confirmation denied: {}",
310 redact_secret_text(&crate::session::sanitize_prompt_text(&message))
311 ),
312 },
313 }
314 }
315 other => other,
316 }
317}
318
319pub async fn resolve_before_tool_call_decision(
321 original_input: Value,
322 hook_result: crate::extensions::hooks::events::HookResult,
323 secret_prompt: Option<&crate::tools::SecretPromptHandle>,
324 auto_approve_confirms: bool,
325 session_allow_all: Option<&crate::tools::SessionConfirmLatch>,
326) -> BeforeToolCallDecision {
327 match resolve_before_tool_call_result(
328 hook_result,
329 secret_prompt,
330 auto_approve_confirms,
331 session_allow_all,
332 )
333 .await
334 {
335 crate::extensions::hooks::events::HookResult::Block { reason } => {
336 BeforeToolCallDecision::Block { reason }
337 }
338 crate::extensions::hooks::events::HookResult::Modify { input } => {
339 BeforeToolCallDecision::Continue { input }
340 }
341 _ => BeforeToolCallDecision::Continue {
342 input: original_input,
343 },
344 }
345}
346
347const MAX_REPLACE_OUTPUT: usize = 1024 * 1024; pub async fn emit_after_tool_call(
372 hook_bus: &Arc<crate::extensions::hooks::HookBus>,
373 tool_name: &str,
374 runtime_tool_name: Option<&str>,
375 input: Value,
376 output: String,
377 max_tool_output: usize,
378 session_id: Option<&str>,
379) -> String {
380 emit_after_tool_call_outcome(
381 hook_bus,
382 tool_name,
383 runtime_tool_name,
384 input,
385 output,
386 max_tool_output,
387 session_id,
388 )
389 .await
390 .output
391}
392
393pub(super) struct AfterToolCallOutcome {
396 pub output: String,
397 pub replaced: bool,
398}
399
400pub(super) async fn emit_after_tool_call_outcome(
401 hook_bus: &Arc<crate::extensions::hooks::HookBus>,
402 tool_name: &str,
403 runtime_tool_name: Option<&str>,
404 input: Value,
405 output: String,
406 max_tool_output: usize,
407 session_id: Option<&str>,
408) -> AfterToolCallOutcome {
409 use crate::extensions::hooks::events::HookResult;
410 let original = output.clone();
412 let mut event =
413 crate::extensions::hooks::events::HookEvent::after_tool_call(tool_name, input, output)
414 .with_session(session_id);
415 if let Some(runtime_tool_name) = runtime_tool_name {
416 event.tool_runtime_name = Some(runtime_tool_name.to_string());
417 }
418 let mut replaced = false;
419 let post_hook = match hook_bus.emit(&event).await {
420 HookResult::Replace { mut output } => {
421 replaced = true;
422 if output.len() > MAX_REPLACE_OUTPUT {
423 tracing::warn!(
424 tool = %tool_name,
425 len = output.len(),
426 cap = MAX_REPLACE_OUTPUT,
427 "Extension Replace output exceeds cap — clamping",
428 );
429 let mut boundary = MAX_REPLACE_OUTPUT;
431 while boundary > 0 && !output.is_char_boundary(boundary) {
432 boundary -= 1;
433 }
434 output.truncate(boundary);
435 }
436 output
437 }
438 HookResult::Continue => original,
439 other => {
440 tracing::warn!(
441 tool = %tool_name,
442 ?other,
443 "Unexpected after_tool_call result — using original output",
444 );
445 original
446 }
447 };
448 AfterToolCallOutcome {
452 output: crate::runtime::helpers::HelperMethods::truncate_tool_result(
453 &post_hook,
454 max_tool_output,
455 ),
456 replaced,
457 }
458}
459
460#[derive(Debug, Clone, Copy, PartialEq, Eq)]
463pub struct ReasoningClamp {
464 pub from: agent_core::reasoning::ReasoningLevel,
465 pub to: agent_core::reasoning::ReasoningLevel,
466}
467
468fn validated_single_tool_output(
469 model: &str,
470 output: crate::ToolOutput,
471) -> (String, Option<Vec<Value>>) {
472 let (summary, blocks) = output.into_parts();
473 if let Some(ref blocks) = blocks {
474 if let Err(error) = attachments::validate_tool_blocks(model, blocks) {
475 return (format!("Attachment not sent: {error}"), None);
476 }
477 }
478 (summary, blocks)
479}
480
481fn retain_single_tool_blocks(
484 blocks: Option<Vec<Value>>,
485 original: &str,
486 hooked: &str,
487) -> Option<Vec<Value>> {
488 if original == hooked {
489 blocks
490 } else {
491 None
492 }
493}
494
495fn single_tool_result_content(
496 result: &str,
497 blocks: Option<Vec<Value>>,
498 max_tool_output: usize,
499) -> Value {
500 match blocks {
501 Some(blocks) => Value::Array(blocks),
502 None => Value::String(HelperMethods::truncate_tool_result(result, max_tool_output)),
503 }
504}
505
506#[derive(Clone)]
509struct PromptReloadSource {
510 manifest: PathBuf,
511 context: agent_core::prompt::SelectionContext,
512 user_module: Option<agent_core::prompt::PromptModule>,
513 delegation_policy_digest: Option<String>,
514}
515
516pub struct Runtime {
517 client: Client,
518 anthropic_stream_client: Arc<std::sync::OnceLock<Client>>,
519 auth: Arc<RwLock<AuthState>>,
520 model: String,
521 tools: Arc<RwLock<ToolRegistry>>,
522 system_prompt: Option<String>,
523 effective_prompt: Option<agent_core::prompt::PromptStack>,
525 prompt_generation: u64,
526 prompt_reload_source: Option<PromptReloadSource>,
528 thinking_budget: u32,
529 named_level: Option<agent_core::reasoning::ReasoningLevel>,
533 explicit_reasoning: bool,
538 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole,
541 context_window_override: Option<u64>,
546 pub(crate) continuation: continuation::SharedContinuation,
549 compaction_model: Option<String>,
551 compaction_mode: agent_core::compaction::CompactionMode,
553 compaction_exclusions: Vec<agent_core::compaction::ContentClass>,
555 remote_summarization_attempts: Arc<std::sync::atomic::AtomicU64>,
560 subagent_registry: Arc<Mutex<crate::runtime::subagent::SubagentRegistry>>,
562 orchestration: Option<Arc<crate::orchestration::OrchestrationRuntime>>,
564 event_queue: Arc<crate::events::EventQueue>,
566 pub watcher_exit_path: Option<PathBuf>,
568 max_tool_output: usize,
570 bash_timeout: u64,
571 bash_max_timeout: u64,
572 subagent_timeout: u64,
573 api_retries: u32,
574 refusal_retries: u32,
575 telemetry_level: crate::runtime::telemetry::TelemetryLevel,
577 telemetry_writer: Option<crate::runtime::telemetry::TelemetryWriter>,
583 trace_ctx: trace::TraceContext,
588 trace_controls: std::sync::Arc<trace::TraceControls>,
592 memory_context_state: std::sync::Arc<std::sync::Mutex<memory_context::SessionMemoryState>>,
604 memory_backend: crate::memory_backend::MemoryBinding,
607 memory_backend_config: Option<crate::config::MemoryBackendConfig>,
609 memory_backend_reconfigure_denied: Arc<std::sync::atomic::AtomicBool>,
611 #[cfg(any(test, feature = "testing"))]
615 capture_provider_for_test: std::sync::Arc<
616 std::sync::Mutex<Option<std::sync::Arc<dyn capture_worker::CaptureProvider>>>,
617 >,
618 pending_history_import_preview:
622 std::sync::Arc<std::sync::Mutex<Option<memory_history::HistoryImportPreview>>>,
623 history_import_plan: std::sync::Arc<std::sync::Mutex<Option<memory_history::ImportPlan>>>,
625 pending_memory_segment:
636 std::sync::Arc<std::sync::Mutex<Option<memory_context::ContextSegment>>>,
637 retained_recall_turn:
644 std::sync::Arc<std::sync::Mutex<Option<memory_context::RetainedRecallTurn>>>,
645 one_shot_trace_writer:
653 std::sync::Arc<std::sync::Mutex<Option<crate::runtime::telemetry::TelemetryWriter>>>,
654 capture_dir: std::path::PathBuf,
660 cache_diagnostics: bool,
662 cache_ttl: crate::core::config::CacheTtl,
665 ttl_downgrade_notified: std::sync::Arc<std::sync::atomic::AtomicBool>,
668 saw_1h_honored: std::sync::Arc<std::sync::atomic::AtomicBool>,
672 cache_anchor: std::sync::Arc<keepwarm::CacheAnchorCell>,
676 last_msg_id: Arc<Mutex<Option<String>>>,
680 cache_diag_rejected: Arc<std::sync::atomic::AtomicBool>,
683 session_manager: std::sync::Arc<crate::tools::shell::SessionManager>,
684 hook_bus: Arc<crate::extensions::hooks::HookBus>,
686 #[allow(dead_code)]
688 reaper_handle: Option<tokio::task::JoinHandle<()>>,
689 #[allow(dead_code)]
690 reaper_cancel: Option<tokio_util::sync::CancellationToken>,
691 credential_source: crate::auth::CredentialSource,
695 token_cache: crate::auth::TokenCache,
698 trusted_worker_models: Vec<agent_core::prompt::QualifiedModelId>,
702 subagent_limits: crate::config::SubagentLimits,
706 progressive_tool_disclosure: bool,
710 deferred_tool_loading: bool,
713 activation_confirm: agent_core::config::ActivationConfirm,
716 delegation_parent: Option<String>,
719 session_allow_all: std::sync::Arc<crate::tools::SessionConfirmLatch>,
724 mcp_runtime: Option<std::sync::Arc<crate::mcp::McpRuntimeManager>>,
728 mcp_session_scope: Option<std::sync::Arc<crate::mcp::McpSessionEndGuard>>,
732 extension_runtime: Option<std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>>,
736 extension_session_scope:
740 Option<std::sync::Arc<crate::extensions::lease::ExtensionSessionEndGuard>>,
741 turn_budget: crate::runtime::budget::TurnBudget,
744 owner_steer_budget: Option<Arc<crate::runtime::budget::OwnerSteerBudget>>,
748 host_tool_session: crate::tools::activation::SessionId,
756 tool_catalog_prompt: Arc<std::sync::Mutex<Option<Option<String>>>>,
761 retained_tool_set: crate::tools::activation::RetainedSessionToolSet,
765 pending_tool_restore: crate::tools::activation::PendingToolRestore,
769 session_id: Option<String>,
773 cwd: Option<PathBuf>,
777 env: Option<crate::session::types::SessionEnv>,
781 env_stripped: Vec<String>,
783 env_warned: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
787}
788
789fn fresh_host_tool_session() -> crate::tools::activation::SessionId {
793 crate::tools::activation::SessionId::parse(&format!(
794 "runtime-{}-{}",
795 std::process::id(),
796 uuid::Uuid::new_v4()
797 ))
798 .expect("generated runtime session id is always valid")
799}
800
801fn fresh_memory_context_state(
808) -> std::sync::Arc<std::sync::Mutex<memory_context::SessionMemoryState>> {
809 let session = memory_context::SessionId::parse(&format!(
810 "memctx-{}-{}",
811 std::process::id(),
812 uuid::Uuid::new_v4()
813 ))
814 .expect("generated memory session id is always valid");
815 std::sync::Arc::new(std::sync::Mutex::new(
816 memory_context::SessionMemoryState::new(session),
817 ))
818}
819
820fn memory_project_scope() -> std::result::Result<agent_core::memory::store::ProjectScope, String> {
824 let cwd = std::env::current_dir()
825 .map_err(|error| format!("cannot resolve current project directory: {error}"))?;
826 agent_core::memory::store::ProjectScope::discover(&cwd)
827 .map_err(|error| format!("cannot resolve project scope: {error}"))
828}
829
830fn memory_project_id() -> memory_context::ProjectId {
831 match memory_project_scope() {
832 Ok(scope) => memory_context::ProjectId::parse(scope.key())
833 .expect("host project scope key is always a valid identifier"),
834 Err(_) => memory_context::ProjectId::parse("project-unresolved")
835 .expect("static fallback project id is valid"),
836 }
837}
838
839fn memory_capture_worker() -> &'static capture_worker::CaptureWorker {
848 static WORKER: std::sync::OnceLock<capture_worker::CaptureWorker> = std::sync::OnceLock::new();
849 WORKER.get_or_init(|| capture_worker::CaptureWorker::new(32))
850}
851
852struct ExtensionCaptureProvider {
853 manager: std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
854 session: crate::tools::activation::SessionId,
855 plugin: String,
856 digest: crate::tools::catalog::SchemaDigest,
857 handle: tokio::runtime::Handle,
858}
859
860impl capture_worker::CaptureProvider for ExtensionCaptureProvider {
861 fn capture(
862 &self,
863 capture: chat_capture::ChatTurnCapture,
864 ) -> std::result::Result<(), capture_worker::CaptureFailure> {
865 let params = memory_context::capture_request_wire(&capture);
866 self.handle
867 .block_on(
868 crate::extensions::lease::ExtensionLeaseCapability::new(
869 self.session.clone(),
870 self.manager.clone(),
871 )
872 .call_exact(
873 &self.plugin,
874 memory_context::MEMORY_CAPTURE_TOOL_NAME,
875 &self.digest,
876 params,
877 ),
878 )
879 .map(|_| ())
880 .map_err(|_| capture_worker::CaptureFailure {
881 code: "provider_call_failed",
882 })
883 }
884
885 fn contains_capture(
886 &self,
887 capture_id: &[u8; 32],
888 ) -> std::result::Result<capture_worker::CaptureCommitState, capture_worker::CaptureFailure>
889 {
890 let capture_id = chat_capture::CaptureId::from_bytes(*capture_id);
891 let response = self
892 .handle
893 .block_on(
894 crate::extensions::lease::ExtensionLeaseCapability::new(
895 self.session.clone(),
896 self.manager.clone(),
897 )
898 .call_exact(
899 &self.plugin,
900 memory_context::MEMORY_CAPTURE_TOOL_NAME,
901 &self.digest,
902 memory_context::capture_query_wire(&capture_id),
903 ),
904 )
905 .map_err(|_| capture_worker::CaptureFailure {
906 code: "provider_query_failed",
907 })?;
908 Ok(
909 if response
910 .get("committed")
911 .and_then(serde_json::Value::as_bool)
912 == Some(true)
913 {
914 capture_worker::CaptureCommitState::Committed
915 } else {
916 capture_worker::CaptureCommitState::Absent
917 },
918 )
919 }
920
921 fn capture_summary(
922 &self,
923 capture: chat_capture::ConversationSummaryCapture,
924 ) -> std::result::Result<(), capture_worker::CaptureFailure> {
925 let params = memory_context::summary_capture_request_wire(&capture);
926 self.handle
927 .block_on(
928 crate::extensions::lease::ExtensionLeaseCapability::new(
929 self.session.clone(),
930 self.manager.clone(),
931 )
932 .call_exact(
933 &self.plugin,
934 memory_context::MEMORY_CAPTURE_TOOL_NAME,
935 &self.digest,
936 params,
937 ),
938 )
939 .map(|_| ())
940 .map_err(|_| capture_worker::CaptureFailure {
941 code: "provider_call_failed",
942 })
943 }
944}
945
946fn terminal_capture_history(
947 lease: &memory_context::MemoryContextLease,
948 messages: &[crate::SharedMessage],
949 started_at: std::time::SystemTime,
950) -> chat_capture::TerminalTurnHistory {
951 use agent_core::core::disclosure::DisclosureClass;
952 let mut items = Vec::new();
953 for message in messages {
954 let Some(role) = message.get("role").and_then(serde_json::Value::as_str) else {
955 continue;
956 };
957 let class = match role {
958 "user" => chat_capture::CaptureContentClass::UserMessage,
959 "assistant" => chat_capture::CaptureContentClass::AssistantFinal,
960 _ => continue,
961 };
962 let text = message
963 .get("content")
964 .and_then(serde_json::Value::as_str)
965 .map(str::to_owned)
966 .unwrap_or_else(|| {
967 message
968 .get("content")
969 .map(ToString::to_string)
970 .unwrap_or_default()
971 });
972 items.push(chat_capture::CanonicalCaptureItem {
973 project_id: lease.project_id.clone(),
974 class,
975 disclosure: DisclosureClass::ModelVisible,
976 sensitivity: chat_capture::Sensitivity::Normal,
977 text,
978 tool_name: None,
979 });
980 }
981 chat_capture::TerminalTurnHistory {
982 project_id: lease.project_id.clone(),
983 session_id: lease.session_id.clone(),
984 turn_id: memory_context::TurnId::parse(&format!("turn-{}", uuid::Uuid::new_v4()))
985 .expect("generated turn id"),
986 turn_ordinal: 0,
987 started_at,
988 completed_at: std::time::SystemTime::now(),
989 outcome: agent_core::TurnOutcome::Completed,
990 items,
991 compaction: None,
992 }
993}
994
995fn memory_provider_id() -> memory_context::ContextProviderId {
996 memory_context::ContextProviderId::parse("axel-memory")
997 .expect("static provider id is always valid")
998}
999
1000fn terminal_capture_start(messages: &[crate::SharedMessage]) -> Option<crate::SharedMessage> {
1001 messages
1002 .iter()
1003 .rfind(|message| {
1004 message["role"] == "user"
1005 && (message["content"].is_string()
1006 || message["content"].as_array().is_some_and(|blocks| {
1007 blocks.iter().any(|block| block["type"] != "tool_result")
1008 }))
1009 })
1010 .cloned()
1011}
1012
1013fn terminal_capture_messages<'a>(
1014 messages: &'a [crate::SharedMessage],
1015 start: &crate::SharedMessage,
1016) -> Option<&'a [crate::SharedMessage]> {
1017 let index = messages
1021 .iter()
1022 .position(|message| Arc::ptr_eq(message, start))?;
1023 Some(&messages[index..])
1024}
1025
1026fn dispatch_completed_terminal_capture(
1029 completed: bool,
1030 final_history: &std::sync::Mutex<Option<Vec<crate::SharedMessage>>>,
1031 state: &std::sync::Mutex<memory_context::SessionMemoryState>,
1032 selected: Option<(
1033 memory_context::MemoryContextLease,
1034 Arc<dyn capture_worker::CaptureProvider>,
1035 )>,
1036 started_at: std::time::SystemTime,
1037 turn_start: Option<&crate::SharedMessage>,
1038) -> bool {
1039 let history = final_history
1040 .lock()
1041 .unwrap_or_else(std::sync::PoisonError::into_inner)
1042 .take();
1043 let (Some(messages), Some((lease, provider))) = (history, selected) else {
1044 return false;
1045 };
1046 if !completed {
1047 return false;
1048 }
1049 let Some(messages) = turn_start.and_then(|start| terminal_capture_messages(&messages, start))
1050 else {
1051 return false;
1052 };
1053 let state = state
1054 .lock()
1055 .unwrap_or_else(std::sync::PoisonError::into_inner);
1056 if state
1057 .capture_lease_at(std::time::SystemTime::now())
1058 .as_ref()
1059 != Some(&lease)
1060 {
1061 return false;
1062 }
1063 memory_capture_worker()
1064 .submit_terminal(
1065 &lease,
1066 terminal_capture_history(&lease, messages, started_at),
1067 memory_context::RetentionClass::Standard,
1068 provider,
1069 )
1070 .unwrap_or(false)
1071}
1072
1073const HTTP_READ_TIMEOUT: Duration = Duration::from_secs(90);
1078
1079fn build_http_client(read_timeout: Duration) -> reqwest::Result<Client> {
1092 Client::builder()
1093 .tls_built_in_webpki_certs(true)
1094 .tls_built_in_native_certs(true)
1095 .connect_timeout(Duration::from_secs(10))
1096 .read_timeout(read_timeout)
1097 .build()
1098}
1099
1100pub fn build_host_http_client() -> Result<Client> {
1104 build_http_client(HTTP_READ_TIMEOUT)
1105 .map_err(|e| RuntimeError::Config(format!("Failed to build HTTP client: {}", e)))
1106}
1107
1108pub(crate) struct RuntimeParts {
1112 pub host: crate::host::HostParts,
1113 pub session_manager: Arc<crate::tools::shell::SessionManager>,
1114 pub reaper: Option<(tokio::task::JoinHandle<()>, CancellationToken)>,
1115}
1116
1117impl RuntimeParts {
1118 pub(crate) fn with_reaper(host: crate::host::HostParts) -> Self {
1120 let session_manager = fresh_session_manager();
1121 let cancel = CancellationToken::new();
1122 let handle =
1123 crate::tools::shell::session::start_reaper(session_manager.clone(), cancel.clone());
1124 Self {
1125 host,
1126 session_manager,
1127 reaper: Some((handle, cancel)),
1128 }
1129 }
1130
1131 #[cfg_attr(not(any(test, feature = "testing")), allow(dead_code))]
1133 pub(crate) fn without_reaper(host: crate::host::HostParts) -> Self {
1134 Self {
1135 host,
1136 session_manager: fresh_session_manager(),
1137 reaper: None,
1138 }
1139 }
1140}
1141
1142fn fresh_session_manager() -> Arc<crate::tools::shell::SessionManager> {
1143 let config = crate::tools::shell::ShellConfig::default();
1144 crate::tools::shell::SessionManager::new(config)
1145}
1146
1147fn fresh_host_parts() -> Result<crate::host::HostParts> {
1151 let client = build_host_http_client()?;
1152 let capture_dir = trace::default_capture_dir();
1157 let _ = trace::sweep_expired_captures(&capture_dir);
1158 Ok(crate::host::HostParts {
1159 client,
1160 tools: Arc::new(RwLock::new(ToolRegistry::new())),
1161 hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
1162 credential_source: crate::auth::CredentialSource::Local,
1163 token_cache: crate::auth::TokenCache::new(),
1164 mcp_runtime: None,
1165 extension_runtime: None,
1166 capture_dir,
1167 progressive_tool_disclosure: false,
1168 })
1169}
1170
1171fn canonical_trusted_worker_model(model: &str) -> String {
1175 let model = model.trim();
1176 if let Some(id) = model.strip_prefix("claude/") {
1177 format!("anthropic/{id}")
1178 } else if model.starts_with("claude-") {
1179 format!("anthropic/{model}")
1180 } else {
1181 model.to_owned()
1182 }
1183}
1184
1185impl Drop for Runtime {
1189 fn drop(&mut self) {
1190 if let Some(c) = &self.reaper_cancel {
1191 c.cancel();
1192 }
1193 }
1194}
1195
1196impl Runtime {
1197 pub async fn new() -> Result<Self> {
1198 Ok(Self::from_parts(RuntimeParts::with_reaper(
1200 fresh_host_parts()?,
1201 )))
1202 }
1203
1204 pub(crate) fn from_parts(parts: RuntimeParts) -> Self {
1207 let (auth_token, auth_type, refresh_token, token_expires) =
1211 (String::new(), "oauth".to_string(), None, Some(0));
1212 let RuntimeParts {
1213 host,
1214 session_manager,
1215 reaper,
1216 } = parts;
1217 let (reaper_handle, reaper_cancel) = match reaper {
1218 Some((h, c)) => (Some(h), Some(c)),
1219 None => (None, None),
1220 };
1221 let mcp_runtime = host.mcp_runtime;
1222 let extension_runtime = host.extension_runtime;
1223
1224 let mut runtime = Runtime {
1225 client: host.client,
1226 anthropic_stream_client: Default::default(),
1227 auth: Arc::new(RwLock::new(AuthState {
1228 auth_token,
1229 auth_type,
1230 refresh_token,
1231 token_expires,
1232 auth_file_mtime: None,
1233 bound_credential: None,
1234 })),
1235 model: crate::models::default_model().to_string(),
1236 tools: host.tools,
1237 system_prompt: None,
1238 effective_prompt: None,
1239 prompt_generation: 0,
1240 prompt_reload_source: None,
1241 thinking_budget: 4096,
1242 named_level: None,
1243 explicit_reasoning: false,
1244 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole::Foreground,
1245 context_window_override: None,
1246 compaction_model: None,
1247 compaction_mode: agent_core::compaction::CompactionMode::default(),
1248 compaction_exclusions: Vec::new(),
1249 remote_summarization_attempts: Arc::new(std::sync::atomic::AtomicU64::new(0)),
1250 subagent_registry: Arc::new(Mutex::new(
1251 crate::runtime::subagent::SubagentRegistry::new(),
1252 )),
1253 orchestration: None,
1254 event_queue: Arc::new(crate::events::EventQueue::new(1000)),
1255 watcher_exit_path: None,
1256 max_tool_output: 30000,
1257 bash_timeout: 30,
1258 bash_max_timeout: 300,
1259 subagent_timeout: 300,
1260 api_retries: 3,
1261 refusal_retries: 2,
1262 telemetry_level: crate::runtime::telemetry::TelemetryLevel::Off,
1263 telemetry_writer: None,
1264 trace_ctx: trace::TraceContext::disabled(),
1265 trace_controls: std::sync::Arc::new(trace::TraceControls::new()),
1266 memory_context_state: fresh_memory_context_state(),
1269 memory_backend: crate::memory_backend::MemoryBinding::legacy_current(),
1271 memory_backend_config: None,
1272 memory_backend_reconfigure_denied: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1273 continuation: Arc::new(Mutex::new(continuation::ContinuationState::default())),
1274 pending_history_import_preview: std::sync::Arc::new(std::sync::Mutex::new(None)),
1275 history_import_plan: std::sync::Arc::new(std::sync::Mutex::new(None)),
1276 #[cfg(any(test, feature = "testing"))]
1277 capture_provider_for_test: std::sync::Arc::new(std::sync::Mutex::new(None)),
1278 pending_memory_segment: std::sync::Arc::new(std::sync::Mutex::new(None)),
1281 retained_recall_turn: std::sync::Arc::new(std::sync::Mutex::new(None)),
1282 one_shot_trace_writer: std::sync::Arc::new(std::sync::Mutex::new(None)),
1283 capture_dir: host.capture_dir,
1284 cache_diagnostics: false,
1285 cache_ttl: crate::core::config::CacheTtl::default(),
1286 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1287 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1288 cache_anchor: Default::default(),
1289 last_msg_id: Arc::new(Mutex::new(None)),
1290 cache_diag_rejected: Default::default(),
1291 session_manager,
1292 hook_bus: host.hook_bus,
1293 reaper_handle,
1294 reaper_cancel,
1295 credential_source: host.credential_source,
1296 token_cache: host.token_cache,
1297 trusted_worker_models: Vec::new(),
1298 subagent_limits: crate::config::SubagentLimits::default(),
1299 progressive_tool_disclosure: host.progressive_tool_disclosure,
1300 deferred_tool_loading: true,
1301 activation_confirm: agent_core::config::ActivationConfirm::default(),
1302 delegation_parent: None,
1303 session_allow_all: std::sync::Arc::new(crate::tools::SessionConfirmLatch::new()),
1304 mcp_runtime: None,
1305 mcp_session_scope: None,
1306 extension_runtime: None,
1307 extension_session_scope: None,
1308 turn_budget: crate::runtime::budget::TurnBudget::for_role(
1309 crate::runtime::budget::TurnRole::Foreground,
1310 ),
1311 owner_steer_budget: None,
1312 host_tool_session: fresh_host_tool_session(),
1313 tool_catalog_prompt: Arc::new(std::sync::Mutex::new(None)),
1314 retained_tool_set: Default::default(),
1315 pending_tool_restore: Default::default(),
1316 session_id: None,
1317 cwd: None,
1318 env: None,
1319 env_stripped: Vec::new(),
1320 env_warned: Default::default(),
1321 };
1322 if let Some(m) = mcp_runtime {
1326 runtime.install_mcp_runtime(m);
1327 }
1328 if let Some(m) = extension_runtime {
1329 runtime.install_extension_runtime(m);
1330 }
1331 runtime
1332 }
1333
1334
1335 #[cfg(any(test, feature = "testing"))]
1346 pub fn new_headless() -> Self {
1347 let client = build_http_client(HTTP_READ_TIMEOUT)
1348 .expect("reqwest client construction is infallible with built-in roots");
1349 let host = crate::host::HostParts {
1350 client,
1351 tools: Arc::new(RwLock::new(ToolRegistry::new())),
1352 hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
1353 credential_source: crate::auth::CredentialSource::Local,
1354 token_cache: crate::auth::TokenCache::new(),
1355 mcp_runtime: None,
1356 extension_runtime: None,
1357 capture_dir: trace::default_capture_dir(),
1358 progressive_tool_disclosure: false,
1359 };
1360 let mut runtime = Self::from_parts(RuntimeParts::without_reaper(host));
1361 runtime.auth = Arc::new(RwLock::new(AuthState {
1362 auth_token: "test-token".to_string(),
1363 auth_type: "api_key".to_string(),
1364 refresh_token: None,
1365 token_expires: None,
1366 auth_file_mtime: None,
1367 bound_credential: None,
1368 }));
1369 runtime
1370 }
1371
1372 pub fn set_system_prompt(&mut self, prompt: String) {
1373 self.system_prompt = Some(prompt);
1374 }
1375
1376 pub fn system_prompt(&self) -> Option<&str> {
1377 self.system_prompt.as_deref()
1378 }
1379
1380 pub async fn effective_system_prompt(&self) -> Option<String> {
1392 let base = self.orchestrated_system_prompt().await;
1393 if self.effective_prompt.is_some() {
1395 return base;
1396 }
1397 match self.frozen_tool_catalog().await {
1398 Some(catalog) => Some(match base {
1399 Some(base) if !base.is_empty() => format!("{base}\n\n{catalog}"),
1400 _ => catalog,
1401 }),
1402 None => base,
1403 }
1404 }
1405
1406 pub async fn frozen_tool_catalog(&self) -> Option<String> {
1412 if !self.progressive_tool_disclosure
1413 || self.activation_confirm == agent_core::config::ActivationConfirm::Deny
1414 {
1415 return None;
1416 }
1417 if let Some(frozen) = self
1418 .tool_catalog_prompt
1419 .lock()
1420 .unwrap_or_else(std::sync::PoisonError::into_inner)
1421 .clone()
1422 {
1423 return frozen;
1424 }
1425 let rendered = {
1426 let registry = self.tools.read().await;
1427 if registry.get("activate_tools").is_none() {
1428 None
1429 } else {
1430 let core_set = continuation::context_tool_set(
1431 self.host_tool_session.clone(),
1432 registry.catalog(),
1433 true,
1434 self.context_management_enabled(),
1435 );
1436 let core: std::collections::HashSet<String> = core_set
1437 .core_ids()
1438 .map(|id| id.as_str().to_string())
1439 .collect();
1440 crate::tools::prompt_catalog::render(
1441 registry.catalog(),
1442 &core,
1443 crate::tools::activation::is_activatable_record,
1444 )
1445 }
1446 };
1447 let mut slot = self
1448 .tool_catalog_prompt
1449 .lock()
1450 .unwrap_or_else(std::sync::PoisonError::into_inner);
1451 slot.get_or_insert(rendered).clone()
1453 }
1454
1455 async fn orchestrated_system_prompt(&self) -> Option<String> {
1459 if self.effective_prompt.is_some() {
1460 return self.system_prompt.clone();
1461 }
1462 if self.tools.read().await.get("subagent_start").is_none() {
1463 return self.system_prompt.clone();
1464 }
1465 let Ok(model) = crate::orchestration::canonical_foreground_identity(&self.model) else {
1466 return self.system_prompt.clone();
1467 };
1468 let workflow_mode = match self.reasoning_level() {
1469 agent_core::reasoning::ReasoningLevel::UltraCode => {
1470 Some(agent_core::prompt::WorkflowMode::UltraCode)
1471 }
1472 agent_core::reasoning::ReasoningLevel::Max => {
1473 Some(agent_core::prompt::WorkflowMode::Max)
1474 }
1475 agent_core::reasoning::ReasoningLevel::XHigh => {
1476 Some(agent_core::prompt::WorkflowMode::XHigh)
1477 }
1478 _ => None,
1479 };
1480 let Ok(context) = agent_core::prompt::SelectionContext::new(model, None)
1481 .map(|context| context.with_workflow_mode(workflow_mode))
1482 else {
1483 return self.system_prompt.clone();
1484 };
1485 agent_core::prompt::compose_orchestration_prompt(self.system_prompt.as_deref(), &context)
1486 }
1487
1488 pub fn effective_prompt(&self) -> Option<&agent_core::prompt::PromptStack> {
1489 self.effective_prompt.as_ref()
1490 }
1491
1492 pub fn prompt_generation(&self) -> u64 {
1493 self.prompt_generation
1494 }
1495
1496 pub fn retain_prompt_reload_source(
1497 &mut self,
1498 manifest: PathBuf,
1499 context: agent_core::prompt::SelectionContext,
1500 user_module: Option<agent_core::prompt::PromptModule>,
1501 delegation_policy_digest: Option<String>,
1502 ) {
1503 self.prompt_reload_source = Some(PromptReloadSource {
1504 manifest,
1505 context,
1506 user_module,
1507 delegation_policy_digest,
1508 });
1509 }
1510
1511 pub fn reload_prompt(&mut self) -> std::result::Result<u64, agent_core::prompt::PromptError> {
1513 let source = self.prompt_reload_source.clone().ok_or_else(|| {
1514 agent_core::prompt::PromptError::Invalid("no prompt manifest is active".into())
1515 })?;
1516 let raw = std::fs::read_to_string(&source.manifest).map_err(|_| {
1517 agent_core::prompt::PromptError::Invalid("prompt manifest is unavailable".into())
1518 })?;
1519 let manifest = agent_core::prompt::PromptManifest::parse(&raw)?;
1520 let reload_catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
1521 source.context.model(),
1522 manifest.delegation_catalog_candidates(),
1523 )
1524 .map_err(|error| agent_core::prompt::PromptError::Invalid(error.into()))?;
1525 let candidate_policy_digest = manifest
1526 .delegation_policy(source.context.model().clone(), &reload_catalog)?
1527 .map(|policy| policy.digest());
1528 if candidate_policy_digest != source.delegation_policy_digest {
1529 return Err(agent_core::prompt::PromptError::Invalid(
1530 "hot reload cannot safely change delegation policy".into(),
1531 ));
1532 }
1533 let registry = manifest.registry(source.manifest.parent())?;
1534 let candidate = agent_core::prompt::compile_prompt_stack(
1535 &manifest,
1536 ®istry,
1537 &source.context,
1538 source.user_module,
1539 )?;
1540 self.apply_prompt_stack(candidate)
1541 }
1542
1543 pub fn apply_prompt_stack(
1545 &mut self,
1546 candidate: agent_core::prompt::PromptStack,
1547 ) -> std::result::Result<u64, agent_core::prompt::PromptError> {
1548 if let Some(current) = &self.effective_prompt {
1549 current.validate_hot_reload(&candidate)?;
1550 }
1551 let composed = candidate.composed().to_owned();
1552 self.effective_prompt = Some(candidate);
1553 self.system_prompt = Some(composed);
1554 self.prompt_generation = self.prompt_generation.saturating_add(1);
1555 Ok(self.prompt_generation)
1556 }
1557
1558 pub fn prompt_inspection_json(&self) -> Option<String> {
1559 let mode = self
1560 .orchestration
1561 .as_ref()
1562 .map(|runtime| runtime.enforcement_mode())
1563 .unwrap_or(agent_core::orchestration::EnforcementMode::Off);
1564 self.effective_prompt.as_ref().and_then(|stack| {
1565 serde_json::to_string(&serde_json::json!({
1566 "generation": self.prompt_generation,
1567 "effective": stack.inspect(mode),
1568 "token_estimate": stack.composed().len().div_ceil(4),
1569 }))
1570 .ok()
1571 })
1572 }
1573
1574 pub fn install_mcp_runtime(&mut self, manager: std::sync::Arc<crate::mcp::McpRuntimeManager>) {
1579 self.mcp_session_scope = Some(std::sync::Arc::new(crate::mcp::McpSessionEndGuard::new(
1580 self.host_tool_session.clone(),
1581 std::sync::Arc::clone(&manager),
1582 )));
1583 self.mcp_runtime = Some(manager);
1584 }
1585
1586 pub fn set_turn_budget(&mut self, budget: crate::runtime::budget::TurnBudget) {
1590 self.turn_budget = budget;
1591 }
1592
1593 pub fn set_owner_steer_budget(
1597 &mut self,
1598 lift: Option<Arc<crate::runtime::budget::OwnerSteerBudget>>,
1599 ) {
1600 self.owner_steer_budget = lift;
1601 }
1602
1603 pub fn turn_budget(&self) -> &crate::runtime::budget::TurnBudget {
1605 &self.turn_budget
1606 }
1607
1608 pub fn install_extension_runtime(
1613 &mut self,
1614 manager: std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
1615 ) {
1616 manager.bind_host_scope(self.host_tool_session.clone());
1620 self.extension_session_scope = Some(std::sync::Arc::new(
1621 crate::extensions::lease::ExtensionSessionEndGuard::new(
1622 self.host_tool_session.clone(),
1623 std::sync::Arc::clone(&manager),
1624 ),
1625 ));
1626 self.extension_runtime = Some(manager);
1627 }
1628
1629 pub fn install_orchestration(
1630 &mut self,
1631 runtime: Arc<crate::orchestration::OrchestrationRuntime>,
1632 ) {
1633 for model in &self.trusted_worker_models {
1634 if let Err(error) = runtime.grant_worker_model(model.as_str()) {
1635 tracing::warn!(
1636 model = model.as_str(),
1637 error = %error,
1638 "failed to apply configured worker-model trust"
1639 );
1640 }
1641 }
1642 self.orchestration = Some(runtime);
1643 }
1644
1645 pub fn install_worker_orchestration(
1651 &mut self,
1652 runtime: Arc<crate::orchestration::OrchestrationRuntime>,
1653 ) {
1654 self.orchestration = Some(runtime);
1655 }
1656
1657 pub fn subagent_limits(&self) -> crate::config::SubagentLimits {
1659 self.subagent_limits
1660 }
1661
1662 pub fn set_delegation_parent(&mut self, parent: Option<String>) {
1663 self.delegation_parent = parent;
1664 }
1665
1666 pub fn orchestration(&self) -> Option<&Arc<crate::orchestration::OrchestrationRuntime>> {
1667 self.orchestration.as_ref()
1668 }
1669
1670 pub fn grant_worker_model(&self, model: &str) -> std::result::Result<(), String> {
1675 self.orchestration
1676 .as_ref()
1677 .ok_or_else(|| "delegation policy unavailable".to_string())?
1678 .grant_worker_model(model)
1679 }
1680
1681 pub fn set_model(&mut self, model: String) -> Option<ReasoningClamp> {
1685 self.try_set_model(model).ok().flatten()
1686 }
1687
1688 pub fn try_set_model(
1695 &mut self,
1696 model: String,
1697 ) -> std::result::Result<Option<ReasoningClamp>, String> {
1698 let trimmed = model.trim();
1703 let cleaned = trimmed
1704 .split_once(char::is_whitespace)
1705 .and_then(|(_, rest)| {
1706 let rest = rest.trim_start();
1707 let (latency, candidate) = rest.split_once(char::is_whitespace)?;
1708 let millis = latency.strip_suffix("ms")?;
1709 if !millis.is_empty() && millis.chars().all(|c| c.is_ascii_digit()) {
1710 let candidate = candidate.trim();
1711 if !candidate.is_empty() && !candidate.chars().any(char::is_whitespace) {
1712 return Some(candidate);
1713 }
1714 }
1715 None
1716 })
1717 .unwrap_or(trimmed);
1718 if self.prompt_reload_source.is_none()
1724 && self
1725 .orchestration
1726 .as_ref()
1727 .is_some_and(|current| !current.unreconciled_runtime_handles().is_empty())
1728 {
1729 return Err("model change blocked: workers require collection/reconciliation".into());
1730 }
1731 let orchestration_replacement = if self.prompt_reload_source.is_none()
1732 && self.orchestration.is_some()
1733 {
1734 let foreground = crate::orchestration::canonical_foreground_identity(cleaned)
1735 .map_err(|_| "model change blocked: unresolved foreground model".to_string())?;
1736 let replacement = crate::orchestration::OrchestrationRuntime::from_limits(
1737 foreground,
1738 self.subagent_limits,
1739 )
1740 .map_err(|_| "model change blocked: trusted worker catalog unavailable".to_string())?;
1741 for trusted in &self.trusted_worker_models {
1742 replacement
1743 .grant_worker_model(trusted.as_str())
1744 .map_err(|_| {
1745 "model change blocked: configured worker trust invalid".to_string()
1746 })?;
1747 }
1748 Some(Arc::new(replacement))
1749 } else {
1750 None
1751 };
1752 self.model = cleaned.to_owned();
1753 if let Some(replacement) = orchestration_replacement {
1757 self.orchestration = Some(replacement);
1758 }
1759 if !self.explicit_reasoning {
1765 if let Some(level) =
1766 crate::runtime::openai::catalog::validation::default_level_for_model(cleaned)
1767 {
1768 self.set_reasoning_level(level);
1769 }
1770 }
1771 Ok(self.clamp_reasoning_to_model())
1776 }
1777
1778 fn clamp_reasoning_to_model(&mut self) -> Option<ReasoningClamp> {
1784 use crate::runtime::openai::catalog::validation::{
1785 default_level_for_model, thinking_options_for_model, validate_reasoning_mutation,
1786 };
1787 let model = self.model.as_str();
1788 let from = self.reasoning_level();
1789 if validate_reasoning_mutation(model, from).is_ok() {
1790 return None;
1791 }
1792 let to = default_level_for_model(model)
1793 .filter(|level| validate_reasoning_mutation(model, *level).is_ok())
1794 .or_else(|| {
1795 thinking_options_for_model(model)
1796 .iter()
1797 .filter_map(|opt| agent_core::reasoning::ReasoningLevel::parse(opt))
1798 .find(|level| validate_reasoning_mutation(model, *level).is_ok())
1799 })?;
1800 let explicit = self.explicit_reasoning;
1801 self.set_reasoning_level(to);
1802 self.explicit_reasoning = explicit;
1803 Some(ReasoningClamp { from, to })
1804 }
1805
1806 pub fn set_tools(&mut self, tools: ToolRegistry) {
1807 self.tools = Arc::new(RwLock::new(tools));
1808 }
1809
1810 pub fn subagent_registry(&self) -> &Arc<Mutex<crate::runtime::subagent::SubagentRegistry>> {
1811 &self.subagent_registry
1812 }
1813
1814 pub fn event_queue(&self) -> &Arc<crate::events::EventQueue> {
1815 &self.event_queue
1816 }
1817
1818 pub fn set_event_queue(&mut self, queue: Arc<crate::events::EventQueue>) {
1822 self.event_queue = queue;
1823 }
1824
1825 pub fn hook_bus(&self) -> &Arc<crate::extensions::hooks::HookBus> {
1827 &self.hook_bus
1828 }
1829
1830 pub fn session_manager(&self) -> &Arc<crate::tools::shell::SessionManager> {
1833 &self.session_manager
1834 }
1835
1836 pub fn host_tool_session_id(&self) -> &crate::tools::activation::SessionId {
1840 &self.host_tool_session
1841 }
1842
1843 pub fn set_session_id(&mut self, id: Option<String>) {
1848 self.session_id = id;
1849 }
1850
1851 pub fn begin_conversation(&mut self, id: Option<String>) {
1857 self.session_allow_all.revoke();
1858 self.session_allow_all = std::sync::Arc::new(crate::tools::SessionConfirmLatch::new());
1859 *self
1862 .last_msg_id
1863 .lock()
1864 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1865 self.set_session_id(id);
1866 }
1867
1868 pub fn session_id(&self) -> Option<&str> {
1869 self.session_id.as_deref()
1870 }
1871
1872 pub fn set_cwd(&mut self, cwd: Option<PathBuf>) {
1875 self.cwd = cwd;
1876 }
1877
1878 pub fn cwd(&self) -> Option<&Path> {
1879 self.cwd.as_deref()
1880 }
1881
1882 pub fn set_env(&mut self, env: Option<crate::session::types::SessionEnv>) {
1885 self.env = env;
1886 }
1887
1888 pub fn set_env_stripped(&mut self, stripped: Vec<String>) {
1889 self.env_stripped = stripped;
1890 }
1891
1892 pub fn env(&self) -> Option<&crate::session::types::SessionEnv> {
1893 self.env.as_ref()
1894 }
1895
1896 pub fn env_stripped(&self) -> &[String] {
1897 &self.env_stripped
1898 }
1899
1900 pub fn tools_shared(&self) -> Arc<RwLock<ToolRegistry>> {
1902 Arc::clone(&self.tools)
1903 }
1904
1905 pub fn model(&self) -> &str {
1906 &self.model
1907 }
1908
1909 pub fn http_client(&self) -> &Client {
1910 &self.client
1911 }
1912
1913 pub async fn prepare_model_capabilities(&self, model: &str) {
1921 if model.starts_with("muse/") {
1922 let broker = crate::auth::broker_from_source(
1923 &self.credential_source,
1924 &self.token_cache,
1925 self.client.clone(),
1926 );
1927 crate::runtime::openai::catalog::ensure_muse_catalog_for(model, &broker).await;
1928 }
1929 }
1930
1931 pub fn progressive_tool_disclosure(&self) -> bool {
1933 self.progressive_tool_disclosure
1934 }
1935
1936 pub fn carried_tool_activations(&self) -> Vec<crate::tools::activation::CarriedActivation> {
1942 let retained = self
1943 .retained_tool_set
1944 .lock()
1945 .unwrap_or_else(std::sync::PoisonError::into_inner)
1946 .clone();
1947 if let Some(shared) = retained {
1948 return shared
1949 .read()
1950 .unwrap_or_else(std::sync::PoisonError::into_inner)
1951 .carried_activations();
1952 }
1953 self.pending_tool_restore
1954 .lock()
1955 .unwrap_or_else(std::sync::PoisonError::into_inner)
1956 .clone()
1957 .unwrap_or_default()
1958 }
1959
1960 pub fn restore_tool_activations(
1968 &self,
1969 carried: Vec<crate::tools::activation::CarriedActivation>,
1970 ) {
1971 if carried.is_empty() {
1972 return;
1973 }
1974 let mut pending = self
1975 .pending_tool_restore
1976 .lock()
1977 .unwrap_or_else(std::sync::PoisonError::into_inner);
1978 match pending.as_mut() {
1979 Some(list) => {
1980 for c in carried {
1981 if !list.iter().any(|p| p.tool_id == c.tool_id) {
1982 list.push(c);
1983 }
1984 }
1985 }
1986 None => *pending = Some(carried),
1987 }
1988 }
1989
1990 pub fn activation_confirm(&self) -> agent_core::config::ActivationConfirm {
1992 self.activation_confirm
1993 }
1994
1995 pub fn set_activation_confirm(&mut self, mode: agent_core::config::ActivationConfirm) {
1998 self.activation_confirm = mode;
1999 }
2000
2001 #[cfg(any(test, feature = "testing"))]
2005 pub fn http_client_pool_id(&self) -> usize {
2006 const _: () = assert!(std::mem::size_of::<Client>() == std::mem::size_of::<usize>());
2007 unsafe { std::mem::transmute_copy::<Client, usize>(&self.client) }
2010 }
2011 pub fn set_thinking_budget(&mut self, budget: u32) {
2012 self.thinking_budget = budget;
2013 self.named_level = Some(agent_core::reasoning::ReasoningLevel::from_legacy_budget(
2015 budget,
2016 ));
2017 self.explicit_reasoning = false;
2019 }
2020
2021 pub fn set_reasoning_level(&mut self, level: agent_core::reasoning::ReasoningLevel) {
2027 self.named_level = Some(level);
2028 if let Some(budget) = level.to_legacy_budget() {
2029 self.thinking_budget = budget;
2030 }
2031 self.explicit_reasoning = false;
2033 }
2034
2035 pub fn set_reasoning_level_explicit(&mut self, level: agent_core::reasoning::ReasoningLevel) {
2040 self.set_reasoning_level(level);
2041 self.explicit_reasoning = true;
2042 }
2043
2044 pub fn restore_session_reasoning(&mut self, thinking_level: &str) -> Option<ReasoningClamp> {
2050 if let Some(level) = agent_core::reasoning::ReasoningLevel::parse(thinking_level) {
2051 self.set_reasoning_level_explicit(level);
2052 } else if let Some(budget) = crate::models::budget_for_thinking_level(thinking_level) {
2053 self.set_thinking_budget_explicit(budget);
2054 } else {
2055 return None;
2056 }
2057 self.clamp_reasoning_to_model()
2058 }
2059
2060 pub fn set_thinking_budget_explicit(&mut self, budget: u32) {
2065 self.thinking_budget = budget;
2066 self.named_level = Some(agent_core::reasoning::ReasoningLevel::from_legacy_budget(
2067 budget,
2068 ));
2069 self.explicit_reasoning = true;
2070 }
2071
2072 pub fn is_reasoning_explicit(&self) -> bool {
2074 self.explicit_reasoning
2075 }
2076
2077 pub fn thinking_budget_raw(&self) -> u32 {
2079 self.thinking_budget
2080 }
2081
2082 pub fn set_reasoning_level_checked(
2087 &mut self,
2088 level: agent_core::reasoning::ReasoningLevel,
2089 ) -> std::result::Result<(), String> {
2090 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
2091 &self.model,
2092 level,
2093 )?;
2094 if self.model.starts_with("anthropic/")
2095 && level == agent_core::reasoning::ReasoningLevel::UltraCode
2096 {
2097 if self.codex_request_role()
2098 != crate::runtime::openai::catalog::ExecutionRole::Foreground
2099 {
2100 return Err("ultracode_requires_foreground".into());
2101 }
2102 if self.effective_prompt.is_some() {
2103 return Err("typed_prompt_manifest_blocks_required_doctrine".into());
2104 }
2105 if self.orchestration.is_none() {
2106 return Err("ultracode_requires_orchestration".into());
2107 }
2108 let tools = self.tools.try_read().map_err(|_| {
2109 "ultracode prerequisite state is busy; refusing mutation".to_string()
2110 })?;
2111 let required = ["subagent_start", "subagent_status", "subagent_collect"];
2112 if !required.iter().all(|name| {
2113 tools
2114 .get(name)
2115 .is_some_and(|tool| tool.extension_id().is_none())
2116 }) {
2117 return Err("ultracode_requires_lifecycle_tools".into());
2118 }
2119 }
2120 self.set_reasoning_level_explicit(level);
2121 Ok(())
2122 }
2123
2124 pub fn set_thinking_budget_checked(&mut self, budget: u32) -> std::result::Result<(), String> {
2131 let level = agent_core::reasoning::ReasoningLevel::from_legacy_budget(budget);
2132 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
2133 &self.model,
2134 level,
2135 )?;
2136 self.set_thinking_budget_explicit(budget);
2137 Ok(())
2138 }
2139
2140 pub fn reasoning_level(&self) -> agent_core::reasoning::ReasoningLevel {
2141 self.named_level.unwrap_or_else(|| {
2142 agent_core::reasoning::ReasoningLevel::from_legacy_budget(self.thinking_budget)
2143 })
2144 }
2145
2146 pub(crate) fn set_codex_request_role(
2147 &mut self,
2148 role: crate::runtime::openai::catalog::CodexRequestRole,
2149 ) {
2150 self.codex_request_role = role;
2151 }
2152
2153 pub(crate) fn codex_request_role(&self) -> crate::runtime::openai::catalog::CodexRequestRole {
2154 self.codex_request_role
2155 }
2156
2157 pub(crate) fn codex_delegation_plan(
2158 model: &str,
2159 level: agent_core::reasoning::ReasoningLevel,
2160 role: crate::runtime::openai::catalog::CodexRequestRole,
2161 ) -> Option<crate::runtime::openai::catalog::CodexExecutionPlan> {
2162 use crate::runtime::openai::catalog::{plan_codex_execution, CodexRequestRole};
2163 if role != CodexRequestRole::Foreground
2164 || level != agent_core::reasoning::ReasoningLevel::Ultra
2165 || !model.starts_with("openai-codex/")
2166 {
2167 return None;
2168 }
2169 plan_codex_execution(model, level, role, None).ok()
2170 }
2171
2172 async fn authorized_anthropic_plan(
2173 &self,
2174 ) -> Result<Option<crate::runtime::openai::catalog::AnthropicExecutionPlan>> {
2175 if !self.model.starts_with("anthropic/") {
2176 return Ok(None);
2177 }
2178 let tools = self.tools.read().await;
2179 let builtin = |name: &str| {
2180 tools
2181 .get(name)
2182 .is_some_and(|tool| tool.extension_id().is_none())
2183 };
2184 let readiness = self
2185 .orchestration
2186 .as_ref()
2187 .and_then(|runtime| runtime.ultracode_readiness(&self.model).ok());
2188 let prerequisites = crate::runtime::openai::catalog::AnthropicPlanPrerequisites {
2189 orchestration_policy: self.orchestration.is_some(),
2190 foreground_worker_authorized: readiness.is_some(),
2191 concurrent_limit: readiness.map_or(0, |limits| limits.0),
2192 total_limit: readiness.map_or(0, |limits| limits.1),
2193 lifecycle_start: builtin("subagent_start"),
2194 lifecycle_status: builtin("subagent_status"),
2195 lifecycle_steer: builtin("subagent_steer"),
2196 lifecycle_collect: builtin("subagent_collect"),
2197 lifecycle_resume: builtin("subagent_resume"),
2198 };
2199 crate::runtime::openai::catalog::plan_anthropic_execution(
2200 &self.model,
2201 self.reasoning_level(),
2202 self.codex_request_role(),
2203 prerequisites,
2204 crate::runtime::openai::catalog::capability_cache::get(&self.model).and_then(|entry| {
2205 match entry.reasoning {
2206 crate::runtime::openai::catalog::ReasoningSupport::AnthropicAdaptive {
2207 adaptive,
2208 } => Some(adaptive),
2209 crate::runtime::openai::catalog::ReasoningSupport::None => Some(false),
2210 _ => None,
2211 }
2212 }),
2213 )
2214 .map(Some)
2215 .map_err(|error| RuntimeError::Config(error.to_string()))
2216 }
2217
2218 async fn validate_request_preflight(&self) -> Result<()> {
2221 self.validate_request_preflight_for(&self.model, self.codex_request_role)
2222 .await
2223 }
2224
2225 pub(crate) async fn validate_session_driver_preflight(&self) -> Result<()> {
2228 self.validate_request_preflight().await
2229 }
2230
2231 async fn validate_request_preflight_for(
2232 &self,
2233 model: &str,
2234 role: crate::runtime::openai::catalog::CodexRequestRole,
2235 ) -> Result<()> {
2236 if self
2240 .memory_backend_reconfigure_denied
2241 .load(std::sync::atomic::Ordering::SeqCst)
2242 {
2243 return Err(RuntimeError::Config(
2244 "memory backend cannot be reconfigured at runtime (including live legacy extension processes); restart required; request denied".into(),
2245 ));
2246 }
2247 let level = self.reasoning_level();
2248 if model.starts_with("anthropic/")
2249 && level == agent_core::reasoning::ReasoningLevel::UltraCode
2250 && self.effective_prompt.is_some()
2251 {
2252 return Err(RuntimeError::Config(
2253 "Anthropic execution plan rejected: typed_prompt_manifest_blocks_required_doctrine"
2254 .into(),
2255 ));
2256 }
2257 if model.starts_with("anthropic/")
2258 && matches!(
2259 level,
2260 agent_core::reasoning::ReasoningLevel::Max
2261 | agent_core::reasoning::ReasoningLevel::UltraCode
2262 | agent_core::reasoning::ReasoningLevel::Ultra
2263 )
2264 {
2265 let tools = self.tools.read().await;
2266 let builtin = |name: &str| {
2267 tools
2268 .get(name)
2269 .is_some_and(|tool| tool.extension_id().is_none())
2270 };
2271 let readiness = self
2272 .orchestration
2273 .as_ref()
2274 .and_then(|runtime| runtime.ultracode_readiness(model).ok());
2275 let prerequisites = crate::runtime::openai::catalog::AnthropicPlanPrerequisites {
2276 orchestration_policy: self.orchestration.is_some(),
2277 foreground_worker_authorized: readiness.is_some(),
2278 concurrent_limit: readiness.map_or(0, |limits| limits.0),
2279 total_limit: readiness.map_or(0, |limits| limits.1),
2280 lifecycle_start: builtin("subagent_start"),
2281 lifecycle_status: builtin("subagent_status"),
2282 lifecycle_steer: builtin("subagent_steer"),
2283 lifecycle_collect: builtin("subagent_collect"),
2284 lifecycle_resume: builtin("subagent_resume"),
2285 };
2286 drop(tools);
2287 let result = crate::runtime::openai::catalog::plan_anthropic_execution(
2288 model,
2289 level,
2290 role,
2291 prerequisites,
2292 crate::runtime::openai::catalog::capability_cache::get(model).and_then(|entry| {
2293 match entry.reasoning {
2294 crate::runtime::openai::catalog::ReasoningSupport::AnthropicAdaptive {
2295 adaptive,
2296 } => Some(adaptive),
2297 crate::runtime::openai::catalog::ReasoningSupport::None => Some(false),
2298 _ => None,
2299 }
2300 }),
2301 );
2302 match result {
2303 Ok(plan) => {
2304 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);
2305 return Ok(());
2306 }
2307 Err(error) => {
2308 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);
2309 return Err(RuntimeError::Config(error.to_string()));
2310 }
2311 }
2312 }
2313 if model.starts_with("openai-codex/") {
2314 let plan =
2315 crate::runtime::openai::catalog::plan_codex_execution(model, level, role, None)
2316 .map_err(|error| {
2317 tracing::debug!(
2318 event = "codex_mode_plan",
2319 qualified_model = %model,
2320 requested_level = %level,
2321 runtime_role = role.as_str(),
2322 decision = "deny",
2323 deny_code = error.code().as_str(),
2324 network_attempted = false,
2325 "Codex request preflight denied"
2326 );
2327 RuntimeError::Config(error.to_string())
2328 })?;
2329
2330 if plan.automatic_delegation() {
2331 if self.orchestration.is_none() {
2332 tracing::debug!(
2333 event = "codex_mode_plan",
2334 qualified_model = %model,
2335 requested_level = %level,
2336 runtime_role = role.as_str(),
2337 decision = "deny",
2338 deny_code = "ultra_requires_orchestration",
2339 network_attempted = false,
2340 "Codex request preflight denied"
2341 );
2342 return Err(RuntimeError::Config(
2343 "Ultra requires an installed orchestration policy".to_string(),
2344 ));
2345 }
2346
2347 let tools = self.tools.read().await;
2348 let required = ["subagent_start", "subagent_status", "subagent_collect"];
2349 let tools_ready = required.iter().all(|name| {
2350 tools
2351 .get(name)
2352 .is_some_and(|tool| tool.extension_id().is_none())
2353 });
2354 if !tools_ready {
2355 tracing::debug!(
2356 event = "codex_mode_plan",
2357 qualified_model = %model,
2358 requested_level = %level,
2359 runtime_role = role.as_str(),
2360 decision = "deny",
2361 deny_code = "ultra_requires_subagent_tools",
2362 network_attempted = false,
2363 "Codex request preflight denied"
2364 );
2365 return Err(RuntimeError::Config(
2366 "Ultra requires built-in subagent_start, subagent_status, and subagent_collect tools"
2367 .to_string(),
2368 ));
2369 }
2370 }
2371 return Ok(());
2372 }
2373
2374 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(model, level)
2375 .map_err(|error| {
2376 tracing::debug!(
2377 event = "codex_mode_plan",
2378 qualified_model = %model,
2379 requested_level = %level,
2380 runtime_role = role.as_str(),
2381 decision = "deny",
2382 deny_code = "unsupported_reasoning_level",
2383 network_attempted = false,
2384 "request preflight denied"
2385 );
2386 RuntimeError::Config(error)
2387 })
2388 }
2389
2390 pub fn set_compaction_model(&mut self, model: Option<String>) {
2391 self.compaction_model = model;
2392 }
2393
2394 pub fn set_compaction_mode(&mut self, mode: agent_core::compaction::CompactionMode) {
2396 self.compaction_mode = mode;
2397 }
2398
2399 pub fn set_compaction_exclusions(
2401 &mut self,
2402 exclude: Vec<agent_core::compaction::ContentClass>,
2403 ) {
2404 self.compaction_exclusions = exclude;
2405 }
2406
2407 pub fn remote_summarization_attempts(&self) -> u64 {
2410 self.remote_summarization_attempts
2411 .load(std::sync::atomic::Ordering::SeqCst)
2412 }
2413
2414 pub fn compaction_policy(&self) -> crate::runtime::compaction::DisclosurePolicy {
2417 crate::runtime::compaction::DisclosurePolicy {
2418 mode: self.compaction_mode,
2419 exclude: self.compaction_exclusions.clone(),
2420 }
2421 }
2422
2423 pub fn set_context_window(&mut self, window: Option<u64>) {
2424 self.context_window_override = window;
2425 }
2426
2427 pub fn compaction_model(&self) -> &str {
2430 self.compaction_model
2431 .as_deref()
2432 .unwrap_or("claude-sonnet-4-6")
2433 }
2434
2435 pub fn context_window(&self) -> u64 {
2436 self.context_window_override
2437 .unwrap_or_else(|| crate::models::context_window_for_model(&self.model))
2438 }
2439
2440 pub async fn assess_context(
2454 &self,
2455 messages: &[crate::SharedMessage],
2456 ) -> context::ContextAssessment {
2457 let system = self.effective_system_prompt().await;
2458 let schema = self.tools.read().await.tools_schema();
2459 let memory_rendered: Option<String> = self
2464 .pending_memory_segment
2465 .lock()
2466 .unwrap_or_else(std::sync::PoisonError::into_inner)
2467 .as_ref()
2468 .map(|segment| segment.rendered_text().to_string());
2469 let memory_contents: Vec<&str> = memory_rendered.as_deref().into_iter().collect();
2470 context::assess(&context::ContextBudgetInputs {
2471 model: &self.model,
2472 provider_window: self.context_window(),
2473 system_prompt: system.as_deref(),
2474 tools_schema: &schema,
2475 messages,
2476 skill_contents: &[],
2477 memory_contents: &memory_contents,
2478 thinking_budget_tokens: self.thinking_budget as u64,
2479 next_tool_result_bytes: self.max_tool_output as u64,
2480 output_reserve_tokens: HelperMethods::max_tokens_for_model(&self.model),
2481 })
2482 }
2483
2484 #[allow(dead_code)] pub(crate) fn hold_memory_contribution(
2502 &self,
2503 contribution: memory_context::MemoryContextContribution,
2504 ) -> std::result::Result<(), memory_context::MemoryContextError> {
2505 let budget = memory_context::memory_budget_tokens(self.context_window())
2506 .ok_or(memory_context::MemoryContextError::RecallBudgetBelowMinimum)?;
2507 memory_context::validate_contribution(
2511 &contribution,
2512 &self.memory_context_project_id(),
2513 budget,
2514 &memory_context::DisclosureGrantSet::model_visible_only(),
2515 )?;
2516 *self
2517 .pending_memory_segment
2518 .lock()
2519 .unwrap_or_else(std::sync::PoisonError::into_inner) =
2520 Some(memory_context::ContextSegment::Memory(contribution));
2521 Ok(())
2522 }
2523
2524 pub(crate) fn clear_memory_contribution(&self) {
2528 *self
2529 .pending_memory_segment
2530 .lock()
2531 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
2532 }
2533
2534 pub fn memory_recall_why(&self) -> Option<memory_context::RecallTurnMetadata> {
2540 self.retained_recall_turn
2541 .lock()
2542 .unwrap_or_else(std::sync::PoisonError::into_inner)
2543 .as_ref()
2544 .map(|retained| retained.why.clone())
2545 }
2546
2547 async fn apply_turn_memory_recall(&self, messages: &mut Vec<crate::SharedMessage>) {
2565 if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
2566 || self
2567 .memory_backend_reconfigure_denied
2568 .load(std::sync::atomic::Ordering::SeqCst)
2569 {
2570 self.clear_memory_contribution();
2571 tracing::debug!("memory recall unavailable for the selected host backend");
2572 return;
2573 }
2574 let extension_runtime = self.extension_runtime.clone();
2575 let binding = self.memory_backend.clone();
2576 let session = self.host_tool_session.clone();
2577 let outcome = memory_context::resolve_turn_recall(
2578 &self.memory_context_state,
2579 &self.retained_recall_turn,
2580 &self.memory_context_project_id(),
2581 self.context_window(),
2582 messages,
2583 memory_context::RECALL_HARD_TIMEOUT,
2584 move |lease: memory_context::MemoryContextLease,
2585 request: memory_context::RecallRequest| async move {
2586 if binding.is_axel() {
2587 return axel_context::recall(binding, lease, request).await;
2588 }
2589 let Some(manager) = extension_runtime else {
2590 return Err(memory_context::RecallCallError::ProviderUnavailable);
2591 };
2592 let mut parts = lease.provider_id.as_str().splitn(3, ':');
2593 let (Some("extension"), Some(plugin), Some(local)) =
2594 (parts.next(), parts.next(), parts.next())
2595 else {
2596 return Err(memory_context::RecallCallError::ProviderUnavailable);
2597 };
2598 let Some(digest) =
2599 manager.declared_tool_digest(plugin, memory_context::MEMORY_RECALL_TOOL_NAME)
2600 else {
2601 return Err(memory_context::RecallCallError::ProviderUnavailable);
2602 };
2603 let mut response =
2604 crate::extensions::lease::ExtensionLeaseCapability::new(session, manager)
2605 .call_exact(
2606 plugin,
2607 memory_context::MEMORY_RECALL_TOOL_NAME,
2608 &digest,
2609 memory_context::recall_request_wire(&request),
2610 )
2611 .await
2612 .map_err(|_| memory_context::RecallCallError::CallFailed)?;
2613 if response["provider_id"] != local
2616 && response["provider_id"] != lease.provider_id.as_str()
2617 {
2618 return Err(memory_context::RecallCallError::CallFailed);
2619 }
2620 response["provider_id"] = json!(lease.provider_id.as_str());
2621 Ok(response)
2622 },
2623 )
2624 .await;
2625 match outcome {
2626 memory_context::TurnRecallOutcome::Injected
2627 | memory_context::TurnRecallOutcome::ReusedRetained => {
2628 let contribution = self
2629 .retained_recall_turn
2630 .lock()
2631 .unwrap_or_else(std::sync::PoisonError::into_inner)
2632 .as_ref()
2633 .map(|retained| retained.contribution.clone());
2634 if let Some(contribution) = contribution {
2635 *self
2636 .pending_memory_segment
2637 .lock()
2638 .unwrap_or_else(std::sync::PoisonError::into_inner) =
2639 Some(memory_context::ContextSegment::Memory(contribution));
2640 }
2641 }
2642 memory_context::TurnRecallOutcome::NotEligible => {
2643 self.clear_memory_contribution();
2644 }
2645 memory_context::TurnRecallOutcome::SkippedOpen(reason) => {
2646 self.clear_memory_contribution();
2647 tracing::debug!(
2650 reason = reason.as_str(),
2651 "memory recall skipped; turn proceeds without memory"
2652 );
2653 }
2654 }
2655 }
2656
2657 #[cfg(any(test, feature = "testing"))]
2667 pub async fn apply_turn_memory_recall_for_harness(
2668 &self,
2669 messages: &mut Vec<crate::SharedMessage>,
2670 ) {
2671 self.apply_turn_memory_recall(messages).await;
2672 }
2673
2674 #[cfg(any(test, feature = "testing"))]
2678 pub fn capture_completed_turn_for_harness(
2679 &self,
2680 messages: Vec<crate::SharedMessage>,
2681 turn_ordinal: u64,
2682 ) -> std::result::Result<(), &'static str> {
2683 let started_at = std::time::SystemTime::now();
2684 let lease = self
2685 .memory_context_lock()
2686 .capture_lease_at(started_at)
2687 .ok_or("capture lease unavailable")?;
2688 let provider = self
2689 .extension_capture_provider(&lease)
2690 .ok_or("capture provider unavailable")?;
2691 let mut history = terminal_capture_history(&lease, &messages, started_at);
2692 history.turn_ordinal = turn_ordinal;
2693 memory_capture_worker()
2694 .submit_terminal(
2695 &lease,
2696 history,
2697 memory_context::RetentionClass::Standard,
2698 provider,
2699 )
2700 .map_err(|_| "capture build failed")?;
2701 Ok(())
2702 }
2703
2704 #[cfg(any(test, feature = "testing"))]
2707 pub fn capture_compaction_summary_for_harness(
2708 &self,
2709 first_turn_ordinal: u64,
2710 last_turn_ordinal: u64,
2711 source_digest: &str,
2712 source_message_count: usize,
2713 summary_text: &str,
2714 ) -> std::result::Result<(), &'static str> {
2715 let summarized_at = std::time::SystemTime::now();
2716 let lease = self
2717 .memory_context_lock()
2718 .capture_lease_at(summarized_at)
2719 .ok_or("capture lease unavailable")?;
2720 let source_digest = chat_capture::MessageRangeDigest::from_hex(source_digest)
2721 .ok_or("invalid source digest")?;
2722 let provider = self
2723 .extension_capture_provider(&lease)
2724 .ok_or("capture provider unavailable")?;
2725 let source = chat_capture::CompactionSource {
2726 schema: chat_capture::CompactionSchemaVersion::V1,
2727 project_id: lease.project_id.clone(),
2728 source_session_id: lease.session_id.clone(),
2729 first_turn_ordinal,
2730 last_turn_ordinal,
2731 source_digest,
2732 summary_origin: chat_capture::CompactionSummaryOrigin::LocalOnly,
2733 prompt_stack_digest: chat_capture::PromptStackDigest::from_bytes([7; 32]),
2734 redaction: chat_capture::RedactionPolicy::HostRedacted,
2735 content_classes: vec![agent_core::compaction::ContentClass::UserText],
2736 summarized_at,
2737 };
2738 memory_capture_worker()
2739 .submit_summary(
2740 &lease,
2741 source,
2742 source_message_count,
2743 summary_text,
2744 agent_core::compaction::RedactionPolicy::PolicyExclusions,
2745 memory_context::RetentionClass::Standard,
2746 provider,
2747 )
2748 .map_err(|_| "summary capture build failed")?;
2749 Ok(())
2750 }
2751
2752 #[cfg(any(test, feature = "testing"))]
2755 pub fn cancel_memory_forwarding_for_harness(&self) {
2756 self.clear_memory_contribution();
2757 self.memory_context_disable();
2758 }
2759
2760 #[cfg(any(test, feature = "testing"))]
2762 pub fn extension_lease_count_for_harness(&self) -> usize {
2763 self.extension_runtime
2764 .as_ref()
2765 .map_or(0, |manager| manager.lease_count())
2766 }
2767
2768 pub(crate) fn inherit_memory_backend(&mut self, binding: crate::memory_backend::MemoryBinding) {
2772 self.memory_backend = binding.fork_for_worker();
2773 }
2774
2775 pub fn apply_memory_backend_config(&mut self, config: &crate::config::MemoryBackendConfig) {
2777 if let Some(selected) = &self.memory_backend_config {
2778 if selected != config {
2779 self.memory_backend_reconfigure_denied
2780 .store(true, std::sync::atomic::Ordering::SeqCst);
2781 tracing::warn!("memory backend change denied: live extension processes cannot be reconfigured at runtime; original binding retained; restart required");
2782 self.memory_context_disable();
2783 self.clear_memory_contribution();
2784 }
2785 return;
2786 }
2787 if config.kind != crate::config::MemoryBackendKind::Legacy {
2788 self.memory_context_disable();
2789 }
2790 self.memory_backend = crate::memory_backend::MemoryBinding::from_config_with_cwd(
2794 config,
2795 self.cwd.clone().or_else(|| std::env::current_dir().ok()),
2796 );
2797 self.memory_backend_config = Some(config.clone());
2798 }
2799
2800 #[cfg(test)]
2801 #[allow(dead_code)] pub(crate) fn memory_backend_for_test(&self) -> crate::memory_backend::MemoryBinding {
2803 self.memory_backend.clone()
2804 }
2805
2806 pub fn memory_backend_is_axel(&self) -> bool {
2811 self.memory_backend.is_axel()
2812 }
2813
2814 pub fn memory_backend_exclusive(&self) -> bool {
2815 self.memory_backend.exclusive()
2816 }
2817
2818 fn memory_context_project_id(&self) -> memory_context::ProjectId {
2819 if self.memory_backend.exclusive() {
2820 self.memory_backend
2821 .scope()
2822 .ok()
2823 .and_then(|scope| memory_context::ProjectId::parse(scope.key()).ok())
2824 .unwrap_or_else(|| {
2825 memory_context::ProjectId::parse("project-unresolved").expect("static id")
2826 })
2827 } else {
2828 memory_project_id()
2829 }
2830 }
2831
2832 pub fn memory_tool_capability(&self) -> Option<memory_context::MemoryContextCapability> {
2833 if !self.memory_backend.exclusive() {
2834 return None;
2835 }
2836 let runtime = self.clone();
2837 Some(memory_context::MemoryContextCapability::control_only(
2838 self.memory_context_state.clone(),
2839 self.memory_context_project_id(),
2840 memory_context::ContextProviderId::parse(axel_context::PROVIDER_ID).expect("static id"),
2841 Arc::new(move || runtime.memory_context_disable()),
2842 ))
2843 }
2844
2845 pub fn apply_config(&mut self, config: &crate::config::SynapsConfig) {
2849 self.apply_config_inner(config, true);
2850 }
2851
2852 pub(crate) fn apply_config_keep_tools(&mut self, config: &crate::config::SynapsConfig) {
2859 self.apply_config_inner(config, false);
2860 }
2861
2862 fn apply_config_inner(&mut self, config: &crate::config::SynapsConfig, disable_tools: bool) {
2863 self.apply_memory_backend_config(&config.memory_backend);
2865 if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
2866 || self
2867 .memory_backend_reconfigure_denied
2868 .load(std::sync::atomic::Ordering::SeqCst)
2869 {
2870 self.memory_context_disable();
2872 self.clear_memory_contribution();
2873 *self
2874 .retained_recall_turn
2875 .lock()
2876 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
2877 }
2878 if let Some(ref model) = config.model {
2879 self.set_model(model.clone());
2880 }
2881 if let Some(level) = config.thinking_level {
2884 self.set_reasoning_level_explicit(level);
2885 } else if let Some(budget) = config.thinking_budget {
2886 self.set_thinking_budget_explicit(budget);
2887 }
2888 if let Some(clamp) = self.clamp_reasoning_to_model() {
2891 tracing::warn!(
2892 from = clamp.from.as_str(),
2893 to = clamp.to.as_str(),
2894 model = %self.model,
2895 "configured reasoning level not supported by model; clamped"
2896 );
2897 }
2898 self.context_window_override = config.context_window;
2899 self.continuation
2900 .lock()
2901 .unwrap_or_else(std::sync::PoisonError::into_inner)
2902 .config = config.context_management;
2903 self.compaction_model = config.compaction_model.clone();
2904 self.compaction_mode = config.compaction_mode;
2905 self.compaction_exclusions = config.compaction_exclude.clone();
2906 self.max_tool_output = config.max_tool_output;
2907 self.bash_timeout = config.bash_timeout;
2908 self.bash_max_timeout = config.bash_max_timeout;
2909 self.subagent_timeout = config.subagent_timeout;
2910 self.api_retries = config.api_retries;
2911 self.refusal_retries = config.refusal_retries;
2912 self.telemetry_level =
2913 crate::runtime::telemetry::TelemetryLevel::from_str_key(&config.telemetry);
2914 self.sync_observability();
2915 self.cache_diagnostics = config.cache_diagnostics;
2916 self.cache_ttl = config.cache_ttl;
2917 self.progressive_tool_disclosure = config.progressive_tool_disclosure;
2918 self.deferred_tool_loading = config.tools_deferred_loading;
2919 self.activation_confirm = config.tools_activation_confirm;
2920 tracing::info!(
2921 mode = config.tools_activation_confirm.as_str(),
2922 auto_approve_confirms = config.server.auto_approve_confirms,
2923 "tools.activation_confirm: model-initiated activate_tools policy"
2924 );
2925 self.trusted_worker_models = config
2926 .favorite_models
2927 .iter()
2928 .filter_map(|model| {
2929 let canonical = canonical_trusted_worker_model(model);
2930 match agent_core::prompt::QualifiedModelId::parse(canonical) {
2931 Ok(model) => Some(model),
2932 Err(_) => {
2933 tracing::warn!(
2934 model = model.as_str(),
2935 "ignoring invalid favorite worker-model identity"
2936 );
2937 None
2938 }
2939 }
2940 })
2941 .collect();
2942 self.trusted_worker_models.sort();
2943 self.trusted_worker_models.dedup();
2944 self.subagent_limits = config.subagent;
2945 if let Some(orchestration) = &self.orchestration {
2946 for model in &self.trusted_worker_models {
2947 if let Err(error) = orchestration.grant_worker_model(model.as_str()) {
2948 tracing::warn!(
2949 model = model.as_str(),
2950 error = %error,
2951 "failed to apply configured worker-model trust"
2952 );
2953 }
2954 }
2955 }
2956 self.apply_auth_config(config);
2957
2958 if disable_tools && !config.disabled_tools.is_empty() {
2962 if let Ok(mut reg) = self.tools.try_write() {
2963 reg.disable(&config.disabled_tools);
2964 }
2965 }
2966 }
2967
2968 pub fn apply_auth_config(&mut self, config: &crate::config::SynapsConfig) {
2976 let new_source = config.auth.credential_source();
2977 if new_source != self.credential_source {
2978 self.token_cache.invalidate("anthropic");
2979 }
2980 self.credential_source = new_source;
2981 if self.credential_source.is_remote() {
2982 if let Ok(mut auth) = self.auth.try_write() {
2983 AuthMethods::scrub_for_remote(&mut auth);
2984 }
2985 }
2986 if let Ok(mut auth) = self.auth.try_write() {
2993 if auth.auth_type == "oauth" && !auth.auth_token.is_empty() {
2994 let selector = config
2995 .auth
2996 .account_selector(crate::auth::OAuthProviderId::Anthropic);
2997 let check = crate::runtime::auth::check_binding(
2998 &self.credential_source,
2999 &selector,
3000 auth.bound_credential.as_deref(),
3001 crate::runtime::auth::RefreshPoint::TurnStart,
3002 );
3003 if check != crate::runtime::auth::BindingCheck::Matches {
3004 AuthMethods::scrub_for_account_switch(&mut auth);
3005 self.token_cache.invalidate("anthropic");
3006 }
3007 }
3008 }
3009 crate::auth::set_global_broker(crate::auth::broker_from_source(
3014 &self.credential_source,
3015 &self.token_cache,
3016 self.client.clone(),
3017 ));
3018 }
3019
3020 pub fn thinking_budget(&self) -> u32 {
3021 self.thinking_budget
3022 }
3023
3024 pub fn max_tool_output(&self) -> usize {
3025 self.max_tool_output
3026 }
3027
3028 pub fn bash_timeout(&self) -> u64 {
3029 self.bash_timeout
3030 }
3031
3032 pub fn bash_max_timeout(&self) -> u64 {
3033 self.bash_max_timeout
3034 }
3035
3036 pub fn subagent_timeout(&self) -> u64 {
3037 self.subagent_timeout
3038 }
3039
3040 pub fn api_retries(&self) -> u32 {
3041 self.api_retries
3042 }
3043
3044 pub fn set_max_tool_output(&mut self, v: usize) {
3045 self.max_tool_output = v;
3046 }
3047
3048 pub fn set_bash_timeout(&mut self, v: u64) {
3049 self.bash_timeout = v;
3050 }
3051
3052 pub fn set_bash_max_timeout(&mut self, v: u64) {
3053 self.bash_max_timeout = v;
3054 }
3055
3056 pub fn set_subagent_timeout(&mut self, v: u64) {
3057 self.subagent_timeout = v;
3058 }
3059
3060 pub(crate) fn apply_retry_config(&mut self, config: &crate::SynapsConfig) {
3063 self.api_retries = config.api_retries;
3064 self.refusal_retries = config.refusal_retries;
3065 }
3066
3067 pub fn refusal_retries(&self) -> u32 {
3068 self.refusal_retries
3069 }
3070
3071 pub fn set_api_retries(&mut self, v: u32) {
3072 self.api_retries = v;
3073 }
3074
3075 pub fn telemetry_level(&self) -> crate::runtime::telemetry::TelemetryLevel {
3076 self.telemetry_level
3077 }
3078
3079 pub fn set_telemetry_level(&mut self, level: crate::runtime::telemetry::TelemetryLevel) {
3080 self.telemetry_level = level;
3081 self.sync_observability();
3082 }
3083
3084 pub fn worker_telemetry(&self) -> crate::runtime::telemetry::WorkerTelemetry {
3086 crate::runtime::telemetry::WorkerTelemetry {
3087 level: self.telemetry_level,
3088 writer: self.telemetry_writer.clone(),
3089 }
3090 }
3091
3092 pub fn inherit_telemetry(&mut self, parent: &crate::runtime::telemetry::WorkerTelemetry) {
3103 match (&parent.writer, parent.level.enabled()) {
3104 (Some(writer), true) => {
3105 self.telemetry_level = parent.level;
3106 self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
3107 crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
3108 ));
3109 self.telemetry_writer = Some(writer.clone());
3110 }
3111 _ => self.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Off),
3112 }
3113 }
3114
3115 fn sync_observability(&mut self) {
3122 if self.telemetry_level.enabled() {
3123 if self.telemetry_writer.is_none() {
3124 let writer = crate::runtime::telemetry::TelemetryWriter::new(
3125 crate::runtime::telemetry::WriterOptions::default(),
3126 );
3127 self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
3128 crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
3129 ));
3130 self.telemetry_writer = Some(writer);
3131 }
3132 } else {
3133 self.telemetry_writer = None;
3137 self.trace_ctx = trace::TraceContext::disabled();
3138 }
3139 }
3140
3141 pub fn trace_context(&self) -> trace::TraceContext {
3143 self.trace_ctx.clone()
3144 }
3145
3146 pub(crate) fn effective_trace_context(&self) -> trace::TraceContext {
3167 match self.trace_controls.consume() {
3168 None => self.trace_ctx.clone(),
3169 Some(with_content) => {
3170 let base = if self.trace_ctx.enabled() {
3171 self.trace_ctx.clone()
3172 } else {
3173 let writer = crate::runtime::telemetry::TelemetryWriter::new(
3174 crate::runtime::telemetry::WriterOptions::default(),
3175 );
3176 *self
3180 .one_shot_trace_writer
3181 .lock()
3182 .expect("one-shot trace writer lock poisoned") = Some(writer.clone());
3183 self.trace_ctx
3184 .fork_with_sink(Arc::new(crate::runtime::telemetry::WriterTraceSink::new(
3185 writer,
3186 )))
3187 .with_one_shot_request_gate()
3188 };
3189 if with_content {
3190 base.with_content_capture(Arc::new(trace::ContentCapture::new(
3191 self.capture_dir.clone(),
3192 )))
3193 } else {
3194 base
3195 }
3196 }
3197 }
3198 }
3199
3200 pub fn trace_arm_next(&self, with_content: bool) {
3204 self.trace_controls.arm_next(with_content);
3205 }
3206
3207 pub fn trace_status(&self) -> trace::TraceStatusReport {
3212 let _ = trace::sweep_expired_captures(&self.capture_dir);
3213 trace::TraceStatusReport {
3214 persistent_enabled: self.telemetry_writer.is_some(),
3215 arm: self.trace_controls.peek(),
3216 trace_path: crate::runtime::telemetry::default_trace_log_path(),
3217 writer_stats: self.telemetry_writer.as_ref().map(|w| w.stats()),
3218 degraded_records: self.trace_ctx.degraded_records(),
3219 }
3220 }
3221
3222 fn memory_context_lock(&self) -> std::sync::MutexGuard<'_, memory_context::SessionMemoryState> {
3229 self.memory_context_state
3230 .lock()
3231 .unwrap_or_else(std::sync::PoisonError::into_inner)
3232 }
3233
3234 pub fn memory_context_status(&self) -> memory_context::MemoryContextStatus {
3237 self.memory_context_lock().status()
3238 }
3239
3240 pub fn memory_context_disable(&self) -> memory_context::MemoryContextStatus {
3254 let (status, bound, session) = {
3255 let mut state = self.memory_context_lock();
3256 let session = state.session_id().clone();
3257 let bound = state.bound_provider_ids();
3258 let status = match memory_context::apply_memory_context_action(
3259 &mut state,
3260 memory_context::AuthorizedMemoryAction::Disable {
3261 session: session.clone(),
3262 },
3263 ) {
3264 Ok(status) => status,
3265 Err(_) => state.status(),
3269 };
3270 (status, bound, session)
3271 };
3272 memory_context::emit_memory_observability_event(
3275 &memory_context::MemoryObservabilityEvent::context_disabled(
3276 &session,
3277 &self.memory_context_project_id(),
3278 ),
3279 );
3280 self.revoke_memory_provider_leases(&bound);
3284 status
3285 }
3286
3287 fn revoke_memory_provider_leases(&self, bound: &[memory_context::ContextProviderId]) {
3295 let Some(extension_runtime) = &self.extension_runtime else {
3296 return;
3297 };
3298 for provider in bound {
3299 let mut parts = provider.as_str().splitn(3, ':');
3300 if let (Some("extension"), Some(plugin), Some(_local_id)) =
3301 (parts.next(), parts.next(), parts.next())
3302 {
3303 if !plugin.is_empty() {
3304 extension_runtime.revoke_plugin_lease(&self.host_tool_session, plugin);
3305 }
3306 }
3307 }
3308 }
3309
3310 fn resolve_memory_provider(
3330 &self,
3331 requested: Option<&str>,
3332 ) -> std::result::Result<memory_context::ContextProviderId, memory_context::MemoryContextError>
3333 {
3334 use crate::extensions::context_provider as ext_cp;
3335 if memory_context::provider_unavailable_for_test() {
3336 return Err(memory_context::MemoryContextError::ProviderNotRegistered);
3337 }
3338 if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
3339 || self
3340 .memory_backend_reconfigure_denied
3341 .load(std::sync::atomic::Ordering::SeqCst)
3342 {
3343 return Err(memory_context::MemoryContextError::ProviderNotRegistered);
3345 }
3346 if self.memory_backend.is_axel() {
3347 self.memory_backend
3348 .validate_axel_config()
3349 .map_err(|_| memory_context::MemoryContextError::ProviderNotRegistered)?;
3350 if requested.is_some_and(|id| id != axel_context::PROVIDER_ID) {
3351 return Err(memory_context::MemoryContextError::ProviderNotRegistered);
3352 }
3353 return memory_context::ContextProviderId::parse(axel_context::PROVIDER_ID);
3354 }
3355 let Some(extension_runtime) = &self.extension_runtime else {
3356 return Ok(memory_provider_id());
3357 };
3358 let requested_id = match requested {
3359 None => None,
3360 Some(raw) => Some(
3363 ext_cp::ContextProviderId::parse(raw)
3364 .map_err(|_| memory_context::MemoryContextError::ProviderNotRegistered)?,
3365 ),
3366 };
3367 match extension_runtime.resolve_context_provider(requested_id.as_ref()) {
3368 Ok(descriptor) => {
3369 memory_context::ContextProviderId::parse(&descriptor.runtime_address())
3370 }
3371 Err(ext_cp::ContextProviderLookupError::NotRegistered) => {
3372 Err(memory_context::MemoryContextError::ProviderNotRegistered)
3373 }
3374 Err(ext_cp::ContextProviderLookupError::Ambiguous) => {
3375 Err(memory_context::MemoryContextError::ProviderAmbiguous)
3376 }
3377 }
3378 }
3379
3380 pub(crate) fn memory_context_enable(
3388 &self,
3389 mode: memory_context::MemoryContextMode,
3390 proof: memory_context::UserIntentProof,
3391 ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
3392 {
3393 self.memory_context_enable_resolved(mode, proof, None)
3394 }
3395
3396 pub(crate) fn memory_context_enable_resolved(
3401 &self,
3402 mode: memory_context::MemoryContextMode,
3403 proof: memory_context::UserIntentProof,
3404 requested_provider: Option<&str>,
3405 ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
3406 {
3407 let mut state = self.memory_context_lock();
3408 let lease = self.grant_command_memory_lease(&state, mode, proof, requested_provider)?;
3409 let enabled_event = memory_context::MemoryObservabilityEvent::context_enabled(&lease);
3413 let result = memory_context::apply_memory_context_action(
3414 &mut state,
3415 memory_context::AuthorizedMemoryAction::Enable { lease },
3416 );
3417 if result.is_ok() {
3418 memory_context::emit_memory_observability_event(&enabled_event);
3419 }
3420 result
3421 }
3422
3423 pub(crate) fn apply_memory_default_mode(
3435 &self,
3436 config: &agent_core::config::MemoryConfig,
3437 ) -> Option<
3438 std::result::Result<
3439 memory_context::MemoryContextStatus,
3440 memory_context::MemoryContextError,
3441 >,
3442 > {
3443 use memory_context::MemoryContextMode as Mode;
3444 if !config.default_mode_confirmed {
3447 return None;
3448 }
3449 let mode = match config.default_mode.as_str() {
3450 "recall_once" => Mode::RecallOnce,
3451 "recall_each_prompt" => Mode::RecallEachPrompt,
3452 "capture_only" => Mode::CaptureOnly,
3453 "capture_and_recall" => Mode::CaptureAndRecall,
3454 _ => return None,
3455 };
3456 if self.memory_context_status().durable != memory_context::DurableStatus::Off {
3457 return None;
3458 }
3459 let proof = memory_context::UserIntentProof::OperatorConfig;
3460 let result = if mode == Mode::RecallOnce {
3461 self.memory_context_recall_once(proof)
3462 } else {
3463 self.memory_context_enable(mode, proof)
3464 };
3465 match &result {
3466 Ok(_) => tracing::info!(
3467 mode = mode.as_str(),
3468 "memory.default_mode applied to session"
3469 ),
3470 Err(e) => tracing::warn!(
3471 mode = mode.as_str(),
3472 error = %e,
3473 "memory.default_mode not applied; session starts with memory off"
3474 ),
3475 }
3476 Some(result)
3477 }
3478
3479 pub(crate) fn restore_session_memory_mode(&self, mode: crate::session::SessionMemoryMode) {
3486 let Some(lease) = mode.lease_mode() else {
3487 return;
3488 };
3489 if let Err(e) =
3490 self.memory_context_enable(lease, memory_context::mint_explicit_command_proof())
3491 {
3492 tracing::warn!(
3493 mode = lease.as_str(),
3494 error = %e,
3495 "session memory mode not restored; memory stays off"
3496 );
3497 }
3498 }
3499
3500 pub(crate) fn memory_context_recall_once(
3505 &self,
3506 proof: memory_context::UserIntentProof,
3507 ) -> std::result::Result<memory_context::MemoryContextStatus, memory_context::MemoryContextError>
3508 {
3509 let mut state = self.memory_context_lock();
3510 let lease = self.grant_command_memory_lease(
3511 &state,
3512 memory_context::MemoryContextMode::RecallOnce,
3513 proof,
3514 None,
3515 )?;
3516 let enabled_event = memory_context::MemoryObservabilityEvent::context_enabled(&lease);
3519 let result = memory_context::apply_memory_context_action(
3520 &mut state,
3521 memory_context::AuthorizedMemoryAction::RecallOnce { lease },
3522 );
3523 if result.is_ok() {
3524 memory_context::emit_memory_observability_event(&enabled_event);
3525 }
3526 result
3527 }
3528
3529 fn grant_command_memory_lease(
3536 &self,
3537 state: &memory_context::SessionMemoryState,
3538 mode: memory_context::MemoryContextMode,
3539 proof: memory_context::UserIntentProof,
3540 requested_provider: Option<&str>,
3541 ) -> std::result::Result<memory_context::MemoryContextLease, memory_context::MemoryContextError>
3542 {
3543 let provider_id = self.resolve_memory_provider(requested_provider)?;
3544 memory_context::MemoryContextLease::grant(
3545 memory_context::MemoryLeaseId::parse(&format!("memctx-cmd-{}", uuid::Uuid::new_v4()))?,
3546 state.session_id().clone(),
3547 self.memory_context_project_id(),
3548 provider_id,
3549 mode,
3550 memory_context::CapturePolicy::default(),
3551 memory_context::RecallPolicy::default(),
3552 proof,
3553 std::time::SystemTime::now(),
3554 None,
3555 )
3556 }
3557
3558 #[cfg(test)]
3559 pub(crate) fn set_capture_provider_for_test(
3560 &self,
3561 provider: std::sync::Arc<dyn capture_worker::CaptureProvider>,
3562 ) {
3563 *self
3564 .capture_provider_for_test
3565 .lock()
3566 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(provider);
3567 }
3568
3569 pub(crate) fn compaction_capture_lease_at(
3570 &self,
3571 now: std::time::SystemTime,
3572 ) -> Option<memory_context::MemoryContextLease> {
3573 self.memory_context_lock().capture_lease_at(now)
3574 }
3575
3576 pub(crate) fn submit_compaction_summary_capture(
3577 &self,
3578 capture_lease: Option<memory_context::MemoryContextLease>,
3579 current: &agent_core::session::Session,
3580 api_messages: &[crate::SharedMessage],
3581 outcome: &compaction::CompactionOutcome,
3582 summarized_at: std::time::SystemTime,
3583 ) {
3584 let Some(lease) = capture_lease else {
3585 return;
3586 };
3587 let Ok(source_session_id) = memory_context::SessionId::parse(¤t.id) else {
3588 return;
3589 };
3590 if api_messages.is_empty() {
3595 return;
3596 }
3597 let first_turn_ordinal = 0;
3598 let last_turn_ordinal = (api_messages.len() - 1) as u64;
3599 let Some(source_digest) = chat_capture::MessageRangeDigest::from_hex(
3600 &agent_core::compaction::message_range_digest(api_messages),
3601 ) else {
3602 return;
3603 };
3604 let Some(prompt_stack_digest) =
3605 chat_capture::PromptStackDigest::from_hex(&outcome.prompt_stack_digest)
3606 else {
3607 return;
3608 };
3609 let summary_origin = if outcome.local_only {
3610 chat_capture::CompactionSummaryOrigin::LocalOnly
3611 } else {
3612 chat_capture::CompactionSummaryOrigin::Provider {
3613 provider_id: outcome.summary_provider.clone(),
3614 model_id: outcome.summary_model.clone(),
3615 }
3616 };
3617 let content_classes = outcome.included_classes.clone();
3618 let source = chat_capture::CompactionSource {
3619 schema: chat_capture::CompactionSchemaVersion::V1,
3620 project_id: lease.project_id.clone(),
3621 source_session_id,
3622 first_turn_ordinal,
3623 last_turn_ordinal,
3624 source_digest,
3625 summary_origin,
3626 prompt_stack_digest,
3627 redaction: match outcome.redaction_policy {
3628 agent_core::compaction::RedactionPolicy::TruncationOnly => {
3629 chat_capture::RedactionPolicy::None
3630 }
3631 agent_core::compaction::RedactionPolicy::PolicyExclusions => {
3632 chat_capture::RedactionPolicy::HostRedacted
3633 }
3634 },
3635 content_classes,
3636 summarized_at,
3637 };
3638
3639 #[cfg(any(test, feature = "testing"))]
3640 let test_provider = self
3641 .capture_provider_for_test
3642 .lock()
3643 .unwrap_or_else(std::sync::PoisonError::into_inner)
3644 .clone();
3645 #[cfg(not(any(test, feature = "testing")))]
3646 let test_provider: Option<std::sync::Arc<dyn capture_worker::CaptureProvider>> = None;
3647 let provider = test_provider.or_else(|| self.extension_capture_provider(&lease));
3648 if let Some(provider) = provider {
3649 let _ = memory_capture_worker().submit_summary(
3650 &lease,
3651 source,
3652 api_messages.len(),
3653 &outcome.summary_text,
3654 outcome.redaction_policy,
3655 memory_context::RetentionClass::Standard,
3656 provider,
3657 );
3658 }
3659 }
3660
3661 fn extension_capture_provider(
3662 &self,
3663 lease: &memory_context::MemoryContextLease,
3664 ) -> Option<std::sync::Arc<dyn capture_worker::CaptureProvider>> {
3665 if (self.memory_backend.exclusive() && !self.memory_backend.is_axel())
3666 || self
3667 .memory_backend_reconfigure_denied
3668 .load(std::sync::atomic::Ordering::SeqCst)
3669 {
3670 return None;
3671 }
3672 if self.memory_backend.is_axel() {
3673 if lease.provider_id.as_str() != axel_context::PROVIDER_ID
3674 || lease.project_id != self.memory_context_project_id()
3675 {
3676 return None;
3677 }
3678 return Some(Arc::new(axel_context::AxelCaptureProvider {
3679 binding: self.memory_backend.clone(),
3680 state: self.memory_context_state.clone(),
3681 lease: lease.clone(),
3682 }));
3683 }
3684 let manager = self.extension_runtime.clone()?;
3685 let mut parts = lease.provider_id.as_str().splitn(3, ':');
3686 let (Some("extension"), Some(plugin), Some(_)) = (parts.next(), parts.next(), parts.next())
3687 else {
3688 return None;
3689 };
3690 let digest =
3691 manager.declared_tool_digest(plugin, memory_context::MEMORY_CAPTURE_TOOL_NAME)?;
3692 let handle = tokio::runtime::Handle::try_current().ok()?;
3693 Some(std::sync::Arc::new(ExtensionCaptureProvider {
3694 manager,
3695 session: self.host_tool_session.clone(),
3696 plugin: plugin.to_owned(),
3697 digest,
3698 handle,
3699 }))
3700 }
3701
3702 pub fn memory_history_preview(
3706 &self,
3707 ) -> std::result::Result<memory_history::HistoryImportPreview, memory_history::HistoryImportError>
3708 {
3709 let host = memory_history::HistoryImportHostState::from_current_host()?;
3710 let mut io = memory_history::CanonicalHistoryMetadataIo::new();
3711 let preview = memory_history::preview_history_import(&host, &mut io)?;
3712 *self
3713 .pending_history_import_preview
3714 .lock()
3715 .expect("history preview mutex poisoned") = Some(preview.clone());
3716 Ok(preview)
3717 }
3718
3719 pub(crate) fn memory_history_confirm(
3723 &self,
3724 ) -> std::result::Result<memory_history::HistoryImportReport, memory_history::HistoryImportError>
3725 {
3726 let preview = self
3727 .pending_history_import_preview
3728 .lock()
3729 .expect("history preview mutex poisoned")
3730 .take()
3731 .ok_or(memory_history::HistoryImportError::ConsentRequired)?;
3732 let intent = memory_history::ConfirmedUserIntent::from_explicit_command(
3733 memory_context::mint_explicit_command_id(),
3734 );
3735 let mut io = memory_history::CanonicalHistoryMetadataIo::new();
3736 match memory_history::authorize_history_import(
3737 preview,
3738 memory_history::HistoryImportConsent::Confirmed(intent),
3739 memory_history::RequestAuthority::UserFrontend,
3740 &mut io,
3741 )? {
3742 memory_history::HistoryImportOutcome::Ready(plan) => {
3743 *self
3744 .history_import_plan
3745 .lock()
3746 .expect("history plan mutex poisoned") = Some(plan.clone());
3747 let lease = self
3748 .memory_context_lock()
3749 .capture_lease_at(std::time::SystemTime::now())
3750 .ok_or(memory_history::HistoryImportError::CaptureLeaseUnavailable)?;
3751 let provider = self
3754 .extension_capture_provider(&lease)
3755 .ok_or(memory_history::HistoryImportError::CaptureProviderUnavailable)?;
3756 let sessions_dir = agent_core::config::get_active_config_dir().join("sessions");
3757 memory_history::import_history_from_dir(
3758 &plan,
3759 &lease,
3760 &sessions_dir,
3761 memory_history::IMPORT_BATCH_MAX_RECORDS,
3762 memory_capture_worker(),
3763 provider,
3764 )
3765 }
3766 memory_history::HistoryImportOutcome::Declined => {
3767 Err(memory_history::HistoryImportError::ConsentRequired)
3768 }
3769 }
3770 }
3771
3772 pub(crate) fn memory_history_decline(&self) -> bool {
3774 self.pending_history_import_preview
3775 .lock()
3776 .expect("history preview mutex poisoned")
3777 .take()
3778 .is_some()
3779 }
3780
3781 #[cfg(test)]
3783 pub(crate) fn memory_durable_proof_for_test(&self) -> Option<memory_context::UserIntentProof> {
3784 self.memory_context_lock().durable_proof().cloned()
3785 }
3786
3787 #[cfg(test)]
3789 pub(crate) fn memory_one_shot_proof_for_test(&self) -> Option<memory_context::UserIntentProof> {
3790 self.memory_context_lock().one_shot_pending_proof().cloned()
3791 }
3792
3793 #[cfg(test)]
3796 pub(crate) fn memory_bound_providers_for_test(&self) -> Vec<memory_context::ContextProviderId> {
3797 self.memory_context_lock().bound_provider_ids()
3798 }
3799
3800 pub fn context_report(&self, history: Option<&[crate::SharedMessage]>) -> trace::ContextReport {
3809 use trace::ReportValue;
3810 let (history_messages, history_bytes) = match history {
3811 Some(msgs) => (
3812 ReportValue::Count(msgs.len() as u64),
3813 ReportValue::Count(
3814 msgs.iter()
3815 .map(|m| {
3816 serde_json::to_vec(&**m)
3817 .map(|v| v.len() as u64)
3818 .unwrap_or(0)
3819 })
3820 .sum(),
3821 ),
3822 ),
3823 None => (ReportValue::Unavailable, ReportValue::Unavailable),
3824 };
3825 let tool_count = match self.tools.try_read() {
3828 Ok(tools) => ReportValue::Count(tools.iter_tools_sorted().len() as u64),
3829 Err(_) => ReportValue::Unavailable,
3830 };
3831 trace::ContextReport {
3832 model: self.model.clone(),
3833 system_prompt_bytes: ReportValue::Count(
3834 self.system_prompt().map(|s| s.len() as u64).unwrap_or(0),
3835 ),
3836 tool_count,
3837 history_messages,
3838 history_bytes,
3839 loaded_skills: ReportValue::Unavailable,
3840 loaded_memories: ReportValue::Unavailable,
3841 cache: self.trace_ctx.cache_snapshots().last_activity(),
3842 trace_enabled: self.trace_ctx.enabled(),
3843 writer_stats: self.telemetry_writer.as_ref().map(|w| w.stats()),
3844 degraded_records: self.trace_ctx.degraded_records(),
3845 }
3846 }
3847
3848 pub fn telemetry_writer(&self) -> Option<crate::runtime::telemetry::TelemetryWriter> {
3850 self.telemetry_writer.clone()
3851 }
3852
3853 pub fn shutdown_observability(
3867 &self,
3868 timeout: std::time::Duration,
3869 ) -> Option<crate::runtime::telemetry::ShutdownOutcome> {
3870 let _ = trace::sweep_expired_captures(&self.capture_dir);
3875 let ephemeral = self
3881 .one_shot_trace_writer
3882 .lock()
3883 .expect("one-shot trace writer lock poisoned")
3884 .take();
3885 let ephemeral_outcome = ephemeral.map(|w| w.shutdown(timeout));
3886 self.telemetry_writer
3887 .as_ref()
3888 .map(|w| w.shutdown(timeout))
3889 .or(ephemeral_outcome)
3890 }
3891
3892 pub async fn shutdown_observability_async(
3905 &self,
3906 timeout: std::time::Duration,
3907 ) -> Option<crate::runtime::telemetry::ShutdownOutcome> {
3908 let capture_dir = self.capture_dir.clone();
3912 let _ =
3913 tokio::task::spawn_blocking(move || trace::sweep_expired_captures(&capture_dir)).await;
3914 let ephemeral = self
3916 .one_shot_trace_writer
3917 .lock()
3918 .expect("one-shot trace writer lock poisoned")
3919 .take();
3920 let ephemeral_outcome = match ephemeral {
3921 Some(writer) => Some(writer.shutdown_async(timeout).await),
3922 None => None,
3923 };
3924 match self.telemetry_writer.clone() {
3925 Some(writer) => Some(writer.shutdown_async(timeout).await),
3926 None => ephemeral_outcome,
3927 }
3928 }
3929
3930 #[cfg(any(test, feature = "testing"))]
3934 pub fn install_observability_for_tests(
3935 &mut self,
3936 writer: crate::runtime::telemetry::TelemetryWriter,
3937 ) {
3938 self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
3939 crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
3940 ));
3941 self.telemetry_writer = Some(writer);
3942 }
3943
3944 pub fn cache_diagnostics(&self) -> bool {
3945 self.cache_diagnostics
3946 }
3947
3948 pub fn set_cache_diagnostics(&mut self, v: bool) {
3949 self.cache_diagnostics = v;
3950 }
3951
3952 pub fn cache_ttl(&self) -> crate::core::config::CacheTtl {
3953 self.cache_ttl
3954 }
3955
3956 pub fn share_ttl_latches(&mut self, parent: &Runtime) {
3960 self.ttl_downgrade_notified = parent.ttl_downgrade_notified.clone();
3961 self.saw_1h_honored = parent.saw_1h_honored.clone();
3962 self.cache_anchor = parent.cache_anchor.clone();
3963 self.last_msg_id = parent.last_msg_id.clone();
3966 self.cache_diag_rejected = parent.cache_diag_rejected.clone();
3967 }
3968
3969 pub(crate) fn cache_diagnosis(&self) -> Option<cache_diagnosis::CacheDiagnosis> {
3972 self.cache_diagnostics.then(|| {
3973 cache_diagnosis::CacheDiagnosis::new(
3974 Arc::clone(&self.last_msg_id),
3975 Arc::clone(&self.cache_diag_rejected),
3976 )
3977 })
3978 }
3979
3980 pub fn set_cache_ttl(&mut self, ttl: crate::core::config::CacheTtl) {
3984 self.cache_ttl = ttl;
3985 }
3986
3987 pub fn thinking_level(&self) -> &str {
3988 match self.named_level {
3991 Some(level) => level.as_str(),
3992 None => crate::core::models::thinking_level_for_budget(self.thinking_budget),
3993 }
3994 }
3995
3996 pub async fn refresh_if_needed(&self) -> Result<()> {
3998 self.refresh_if_needed_for_model(&self.model).await
3999 }
4000
4001 pub async fn reload_auth(&self) -> Result<String> {
4006 AuthMethods::reload_after_rejection(
4007 Arc::clone(&self.auth),
4008 &self.client,
4009 &self.credential_source,
4010 &self.token_cache,
4011 None,
4012 )
4013 .await?;
4014 let expires = self.auth.read().await.token_expires;
4015 let source = if self.credential_source.is_remote() {
4016 "remote broker"
4017 } else {
4018 "auth.json"
4019 };
4020 Ok(match expires {
4021 Some(exp) => {
4022 let mins = exp.saturating_sub(crate::epoch_millis()) / 60_000;
4023 format!("Anthropic credentials reloaded from {source} (valid ~{mins} min)")
4024 }
4025 None => format!("Anthropic credentials reloaded from {source}"),
4026 })
4027 }
4028
4029 async fn refresh_if_needed_for_model(&self, model: &str) -> Result<()> {
4030 if !crate::runtime::auth::model_is_anthropic(model) {
4033 return Ok(());
4034 }
4035 AuthMethods::refresh_if_needed(
4036 Arc::clone(&self.auth),
4037 &self.client,
4038 &self.credential_source,
4039 &self.token_cache,
4040 Some(model),
4041 crate::runtime::auth::RefreshPoint::TurnStart,
4042 )
4043 .await
4044 }
4045
4046 pub async fn compact_call(&self, messages: Vec<crate::SharedMessage>) -> Result<String> {
4052 self.remote_summarization_attempts
4055 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4056 let model = self.compaction_model();
4057 self.validate_request_preflight_for(
4058 model,
4059 crate::runtime::openai::catalog::CodexRequestRole::Internal,
4060 )
4061 .await?;
4062 self.refresh_if_needed_for_model(model).await?;
4063
4064 use crate::runtime::compaction::COMPACTION_SYSTEM_PROMPT;
4065
4066 ApiMethods::call_api_simple(
4067 &self.auth,
4068 &self.client,
4069 model,
4070 COMPACTION_SYSTEM_PROMPT,
4071 self.thinking_budget,
4072 self.reasoning_level(),
4073 &messages,
4074 self.api_retries,
4075 &api::ApiOptions {
4081 trace: self.trace_ctx.clone(),
4082 telemetry: self.telemetry_writer.clone(),
4083 ..api::ApiOptions::default()
4084 },
4085 )
4086 .await
4087 }
4088
4089 pub async fn run_single(&self, prompt: &str) -> Result<String> {
4092 if self
4093 .continuation
4094 .lock()
4095 .unwrap_or_else(std::sync::PoisonError::into_inner)
4096 .durability_blocked
4097 {
4098 return Err(RuntimeError::Session(
4099 "context head save is unresolved; reload the session before further inference"
4100 .into(),
4101 ));
4102 }
4103 self.validate_request_preflight().await?;
4104 let anthropic_execution_plan = self.authorized_anthropic_plan().await?;
4105 self.refresh_if_needed().await?;
4107
4108 let mut messages: Vec<crate::SharedMessage> = vec![std::sync::Arc::new(
4109 json!({"role": "user", "content": prompt}),
4110 )];
4111 let system_prompt = self.effective_system_prompt().await;
4112
4113 loop {
4114 let response = ApiMethods::call_api(
4115 &self.auth,
4116 &self.client,
4117 &self.model,
4118 &*self.tools.read().await,
4119 &system_prompt,
4120 self.thinking_budget,
4121 self.reasoning_level(),
4122 &messages,
4123 self.api_retries,
4124 &api::ApiOptions {
4125 memory_backend: None,
4126 use_1m_context: self.context_window_override == Some(1_000_000),
4127 cache_ttl: self.cache_ttl,
4128 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
4129 saw_1h_honored: self.saw_1h_honored.clone(),
4130 credential_source: self.credential_source.clone(),
4131 token_cache: self.token_cache.clone(),
4132 anthropic_base_url: None,
4133 anthropic_stream_client: self.anthropic_stream_client.clone(),
4134 anthropic_execution_plan: anthropic_execution_plan.clone(),
4135 codex_request_role: self.codex_request_role(),
4136 trace: self.effective_trace_context(),
4141 request_correlation: None,
4142 suppress_stream_deltas: true,
4143 telemetry: self.telemetry_writer.clone(),
4144 tool_session_id: Some(self.host_tool_session.clone()),
4151 session_tool_set: None,
4155 request_tools_schema: None,
4156 anthropic_tool_split: None,
4157 usage_counters: None,
4158 cache_anchor: None,
4159 keepwarm: None,
4160 cache_diagnosis: None,
4162 },
4163 )
4164 .await?;
4165
4166 if let Some(content) = response["content"].as_array() {
4168 let mut response_text = String::new();
4169 let mut tool_uses = Vec::new();
4170
4171 for item in content {
4173 match item["type"].as_str() {
4174 Some("text") => {
4175 if let Some(text) = item["text"].as_str() {
4176 response_text.push_str(text);
4177 }
4178 }
4179 Some("tool_use") => {
4180 tool_uses.push(item.clone());
4181 }
4182 _ => {}
4183 }
4184 }
4185
4186 if tool_uses.is_empty() {
4188 if let Some(orchestration) = &self.orchestration {
4189 if let agent_core::orchestration::CompletionGate::Blocked { workers } =
4190 orchestration.completion_gate()
4191 {
4192 return Err(crate::RuntimeError::Tool(format!(
4193 "completion blocked: {} worker(s) require collection/reconciliation: {} (call subagent_collect with reconciled=true after inspecting each result)",
4194 workers.len(),
4195 workers.join(", ")
4196 )));
4197 }
4198 }
4199 return Ok(response_text);
4200 }
4201
4202 messages.push(std::sync::Arc::new(json!({
4204 "role": "assistant",
4205 "content": content
4206 })));
4207
4208 let mut tool_results = Vec::new();
4210
4211 if tool_uses.len() == 1 {
4212 let tool_use = &tool_uses[0];
4214 if let (Some(tool_name), Some(tool_id)) =
4215 (tool_use["name"].as_str(), tool_use["id"].as_str())
4216 {
4217 let input = &tool_use["input"];
4218 let (result, rich_blocks) = match self.tools.read().await.get(tool_name).cloned() {
4219 Some(tool) => {
4220 let input = self
4221 .tools
4222 .read()
4223 .await
4224 .translate_input_for_api_tool(tool_name, input.clone());
4225 let runtime_name = self
4226 .tools
4227 .read()
4228 .await
4229 .runtime_name_for_api(tool_name)
4230 .to_string();
4231 let ctx = crate::ToolContext {
4232 channels: crate::tools::ToolChannels {
4233 tx_delta: None,
4234 tx_events: None,
4235 },
4236 capabilities: crate::tools::ToolCapabilities {
4237 session_allow_all: Some(self.session_allow_all.clone()),
4238 launch_cancel: None,
4239 memory_backend: Some(self.memory_backend.clone()),
4240 telemetry: Some(self.worker_telemetry()),
4241 watcher_exit_path: self.watcher_exit_path.clone(),
4242 tool_register_tx: None,
4243 session_manager: Some(self.session_manager.clone()),
4244 subagent_registry: Some(self.subagent_registry.clone()),
4245 delegation_parent: None,
4246 codex_parent_plan: Self::codex_delegation_plan(
4247 &self.model,
4248 self.reasoning_level(),
4249 self.codex_request_role(),
4250 ),
4251 event_queue: Some(self.event_queue.clone()),
4252 secret_prompt: None,
4253 orchestration: self.orchestration.clone(),
4254 tool_activation: None,
4255 mcp_leases: None,
4256 extension_leases: None,
4257 memory_context: self.memory_tool_capability(),
4258 cwd: self.cwd.clone(),
4259 env: self.env.clone(),
4260 env_stripped: self.env_stripped.clone(),
4261 env_warned: self.env_warned.clone(),
4262 session_id: self.session_id.clone(),
4263 },
4264 limits: crate::tools::ToolLimits {
4265 max_tool_output: self.max_tool_output,
4266 max_tool_buffer: 256 * 1024,
4267 bash_timeout: self.bash_timeout,
4268 bash_max_timeout: self.bash_max_timeout,
4269 subagent_timeout: self.subagent_timeout,
4270 },
4271 };
4272 let decision = resolve_before_tool_call_decision(
4273 input.clone(),
4274 emit_before_tool_call(
4275 &self.hook_bus,
4276 tool_name,
4277 Some(&runtime_name),
4278 input.clone(),
4279 self.session_id.as_deref(),
4280 )
4281 .await,
4282 None,
4283 false,
4284 Some(&self.session_allow_all),
4285 )
4286 .await;
4287 if let BeforeToolCallDecision::Block { reason } = decision {
4288 (format!("Tool call blocked by extension: {}", reason), None)
4289 } else {
4290 let BeforeToolCallDecision::Continue { input } = decision
4291 else {
4292 unreachable!()
4293 };
4294 let input_for_hook = input.clone();
4295 let (output, rich_blocks) = match tool.execute_rich(input, ctx).await {
4296 Ok(output) => validated_single_tool_output(&self.model, output),
4297 Err(e) => (e.to_string(), None),
4298 };
4299 let hooked_output = emit_after_tool_call(
4300 &self.hook_bus,
4301 tool_name,
4302 Some(&runtime_name),
4303 input_for_hook,
4304 output.clone(),
4305 self.max_tool_output,
4306 self.session_id.as_deref(),
4307 )
4308 .await;
4309 let rich_blocks = retain_single_tool_blocks(rich_blocks, &output, &hooked_output);
4310 (hooked_output, rich_blocks)
4311 }
4312 }
4313 None => (format!("Unknown tool: {}", tool_name), None),
4314 };
4315 tool_results.push(json!({
4316 "type": "tool_result",
4317 "tool_use_id": tool_id,
4318 "content": single_tool_result_content(&result, rich_blocks, self.max_tool_output)
4319 }));
4320 }
4321 } else {
4322 let mut join_set = tokio::task::JoinSet::new();
4324
4325 let cfg_max_tool_output = self.max_tool_output;
4327 let cfg_bash_timeout = self.bash_timeout;
4328 let cfg_bash_max_timeout = self.bash_max_timeout;
4329 let cfg_subagent_timeout = self.subagent_timeout;
4330 let session_mgr = self.session_manager.clone();
4331 let cfg_subagent_registry = self.subagent_registry.clone();
4332 let cfg_event_queue = self.event_queue.clone();
4333 let cfg_hook_bus = self.hook_bus.clone();
4334 let cfg_orchestration = self.orchestration.clone();
4335 let codex_parent_plan = Self::codex_delegation_plan(
4336 &self.model,
4337 self.reasoning_level(),
4338 self.codex_request_role(),
4339 );
4340 let cfg_cwd = self.cwd.clone();
4341 let cfg_env = self.env.clone();
4342 let cfg_session_id = self.session_id.clone();
4343
4344 for tool_use in &tool_uses {
4345 if let (Some(tool_name), Some(tool_id)) = (
4346 tool_use["name"].as_str().map(|s| s.to_string()),
4347 tool_use["id"].as_str().map(|s| s.to_string()),
4348 ) {
4349 let input = tool_use["input"].clone();
4350 let tools_snapshot = self.tools.read().await;
4351 let input =
4352 tools_snapshot.translate_input_for_api_tool(&tool_name, input);
4353 let runtime_name =
4354 tools_snapshot.runtime_name_for_api(&tool_name).to_string();
4355 let tool = tools_snapshot.get(&tool_name).cloned();
4356 drop(tools_snapshot);
4357 let exit_path = self.watcher_exit_path.clone();
4358 let session_mgr_inner = session_mgr.clone();
4359 let registry_inner = cfg_subagent_registry.clone();
4360 let event_queue_inner = cfg_event_queue.clone();
4361 let hook_bus_inner = cfg_hook_bus.clone();
4362 let session_allow_all_inner = self.session_allow_all.clone();
4363 let orchestration_inner = cfg_orchestration.clone();
4364 let memory_backend_inner = self.memory_backend.clone();
4365 let worker_telemetry_inner = self.worker_telemetry();
4366 let memory_context_inner = self.memory_tool_capability();
4367 let codex_parent_plan_inner = codex_parent_plan.clone();
4368 let cwd_inner = cfg_cwd.clone();
4369 let env_inner = cfg_env.clone();
4370 let env_stripped_inner = self.env_stripped.clone();
4371 let env_warned_inner = self.env_warned.clone();
4372 let session_id_inner = cfg_session_id.clone();
4373 let attachment_model = self.model.clone();
4374 let tool_name_for_hook = tool_name.clone();
4375 let runtime_name_for_hook = runtime_name.clone();
4376
4377 join_set.spawn(async move {
4378 let result = match tool {
4379 Some(t) => {
4380 let decision =
4381 crate::runtime::resolve_before_tool_call_decision(
4382 input.clone(),
4383 crate::runtime::emit_before_tool_call(
4384 &hook_bus_inner,
4385 &tool_name_for_hook,
4386 Some(&runtime_name_for_hook),
4387 input.clone(),
4388 session_id_inner.as_deref(),
4389 )
4390 .await,
4391 None,
4392 false,
4393 Some(&session_allow_all_inner),
4394 )
4395 .await;
4396 if let crate::runtime::BeforeToolCallDecision::Block {
4397 reason,
4398 } = decision
4399 {
4400 (format!("Tool call blocked by extension: {}", reason), None)
4401 } else {
4402 let crate::runtime::BeforeToolCallDecision::Continue {
4403 input,
4404 } = decision
4405 else {
4406 unreachable!()
4407 };
4408 let ctx = crate::ToolContext {
4409 channels: crate::tools::ToolChannels {
4410 tx_delta: None,
4411 tx_events: None,
4412 },
4413 capabilities: crate::tools::ToolCapabilities {
4414 session_allow_all: Some(session_allow_all_inner.clone()),
4415 launch_cancel: None,
4416 memory_backend: Some(memory_backend_inner),
4417 telemetry: Some(worker_telemetry_inner),
4418 watcher_exit_path: exit_path,
4419 tool_register_tx: None,
4420 session_manager: Some(session_mgr_inner),
4421 subagent_registry: Some(registry_inner),
4422 delegation_parent: None,
4423 codex_parent_plan: codex_parent_plan_inner,
4424 event_queue: Some(event_queue_inner),
4425 secret_prompt: None,
4426 orchestration: orchestration_inner,
4427 tool_activation: None,
4428 mcp_leases: None,
4429 extension_leases: None,
4430 memory_context: memory_context_inner,
4431 cwd: cwd_inner,
4432 env: env_inner,
4433 env_stripped: env_stripped_inner,
4434 env_warned: env_warned_inner,
4435 session_id: session_id_inner.clone(),
4436 },
4437 limits: crate::tools::ToolLimits {
4438 max_tool_output: cfg_max_tool_output,
4439 max_tool_buffer: 256 * 1024,
4440 bash_timeout: cfg_bash_timeout,
4441 bash_max_timeout: cfg_bash_max_timeout,
4442 subagent_timeout: cfg_subagent_timeout,
4443 },
4444 };
4445 let input_for_hook = input.clone();
4446 let (output, rich_blocks) =
4447 match t.execute_rich(input, ctx).await {
4448 Ok(output) => validated_single_tool_output(
4449 &attachment_model,
4450 output,
4451 ),
4452 Err(e) => (e.to_string(), None),
4453 };
4454 let hooked_output =
4455 crate::runtime::emit_after_tool_call(
4456 &hook_bus_inner,
4457 &tool_name_for_hook,
4458 Some(&runtime_name_for_hook),
4459 input_for_hook,
4460 output.clone(),
4461 cfg_max_tool_output,
4462 session_id_inner.as_deref(),
4463 )
4464 .await;
4465 let rich_blocks = retain_single_tool_blocks(
4466 rich_blocks,
4467 &output,
4468 &hooked_output,
4469 );
4470 (hooked_output, rich_blocks)
4471 }
4472 }
4473 None => (format!("Unknown tool: {}", tool_name), None),
4474 };
4475 (tool_id, result)
4476 });
4477 }
4478 }
4479
4480 let mut results_map = std::collections::HashMap::new();
4482 while let Some(res) = join_set.join_next().await {
4483 match res {
4484 Ok((tool_id, result)) => {
4485 results_map.insert(tool_id, result);
4486 }
4487 Err(e) => {
4488 tracing::error!("Parallel tool task panicked: {}", e);
4490 }
4491 }
4492 }
4493
4494 for tool_use in &tool_uses {
4496 if let Some(tool_id) = tool_use["id"].as_str() {
4497 let (result, rich_blocks) = results_map.remove(tool_id).unwrap_or_else(|| {
4498 ("Tool execution failed: task panicked".to_string(), None)
4499 });
4500 tool_results.push(json!({
4501 "type": "tool_result",
4502 "tool_use_id": tool_id,
4503 "content": single_tool_result_content(&result, rich_blocks, self.max_tool_output)
4504 }));
4505 }
4506 }
4507 }
4508
4509 messages.push(std::sync::Arc::new(json!({
4511 "role": "user",
4512 "content": tool_results
4513 })));
4514
4515 } else {
4517 return Err(RuntimeError::Tool("Invalid response format".to_string()));
4518 }
4519 }
4520 }
4521
4522 pub async fn run_stream(
4525 &self,
4526 prompt: String,
4527 cancel: CancellationToken,
4528 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
4529 self.run_stream_with_messages(
4530 vec![std::sync::Arc::new(
4531 json!({"role": "user", "content": prompt}),
4532 )],
4533 cancel,
4534 None,
4535 None,
4536 false,
4537 )
4538 .await
4539 }
4540
4541 pub fn session_allow_all(&self) -> &std::sync::Arc<crate::tools::SessionConfirmLatch> {
4544 &self.session_allow_all
4545 }
4546
4547 pub(crate) fn inherit_session_allow_all(
4549 &mut self,
4550 parent: Option<&std::sync::Arc<crate::tools::SessionConfirmLatch>>,
4551 ) {
4552 if let Some(parent) = parent {
4553 self.session_allow_all = parent.clone();
4554 }
4555 }
4556
4557 pub async fn run_stream_with_messages(
4561 &self,
4562 messages: Vec<crate::SharedMessage>,
4563 cancel: CancellationToken,
4564 steering_rx: Option<mpsc::UnboundedReceiver<String>>,
4565 secret_prompt: Option<crate::tools::SecretPromptHandle>,
4566 auto_approve_confirms: bool,
4567 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
4568 self.run_stream_tracked(
4569 messages,
4570 cancel,
4571 steering_rx,
4572 secret_prompt,
4573 auto_approve_confirms,
4574 TurnCompletion::new(),
4575 )
4576 .await
4577 }
4578
4579 pub async fn run_stream_tracked(
4583 &self,
4584 messages: Vec<crate::SharedMessage>,
4585 cancel: CancellationToken,
4586 steering_rx: Option<mpsc::UnboundedReceiver<String>>,
4587 secret_prompt: Option<crate::tools::SecretPromptHandle>,
4588 auto_approve_confirms: bool,
4589 completion: TurnCompletion,
4590 ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
4591 let (tx, internal_rx) = mpsc::unbounded_channel();
4597 let bounded_rx =
4598 crate::runtime::relay::spawn_bounded_stream_relay(internal_rx, cancel.clone());
4599
4600 let turn_correlation_id = agent_core::next_turn_correlation_id();
4603
4604 if self
4605 .continuation
4606 .lock()
4607 .unwrap_or_else(std::sync::PoisonError::into_inner)
4608 .durability_blocked
4609 {
4610 let error = RuntimeError::Session(
4611 "context head save is unresolved; reload the session before further inference"
4612 .into(),
4613 );
4614 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
4615 helpers::turn_error_for(&error, &turn_correlation_id),
4616 )));
4617 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
4618 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
4619 }
4620
4621 if let Err(error) = self.validate_request_preflight().await {
4622 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
4623 helpers::turn_error_for(&error, &turn_correlation_id),
4624 )));
4625 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
4626 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
4627 }
4628
4629 let anthropic_execution_plan = match self.authorized_anthropic_plan().await {
4630 Ok(plan) => plan,
4631 Err(error) => {
4632 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
4633 helpers::turn_error_for(&error, &turn_correlation_id),
4634 )));
4635 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
4636 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
4637 }
4638 };
4639
4640 if let Err(e) = self.refresh_if_needed().await {
4642 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
4643 helpers::turn_error_for(&e, &turn_correlation_id),
4644 )));
4645 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
4646 return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
4647 }
4648
4649 let capture_started_at = std::time::SystemTime::now();
4659 let capture_turn_start = terminal_capture_start(&messages);
4660 let capture_lease = self
4663 .memory_context_lock()
4664 .capture_lease_at(capture_started_at);
4665 let mut messages = messages;
4666 self.apply_turn_memory_recall(&mut messages).await;
4667 let messages = messages;
4668
4669 let auth = Arc::clone(&self.auth);
4672 let client = self.client.clone();
4673 let credential_source = self.credential_source.clone();
4674 let token_cache = self.token_cache.clone();
4675 let model = self.model.clone();
4676 let tools = self.tools.clone();
4677 let system_prompt = self.effective_system_prompt().await;
4678 let thinking_budget = self.thinking_budget;
4679 let reasoning_level = self.reasoning_level();
4680 let watcher_exit_path = self.watcher_exit_path.clone();
4681 let max_tool_output = self.max_tool_output;
4682 let bash_timeout = self.bash_timeout;
4683 let bash_max_timeout = self.bash_max_timeout;
4684 let subagent_timeout = self.subagent_timeout;
4685 let api_retries = self.api_retries;
4686 let refusal_retries = self.refusal_retries;
4687 let session_manager = self.session_manager.clone();
4688 let subagent_registry = self.subagent_registry.clone();
4692 let reaper_registry = Arc::clone(&subagent_registry);
4695 let reaper_orchestration = self.orchestration.clone();
4696 let capture_provider = capture_lease
4697 .as_ref()
4698 .and_then(|lease| self.extension_capture_provider(lease));
4699 let capture_state = self.memory_context_state.clone();
4700 let event_queue = self.event_queue.clone();
4701 let options = api::ApiOptions {
4702 memory_backend: None,
4703 use_1m_context: self.context_window_override == Some(1_000_000),
4704 cache_ttl: self.cache_ttl,
4705 ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
4706 saw_1h_honored: self.saw_1h_honored.clone(),
4707 credential_source: self.credential_source.clone(),
4708 token_cache: self.token_cache.clone(),
4709 anthropic_base_url: None,
4710 anthropic_stream_client: self.anthropic_stream_client.clone(),
4711 anthropic_execution_plan,
4712 codex_request_role: self.codex_request_role(),
4713 trace: self.effective_trace_context(),
4714 request_correlation: None,
4715 suppress_stream_deltas: false,
4716 telemetry: self.telemetry_writer.clone(),
4717 tool_session_id: Some(self.host_tool_session.clone()),
4720 session_tool_set: None,
4724 request_tools_schema: None,
4725 anthropic_tool_split: None,
4726 usage_counters: None,
4727 cache_anchor: Some(self.cache_anchor.clone()),
4730 keepwarm: None,
4731 cache_diagnosis: self.cache_diagnosis(),
4734 };
4735
4736 let final_capture_history: Arc<Mutex<Option<Vec<crate::SharedMessage>>>> = Arc::new(Mutex::new(None));
4737 let session = crate::runtime::stream::StreamSession {
4738 memory_backend: self.memory_backend.clone(),
4739 memory_context: self.memory_tool_capability(),
4740 final_capture_history: final_capture_history.clone(),
4741 turn_completion: completion,
4742 context_window: self.context_window(),
4743 continuation: self.continuation.clone(),
4744 auth,
4745 client,
4746 credential_source,
4747 token_cache,
4748 options,
4749 api_retries,
4750 refusal_retries,
4751 model,
4752 tools,
4753 system_prompt,
4754 thinking_budget,
4755 reasoning_level,
4756 tx: tx.clone(),
4757 cancel,
4758 steering_rx,
4759 watcher_exit_path,
4760 max_tool_output,
4761 bash_timeout,
4762 bash_max_timeout,
4763 subagent_timeout,
4764 session_manager,
4765 subagent_registry,
4766 event_queue,
4767 secret_prompt,
4768 hook_bus: self.hook_bus.clone(),
4769 session_id: self.session_id.clone(),
4770 cwd: self.cwd.clone(),
4771 env: self.env.clone(),
4772 env_stripped: self.env_stripped.clone(),
4773 env_warned: self.env_warned.clone(),
4774 auto_approve_confirms,
4775 session_allow_all: self.session_allow_all.clone(),
4776 telemetry_level: self.telemetry_level,
4777 orchestration: self.orchestration.clone(),
4778 delegation_parent: self.delegation_parent.clone(),
4779 turn_correlation_id: turn_correlation_id.clone(),
4780 progressive_tool_disclosure: self.progressive_tool_disclosure,
4781 deferred_tool_loading: self.deferred_tool_loading,
4782 activation_confirm: self.activation_confirm,
4783 tool_session_id: self.host_tool_session.clone(),
4784 retained_tool_set: std::sync::Arc::clone(&self.retained_tool_set),
4785 pending_tool_restore: std::sync::Arc::clone(&self.pending_tool_restore),
4786 mcp_runtime: self.mcp_runtime.clone(),
4787 mcp_session_scope: self.mcp_session_scope.clone(),
4788 extension_runtime: self.extension_runtime.clone(),
4789 extension_session_scope: self.extension_session_scope.clone(),
4790 turn_budget: self.turn_budget.clone(),
4791 owner_steer_budget: self.owner_steer_budget.clone(),
4792 };
4793
4794 tokio::spawn(async move {
4795 let completed = match StreamMethods::run_stream_internal(session, messages).await {
4796 Ok(()) => true,
4797 Err(e) => {
4798 let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
4799 helpers::turn_error_for(&e, &turn_correlation_id),
4800 )));
4801 false
4802 }
4803 };
4804 dispatch_completed_terminal_capture(
4807 completed,
4808 &final_capture_history,
4809 &capture_state,
4810 capture_lease.zip(capture_provider),
4811 capture_started_at,
4812 capture_turn_start.as_ref(),
4813 );
4814 crate::runtime::subagent::reap_finished(
4818 &reaper_registry,
4819 reaper_orchestration.as_deref(),
4820 );
4821 agent_core::core::memstat::log_turn_memory();
4822 let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
4823 });
4824
4825 Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx))
4826 }
4827}
4828
4829impl Clone for Runtime {
4830 fn clone(&self) -> Self {
4831 Self {
4832 client: self.client.clone(),
4833 anthropic_stream_client: self.anthropic_stream_client.clone(),
4834 auth: Arc::clone(&self.auth),
4835 model: self.model.clone(),
4836 tools: self.tools.clone(),
4837 system_prompt: self.system_prompt.clone(),
4838 effective_prompt: self.effective_prompt.clone(),
4839 prompt_generation: self.prompt_generation,
4840 prompt_reload_source: self.prompt_reload_source.clone(),
4841 thinking_budget: self.thinking_budget,
4842 named_level: self.named_level,
4843 explicit_reasoning: self.explicit_reasoning,
4844 codex_request_role: self.codex_request_role,
4845 context_window_override: self.context_window_override,
4846 continuation: Arc::clone(&self.continuation),
4847 compaction_model: self.compaction_model.clone(),
4848 compaction_mode: self.compaction_mode,
4849 compaction_exclusions: self.compaction_exclusions.clone(),
4850 remote_summarization_attempts: Arc::clone(&self.remote_summarization_attempts),
4851 subagent_registry: self.subagent_registry.clone(),
4852 orchestration: self.orchestration.clone(),
4853 event_queue: self.event_queue.clone(),
4854 watcher_exit_path: self.watcher_exit_path.clone(),
4855 max_tool_output: self.max_tool_output,
4856 bash_timeout: self.bash_timeout,
4857 bash_max_timeout: self.bash_max_timeout,
4858 subagent_timeout: self.subagent_timeout,
4859 api_retries: self.api_retries,
4860 refusal_retries: self.refusal_retries,
4861 telemetry_level: self.telemetry_level,
4862 telemetry_writer: self.telemetry_writer.clone(),
4866 trace_ctx: self.trace_ctx.clone(),
4867 trace_controls: Arc::clone(&self.trace_controls),
4868 memory_context_state: std::sync::Arc::clone(&self.memory_context_state),
4874 memory_backend: self.memory_backend.clone(),
4875 memory_backend_config: self.memory_backend_config.clone(),
4876 memory_backend_reconfigure_denied: Arc::clone(&self.memory_backend_reconfigure_denied),
4877 pending_history_import_preview: std::sync::Arc::clone(
4878 &self.pending_history_import_preview,
4879 ),
4880 history_import_plan: std::sync::Arc::clone(&self.history_import_plan),
4881 #[cfg(any(test, feature = "testing"))]
4882 capture_provider_for_test: std::sync::Arc::clone(&self.capture_provider_for_test),
4883 pending_memory_segment: std::sync::Arc::clone(&self.pending_memory_segment),
4886 retained_recall_turn: std::sync::Arc::clone(&self.retained_recall_turn),
4889 one_shot_trace_writer: Arc::clone(&self.one_shot_trace_writer),
4890 capture_dir: self.capture_dir.clone(),
4891 cache_diagnostics: self.cache_diagnostics,
4892 cache_ttl: self.cache_ttl,
4893 ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
4897 saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
4898 cache_anchor: Default::default(),
4899 last_msg_id: Arc::new(Mutex::new(None)),
4902 cache_diag_rejected: Arc::clone(&self.cache_diag_rejected),
4903 session_manager: self.session_manager.clone(),
4904 hook_bus: self.hook_bus.clone(),
4905 reaper_handle: None, reaper_cancel: None, credential_source: self.credential_source.clone(),
4908 token_cache: self.token_cache.clone(), trusted_worker_models: self.trusted_worker_models.clone(),
4910 subagent_limits: self.subagent_limits,
4911 progressive_tool_disclosure: self.progressive_tool_disclosure,
4912 deferred_tool_loading: self.deferred_tool_loading,
4913 activation_confirm: self.activation_confirm,
4914 delegation_parent: self.delegation_parent.clone(),
4915 session_allow_all: self.session_allow_all.clone(),
4916 mcp_runtime: self.mcp_runtime.clone(),
4917 mcp_session_scope: self.mcp_session_scope.clone(),
4920 extension_runtime: self.extension_runtime.clone(),
4921 extension_session_scope: self.extension_session_scope.clone(),
4923 turn_budget: self.turn_budget.clone(),
4924 owner_steer_budget: self.owner_steer_budget.clone(),
4925 host_tool_session: self.host_tool_session.clone(),
4930 tool_catalog_prompt: Arc::clone(&self.tool_catalog_prompt),
4931 retained_tool_set: std::sync::Arc::clone(&self.retained_tool_set),
4932 pending_tool_restore: std::sync::Arc::clone(&self.pending_tool_restore),
4933 session_id: self.session_id.clone(),
4935 cwd: self.cwd.clone(),
4936 env: self.env.clone(),
4937 env_stripped: self.env_stripped.clone(),
4938 env_warned: self.env_warned.clone(),
4939 }
4940 }
4941}
4942
4943#[cfg(test)]
4944mod rich_output_validation_tests {
4945 use super::*;
4946
4947 const MODEL: &str = "anthropic/claude-sonnet-4-6";
4948
4949 fn rich_output(summary: &str) -> crate::ToolOutput {
4950 crate::ToolOutput::Blocks {
4951 summary: summary.to_string(),
4952 blocks: vec![json!({
4954 "type": "image",
4955 "source": {
4956 "type": "base64",
4957 "media_type": "image/png",
4958 "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l+QAAAAASUVORK5CYII="
4959 }
4960 })],
4961 }
4962 }
4963
4964 #[test]
4965 fn unchanged_summary_retains_rich_array_without_text_truncation() {
4966 let (summary, blocks) = validated_single_tool_output(MODEL, rich_output("image"));
4967 assert_eq!(summary, "image");
4968 let expected = blocks.clone().expect("supported image");
4969 assert_eq!(expected[0], json!({"type": "text", "text": summary}));
4970 assert_eq!(expected[1]["type"], "image");
4971 let retained = retain_single_tool_blocks(blocks, &summary, &summary);
4972 assert_eq!(
4973 single_tool_result_content(&summary, retained, summary.len()),
4974 Value::Array(expected)
4975 );
4976 }
4977
4978 #[test]
4979 fn rewritten_or_truncated_summary_drops_rich_blocks() {
4980 let (summary, blocks) = validated_single_tool_output(MODEL, rich_output("image summary"));
4981 assert!(blocks.is_some());
4982 for hooked in [
4983 "redacted".to_string(),
4984 String::new(),
4985 helpers::HelperMethods::truncate_tool_result(&summary, 5),
4986 ] {
4987 let retained = retain_single_tool_blocks(blocks.clone(), &summary, &hooked);
4988 assert!(retained.is_none());
4989 assert_eq!(
4990 single_tool_result_content(&hooked, retained, 5),
4991 Value::String(helpers::HelperMethods::truncate_tool_result(&hooked, 5))
4992 );
4993 }
4994 }
4995
4996 #[test]
4997 fn unsupported_or_malformed_media_becomes_explicit_text() {
4998 let mut malformed = rich_output("private summary");
4999 if let crate::ToolOutput::Blocks { blocks, .. } = &mut malformed {
5000 blocks[0]["source"]["data"] = json!("private invalid payload");
5001 }
5002 for (model, output) in [
5003 (
5004 "google-gemini/unsupported-rich-test",
5005 rich_output("private summary"),
5006 ),
5007 (MODEL, malformed),
5008 ] {
5009 let (summary, blocks) = validated_single_tool_output(model, output);
5010 assert!(summary.starts_with("Attachment not sent: "), "{summary}");
5011 assert!(!summary.contains("private"));
5012 assert!(blocks.is_none());
5013 assert_eq!(
5014 single_tool_result_content(&summary, blocks, 1024),
5015 Value::String(summary)
5016 );
5017 }
5018 }
5019
5020 #[test]
5021 fn plain_text_and_errors_keep_legacy_truncation() {
5022 for text in ["plain output", "tool error", "Unknown tool: missing"] {
5023 let (summary, blocks) = validated_single_tool_output(
5024 "google-gemini/unsupported-rich-test",
5025 crate::ToolOutput::Text(text.to_string()),
5026 );
5027 assert_eq!(summary, text);
5028 assert!(blocks.is_none());
5029 assert_eq!(
5030 single_tool_result_content(&summary, blocks, 5),
5031 Value::String(helpers::HelperMethods::truncate_tool_result(text, 5))
5032 );
5033 }
5034 }
5035}
5036
5037#[cfg(test)]
5038mod tests {
5039 use super::*;
5040
5041 #[test]
5046 fn host_tool_session_shared_by_clones_fresh_per_runtime() {
5047 let rt = Runtime::new_headless();
5048 let clone = rt.clone();
5049 assert_eq!(
5050 rt.host_tool_session_id(),
5051 clone.host_tool_session_id(),
5052 "clones share the live registry and therefore the host session"
5053 );
5054
5055 let other = Runtime::new_headless();
5056 assert_ne!(
5057 rt.host_tool_session_id(),
5058 other.host_tool_session_id(),
5059 "independently constructed runtimes must never share a session"
5060 );
5061 }
5062
5063 #[test]
5067 fn handed_activations_are_carried_on_before_the_first_turn() {
5068 use crate::tools::activation::{CarriedActivation, CarriedProvenance};
5069 let probe = |name: &str| CarriedActivation {
5070 tool_id: format!("builtin:{name}"),
5071 schema_digest: "ab".repeat(32),
5072 provenance: CarriedProvenance::BuiltinRuntime,
5073 };
5074 let rt = Runtime::new_headless();
5075 assert!(rt.carried_tool_activations().is_empty());
5076 rt.restore_tool_activations(vec![probe("subagent_start")]);
5077 rt.restore_tool_activations(Vec::new());
5078 rt.restore_tool_activations(vec![probe("subagent_start"), probe("subagent_status")]);
5079 assert_eq!(
5080 rt.carried_tool_activations(),
5081 vec![probe("subagent_start"), probe("subagent_status")],
5082 "merged without duplicates; an empty hand-off clears nothing"
5083 );
5084 assert_eq!(rt.clone().carried_tool_activations().len(), 2);
5085 assert!(Runtime::new_headless().carried_tool_activations().is_empty());
5086 }
5087
5088 #[test]
5093 fn telemetry_level_gates_shared_observability_sink() {
5094 let mut rt = Runtime::new_headless();
5095 assert!(!rt.trace_context().enabled(), "off by default");
5096 assert!(rt.telemetry_writer().is_none());
5097
5098 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Basic);
5099 assert!(rt.trace_context().enabled(), "basic enables the trace sink");
5100 assert!(rt.telemetry_writer().is_some());
5101
5102 let ctx_before = format!("{:?}", rt.trace_context());
5104 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Full);
5105 assert_eq!(format!("{:?}", rt.trace_context()), ctx_before);
5106
5107 let clone = rt.clone();
5109 assert!(clone.trace_context().enabled());
5110 assert_eq!(format!("{:?}", clone.trace_context()), ctx_before);
5111 assert!(clone.telemetry_writer().is_some());
5112
5113 rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Off);
5114 assert!(!rt.trace_context().enabled(), "off disables both");
5115 assert!(rt.telemetry_writer().is_none());
5116 assert!(rt
5117 .shutdown_observability(std::time::Duration::ZERO)
5118 .is_none());
5119 }
5120
5121 #[tokio::test]
5127 async fn shutdown_observability_async_epilogue_contract() {
5128 let rt = Runtime::new_headless();
5130 let budget = crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT;
5131 assert!(rt.shutdown_observability_async(budget).await.is_none());
5132 assert!(rt.shutdown_observability_async(budget).await.is_none());
5133
5134 let tmp = tempfile::tempdir().unwrap();
5136 let telemetry_path = tmp.path().join("synaps/api-log.jsonl");
5137 let mut rt = Runtime::new_headless();
5138 let writer = crate::runtime::telemetry::TelemetryWriter::new(
5139 crate::runtime::telemetry::WriterOptions {
5140 telemetry_path: Some(telemetry_path.clone()),
5141 trace_path: Some(tmp.path().join("synaps/request-trace.jsonl")),
5142 ..Default::default()
5143 },
5144 );
5145 rt.install_observability_for_tests(writer.clone());
5146 writer.enqueue_telemetry(crate::runtime::telemetry::TelemetryRecord {
5147 ts: 7,
5148 model: "claude-sonnet-4-6".to_string(),
5149 ..Default::default()
5150 });
5151 let outcome = rt
5152 .shutdown_observability_async(budget)
5153 .await
5154 .expect("writer installed");
5155 assert!(outcome.is_flushed());
5156 assert_eq!(outcome.stats().written, 1);
5157 assert_eq!(
5158 std::fs::read_to_string(&telemetry_path)
5159 .unwrap()
5160 .lines()
5161 .count(),
5162 1,
5163 "queued final record must be on disk after the epilogue flush"
5164 );
5165 assert!(rt
5167 .shutdown_observability_async(budget)
5168 .await
5169 .expect("writer still installed")
5170 .is_flushed());
5171
5172 let mut rt = Runtime::new_headless();
5174 let slow = crate::runtime::telemetry::TelemetryWriter::new(
5175 crate::runtime::telemetry::WriterOptions {
5176 telemetry_path: Some(tmp.path().join("slow/api-log.jsonl")),
5177 trace_path: Some(tmp.path().join("slow/request-trace.jsonl")),
5178 write_delay: Some(std::time::Duration::from_millis(300)),
5179 ..Default::default()
5180 },
5181 );
5182 rt.install_observability_for_tests(slow.clone());
5183 for _ in 0..10 {
5184 slow.enqueue_telemetry(crate::runtime::telemetry::TelemetryRecord::default());
5185 }
5186 let start = std::time::Instant::now();
5187 let outcome = rt
5188 .shutdown_observability_async(std::time::Duration::from_millis(200))
5189 .await
5190 .expect("writer installed");
5191 assert!(!outcome.is_flushed(), "10×300ms cannot drain in 200ms");
5192 assert!(
5195 start.elapsed() < std::time::Duration::from_millis(1500),
5196 "bounded epilogue must not block exit: {:?}",
5197 start.elapsed()
5198 );
5199 }
5200
5201 #[tokio::test]
5206 async fn one_shot_ephemeral_writer_is_retained_and_drained_at_shutdown() {
5207 let rt = Runtime::new_headless();
5208 assert!(!rt.trace_context().enabled(), "telemetry off");
5209 rt.trace_arm_next(false);
5210 let armed = rt.effective_trace_context();
5211 assert!(armed.enabled(), "armed ephemeral context traces");
5212 let budget = crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT;
5215 let outcome = rt
5216 .shutdown_observability_async(budget)
5217 .await
5218 .expect("retained one-shot writer must be drained by the epilogue");
5219 assert!(outcome.is_flushed());
5220 assert!(rt.shutdown_observability_async(budget).await.is_none());
5222 }
5223
5224 #[tokio::test]
5230 #[serial_test::serial(synaps_base_dir)]
5231 async fn startup_and_shutdown_paths_sweep_expired_captures() {
5232 struct BaseDirGuard(Option<String>);
5233 impl Drop for BaseDirGuard {
5234 fn drop(&mut self) {
5235 match self.0.take() {
5236 Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
5237 None => std::env::remove_var("SYNAPS_BASE_DIR"),
5238 }
5239 }
5240 }
5241 let tmp = tempfile::tempdir().unwrap();
5242 let guard = BaseDirGuard(std::env::var("SYNAPS_BASE_DIR").ok());
5243 std::env::set_var("SYNAPS_BASE_DIR", tmp.path());
5244
5245 let cap_dir = trace::default_capture_dir();
5246 assert!(
5247 cap_dir.starts_with(tmp.path()),
5248 "capture dir must be private to the test"
5249 );
5250 let stale_id = trace::TraceId::new("req-stale").unwrap();
5251 let plant_stale = || {
5252 agent_core::core::private_fs::ensure_private_dir(&cap_dir).unwrap();
5253 let stale = trace::controls::ContentCaptureBundle {
5254 schema: trace::CONTENT_CAPTURE_SCHEMA.to_string(),
5255 request_id: stale_id.clone(),
5256 created_unix_ms: 1_000,
5257 expires_unix_ms: 2_000, redacted: true,
5259 over_budget: false,
5260 body: Some(serde_json::json!({"old": true})),
5261 };
5262 let path = trace::controls::capture_path(&cap_dir, &stale_id);
5263 std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap();
5264 path
5265 };
5266
5267 let stale_path = plant_stale();
5269 let rt = Runtime::new().await.expect("runtime constructs");
5270 assert!(
5271 !stale_path.exists(),
5272 "Runtime startup must physically remove expired capture bundles"
5273 );
5274
5275 let stale_path = plant_stale();
5277 let _ = rt.shutdown_observability(std::time::Duration::ZERO);
5278 assert!(
5279 !stale_path.exists(),
5280 "sync shutdown epilogue must remove expired capture bundles"
5281 );
5282
5283 let stale_path = plant_stale();
5285 let _ = rt
5286 .shutdown_observability_async(std::time::Duration::from_millis(100))
5287 .await;
5288 assert!(
5289 !stale_path.exists(),
5290 "async shutdown epilogue must remove expired capture bundles"
5291 );
5292 drop(guard);
5293 }
5294
5295 #[tokio::test]
5300 #[serial_test::serial(synaps_base_dir)]
5301 async fn capture_root_binds_at_construction_and_survives_env_churn() {
5302 let base_a = tempfile::tempdir().unwrap();
5303 let base_b = tempfile::tempdir().unwrap();
5304 let old = std::env::var("SYNAPS_BASE_DIR").ok();
5305
5306 std::env::set_var("SYNAPS_BASE_DIR", base_a.path());
5307 let rt_a = Runtime::new().await.expect("runtime A");
5308 std::env::set_var("SYNAPS_BASE_DIR", base_b.path());
5309 let rt_b = Runtime::new().await.expect("runtime B");
5310
5311 let cap_a = base_a.path().join("trace").join("capture");
5312 let cap_b = base_b.path().join("trace").join("capture");
5313 let plant = |dir: &std::path::Path, name: &str| {
5314 agent_core::core::private_fs::ensure_private_dir(dir).unwrap();
5315 let id = trace::TraceId::new(name).unwrap();
5316 let stale = trace::controls::ContentCaptureBundle {
5317 schema: trace::CONTENT_CAPTURE_SCHEMA.to_string(),
5318 request_id: id.clone(),
5319 created_unix_ms: 1_000,
5320 expires_unix_ms: 2_000,
5321 redacted: true,
5322 over_budget: false,
5323 body: Some(serde_json::json!({"old": true})),
5324 };
5325 let path = trace::controls::capture_path(dir, &id);
5326 std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap();
5327 path
5328 };
5329
5330 let decoy = tempfile::tempdir().unwrap();
5332 std::env::set_var("SYNAPS_BASE_DIR", decoy.path());
5333
5334 let stale_a = plant(&cap_a, "req-stale-a");
5335 let stale_b = plant(&cap_b, "req-stale-b");
5336 let _ = rt_a.shutdown_observability(std::time::Duration::ZERO);
5337 assert!(
5338 !stale_a.exists(),
5339 "runtime A must sweep the root bound at ITS construction"
5340 );
5341 assert!(
5342 stale_b.exists(),
5343 "runtime A must never sweep another runtime's root"
5344 );
5345 let _ = rt_b
5346 .shutdown_observability_async(std::time::Duration::from_millis(100))
5347 .await;
5348 assert!(
5349 !stale_b.exists(),
5350 "runtime B must sweep the root bound at ITS construction"
5351 );
5352
5353 match old {
5354 Some(v) => std::env::set_var("SYNAPS_BASE_DIR", v),
5355 None => std::env::remove_var("SYNAPS_BASE_DIR"),
5356 }
5357 }
5358
5359 #[test]
5360 fn reload_rejects_policy_change_without_partial_apply() {
5361 let dir = tempfile::tempdir().unwrap();
5362 let path = dir.path().join("prompt.yaml");
5363 let manifest = |total: &str, content: &str| {
5364 format!(
5365 "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"
5366 )
5367 };
5368 std::fs::write(&path, manifest("1", "before")).unwrap();
5369 let parsed =
5370 agent_core::prompt::PromptManifest::parse(&std::fs::read_to_string(&path).unwrap())
5371 .unwrap();
5372 let model =
5373 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6").unwrap();
5374 let context = agent_core::prompt::SelectionContext::new(model.clone(), None).unwrap();
5375 let stack = agent_core::prompt::compile_prompt_stack(
5376 &parsed,
5377 &parsed.registry(path.parent()).unwrap(),
5378 &context,
5379 None,
5380 )
5381 .unwrap();
5382 let catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
5383 &model,
5384 parsed.delegation_catalog_candidates(),
5385 )
5386 .unwrap();
5387 let digest = parsed
5388 .delegation_policy(model, &catalog)
5389 .unwrap()
5390 .map(|p| p.digest());
5391 let mut runtime = Runtime::new_headless();
5392 runtime.apply_prompt_stack(stack).unwrap();
5393 runtime.retain_prompt_reload_source(path.clone(), context, None, digest.clone());
5394 let generation = runtime.prompt_generation();
5395 let composed = runtime.effective_prompt().unwrap().composed().to_owned();
5396
5397 std::fs::write(&path, manifest("2", "after")).unwrap();
5398 let error = runtime.reload_prompt().unwrap_err().to_string();
5399 assert!(error.contains("cannot safely change delegation policy"));
5400 assert_eq!(runtime.prompt_generation(), generation);
5401 assert_eq!(runtime.effective_prompt().unwrap().composed(), composed);
5402 assert_eq!(
5403 runtime
5404 .prompt_reload_source
5405 .as_ref()
5406 .unwrap()
5407 .delegation_policy_digest,
5408 digest
5409 );
5410 }
5411
5412 #[test]
5413 fn worker_install_does_not_replay_process_global_favorite_grants() {
5414 let foreground =
5415 crate::orchestration::canonical_foreground_identity("anthropic/claude-fable-5")
5416 .unwrap();
5417 let session = Arc::new(
5418 crate::orchestration::OrchestrationRuntime::baseline(foreground, 4, 8).unwrap(),
5419 );
5420 let favorite = "openai-codex/gpt-5.6-sol";
5421 assert!(
5422 session.preflight(favorite).is_err(),
5423 "precondition: the parent session did not grant this identity"
5424 );
5425
5426 let mut worker = Runtime::new_headless();
5427 worker.apply_config(&crate::config::SynapsConfig {
5428 favorite_models: vec![favorite.to_owned()],
5429 ..Default::default()
5430 });
5431 worker.install_worker_orchestration(Arc::clone(&session));
5432
5433 assert!(
5434 session.preflight(favorite).is_err(),
5435 "constructing a child must not mint/replay a favorite-model grant"
5436 );
5437 assert!(!session.effective_choices().contains(&favorite.to_owned()));
5438 }
5439
5440 #[test]
5441 fn host_install_may_apply_explicit_operator_favorites() {
5442 let mut runtime = Runtime::new_headless();
5443 let config = crate::config::SynapsConfig {
5444 favorite_models: vec![
5445 "openai-codex/gpt-5.6-luna".to_owned(),
5446 "anthropic/claude-opus-4-6".to_owned(),
5447 "claude/claude-fable-5".to_owned(),
5449 "not-qualified".to_owned(),
5451 ],
5452 ..Default::default()
5453 };
5454 runtime.apply_config(&config);
5455 let foreground =
5456 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
5457 runtime.install_orchestration(std::sync::Arc::new(
5458 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap(),
5459 ));
5460
5461 let orchestration = runtime.orchestration().unwrap();
5462 for trusted in [
5463 "openai-codex/gpt-5.6-sol",
5464 "openai-codex/gpt-5.6-luna",
5465 "anthropic/claude-opus-4-6",
5466 "anthropic/claude-fable-5",
5467 ] {
5468 assert!(
5469 orchestration
5470 .effective_choices()
5471 .contains(&trusted.to_owned()),
5472 "missing configured worker choice {trusted}: {:?}",
5473 orchestration.effective_choices()
5474 );
5475 }
5476 let authorized = orchestration
5477 .resolve_and_authorize("sa_cross_provider", Some("anthropic/claude-opus-4-6"))
5478 .expect("an explicitly favorited cross-provider model must be authorized");
5479 assert_eq!(authorized.model.as_str(), "anthropic/claude-opus-4-6");
5480 assert!(!orchestration
5481 .effective_choices()
5482 .contains(&"not-qualified".to_owned()));
5483 }
5484
5485 #[test]
5486 fn configured_worker_choices_survive_manifestless_foreground_changes() {
5487 let mut runtime = Runtime::new_headless();
5488 runtime.apply_config(&crate::config::SynapsConfig {
5489 favorite_models: vec!["anthropic/claude-opus-4-6".to_owned()],
5490 ..Default::default()
5491 });
5492 let foreground =
5493 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
5494 runtime.install_orchestration(std::sync::Arc::new(
5495 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap(),
5496 ));
5497
5498 runtime
5499 .try_set_model("xai-auth/grok-4.5-latest".to_owned())
5500 .unwrap();
5501
5502 let orchestration = runtime.orchestration().unwrap();
5503 assert_eq!(orchestration.foreground_model(), "xai-auth/grok-4.5-latest");
5504 assert!(orchestration
5505 .effective_choices()
5506 .contains(&"anthropic/claude-opus-4-6".to_owned()));
5507 orchestration
5508 .resolve_and_authorize("sa_after_switch", Some("anthropic/claude-opus-4-6"))
5509 .expect("configured worker trust must survive policy replacement");
5510 }
5511
5512 #[test]
5513 fn configured_subagent_limits_size_the_policy_rebuilt_on_model_change() {
5514 let mut runtime = Runtime::new_headless();
5515 assert_eq!(
5516 runtime.subagent_limits(),
5517 crate::config::SubagentLimits::default(),
5518 "defaults before any config"
5519 );
5520 runtime.apply_config(&crate::config::SynapsConfig {
5521 subagent: crate::config::SubagentLimits {
5522 max_concurrent: 24,
5523 max_total: 96,
5524 max_depth: 2,
5525 },
5526 ..Default::default()
5527 });
5528 let foreground =
5529 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
5530 runtime.install_orchestration(std::sync::Arc::new(
5531 crate::orchestration::OrchestrationRuntime::from_limits(
5532 foreground,
5533 runtime.subagent_limits(),
5534 )
5535 .unwrap(),
5536 ));
5537 runtime
5538 .try_set_model("xai-auth/grok-4.5-latest".to_owned())
5539 .unwrap();
5540
5541 let orchestration = runtime.orchestration().unwrap();
5542 assert_eq!(orchestration.foreground_model(), "xai-auth/grok-4.5-latest");
5543 assert_eq!(
5544 orchestration.ultracode_readiness("xai-auth/grok-4.5-latest"),
5545 Ok((24, 96)),
5546 "the replacement policy uses the configured limits, not a hardcoded 8/64"
5547 );
5548 assert_eq!(orchestration.reserve_delegation("a", None), Ok(1));
5549 assert_eq!(orchestration.reserve_delegation("b", Some("a")), Ok(2));
5550 assert_eq!(
5551 orchestration.reserve_delegation("c", Some("b")),
5552 Err(crate::orchestration::DelegationTreeDenied::DepthLimit { max_depth: 2 })
5553 );
5554 }
5555
5556 #[test]
5557 fn set_model_replaces_manifestless_orchestration_foreground_snapshot() {
5558 let mut runtime = Runtime::new_headless();
5559 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
5560 runtime.install_orchestration(std::sync::Arc::new(
5561 crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
5562 ));
5563
5564 runtime.set_model("openrouter/deepseek/deepseek-v4-pro".to_owned());
5565
5566 let orchestration = runtime.orchestration().unwrap();
5567 assert_eq!(
5568 orchestration.foreground_model(),
5569 "openrouter/deepseek/deepseek-v4-pro"
5570 );
5571 assert!(orchestration
5572 .effective_choices()
5573 .contains(&"openrouter/moonshotai/kimi-k2.7-code".to_owned()));
5574 assert!(orchestration
5575 .resolve_and_authorize("sa_kimi", Some("openrouter/moonshotai/kimi-k2.7-code"))
5576 .is_ok());
5577 }
5578
5579 #[test]
5584 fn set_model_to_uncached_muse_id_rebuilds_orchestration() {
5585 let _guard = crate::runtime::openai::catalog::muse::test_cache_guard();
5586 crate::runtime::openai::catalog::capability_cache::replace_provider("muse", &[]);
5587 let mut runtime = Runtime::new_headless();
5588 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
5589 runtime.install_orchestration(std::sync::Arc::new(
5590 crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
5591 ));
5592
5593 for model in ["muse/muse-wonder-1.0", "muse/muse-live-only-zz5"] {
5594 runtime
5595 .try_set_model(model.to_owned())
5596 .unwrap_or_else(|e| panic!("{model}: {e}"));
5597 assert_eq!(runtime.model(), model);
5598 assert_eq!(runtime.orchestration().unwrap().foreground_model(), model);
5599 }
5600 assert!(runtime.try_set_model("muse/Bad Id".to_owned()).is_err());
5602 assert_eq!(runtime.model(), "muse/muse-live-only-zz5");
5603 }
5604
5605 #[test]
5606 fn set_model_refuses_to_orphan_unreconciled_policy_workers() {
5607 let mut runtime = Runtime::new_headless();
5608 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
5609 let orchestration = std::sync::Arc::new(
5610 crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
5611 );
5612 orchestration
5613 .resolve_and_authorize("sa_active", None)
5614 .unwrap();
5615 runtime.install_orchestration(orchestration.clone());
5616 runtime.model = "openrouter/z-ai/glm-5.2".to_owned();
5617
5618 let error = runtime
5619 .try_set_model("openrouter/deepseek/deepseek-v4-pro".to_owned())
5620 .unwrap_err();
5621
5622 assert!(error.contains("collection/reconciliation"));
5623 assert_eq!(runtime.model(), "openrouter/z-ai/glm-5.2");
5624 assert!(std::sync::Arc::ptr_eq(
5625 runtime.orchestration().unwrap(),
5626 &orchestration
5627 ));
5628 assert!(orchestration.is_unreconciled("sa_active"));
5629 }
5630
5631 #[test]
5632 fn set_model_keeps_active_typed_manifest_orchestration_unchanged() {
5633 let dir = tempfile::tempdir().unwrap();
5634 let manifest_path = dir.path().join("prompt.yaml");
5635 let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
5636 let mut runtime = Runtime::new_headless();
5637 let orchestration = std::sync::Arc::new(
5638 crate::orchestration::OrchestrationRuntime::baseline(glm.clone(), 3, 8).unwrap(),
5639 );
5640 runtime.install_orchestration(orchestration.clone());
5641 runtime.retain_prompt_reload_source(
5642 manifest_path,
5643 agent_core::prompt::SelectionContext::new(glm, None).unwrap(),
5644 None,
5645 None,
5646 );
5647
5648 runtime.set_model("openrouter/deepseek/deepseek-v4-pro".to_owned());
5649
5650 assert!(std::sync::Arc::ptr_eq(
5651 runtime.orchestration().unwrap(),
5652 &orchestration
5653 ));
5654 assert_eq!(
5655 runtime.orchestration().unwrap().foreground_model(),
5656 "openrouter/z-ai/glm-5.2"
5657 );
5658 }
5659
5660 #[test]
5661 fn set_model_preserves_qualified_provider_ids_and_bare_claude() {
5662 let cases = [
5663 (
5664 "github-copilot/claude-opus-4.8",
5665 "github-copilot/claude-opus-4.8",
5666 ),
5667 ("anthropic/claude-sonnet-4-6", "anthropic/claude-sonnet-4-6"),
5668 ("google/gemini-2.5-pro", "google/gemini-2.5-pro"),
5669 ("openai/gpt-5", "openai/gpt-5"),
5670 ("claude-opus-4-8", "claude-opus-4-8"),
5671 ];
5672
5673 for (input, expected) in cases {
5674 let mut runtime = Runtime::new_headless();
5675 runtime.set_model(input.to_owned());
5676 assert_eq!(runtime.model(), expected, "input: {input}");
5677 }
5678 }
5679
5680 #[test]
5681 fn set_model_strips_only_legacy_health_status_decoration() {
5682 let cases = [
5683 ("✅ 339ms groq/llama-3.3-70b", "groq/llama-3.3-70b"),
5684 (
5685 "✅ 339ms github-copilot/claude-opus-4.8",
5686 "github-copilot/claude-opus-4.8",
5687 ),
5688 (
5689 "⚠️ 1200ms anthropic/claude-sonnet-4-6",
5690 "anthropic/claude-sonnet-4-6",
5691 ),
5692 ];
5693
5694 for (input, expected) in cases {
5695 let mut runtime = Runtime::new_headless();
5696 runtime.set_model(input.to_owned());
5697 assert_eq!(runtime.model(), expected, "input: {input}");
5698 }
5699 }
5700
5701 #[tokio::test]
5702 async fn confirm_without_prompt_fails_closed() {
5703 let result = resolve_before_tool_call_result(
5704 crate::extensions::hooks::events::HookResult::confirm("Run deploy?"),
5705 None,
5706 false,
5707 None,
5708 )
5709 .await;
5710
5711 assert!(matches!(
5712 result,
5713 crate::extensions::hooks::events::HookResult::Block { reason }
5714 if reason.contains("requires confirmation") && reason.contains("Run deploy?")
5715 ));
5716 }
5717
5718 #[tokio::test]
5719 async fn modify_result_replaces_tool_input() {
5720 let result = resolve_before_tool_call_decision(
5721 serde_json::json!({"command":"rm -rf /"}),
5722 crate::extensions::hooks::events::HookResult::Modify {
5723 input: serde_json::json!({"command":"echo safe"}),
5724 },
5725 None,
5726 false,
5727 None,
5728 )
5729 .await;
5730
5731 match result {
5732 BeforeToolCallDecision::Continue { input } => {
5733 assert_eq!(input, serde_json::json!({"command":"echo safe"}));
5734 }
5735 BeforeToolCallDecision::Block { reason } => panic!("unexpected block: {reason}"),
5736 }
5737 }
5738
5739 #[tokio::test]
5740 async fn confirm_prompt_yes_continues() {
5741 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5742 let handle = crate::tools::SecretPromptHandle::new(tx);
5743
5744 let task = tokio::spawn(async move {
5745 let request = rx.recv().await.expect("confirm prompt request");
5746 assert_eq!(request.title, "Confirm tool call");
5747 assert!(request.prompt.contains("Run deploy?"));
5748 let _ = request.response_tx.send(Some("yes".to_string()));
5749 });
5750
5751 let result = resolve_before_tool_call_result(
5752 crate::extensions::hooks::events::HookResult::confirm("Run deploy?"),
5753 Some(&handle),
5754 false,
5755 None,
5756 )
5757 .await;
5758
5759 task.await.unwrap();
5760 assert!(matches!(
5761 result,
5762 crate::extensions::hooks::events::HookResult::Continue
5763 ));
5764 }
5765
5766 #[tokio::test]
5767 async fn confirm_prompt_non_yes_blocks() {
5768 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5769 let handle = crate::tools::SecretPromptHandle::new(tx);
5770
5771 let task = tokio::spawn(async move {
5772 let request = rx.recv().await.expect("confirm prompt request");
5773 let _ = request.response_tx.send(Some("no".to_string()));
5774 });
5775
5776 let result = resolve_before_tool_call_result(
5777 crate::extensions::hooks::events::HookResult::confirm("Run deploy?"),
5778 Some(&handle),
5779 false,
5780 None,
5781 )
5782 .await;
5783
5784 task.await.unwrap();
5785 assert!(matches!(
5786 result,
5787 crate::extensions::hooks::events::HookResult::Block { reason }
5788 if reason.contains("confirmation denied")
5789 ));
5790 }
5791
5792 fn ext(ids: &[&str]) -> Vec<String> {
5793 ids.iter().map(|s| s.to_string()).collect()
5794 }
5795
5796 fn key(ids: &[&str]) -> Vec<crate::tools::LatchKey> {
5797 ids.iter().map(|s| (s.to_string(), 0)).collect()
5798 }
5799
5800 fn sourced_confirm(
5801 message: &str,
5802 source: &str,
5803 tool: &str,
5804 ) -> crate::extensions::hooks::events::HookResult {
5805 crate::extensions::hooks::events::HookResult::Confirm {
5806 message: message.into(),
5807 sources: ext(&[source]),
5808 tool: Some(tool.into()),
5809 generations: vec![],
5810 modified_input: None,
5811 modified_by: vec![],
5812 }
5813 }
5814
5815 #[test]
5816 fn session_allow_all_clones_share_but_independent_runtimes_do_not() {
5817 use std::sync::Arc;
5818 let parent = Runtime::new_headless();
5819 let cloned = parent.clone();
5820 let independent = Runtime::new_headless();
5821 assert!(Arc::ptr_eq(
5822 parent.session_allow_all(),
5823 cloned.session_allow_all()
5824 ));
5825 assert!(!Arc::ptr_eq(
5826 parent.session_allow_all(),
5827 independent.session_allow_all()
5828 ));
5829 cloned.session_allow_all().allow(&key(&["jev"]));
5830 assert!(parent.session_allow_all().allows_all(&key(&["jev"])));
5831 assert!(!independent.session_allow_all().allows_all(&key(&["jev"])));
5832 }
5833
5834 #[test]
5835 fn session_allow_all_conversation_boundary_isolates_old_clones() {
5836 use std::sync::Arc;
5837 let mut runtime = Runtime::new_headless();
5838 let old_worker = runtime.clone();
5839 old_worker.session_allow_all().allow(&key(&["jev"]));
5840
5841 runtime.begin_conversation(Some("next".into()));
5842 assert_eq!(runtime.session_id(), Some("next"));
5843 assert!(runtime.session_allow_all().is_empty());
5844 assert!(!Arc::ptr_eq(
5845 runtime.session_allow_all(),
5846 old_worker.session_allow_all()
5847 ));
5848 assert!(
5851 old_worker.session_allow_all().is_empty(),
5852 "old latch must be cleared, not just replaced"
5853 );
5854 assert!(!old_worker.session_allow_all().allow(&key(&["jev"])));
5857 assert!(old_worker.session_allow_all().is_empty());
5858 assert!(runtime.session_allow_all().is_empty());
5859
5860 let new_worker = runtime.clone();
5861 new_worker.session_allow_all().allow(&key(&["jev"]));
5862 assert!(runtime.session_allow_all().allows_all(&key(&["jev"])));
5863 runtime.begin_conversation(Some("next".into()));
5865 assert!(runtime.session_allow_all().is_empty());
5866 assert!(!Arc::ptr_eq(
5867 runtime.session_allow_all(),
5868 new_worker.session_allow_all()
5869 ));
5870 }
5871
5872 #[test]
5873 fn conversation_boundary_resets_the_cache_diagnosis_chain() {
5874 let mut runtime = Runtime::new_headless();
5875 runtime.set_cache_diagnostics(true);
5876 let mut driver = runtime.clone();
5877 driver.share_ttl_latches(&runtime);
5878 runtime.cache_diagnosis().unwrap().record("msg_old");
5879 runtime.set_session_id(Some("successor".into()));
5881 assert_eq!(
5882 runtime
5883 .cache_diagnosis()
5884 .unwrap()
5885 .previous_message_id()
5886 .as_deref(),
5887 Some("msg_old")
5888 );
5889 runtime.begin_conversation(Some("next".into()));
5891 assert_eq!(
5892 runtime.cache_diagnosis().unwrap().previous_message_id(),
5893 None
5894 );
5895 assert_eq!(
5896 driver.cache_diagnosis().unwrap().previous_message_id(),
5897 None
5898 );
5899 }
5900
5901 #[test]
5902 fn session_allow_all_identity_change_preserves_compaction_consent() {
5903 use std::sync::Arc;
5904 let mut runtime = Runtime::new_headless();
5905 let worker = runtime.clone();
5906 worker.session_allow_all().allow(&key(&["jev"]));
5907 runtime.set_session_id(Some("linked-successor".into()));
5908 assert!(Arc::ptr_eq(
5909 runtime.session_allow_all(),
5910 worker.session_allow_all()
5911 ));
5912 assert!(runtime.session_allow_all().allows_all(&key(&["jev"])));
5913 }
5914
5915 #[tokio::test]
5919 async fn confirm_prompt_always_latches_only_the_source_extension() {
5920 let latch = crate::tools::SessionConfirmLatch::new();
5921 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5922 let handle = crate::tools::SecretPromptHandle::new(tx);
5923
5924 let task = tokio::spawn(async move {
5925 let first = rx.recv().await.expect("first confirm prompt");
5926 assert_eq!(
5928 first.confirm_scope,
5929 Some(crate::session::ConfirmScope {
5930 extensions: vec!["jev".into()],
5931 tool: Some("bash".into()),
5932 rewritten_by: vec![],
5933 })
5934 );
5935 assert!(first.prompt.starts_with("Extension: jev\nTool: bash\n"), "{}", first.prompt);
5936 let _ = first
5937 .response_tx
5938 .send(Some(CONFIRM_ANSWER_ALLOW_ALL.to_string()));
5939 let other = rx.recv().await.expect("other extension still prompts");
5942 assert_eq!(
5943 other.confirm_scope.as_ref().map(|s| s.extensions.clone()),
5944 Some(vec!["other".to_string()])
5945 );
5946 let _ = other.response_tx.send(None);
5947 assert!(rx.recv().await.is_none(), "no further prompts");
5948 });
5949
5950 let first = resolve_before_tool_call_result(
5951 sourced_confirm("Run deploy?", "jev", "bash"),
5952 Some(&handle),
5953 false,
5954 Some(&latch),
5955 )
5956 .await;
5957 assert!(matches!(
5958 first,
5959 crate::extensions::hooks::events::HookResult::Continue
5960 ));
5961 assert_eq!(latch.extensions(), ext(&["jev"]), "latch keyed by source");
5962
5963 let second = resolve_before_tool_call_result(
5964 sourced_confirm("Run deploy again?", "jev", "write"),
5965 Some(&handle),
5966 false,
5967 Some(&latch),
5968 )
5969 .await;
5970 assert!(matches!(
5971 second,
5972 crate::extensions::hooks::events::HookResult::Continue
5973 ));
5974
5975 let other = resolve_before_tool_call_result(
5976 sourced_confirm("Run ls?", "other", "bash"),
5977 Some(&handle),
5978 false,
5979 Some(&latch),
5980 )
5981 .await;
5982 assert!(
5983 matches!(other, crate::extensions::hooks::events::HookResult::Block { .. }),
5984 "another extension's confirm is not covered by jev's latch"
5985 );
5986 drop(handle);
5987 task.await.unwrap();
5988 }
5989
5990 #[tokio::test]
5993 async fn latched_plus_unlatched_source_prompts_and_allow_all_latches_both() {
5994 let latch = crate::tools::SessionConfirmLatch::new();
5995 latch.allow(&key(&["a"]));
5996 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5997 let handle = crate::tools::SecretPromptHandle::new(tx);
5998 let task = tokio::spawn(async move {
5999 let req = rx.recv().await.expect("must prompt: b is not latched");
6000 assert_eq!(
6001 req.confirm_scope.as_ref().map(|s| s.extensions.clone()),
6002 Some(vec!["a".to_string(), "b".to_string()])
6003 );
6004 assert!(req.prompt.starts_with("Extension: a, b\n"), "{}", req.prompt);
6005 let _ = req.response_tx.send(Some(CONFIRM_ANSWER_ALLOW_ALL.to_string()));
6006 });
6007 let merged = || crate::extensions::hooks::events::HookResult::Confirm {
6008 message: "[a] x\n\n[b] y".into(),
6009 sources: ext(&["a", "b"]),
6010 generations: vec![],
6011 tool: Some("bash".into()),
6012 modified_input: None,
6013 modified_by: vec![],
6014 };
6015 let first = resolve_before_tool_call_result(merged(), Some(&handle), false, Some(&latch)).await;
6016 task.await.unwrap();
6017 assert!(matches!(first, crate::extensions::hooks::events::HookResult::Continue));
6018 assert!(latch.allows_all(&key(&["a", "b"])));
6019 drop(handle);
6021 let second = resolve_before_tool_call_result(merged(), None, false, Some(&latch)).await;
6022 assert!(matches!(second, crate::extensions::hooks::events::HookResult::Continue));
6023 }
6024
6025 #[tokio::test]
6028 async fn no_latch_means_no_allow_all_scope() {
6029 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6030 let handle = crate::tools::SecretPromptHandle::new(tx);
6031 let task = tokio::spawn(async move {
6032 let req = rx.recv().await.unwrap();
6033 assert!(req.confirm_scope.is_none());
6034 let _ = req.response_tx.send(Some("y".into()));
6035 });
6036 let r = resolve_before_tool_call_result(
6037 sourced_confirm("Run?", "jev", "bash"),
6038 Some(&handle),
6039 false,
6040 None,
6041 )
6042 .await;
6043 task.await.unwrap();
6044 assert!(matches!(r, crate::extensions::hooks::events::HookResult::Continue));
6045 }
6046
6047 #[tokio::test]
6052 async fn rewritten_input_is_shown_in_the_confirm_prompt() {
6053 let latch = crate::tools::SessionConfirmLatch::new();
6054 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6055 let handle = crate::tools::SecretPromptHandle::new(tx);
6056 let task = tokio::spawn(async move {
6057 let req = rx.recv().await.unwrap();
6058 let body = req.prompt.clone();
6059 let scope = req.confirm_scope.clone().unwrap();
6060 let _ = req.response_tx.send(Some("y".into()));
6061 (body, scope)
6062 });
6063 let result = resolve_before_tool_call_result(
6064 crate::extensions::hooks::events::HookResult::Confirm {
6065 message: "Run this bash command?".into(),
6066 sources: ext(&["jev"]),
6067 generations: vec![],
6068 tool: Some("bash".into()),
6069 modified_input: Some(serde_json::json!({"command": "rm -rf build/"})),
6070 modified_by: ext(&["rewriter"]),
6071 },
6072 Some(&handle),
6073 false,
6074 Some(&latch),
6075 )
6076 .await;
6077 let (body, scope) = task.await.unwrap();
6078 let header_end = body.find("Message from jev:\nRun this bash command?").unwrap();
6079 let header = &body[..header_end];
6080 assert!(header.contains("Input rewritten by: rewriter"), "{body}");
6081 assert!(header.contains("Input that will run:\n rm -rf build/"), "{body}");
6082 assert_eq!(scope.rewritten_by, ext(&["rewriter"]));
6083 assert!(matches!(
6084 result,
6085 crate::extensions::hooks::events::HookResult::Modify { input }
6086 if input == serde_json::json!({"command": "rm -rf build/"})
6087 ));
6088 let plain = confirm_prompt_body("m", &ext(&["jev"]), Some("bash"), None);
6090 assert!(!plain.contains("rewritten"), "{plain}");
6091 }
6092
6093 #[test]
6095 fn final_input_preview_redacts_credentials() {
6096 let secrets = [
6097 "SECRET-BEARER-123",
6098 "SECRET-KEY-456",
6099 "SECRET-FIELD-789",
6100 "QmFzaWNTRUNSRVQ=",
6101 "SECRET-TOK-000",
6102 "SECRET-FLAG-111",
6103 "sk-ant-api03-abcdefghijklmnop",
6104 "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl",
6105 "ghp_abcdefghijklmnopqrstuvwxyz0123",
6106 ];
6107 let command = serde_json::json!({
6108 "command": "curl -H 'Authorization: Bearer SECRET-BEARER-123' \
6109 'https://api.example/v1?api_key=SECRET-KEY-456' \
6110 -H \"authorization: Basic QmFzaWNTRUNSRVQ=\" \
6111 && TOKEN=SECRET-TOK-000 deploy --password SECRET-FLAG-111 \
6112 sk-ant-api03-abcdefghijklmnop \
6113 eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl \
6114 ghp_abcdefghijklmnopqrstuvwxyz0123"
6115 });
6116 let json = serde_json::json!({
6117 "url": "https://x/y",
6118 "api_key": "SECRET-FIELD-789",
6119 "headers": {"X-Note": "Bearer SECRET-BEARER-123"},
6120 "args": ["--token=SECRET-TOK-000"],
6121 });
6122 for input in [command, json] {
6123 let p = final_input_preview(&input);
6124 for secret in secrets {
6125 assert!(!p.contains(secret), "{secret} leaked in:\n{p}");
6126 }
6127 assert!(p.contains("[REDACTED]"), "{p}");
6128 }
6129 let plain = final_input_preview(&serde_json::json!({"command": "rm -rf build/ && ls -la"}));
6131 assert!(plain.contains("rm -rf build/ && ls -la"), "{plain}");
6132 }
6133
6134 #[test]
6137 fn redact_secret_text_covers_round4_shapes() {
6138 let rows: &[(&str, &str)] = &[
6139 ("curl -H 'x-api-key: XAPIKEY-SECRET-1' https://a", "XAPIKEY-SECRET-1"),
6140 ("FOO_TOKEN=TOK-SECRET-2 run", "TOK-SECRET-2"),
6141 ("MY_SECRET=SEC-SECRET-3 ./x", "SEC-SECRET-3"),
6142 ("DB_PASSWORD=PW-SECRET-4 ./x", "PW-SECRET-4"),
6143 ("OPENAI_API_KEY=APIKEY-SECRET-5 ./x", "APIKEY-SECRET-5"),
6144 ("curl -H 'Authorization: Basic QkFTSUMtU0VDUkVULTY='", "QkFTSUMtU0VDUkVULTY="),
6145 ("aws_secret_access_key=AWSSEC-SECRET-7", "AWSSEC-SECRET-7"),
6146 ("git clone https://alice:USERINFO-SECRET-8@github.com/o/r", "USERINFO-SECRET-8"),
6147 ("psql postgres://app:PGPW-SECRET-9@db:5432/prod", "PGPW-SECRET-9"),
6148 ("DATABASE_URL=postgres://app:DBURL-SECRET-10@db/prod ./run", "DBURL-SECRET-10"),
6149 ("curl -H 'Cookie: sid=COOKIE-SECRET-11; theme=dark' https://a", "COOKIE-SECRET-11"),
6150 ("printf 'Set-Cookie: sid=SETCOOKIE-SECRET-12'", "SETCOOKIE-SECRET-12"),
6151 ("curl -u alice:CURLU-SECRET-13 https://a", "CURLU-SECRET-13"),
6152 ("mysql -u root -pMYSQL-SECRET-14 prod", "MYSQL-SECRET-14"),
6153 ("sshpass -p SSHPASS-SECRET-15 ssh host", "SSHPASS-SECRET-15"),
6154 ("aws configure set aws_secret_access_key AWSCFG-SECRET-16", "AWSCFG-SECRET-16"),
6155 ("aws x --secret-access-key FLAG-SECRET-17 --region eu", "FLAG-SECRET-17"),
6156 ("aws x --access-key FLAG-SECRET-18", "FLAG-SECRET-18"),
6157 ("STRIPE_KEY=rk_live_STRIPE-SECRET-19 node app", "STRIPE-SECRET-19"),
6158 ("OPENAI_KEY=OAIKEY-SECRET-20 node app", "OAIKEY-SECRET-20"),
6159 ];
6160 let precise = [
6163 "QkFTSUMtU0VDUkVULTY=",
6164 "USERINFO-SECRET-8",
6165 "PGPW-SECRET-9",
6166 "DBURL-SECRET-10",
6167 "COOKIE-SECRET-11",
6168 "SETCOOKIE-SECRET-12",
6169 "CURLU-SECRET-13",
6170 "MYSQL-SECRET-14",
6171 "SSHPASS-SECRET-15",
6172 ];
6173 for (text, secret) in rows {
6174 let out = redact_secret_text(text);
6176 assert!(!out.contains(secret), "{secret} leaked: {out}");
6177 assert!(out.contains("[REDACTED]"), "{out}");
6178 let preview = final_input_preview(&serde_json::json!({ "command": text }));
6179 assert!(!preview.contains(secret), "{secret} leaked in preview: {preview}");
6180 if precise.contains(secret) {
6181 let mut v = serde_json::Value::String(text.to_string());
6182 crate::runtime::trace::export::redact_value(&mut v);
6183 assert!(
6184 !v.as_str().unwrap().contains(secret),
6185 "shared scrub leaked {secret}: {v}"
6186 );
6187 }
6188 }
6189 for ok in [
6191 "mkdir -p build/out",
6192 "ssh -p 2222 host",
6193 "ssh -p2222 host",
6194 "docker run -p 8080:80 nginx",
6195 "mysql -h db -P 3306 -p prod",
6196 "curl -u alice https://example.com/path",
6197 "echo the token is valid and the password field is empty",
6198 "monkey=1 make",
6199 "git log --format=%H -- src/",
6200 "https://example.com/a/b?page=2",
6201 ] {
6202 assert_eq!(redact_secret_text(ok), ok, "over-redacted");
6203 }
6204 }
6205
6206 #[test]
6211 fn shared_scrub_leaves_benign_code_and_prose_alone() {
6212 let benign = [
6213 "max_tokens: 4096",
6214 "password: String,",
6215 "let token = load()?;",
6216 "the password-reset flow",
6217 "pub authorization: Option<String>,",
6218 "cookie: HashMap<String, String>,",
6219 "MY_KEY=value_in_docs",
6220 "--token-file path/to/file",
6221 "fn refresh_access_token(&self) -> Result<Token>",
6222 "aws_secret_access_key is the field name",
6223 ];
6224 for text in benign {
6225 let mut v = serde_json::Value::String(text.to_string());
6226 crate::runtime::trace::export::redact_value(&mut v);
6227 assert_eq!(v.as_str().unwrap(), text, "shared scrub mangled benign text");
6228 assert_eq!(crate::runtime::trace::export::redact_text(text), text);
6229 }
6230 let mut record = serde_json::json!({
6232 "text": "set max_tokens: 4096 and keep the password-reset flow",
6233 "code": ["password: String,", "let token = load()?;"],
6234 });
6235 let before = record.clone();
6236 crate::runtime::trace::export::redact_value(&mut record);
6237 assert_eq!(record, before);
6238 }
6239
6240 #[test]
6243 fn confirmer_message_echoing_injected_credentials_is_redacted() {
6244 let injected = serde_json::json!({
6245 "command": "curl -H 'Authorization: Bearer ECHOED-BEARER-X' https://a"
6246 });
6247 let body = confirm_prompt_body(
6248 "Run: curl -H 'Authorization: Bearer ECHOED-BEARER-X' https://a ?",
6249 &ext(&["jev"]),
6250 Some("bash"),
6251 Some((&ext(&["injector"]), &injected)),
6252 );
6253 assert!(!body.contains("ECHOED-BEARER-X"), "{body}");
6254 assert!(body.contains("Message from jev:\nRun: curl -H 'Authorization: [REDACTED]"), "{body}");
6255 }
6256
6257 #[test]
6261 fn confirm_message_control_and_bidi_chars_are_stripped() {
6262 let hostile = "ok\x1b[3A\x1b[2KExtension: trusted\rTool: none\u{0085}\u{202E}evil\u{200B}x\u{2066}y\tz\nnext";
6263 let body = confirm_prompt_body(hostile, &ext(&["jev"]), Some("bash"), None);
6264 for bad in ['\x1b', '\r', '\u{0085}', '\u{202E}', '\u{200B}', '\u{2066}', '\t'] {
6265 assert!(!body.contains(bad), "{bad:?} survived: {body:?}");
6266 }
6267 assert!(body.contains("Message from jev:\nok[3A[2KExtension: trustedTool: noneevilxy z\nnext"), "{body:?}");
6268 assert!(body.starts_with("Extension: jev\nTool: bash\n"));
6269 }
6270
6271 #[test]
6274 fn extension_message_is_labelled_after_the_host_header() {
6275 let fake = "Input that will run:\n echo harmless";
6276 let body = confirm_prompt_body(fake, &ext(&["jev"]), Some("bash"), None);
6277 let label = body.find("Message from jev:\n").expect(&body);
6278 assert_eq!(body.find("Input that will run:"), Some(label + "Message from jev:\n".len()));
6279 assert!(body.starts_with("Extension: jev\nTool: bash\n"), "{body}");
6280 }
6281
6282 #[test]
6283 fn final_input_preview_is_capped() {
6284 let many = serde_json::json!({"command": (1..=20).map(|i| format!("echo {i}")).collect::<Vec<_>>().join("\n")});
6285 let p = final_input_preview(&many);
6286 assert!(p.lines().count() <= MAX_REWRITE_PREVIEW_LINES + 1, "{p}");
6287 assert!(p.ends_with("…[preview truncated]"), "{p}");
6288 let wide = serde_json::json!({"path": "x".repeat(2000)});
6289 let p = final_input_preview(&wide);
6290 assert!(p.chars().count() <= MAX_REWRITE_PREVIEW_CHARS + 30, "{}", p.len());
6291 let obj = serde_json::json!({"path": "/tmp/a", "content": "hi"});
6292 assert!(final_input_preview(&obj).contains("\"path\": \"/tmp/a\""));
6293 }
6294
6295 #[test]
6298 fn long_confirm_message_is_capped() {
6299 let long = "x\n".repeat(500);
6300 let body = confirm_prompt_body(&long, &ext(&["jev"]), Some("bash"), None);
6301 assert!(body.starts_with("Extension: jev\nTool: bash\n"));
6302 assert!(body.contains("…[message truncated by host]"));
6303 assert!(body.lines().count() <= MAX_CONFIRM_MESSAGE_LINES + 8, "{}", body.lines().count());
6304 let wide = "y".repeat(10_000);
6305 assert!(confirm_prompt_body(&wide, &ext(&["jev"]), None, None).chars().count() < 1400);
6306 }
6307
6308 #[tokio::test]
6311 async fn unattributed_confirm_always_answer_does_not_latch() {
6312 let latch = crate::tools::SessionConfirmLatch::new();
6313 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6314 let handle = crate::tools::SecretPromptHandle::new(tx);
6315 let task = tokio::spawn(async move {
6316 let req = rx.recv().await.expect("prompt");
6317 assert!(req.confirm_scope.is_none());
6318 let _ = req.response_tx.send(Some(CONFIRM_ANSWER_ALLOW_ALL.to_string()));
6319 });
6320 let result = resolve_before_tool_call_result(
6321 crate::extensions::hooks::events::HookResult::confirm("Run deploy?"),
6322 Some(&handle),
6323 false,
6324 Some(&latch),
6325 )
6326 .await;
6327 task.await.unwrap();
6328 assert!(matches!(
6329 result,
6330 crate::extensions::hooks::events::HookResult::Continue
6331 ));
6332 assert!(latch.is_empty());
6333 }
6334
6335 #[tokio::test]
6339 async fn session_allow_all_does_not_override_block() {
6340 let latch = crate::tools::SessionConfirmLatch::new();
6341 latch.allow(&key(&["jev"]));
6342 let blocked = resolve_before_tool_call_result(
6343 crate::extensions::hooks::events::HookResult::Block {
6344 reason: "nope".into(),
6345 },
6346 None,
6347 false,
6348 Some(&latch),
6349 )
6350 .await;
6351 assert!(matches!(
6352 blocked,
6353 crate::extensions::hooks::events::HookResult::Block { reason } if reason == "nope"
6354 ));
6355 let headless = resolve_before_tool_call_result(
6356 sourced_confirm("Run deploy?", "jev", "bash"),
6357 None,
6358 false,
6359 Some(&latch),
6360 )
6361 .await;
6362 assert!(matches!(
6363 headless,
6364 crate::extensions::hooks::events::HookResult::Continue
6365 ));
6366 let other = resolve_before_tool_call_result(
6367 sourced_confirm("Run deploy?", "other", "bash"),
6368 None,
6369 false,
6370 Some(&latch),
6371 )
6372 .await;
6373 assert!(matches!(
6374 other,
6375 crate::extensions::hooks::events::HookResult::Block { .. }
6376 ));
6377 }
6378
6379 #[test]
6380 fn test_max_tokens_for_model() {
6381 assert_eq!(
6383 HelperMethods::max_tokens_for_model("claude-opus-4-6"),
6384 128000
6385 );
6386 assert_eq!(
6387 HelperMethods::max_tokens_for_model("opus-something"),
6388 128000
6389 );
6390
6391 assert_eq!(
6393 HelperMethods::max_tokens_for_model("claude-sonnet-4-20250514"),
6394 64000
6395 );
6396 assert_eq!(HelperMethods::max_tokens_for_model("haiku"), 64000);
6397 assert_eq!(HelperMethods::max_tokens_for_model("claude-3-haiku"), 64000);
6398 assert_eq!(
6399 HelperMethods::max_tokens_for_model("some-other-model"),
6400 64000
6401 );
6402
6403 assert_eq!(HelperMethods::max_tokens_for_model(""), 64000);
6405 assert_eq!(HelperMethods::max_tokens_for_model("OPUS"), 64000); assert_eq!(
6407 HelperMethods::max_tokens_for_model("model-opus-end"),
6408 128000
6409 ); }
6411
6412 #[test]
6413 fn test_truncate_tool_result() {
6414 let default_max = 30000;
6415
6416 let short = "This is a short string.";
6418 assert_eq!(
6419 HelperMethods::truncate_tool_result(short, default_max),
6420 short
6421 );
6422
6423 let exact = "x".repeat(30000);
6425 assert_eq!(
6426 HelperMethods::truncate_tool_result(&exact, default_max),
6427 exact
6428 );
6429
6430 let too_long = "x".repeat(30001);
6432 let truncated = HelperMethods::truncate_tool_result(&too_long, default_max);
6433
6434 assert!(truncated.starts_with(&"x".repeat(30000)));
6436
6437 assert!(truncated.contains("[truncated — 30001 total bytes, showing first 30000]"));
6439
6440 assert!(truncated.len() > 30000);
6442
6443 let very_long = "a".repeat(50000);
6445 let truncated_very_long = HelperMethods::truncate_tool_result(&very_long, default_max);
6446 assert!(
6447 truncated_very_long.contains("[truncated — 50000 total bytes, showing first 30000]")
6448 );
6449 assert!(truncated_very_long.starts_with(&"a".repeat(30000)));
6450
6451 let custom_truncated = HelperMethods::truncate_tool_result(&very_long, 100);
6453 assert!(custom_truncated.starts_with(&"a".repeat(100)));
6454 assert!(custom_truncated.contains("[truncated — 50000 total bytes, showing first 100]"));
6455 }
6456
6457 #[test]
6461 fn truncate_tool_result_enforces_byte_budget() {
6462 let multibyte = "é".repeat(100); let out = HelperMethods::truncate_tool_result(&multibyte, 50);
6464 let body = out.split("\n\n[truncated").next().unwrap();
6465 assert!(
6466 body.len() <= 50,
6467 "retained body must fit the byte budget (got {} bytes)",
6468 body.len()
6469 );
6470 assert!(std::str::from_utf8(body.as_bytes()).is_ok());
6471 assert!(
6472 out.contains("total bytes"),
6473 "marker must report byte counts, got: {out}"
6474 );
6475 }
6476
6477 #[test]
6478 fn test_thinking_level_ranges() {
6479 use crate::core::models::thinking_level_for_budget;
6480
6481 assert_eq!(thinking_level_for_budget(0), "adaptive");
6483
6484 assert_eq!(thinking_level_for_budget(1), "low");
6486 assert_eq!(thinking_level_for_budget(1024), "low");
6487 assert_eq!(thinking_level_for_budget(2048), "low");
6488
6489 assert_eq!(thinking_level_for_budget(2049), "medium");
6491 assert_eq!(thinking_level_for_budget(3000), "medium");
6492 assert_eq!(thinking_level_for_budget(4096), "medium");
6493
6494 assert_eq!(thinking_level_for_budget(4097), "high");
6496 assert_eq!(thinking_level_for_budget(8192), "high");
6497 assert_eq!(thinking_level_for_budget(16384), "high");
6498
6499 assert_eq!(thinking_level_for_budget(16385), "xhigh");
6501 assert_eq!(thinking_level_for_budget(32768), "xhigh");
6502 assert_eq!(thinking_level_for_budget(100000), "xhigh");
6503 }
6504}
6505
6506#[cfg(test)]
6507mod effective_prompt_tests {
6508 use super::*;
6509
6510 struct LateTool;
6513 #[async_trait::async_trait]
6514 impl crate::Tool for LateTool {
6515 fn name(&self) -> &str {
6516 "late_tool"
6517 }
6518 fn description(&self) -> &str {
6519 "Registered after the catalog froze."
6520 }
6521 fn parameters(&self) -> Value {
6522 json!({"type": "object", "properties": {}})
6523 }
6524 async fn execute(&self, _p: Value, _c: crate::ToolContext) -> crate::Result<String> {
6525 Ok(String::new())
6526 }
6527 }
6528
6529 #[tokio::test]
6530 async fn tool_catalog_lists_activatable_tools_outside_the_core() {
6531 let mut runtime = headless("anthropic/claude-sonnet-5-5", "BASE.");
6532 assert!(
6533 runtime.frozen_tool_catalog().await.is_none(),
6534 "flag off: no catalog"
6535 );
6536 runtime.progressive_tool_disclosure = true;
6537 let catalog = runtime.frozen_tool_catalog().await.expect("catalog");
6538 assert!(catalog.starts_with("## Activatable tools\n"));
6539 assert!(
6540 catalog.contains("not instructions"),
6541 "third-party text is framed as data"
6542 );
6543 assert!(catalog.contains("- builtin:subagent_start"), "{catalog}");
6544 for core in [
6545 "builtin:bash",
6546 "builtin:read",
6547 "builtin:activate_tools",
6548 "builtin:search_tools",
6549 ] {
6550 assert!(
6551 !catalog.contains(&format!("- {core}")),
6552 "core {core} listed"
6553 );
6554 }
6555 let prompt = runtime.effective_system_prompt().await.unwrap();
6556 assert!(prompt.starts_with("BASE."), "base stays first");
6557 assert!(prompt.ends_with(&catalog), "catalog appended last");
6558 }
6559
6560 #[tokio::test]
6561 async fn tool_catalog_is_frozen_per_session() {
6562 let mut runtime = headless("anthropic/claude-sonnet-5-5", "BASE.");
6563 runtime.progressive_tool_disclosure = true;
6564 let first = runtime.effective_system_prompt().await.unwrap();
6565 runtime.tools.write().await.register(Arc::new(LateTool));
6566 let clone = runtime.clone();
6567 assert_eq!(runtime.effective_system_prompt().await.unwrap(), first);
6568 assert_eq!(clone.effective_system_prompt().await.unwrap(), first);
6569 assert!(
6570 !first.contains("late_tool"),
6571 "late registration does not rewrite the prefix"
6572 );
6573 }
6574
6575 #[tokio::test]
6576 async fn tool_catalog_absent_when_activation_is_denied_or_impossible() {
6577 let mut denied = headless("anthropic/claude-sonnet-5-5", "BASE.");
6578 denied.progressive_tool_disclosure = true;
6579 denied.set_activation_confirm(agent_core::config::ActivationConfirm::Deny);
6580 assert!(denied.frozen_tool_catalog().await.is_none());
6581 assert_eq!(
6582 denied.effective_system_prompt().await.as_deref(),
6583 Some("BASE.")
6584 );
6585
6586 let mut worker = headless("anthropic/claude-sonnet-5-5", "BASE.");
6587 worker.progressive_tool_disclosure = true;
6588 worker.set_tools(crate::ToolRegistry::without_subagent());
6589 assert!(
6590 worker.frozen_tool_catalog().await.is_none(),
6591 "no activate_tools, no catalog"
6592 );
6593 }
6594
6595 fn headless(model: &str, base: &str) -> Runtime {
6596 let mut runtime = Runtime::new_headless();
6597 runtime.set_model(model.to_string());
6598 runtime.set_system_prompt(base.to_string());
6599 runtime
6600 }
6601
6602 #[tokio::test]
6603 async fn anthropic_foreground_ultracode_composes_standing_doctrine_once() {
6604 let mut runtime = headless("anthropic/claude-fable-5", "BASE.");
6605 runtime.named_level = Some(agent_core::reasoning::ReasoningLevel::UltraCode);
6606 let prompt = runtime.effective_system_prompt().await.expect("doctrine");
6607 assert_eq!(prompt.matches("<anthropic-ultracode-workflow>").count(), 1);
6608 assert!(prompt.contains("subagent_start"));
6609 assert!(prompt.contains("subagent_resume"));
6610 }
6611
6612 #[tokio::test]
6613 async fn codex_foreground_with_subagent_tools_composes_doctrine() {
6614 let runtime = headless("openai-codex/gpt-5.6-sol", "BASE.");
6615 let prompt = runtime
6616 .effective_system_prompt()
6617 .await
6618 .expect("prompt must compose");
6619 assert!(prompt.starts_with("BASE."), "base must lead");
6620 assert!(prompt.contains("## Subagent supervision"));
6621 assert!(prompt.contains("NEVER end your turn"));
6622 }
6623
6624 #[tokio::test]
6625 async fn worker_runtime_without_subagent_tools_stays_clean() {
6626 let mut runtime = headless("openai-codex/gpt-5.6-sol", "WORKER.");
6627 runtime.set_tools(ToolRegistry::without_subagent());
6628 assert_eq!(
6629 runtime.effective_system_prompt().await.as_deref(),
6630 Some("WORKER."),
6631 "workers have no subagent tools; doctrine would be unactionable noise"
6632 );
6633 }
6634
6635 #[tokio::test]
6636 async fn non_codex_foreground_stays_clean() {
6637 let runtime = headless("xai-auth/grok-4.5-latest", "BASE.");
6638 assert_eq!(
6639 runtime.effective_system_prompt().await.as_deref(),
6640 Some("BASE.")
6641 );
6642 }
6643
6644 #[tokio::test]
6645 async fn manifest_prompt_is_returned_verbatim() {
6646 let mut runtime = headless("openai-codex/gpt-5.6-sol", "IGNORED.");
6647 let module = agent_core::prompt::PromptModule::new(
6648 agent_core::prompt::PromptModuleId::parse("kernel.test").unwrap(),
6649 "1.0.0",
6650 agent_core::prompt::PromptModuleSource::User,
6651 0,
6652 agent_core::prompt::PromptSelectors::default(),
6653 agent_core::prompt::ModuleMutability::MutableGuidance,
6654 "KERNEL.",
6655 )
6656 .unwrap();
6657 let context = agent_core::prompt::SelectionContext::new(
6658 agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap(),
6659 None,
6660 )
6661 .unwrap();
6662 let stack = agent_core::prompt::PromptStack::new(vec![module], context).unwrap();
6663 runtime.apply_prompt_stack(stack).unwrap();
6664 assert_eq!(
6665 runtime.effective_system_prompt().await.as_deref(),
6666 Some("KERNEL."),
6667 "manifest authors own the full stack; no builtin injection on top"
6668 );
6669 }
6670
6671 #[tokio::test]
6672 async fn unresolvable_model_identity_fails_closed_to_base() {
6673 let runtime = headless("definitely-not-a-model", "BASE.");
6674 assert_eq!(
6675 runtime.effective_system_prompt().await.as_deref(),
6676 Some("BASE.")
6677 );
6678 }
6679}
6680
6681#[cfg(test)]
6682mod set_reasoning_level_checked_tests {
6683 use super::*;
6684 use agent_core::reasoning::ReasoningLevel;
6685
6686 fn codex_runtime(model_id: &str) -> Runtime {
6687 let mut rt = Runtime::new_headless();
6688 rt.set_model(format!("openai-codex/{model_id}"));
6689 rt.set_reasoning_level(ReasoningLevel::Low);
6690 rt
6691 }
6692
6693 fn provisioned_fable_runtime() -> Runtime {
6694 let mut rt = Runtime::new_headless();
6695 rt.set_model("anthropic/claude-fable-5".into());
6696 rt.set_reasoning_level(ReasoningLevel::Low);
6697 let foreground =
6698 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-fable-5").unwrap();
6699 let orchestration =
6700 crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap();
6701 rt.install_orchestration(Arc::new(orchestration));
6702 rt
6703 }
6704
6705 fn install_typed_manifest(rt: &mut Runtime) {
6706 let module = agent_core::prompt::PromptModule::new(
6707 agent_core::prompt::PromptModuleId::parse("kernel.test").unwrap(),
6708 "1.0.0",
6709 agent_core::prompt::PromptModuleSource::User,
6710 0,
6711 agent_core::prompt::PromptSelectors::default(),
6712 agent_core::prompt::ModuleMutability::MutableGuidance,
6713 "KERNEL.",
6714 )
6715 .unwrap();
6716 let context = agent_core::prompt::SelectionContext::new(
6717 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-fable-5").unwrap(),
6718 None,
6719 )
6720 .unwrap();
6721 rt.apply_prompt_stack(agent_core::prompt::PromptStack::new(vec![module], context).unwrap())
6722 .unwrap();
6723 }
6724
6725 #[test]
6726 fn provisioned_fable_accepts_max_and_ultracode() {
6727 for level in [ReasoningLevel::Max, ReasoningLevel::UltraCode] {
6728 let mut rt = provisioned_fable_runtime();
6729 rt.set_reasoning_level_checked(level).unwrap();
6730 assert_eq!(rt.reasoning_level(), level);
6731 }
6732 }
6733
6734 #[test]
6735 fn fable_ultracode_prerequisites_fail_without_mutation() {
6736 let mut missing_orchestration = Runtime::new_headless();
6737 missing_orchestration.set_model("anthropic/claude-fable-5".into());
6738 missing_orchestration.set_reasoning_level(ReasoningLevel::Low);
6739 assert!(missing_orchestration
6740 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
6741 .is_err());
6742 assert_eq!(missing_orchestration.reasoning_level(), ReasoningLevel::Low);
6743
6744 let mut missing_tools = provisioned_fable_runtime();
6745 missing_tools.set_tools(ToolRegistry::without_subagent());
6746 assert!(missing_tools
6747 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
6748 .is_err());
6749 assert_eq!(missing_tools.reasoning_level(), ReasoningLevel::Low);
6750
6751 let mut worker = provisioned_fable_runtime();
6752 worker.set_codex_request_role(crate::runtime::openai::catalog::ExecutionRole::Worker);
6753 assert!(worker
6754 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
6755 .is_err());
6756 assert_eq!(worker.reasoning_level(), ReasoningLevel::Low);
6757
6758 let mut internal = provisioned_fable_runtime();
6759 internal.set_codex_request_role(crate::runtime::openai::catalog::ExecutionRole::Internal);
6760 assert!(internal
6761 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
6762 .is_err());
6763 assert_eq!(internal.reasoning_level(), ReasoningLevel::Low);
6764
6765 let mut manifest = provisioned_fable_runtime();
6766 install_typed_manifest(&mut manifest);
6767 assert!(manifest
6768 .set_reasoning_level_checked(ReasoningLevel::UltraCode)
6769 .is_err());
6770 assert_eq!(manifest.reasoning_level(), ReasoningLevel::Low);
6771 }
6772
6773 #[test]
6774 fn checked_accepts_client_omission_modes_for_codex() {
6775 for level in [ReasoningLevel::Off, ReasoningLevel::Adaptive] {
6776 let mut rt = codex_runtime("gpt-5.6-sol");
6777 rt.set_reasoning_level_checked(level).unwrap();
6778 assert_eq!(rt.reasoning_level(), level);
6779 }
6780 }
6781
6782 #[test]
6784 fn checked_rejects_ultra_for_luna_no_mutation() {
6785 let mut rt = codex_runtime("gpt-5.6-luna");
6786 let before = rt.reasoning_level();
6787 let err = rt
6788 .set_reasoning_level_checked(ReasoningLevel::Ultra)
6789 .unwrap_err();
6790 assert!(
6791 err.contains("ultra"),
6792 "error must name the rejected level; got: {err}"
6793 );
6794 assert_eq!(
6795 rt.reasoning_level(),
6796 before,
6797 "runtime must not be mutated when validation fails"
6798 );
6799 }
6800
6801 #[test]
6803 fn checked_rejects_max_for_gpt55_no_mutation() {
6804 let mut rt = codex_runtime("gpt-5.5");
6805 rt.set_reasoning_level(ReasoningLevel::Medium);
6806 let err = rt
6807 .set_reasoning_level_checked(ReasoningLevel::Max)
6808 .unwrap_err();
6809 assert!(
6810 err.contains("max"),
6811 "error must name the rejected level; got: {err}"
6812 );
6813 assert_eq!(rt.reasoning_level(), ReasoningLevel::Medium);
6814 }
6815
6816 #[test]
6818 fn checked_accepts_ultra_for_sol_and_mutates() {
6819 let mut rt = codex_runtime("gpt-5.6-sol");
6820 rt.set_reasoning_level_checked(ReasoningLevel::Ultra)
6821 .expect("sol must accept ultra");
6822 assert_eq!(rt.reasoning_level(), ReasoningLevel::Ultra);
6823 }
6824
6825 #[test]
6827 fn checked_rejects_extended_levels_without_metadata() {
6828 let mut rt = Runtime::new_headless();
6829 rt.set_model("claude-opus-4-7".to_string());
6830 rt.set_reasoning_level(ReasoningLevel::Low);
6831 for level in [ReasoningLevel::Max, ReasoningLevel::Ultra] {
6832 assert!(rt.set_reasoning_level_checked(level).is_err());
6833 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
6834 }
6835 }
6836
6837 #[test]
6840 fn checked_rejects_max_ultra_for_unknown_codex_model() {
6841 let mut rt = codex_runtime("gpt-unknown-future");
6842 rt.set_reasoning_level(ReasoningLevel::Low);
6843 for level in [ReasoningLevel::Max, ReasoningLevel::Ultra] {
6844 let err = rt.set_reasoning_level_checked(level).unwrap_err();
6845 assert!(err.contains("no capability metadata"), "{err}");
6846 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
6847 }
6848 }
6849
6850 #[test]
6853 fn checked_enforces_xai_capability_matrix_no_mutation_on_err() {
6854 let mut rt = Runtime::new_headless();
6855 rt.set_model("xai-auth/grok-4.5".to_string());
6856 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
6858 for level in [
6859 ReasoningLevel::Off,
6860 ReasoningLevel::XHigh,
6861 ReasoningLevel::Ultra,
6862 ] {
6863 let before = rt.reasoning_level();
6864 assert!(rt.set_reasoning_level_checked(level).is_err(), "{level}");
6865 assert_eq!(rt.reasoning_level(), before);
6866 }
6867 rt.set_reasoning_level_checked(ReasoningLevel::Low)
6868 .expect("grok-4.5 supports low");
6869 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
6870 }
6871
6872 #[test]
6875 fn checked_rejects_named_on_intrinsic_xai_model_and_defaults_adaptive() {
6876 let mut rt = Runtime::new_headless();
6877 rt.set_model("xai-auth/grok-4.3".to_string());
6878 assert_eq!(rt.reasoning_level(), ReasoningLevel::Adaptive);
6879 assert!(rt
6880 .set_reasoning_level_checked(ReasoningLevel::Medium)
6881 .is_err());
6882 assert_eq!(rt.reasoning_level(), ReasoningLevel::Adaptive);
6883 rt.set_reasoning_level_checked(ReasoningLevel::Adaptive)
6884 .unwrap();
6885 }
6886
6887 #[test]
6889 fn explicit_level_survives_xai_model_switch() {
6890 let mut rt = Runtime::new_headless();
6891 rt.set_model("xai-auth/grok-4.5".to_string());
6892 rt.set_reasoning_level_checked(ReasoningLevel::Low).unwrap();
6893 rt.set_model("xai-auth/grok-4.5-latest".to_string());
6894 assert_eq!(
6895 rt.reasoning_level(),
6896 ReasoningLevel::Low,
6897 "explicit level must not be overwritten by the model default"
6898 );
6899 }
6900
6901 #[test]
6904 fn explicit_unsupported_level_is_clamped_to_model_default_on_switch() {
6905 let mut rt = Runtime::new_headless();
6906 rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
6907 let clamp = rt
6908 .try_set_model("xai-auth/grok-4.6".to_string())
6909 .expect("model switch succeeds");
6910 assert_eq!(
6911 clamp,
6912 Some(ReasoningClamp {
6913 from: ReasoningLevel::XHigh,
6914 to: ReasoningLevel::High,
6915 })
6916 );
6917 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
6918 assert!(
6919 rt.is_reasoning_explicit(),
6920 "clamp keeps explicit provenance"
6921 );
6922 assert_eq!(rt.model(), "xai-auth/grok-4.6");
6923 }
6924
6925 #[test]
6927 fn explicit_supported_level_is_not_clamped_on_switch() {
6928 let mut rt = Runtime::new_headless();
6929 rt.set_reasoning_level_explicit(ReasoningLevel::High);
6930 let clamp = rt.try_set_model("xai-auth/grok-4.6".to_string()).unwrap();
6931 assert_eq!(clamp, None);
6932 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
6933 assert!(rt.is_reasoning_explicit());
6934 }
6935
6936 #[test]
6939 fn non_explicit_default_application_reports_no_clamp() {
6940 let mut rt = Runtime::new_headless();
6941 rt.set_reasoning_level(ReasoningLevel::XHigh);
6942 let clamp = rt.try_set_model("xai-auth/grok-4.6".to_string()).unwrap();
6943 assert_eq!(clamp, None);
6944 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
6945 assert!(!rt.is_reasoning_explicit());
6946 }
6947
6948 #[test]
6951 fn explicit_max_clamps_to_validating_option_on_multi_agent_model() {
6952 let model = "xai-auth/grok-4.20-multi-agent-0309";
6953 let mut rt = Runtime::new_headless();
6954 rt.set_reasoning_level_explicit(ReasoningLevel::Max);
6955 let clamp = rt
6956 .try_set_model(model.to_string())
6957 .unwrap()
6958 .expect("Max is not supported: must clamp");
6959 assert_eq!(clamp.from, ReasoningLevel::Max);
6960 assert!(
6961 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
6962 model, clamp.to
6963 )
6964 .is_ok(),
6965 "clamped level {} must validate",
6966 clamp.to
6967 );
6968 assert_eq!(rt.reasoning_level(), clamp.to);
6969 assert!(rt.is_reasoning_explicit());
6970 }
6971
6972 #[test]
6974 fn set_model_returns_clamp() {
6975 let mut rt = Runtime::new_headless();
6976 rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
6977 let clamp = rt.set_model("xai-auth/grok-4.6".to_string());
6978 assert_eq!(clamp.map(|c| c.to), Some(ReasoningLevel::High));
6979 }
6980
6981 #[test]
6984 fn restore_session_reasoning_clamps_after_reapply() {
6985 let mut rt = Runtime::new_headless();
6986 rt.set_model("xai-auth/grok-4.6".to_string());
6987 let clamp = rt.restore_session_reasoning("xhigh");
6988 assert_eq!(
6989 clamp,
6990 Some(ReasoningClamp {
6991 from: ReasoningLevel::XHigh,
6992 to: ReasoningLevel::High,
6993 })
6994 );
6995 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
6996 assert!(rt.is_reasoning_explicit());
6997 assert!(rt.set_reasoning_level_checked(rt.reasoning_level()).is_ok());
6998 }
6999
7000 #[test]
7002 fn restore_session_reasoning_keeps_supported_level() {
7003 let mut rt = Runtime::new_headless();
7004 rt.set_model("xai-auth/grok-4.6".to_string());
7005 assert_eq!(rt.restore_session_reasoning("low"), None);
7006 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
7007 assert!(rt.is_reasoning_explicit());
7008 }
7009
7010 #[test]
7013 fn apply_config_clamps_unsupported_explicit_level_against_config_model() {
7014 let mut rt = Runtime::new_headless();
7015 rt.apply_config(&crate::config::SynapsConfig {
7016 model: Some("xai-auth/grok-4.6".to_string()),
7017 thinking_level: Some(ReasoningLevel::XHigh),
7018 ..Default::default()
7019 });
7020 assert_eq!(rt.model(), "xai-auth/grok-4.6");
7021 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
7022 assert!(rt.is_reasoning_explicit());
7023 assert!(rt.set_reasoning_level_checked(rt.reasoning_level()).is_ok());
7024 }
7025}
7026
7027#[cfg(test)]
7028mod codex_execution_preflight_tests {
7029 use super::*;
7030 use crate::runtime::openai::catalog::CodexRequestRole;
7031 use agent_core::prompt::QualifiedModelId;
7032 use agent_core::reasoning::ReasoningLevel;
7033
7034 fn ultra_runtime() -> Runtime {
7035 let mut runtime = Runtime::new_headless();
7036 runtime.set_model("openai-codex/gpt-5.6-sol".to_string());
7037 runtime.set_reasoning_level_explicit(ReasoningLevel::Ultra);
7038 runtime
7039 }
7040
7041 fn install_baseline(runtime: &mut Runtime) {
7042 let foreground =
7043 QualifiedModelId::parse("openai-codex/gpt-5.6-sol").expect("qualified model");
7044 let orchestration = crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8)
7045 .expect("baseline orchestration");
7046 runtime.install_orchestration(Arc::new(orchestration));
7047 }
7048
7049 #[tokio::test]
7050 async fn foreground_ultra_preflight_requires_orchestration() {
7051 let runtime = ultra_runtime();
7052 let error = runtime
7053 .validate_request_preflight()
7054 .await
7055 .expect_err("Ultra without orchestration must fail closed");
7056 assert!(error.to_string().contains("orchestration"), "{error}");
7057 }
7058
7059 #[tokio::test]
7060 async fn foreground_ultra_preflight_requires_actionable_subagent_tools() {
7061 let mut runtime = ultra_runtime();
7062 install_baseline(&mut runtime);
7063 runtime.set_tools(ToolRegistry::without_subagent());
7064
7065 let error = runtime
7066 .validate_request_preflight()
7067 .await
7068 .expect_err("Ultra without subagent tools must fail closed");
7069 assert!(error.to_string().contains("subagent"), "{error}");
7070 }
7071
7072 #[tokio::test]
7073 async fn foreground_ultra_preflight_accepts_exact_model_policy_and_tools() {
7074 let mut runtime = ultra_runtime();
7075 install_baseline(&mut runtime);
7076 runtime
7077 .validate_request_preflight()
7078 .await
7079 .expect("fully provisioned Ultra must pass preflight");
7080 }
7081
7082 #[tokio::test]
7083 async fn worker_ultra_preflight_never_requires_or_enables_orchestration() {
7084 let mut runtime = ultra_runtime();
7085 runtime.set_codex_request_role(CodexRequestRole::Worker);
7086 runtime.set_tools(ToolRegistry::without_subagent());
7087 runtime
7088 .validate_request_preflight()
7089 .await
7090 .expect("worker Ultra is underlying max without proactive orchestration");
7091 assert_eq!(runtime.codex_request_role(), CodexRequestRole::Worker);
7092 }
7093
7094 #[tokio::test]
7095 async fn non_codex_ultra_preflight_fails_before_provider_work() {
7096 let mut runtime = Runtime::new_headless();
7097 runtime.set_model("anthropic/claude-opus-4-7".to_string());
7098 runtime.set_reasoning_level_explicit(ReasoningLevel::Ultra);
7099 let error = runtime
7100 .validate_request_preflight()
7101 .await
7102 .expect_err("non-Codex Ultra must fail closed");
7103 assert!(
7104 error.to_string().contains("unsupported_reasoning_level"),
7105 "{error}"
7106 );
7107 }
7108
7109 #[tokio::test]
7110 async fn compaction_preflight_validates_target_before_credentials() {
7111 let mut runtime = ultra_runtime();
7112 runtime.set_compaction_model(Some("claude-sonnet-4-6".to_string()));
7113 {
7114 let mut auth = runtime.auth.write().await;
7115 auth.auth_token.clear();
7116 auth.auth_type = "none".to_string();
7117 auth.refresh_token = None;
7118 auth.token_expires = None;
7119 }
7120
7121 let error = runtime
7122 .compact_call(Vec::new())
7123 .await
7124 .expect_err("Codex Ultra cannot silently cross into Anthropic compaction");
7125 assert!(
7126 error.to_string().contains("authoritative exact-model"),
7127 "preflight must reject the target before auth access: {error}"
7128 );
7129 }
7130}
7131
7132#[cfg(test)]
7133mod http_client_timeout_tests {
7134 use futures::StreamExt;
7148 use std::time::Duration;
7149 use tokio::io::AsyncWriteExt;
7150
7151 const CHUNKED_HEAD: &[u8] =
7152 b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\n\r\n";
7153
7154 fn chunk(data: &str) -> Vec<u8> {
7155 format!("{:x}\r\n{}\r\n", data.len(), data).into_bytes()
7156 }
7157
7158 async fn spawn_chunked_server(n_chunks: usize, gap: Duration, terminate: bool) -> String {
7161 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7162 let addr = listener.local_addr().unwrap();
7163 tokio::spawn(async move {
7164 let (mut sock, _) = listener.accept().await.unwrap();
7165 let mut buf = [0u8; 4096];
7167 use tokio::io::AsyncReadExt;
7168 let _ = sock.read(&mut buf).await;
7169 sock.write_all(CHUNKED_HEAD).await.unwrap();
7170 for i in 0..n_chunks {
7171 sock.write_all(&chunk(&format!("data {i}\n")))
7172 .await
7173 .unwrap();
7174 sock.flush().await.unwrap();
7175 tokio::time::sleep(gap).await;
7176 }
7177 if terminate {
7178 sock.write_all(b"0\r\n\r\n").await.unwrap();
7179 } else {
7180 tokio::time::sleep(Duration::from_secs(60)).await;
7182 }
7183 });
7184 format!("http://{addr}/stream")
7185 }
7186
7187 async fn drain(
7188 client: &reqwest::Client,
7189 url: &str,
7190 ) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
7191 let resp = client.get(url).send().await?;
7192 let mut stream = resp.bytes_stream();
7193 let mut total = 0usize;
7194 while let Some(c) = stream.next().await {
7195 total += c?.len();
7196 }
7197 Ok(total)
7198 }
7199
7200 #[tokio::test]
7205 async fn active_stream_outliving_read_timeout_survives() {
7206 let url = spawn_chunked_server(15, Duration::from_millis(100), true).await;
7208 let client = super::build_http_client(Duration::from_millis(400)).expect("client builds");
7209 let total = drain(&client, &url)
7210 .await
7211 .expect("active stream must never be killed by the idle timeout");
7212 assert!(total > 0);
7213 }
7214
7215 #[tokio::test]
7218 async fn stalled_stream_is_killed_by_read_timeout() {
7219 let url = spawn_chunked_server(2, Duration::from_millis(10), false).await;
7221 let client = super::build_http_client(Duration::from_millis(300)).expect("client builds");
7222 let start = std::time::Instant::now();
7223 let err = drain(&client, &url)
7224 .await
7225 .expect_err("stalled stream must be detected");
7226 assert!(
7227 start.elapsed() < Duration::from_secs(5),
7228 "stall must be detected promptly, took {:?}",
7229 start.elapsed()
7230 );
7231 let msg = crate::core::error::error_chain_string(err.as_ref());
7232 assert!(
7233 msg.contains("timed out") || msg.contains("timeout"),
7234 "must be a timeout error: {msg}"
7235 );
7236 }
7237
7238 #[tokio::test]
7242 async fn hung_headers_are_killed_by_read_timeout() {
7243 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7244 let addr = listener.local_addr().unwrap();
7245 tokio::spawn(async move {
7246 let (_sock, _) = listener.accept().await.unwrap();
7247 tokio::time::sleep(Duration::from_secs(60)).await;
7248 });
7249 let client = super::build_http_client(Duration::from_millis(300)).expect("client builds");
7250 let start = std::time::Instant::now();
7251 let err = client
7252 .get(format!("http://{addr}/codex/responses"))
7253 .send()
7254 .await
7255 .expect_err("hung request must be detected");
7256 assert!(
7257 start.elapsed() < Duration::from_secs(5),
7258 "hung headers must be detected promptly, took {:?}",
7259 start.elapsed()
7260 );
7261 assert!(err.is_timeout(), "must be a timeout: {err:?}");
7262 }
7263}
7264
7265#[cfg(test)]
7267mod memory_context_provider_tests {
7268 use super::{memory_context, Runtime};
7269 use std::sync::Arc;
7270
7271 async fn memory_runtime_with_providers(
7279 plugins: &[(&str, &str)],
7280 ) -> (
7281 Runtime,
7282 std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
7283 ) {
7284 let mut manager = crate::extensions::manager::ExtensionManager::new(Arc::new(
7285 crate::extensions::hooks::HookBus::new(),
7286 ));
7287 manager.set_progressive_deferral(true);
7288 for (plugin, provider) in plugins {
7289 let manifest: crate::extensions::manifest::ExtensionManifest =
7290 serde_json::from_value(serde_json::json!({
7291 "runtime": "process",
7292 "command": "/bin/false",
7293 "permissions": ["context_providers.register"],
7294 "deferred": {
7295 "context_providers": [{
7296 "id": provider,
7297 "capability": "project-memory",
7298 "description": "test context provider",
7299 "schema_version": 1
7300 }]
7301 }
7302 }))
7303 .expect("manifest parses");
7304 manager
7305 .load(plugin, &manifest)
7306 .await
7307 .expect("deferred context-provider load succeeds without spawning");
7308 }
7309 let extension_runtime = manager.extension_runtime();
7310 let mut runtime = Runtime::new_headless();
7311 runtime.install_extension_runtime(std::sync::Arc::clone(&extension_runtime));
7312 (runtime, extension_runtime)
7313 }
7314
7315 fn assert_memory_off_no_lease(runtime: &Runtime) {
7316 let status = runtime.memory_context_status();
7317 assert_eq!(
7318 status.durable,
7319 memory_context::DurableStatus::Off,
7320 "state must stay Off"
7321 );
7322 assert_eq!(
7323 status.one_shot,
7324 memory_context::OneShotStatus::Idle,
7325 "no one-shot lease may exist"
7326 );
7327 assert!(
7328 runtime.memory_bound_providers_for_test().is_empty(),
7329 "no provider may be bound"
7330 );
7331 }
7332
7333 #[tokio::test]
7336 async fn memory_backend_exclusive_rejects_extension_recall_capture_and_history() {
7337 for selector in ["axel", "invalid-selector"] {
7338 let (mut runtime, _manager) =
7339 memory_runtime_with_providers(&[("memory-test", "notes")]).await;
7340 let provider = runtime.resolve_memory_provider(None).unwrap();
7341 runtime.apply_config(&agent_core::config::load_config_from_str(&format!(
7342 "memory.backend = {selector}\n"
7343 )));
7344 assert!(runtime.memory_backend.exclusive());
7345 assert!(matches!(
7346 runtime.resolve_memory_provider(None),
7347 Err(memory_context::MemoryContextError::ProviderNotRegistered)
7348 ));
7349 assert!(matches!(
7350 runtime.resolve_memory_provider(Some(provider.as_str())),
7351 Err(memory_context::MemoryContextError::ProviderNotRegistered)
7352 ));
7353 assert!(matches!(
7354 runtime.memory_history_confirm(),
7355 Err(
7356 super::memory_history::HistoryImportError::CaptureProviderUnavailable
7357 | super::memory_history::HistoryImportError::ConsentRequired
7358 )
7359 ));
7360 assert_memory_off_no_lease(&runtime);
7361 let mut messages = vec![Arc::new(
7362 serde_json::json!({"role": "user", "content": "test"}),
7363 )];
7364 let original = messages.clone();
7365 runtime.apply_turn_memory_recall(&mut messages).await;
7366 assert_eq!(messages, original);
7367 let clone = runtime.clone();
7368 assert!(clone.memory_backend.exclusive());
7369 assert_eq!(clone.memory_backend.base(), runtime.memory_backend.base());
7370 }
7371 }
7372
7373 #[tokio::test]
7374 async fn memory_backend_first_config_is_immutable_and_changes_require_restart() {
7375 for (first, different) in [("legacy", "axel"), ("axel", "legacy")] {
7376 let mut runtime = Runtime::new_headless();
7377 let config =
7378 agent_core::config::load_config_from_str(&format!("memory.backend = {first}\n"));
7379 runtime.apply_config(&config);
7380 let binding = runtime.memory_backend.clone();
7381 runtime.apply_config(&config);
7382 assert!(!runtime
7383 .memory_backend_reconfigure_denied
7384 .load(std::sync::atomic::Ordering::SeqCst));
7385 if let (Ok(a), Ok(b)) = (binding.scope(), runtime.memory_backend.scope()) {
7386 assert!(std::ptr::eq(a, b));
7387 }
7388 let clone = runtime.clone();
7389 runtime.apply_config(&agent_core::config::load_config_from_str(&format!(
7390 "memory.backend = {different}\n"
7391 )));
7392 assert_eq!(runtime.memory_backend.exclusive(), binding.exclusive());
7393 assert_eq!(
7394 runtime.memory_backend_config.as_ref(),
7395 Some(&config.memory_backend)
7396 );
7397 for denied in [&runtime, &clone] {
7398 let error = denied
7399 .validate_request_preflight()
7400 .await
7401 .unwrap_err()
7402 .to_string();
7403 assert!(error.contains("restart required"), "{error}");
7404 assert!(denied.resolve_memory_provider(None).is_err());
7405 }
7406 runtime.apply_config(&config);
7407 assert!(runtime.validate_request_preflight().await.is_err());
7408 }
7409 }
7410
7411 #[tokio::test]
7412 async fn memory_backend_path_changes_also_require_restart() {
7413 for field in ["executable", "brain"] {
7414 let mut runtime = Runtime::new_headless();
7415 let config = crate::config::MemoryBackendConfig::default();
7416 runtime.apply_memory_backend_config(&config);
7417 let mut different = config.clone();
7418 let path = std::env::temp_dir().join("synaps-memory-config-test");
7419 if field == "executable" {
7420 different.executable = Some(path);
7421 } else {
7422 different.brain = Some(path);
7423 }
7424 runtime.apply_memory_backend_config(&different);
7425 assert_eq!(runtime.memory_backend_config.as_ref(), Some(&config));
7426 assert!(!runtime.memory_backend.exclusive());
7427 let error = runtime
7428 .validate_request_preflight()
7429 .await
7430 .unwrap_err()
7431 .to_string();
7432 assert!(error.contains("restart required"), "{error}");
7433 assert!(runtime.resolve_memory_provider(None).is_err());
7434 }
7435 }
7436
7437 fn default_mode_config(mode: &str, confirmed: bool) -> agent_core::config::MemoryConfig {
7438 agent_core::config::MemoryConfig {
7439 default_mode: mode.to_string(),
7440 default_mode_confirmed: confirmed,
7441 ..Default::default()
7442 }
7443 }
7444
7445 #[test]
7448 fn memory_default_mode_confirmed_grants_session_lease() {
7449 for (name, mode) in [
7450 (
7451 "recall_each_prompt",
7452 memory_context::MemoryContextMode::RecallEachPrompt,
7453 ),
7454 (
7455 "capture_only",
7456 memory_context::MemoryContextMode::CaptureOnly,
7457 ),
7458 (
7459 "capture_and_recall",
7460 memory_context::MemoryContextMode::CaptureAndRecall,
7461 ),
7462 ] {
7463 let runtime = Runtime::new_headless();
7464 let status = runtime
7465 .apply_memory_default_mode(&default_mode_config(name, true))
7466 .expect("a confirmed non-off default applies")
7467 .expect("legacy backend grants");
7468 assert!(
7469 matches!(status.durable, memory_context::DurableStatus::Active { mode: m, .. } if m == mode),
7470 "{name}: got {:?}",
7471 status.durable
7472 );
7473 assert_eq!(
7474 runtime.memory_durable_proof_for_test(),
7475 Some(memory_context::UserIntentProof::OperatorConfig),
7476 "{name}"
7477 );
7478 }
7479
7480 let runtime = Runtime::new_headless();
7481 runtime
7482 .apply_memory_default_mode(&default_mode_config("recall_once", true))
7483 .expect("applies")
7484 .expect("grants");
7485 assert_eq!(
7486 runtime.memory_context_status().durable,
7487 memory_context::DurableStatus::Off
7488 );
7489 assert_eq!(
7490 runtime.memory_one_shot_proof_for_test(),
7491 Some(memory_context::UserIntentProof::OperatorConfig)
7492 );
7493 }
7494
7495 #[test]
7497 fn memory_default_mode_off_unknown_or_unconfirmed_grants_nothing() {
7498 for (mode, confirmed) in [
7499 ("off", true),
7500 ("off", false),
7501 ("capture_and_recall", false),
7502 ("recall_once", false),
7503 ("recall_everything_forever", true),
7504 ] {
7505 let runtime = Runtime::new_headless();
7506 assert!(
7507 runtime
7508 .apply_memory_default_mode(&default_mode_config(mode, confirmed))
7509 .is_none(),
7510 "{mode}/{confirmed} must not apply"
7511 );
7512 assert_memory_off_no_lease(&runtime);
7513 }
7514 }
7515
7516 #[test]
7519 fn memory_default_mode_keeps_existing_state_and_off_revokes() {
7520 let runtime = Runtime::new_headless();
7521 runtime
7522 .memory_context_enable(
7523 memory_context::MemoryContextMode::RecallEachPrompt,
7524 memory_context::mint_explicit_command_proof(),
7525 )
7526 .unwrap();
7527 assert!(runtime
7528 .apply_memory_default_mode(&default_mode_config("capture_and_recall", true))
7529 .is_none());
7530 assert!(matches!(
7531 runtime.memory_context_status().durable,
7532 memory_context::DurableStatus::Active {
7533 mode: memory_context::MemoryContextMode::RecallEachPrompt,
7534 ..
7535 }
7536 ));
7537 assert!(matches!(
7538 runtime.memory_durable_proof_for_test(),
7539 Some(memory_context::UserIntentProof::ExplicitCommand { .. })
7540 ));
7541
7542 let runtime = Runtime::new_headless();
7543 runtime
7544 .apply_memory_default_mode(&default_mode_config("capture_and_recall", true))
7545 .unwrap()
7546 .unwrap();
7547 runtime.memory_context_disable();
7548 assert_memory_off_no_lease(&runtime);
7549 }
7550
7551 #[test]
7554 fn memory_default_mode_unusable_backend_fails_closed() {
7555 let mut runtime = Runtime::new_headless();
7556 runtime.apply_config(&agent_core::config::load_config_from_str(
7557 "memory.backend = invalid-not-axel-not-legacy\n",
7558 ));
7559 assert!(runtime
7560 .apply_memory_default_mode(&default_mode_config("capture_and_recall", true))
7561 .expect("a confirmed default is attempted")
7562 .is_err());
7563 assert_memory_off_no_lease(&runtime);
7564 }
7565
7566 #[test]
7567 fn memory_backend_apply_config_revokes_preexisting_legacy_lease() {
7568 let mut runtime = Runtime::new_headless();
7569 runtime
7570 .memory_context_enable(
7571 memory_context::MemoryContextMode::CaptureAndRecall,
7572 memory_context::UserIntentProof::ExplicitCommand {
7573 command_id: memory_context::mint_explicit_command_id(),
7574 },
7575 )
7576 .unwrap();
7577 let lease = runtime
7578 .memory_context_lock()
7579 .capture_lease_at(std::time::SystemTime::now())
7580 .unwrap();
7581 runtime.apply_config(&agent_core::config::load_config_from_str(
7582 "memory.backend = axel\n",
7583 ));
7584 assert_memory_off_no_lease(&runtime);
7585 assert!(runtime.extension_capture_provider(&lease).is_none());
7586 }
7587
7588 #[test]
7589 fn axel_invalid_config_cannot_grant_consent_or_fallback() {
7590 let mut runtime = Runtime::new_headless();
7591 runtime.apply_config(&agent_core::config::load_config_from_str(
7592 "memory.backend = invalid-not-axel-not-legacy\n",
7593 ));
7594 assert!(runtime.memory_backend.exclusive());
7595 assert!(runtime.resolve_memory_provider(None).is_err());
7596 assert_memory_off_no_lease(&runtime);
7597 }
7598
7599 #[tokio::test]
7605 async fn memory_enable_unregistered_provider_fails_closed_grants_nothing() {
7606 let (runtime, _ext) =
7608 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
7609 for requested in ["no-such-provider", "bad:id"] {
7610 let err = runtime
7611 .memory_context_enable_resolved(
7612 memory_context::MemoryContextMode::CaptureAndRecall,
7613 memory_context::mint_explicit_command_proof(),
7614 Some(requested),
7615 )
7616 .expect_err("unregistered provider must fail closed");
7617 assert_eq!(
7618 err,
7619 memory_context::MemoryContextError::ProviderNotRegistered
7620 );
7621 assert_memory_off_no_lease(&runtime);
7622 }
7623
7624 let (runtime, _ext) = memory_runtime_with_providers(&[]).await;
7627 let err = runtime
7628 .memory_context_enable(
7629 memory_context::MemoryContextMode::CaptureAndRecall,
7630 memory_context::mint_explicit_command_proof(),
7631 )
7632 .expect_err("empty catalog must fail closed");
7633 assert_eq!(
7634 err,
7635 memory_context::MemoryContextError::ProviderNotRegistered
7636 );
7637 assert_memory_off_no_lease(&runtime);
7638 let err = runtime
7640 .memory_context_recall_once(memory_context::mint_explicit_command_proof())
7641 .expect_err("one-shot grant must validate the provider too");
7642 assert_eq!(
7643 err,
7644 memory_context::MemoryContextError::ProviderNotRegistered
7645 );
7646 assert_memory_off_no_lease(&runtime);
7647 }
7648
7649 #[tokio::test]
7653 async fn memory_enable_exactly_one_declared_provider_records_exact_identity() {
7654 const ADDRESS: &str = "extension:axel-memory-manager:project-memory";
7655 let (runtime, _ext) =
7657 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
7658 let status = runtime
7659 .memory_context_enable(
7660 memory_context::MemoryContextMode::CaptureAndRecall,
7661 memory_context::mint_explicit_command_proof(),
7662 )
7663 .expect("unique declared provider must resolve");
7664 assert!(matches!(
7665 status.durable,
7666 memory_context::DurableStatus::Active { .. }
7667 ));
7668 let bound = runtime.memory_bound_providers_for_test();
7669 assert_eq!(bound.len(), 1);
7670 assert_eq!(bound[0].as_str(), ADDRESS, "exact runtime address recorded");
7671
7672 let (runtime, _ext) =
7674 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
7675 runtime
7676 .memory_context_enable_resolved(
7677 memory_context::MemoryContextMode::RecallEachPrompt,
7678 memory_context::mint_explicit_command_proof(),
7679 Some("project-memory"),
7680 )
7681 .expect("explicit exact id must resolve");
7682 assert_eq!(
7683 runtime.memory_bound_providers_for_test()[0].as_str(),
7684 ADDRESS
7685 );
7686 }
7687
7688 #[tokio::test]
7692 async fn memory_enable_ambiguous_overlapping_declarations_fails_closed() {
7693 let (runtime, _ext) = memory_runtime_with_providers(&[
7694 ("mem-a", "project-memory"),
7695 ("mem-b", "project-memory"),
7696 ])
7697 .await;
7698 let err = runtime
7699 .memory_context_enable(
7700 memory_context::MemoryContextMode::CaptureAndRecall,
7701 memory_context::mint_explicit_command_proof(),
7702 )
7703 .expect_err("overlapping declarations must fail closed");
7704 assert_eq!(err, memory_context::MemoryContextError::ProviderAmbiguous);
7705 assert_memory_off_no_lease(&runtime);
7706
7707 let err = runtime
7709 .memory_context_enable_resolved(
7710 memory_context::MemoryContextMode::CaptureOnly,
7711 memory_context::mint_explicit_command_proof(),
7712 Some("project-memory"),
7713 )
7714 .expect_err("same id declared by two plugins is ambiguous");
7715 assert_eq!(err, memory_context::MemoryContextError::ProviderAmbiguous);
7716 assert_memory_off_no_lease(&runtime);
7717 }
7718
7719 #[tokio::test]
7725 async fn memory_disable_revokes_runtime_lease_without_spawn_no_panic() {
7726 let (runtime, ext) =
7727 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
7728 runtime
7729 .memory_context_enable(
7730 memory_context::MemoryContextMode::CaptureAndRecall,
7731 memory_context::mint_explicit_command_proof(),
7732 )
7733 .expect("enable succeeds");
7734 assert_eq!(
7735 ext.lease_count(),
7736 0,
7737 "granting a memory lease spawns nothing"
7738 );
7739
7740 let status = runtime.memory_context_disable();
7741 assert_eq!(status.durable, memory_context::DurableStatus::Off);
7742 assert_memory_off_no_lease(&runtime);
7743 assert_eq!(
7744 ext.lease_count(),
7745 0,
7746 "revocation of a never-spawned lease is a no-op"
7747 );
7748
7749 let status = runtime.memory_context_disable();
7751 assert_eq!(status.durable, memory_context::DurableStatus::Off);
7752 assert_eq!(ext.lease_count(), 0);
7753 }
7754
7755 #[test]
7760 fn memory_context_enable_disable_emit_metadata_only_events() {
7761 memory_context::drain_captured_memory_events_for_test();
7762
7763 let runtime = Runtime::new_headless();
7764 runtime
7766 .memory_context_enable(
7767 memory_context::MemoryContextMode::Off,
7768 memory_context::mint_explicit_command_proof(),
7769 )
7770 .expect_err("Off is not an enable mode");
7771 assert!(
7772 memory_context::drain_captured_memory_events_for_test().is_empty(),
7773 "failed enable must emit no event"
7774 );
7775
7776 runtime
7777 .memory_context_enable(
7778 memory_context::MemoryContextMode::CaptureAndRecall,
7779 memory_context::mint_explicit_command_proof(),
7780 )
7781 .expect("enable succeeds");
7782 runtime.memory_context_disable();
7783
7784 let events = memory_context::drain_captured_memory_events_for_test();
7785 assert_eq!(
7786 events
7787 .iter()
7788 .map(|event| (event.event, event.outcome))
7789 .collect::<Vec<_>>(),
7790 vec![
7791 ("memory_context.enabled", "enabled"),
7792 ("memory_context.disabled", "disabled"),
7793 ],
7794 );
7795 assert_eq!(events[0].mode, Some("capture_and_recall"));
7796 assert_eq!(events[1].mode, Some("off"));
7797
7798 let cwd = std::env::current_dir()
7801 .expect("cwd")
7802 .to_string_lossy()
7803 .into_owned();
7804 for event in &events {
7805 let json = serde_json::to_string(event).expect("event serializes");
7806 assert!(
7807 json.contains("\"project_digest\":\"p") || json.contains("project-cwd-"),
7808 "digest identity expected; got: {json}"
7809 );
7810 assert!(
7811 !json.contains(&cwd),
7812 "raw project path leaked into event JSON: {json}"
7813 );
7814 }
7815 }
7816
7817 #[tokio::test]
7823 async fn memory_session_end_guard_drop_fresh_construction_is_off_no_lease() {
7824 let (runtime, ext) =
7825 memory_runtime_with_providers(&[("axel-memory-manager", "project-memory")]).await;
7826 runtime
7827 .memory_context_enable(
7828 memory_context::MemoryContextMode::CaptureAndRecall,
7829 memory_context::mint_explicit_command_proof(),
7830 )
7831 .expect("enable succeeds");
7832 drop(runtime);
7834 assert_eq!(
7835 ext.lease_count(),
7836 0,
7837 "session end must leave no runtime lease (never-spawned reap is a no-op)"
7838 );
7839
7840 let mut fresh = Runtime::new_headless();
7842 fresh.install_extension_runtime(std::sync::Arc::clone(&ext));
7843 assert_memory_off_no_lease(&fresh);
7844 assert_eq!(ext.lease_count(), 0);
7845 }
7846}