1#[cfg(feature = "shared-composition")]
7use std::collections::HashSet;
8use std::path::PathBuf;
9use std::sync::Arc;
10
11#[cfg(feature = "shared-composition")]
12use crate::composition::SharedToolProfile;
13use async_trait::async_trait;
14use serde_json::Value;
15use talos_agent::permission_pipeline::{
16 ApprovalResolver, ApprovalResolverError, PermissionApprovalRequest,
17};
18use talos_agent::session::{AppServerSession, RuntimeAdmissionControl};
19use talos_agent::{Agent, AgentError, SandboxFallbackHandler};
20use talos_core::ApprovalChoice;
21use talos_core::message::Message;
22use talos_core::provider::LanguageModel;
23use talos_core::session::{
24 RuntimePolicy, SessionConfig, SessionEvent, SessionOp, TurnCompletionStatus,
25};
26use talos_core::tool::{AgentTool, ToolRegistry};
27#[cfg(test)]
28use talos_core::tool::{
29 ToolExecutionAuthorization, ToolExecutionOutput, ToolPermissionFacet, ToolResult,
30};
31use talos_permission::{
32 GrantPreview, GrantSource, InteractionCapability, PermissionContext, PermissionEngine,
33 PermissionMode, PermissionRule, PermissionSessionState,
34};
35#[cfg(test)]
36use talos_permission::{GrantScope, PermissionDecision, PermissionRequest};
37use talos_plugin::HookRegistry;
38use talos_sandbox::SandboxProvider;
39use talos_session::{DurableSession, PersistencePolicy};
40use talos_skill::SkillIndex;
41use thiserror::Error;
42use tokio::sync::mpsc;
43
44#[cfg(feature = "language-provider")]
46pub struct LanguageProviderHost {
47 context: talos_text::SharedLanguageProvider,
48}
49
50#[cfg(feature = "language-provider")]
51impl Clone for LanguageProviderHost {
52 fn clone(&self) -> Self {
53 Self {
54 context: self.context.clone(),
55 }
56 }
57}
58
59#[cfg(feature = "language-provider")]
60impl LanguageProviderHost {
61 pub fn new(provider: Box<dyn talos_text::LanguageProviderBundle>) -> Self {
63 Self {
64 context: talos_text::SharedLanguageProvider::new(provider),
65 }
66 }
67
68 pub fn context(&self) -> &talos_text::SharedLanguageProvider {
70 &self.context
71 }
72
73 pub fn shared_context(&self) -> talos_text::SharedLanguageProvider {
75 self.context.clone()
76 }
77
78 pub fn with_symbols<R>(
80 &self,
81 operation: impl FnOnce(&mut dyn talos_text::SymbolProvider) -> R,
82 ) -> Option<R> {
83 self.context.with_symbols(operation)
84 }
85}
86
87mod shutdown;
88
89pub use shutdown::{
90 ActiveTurnPolicy, RuntimeShutdownHandle, ShutdownActiveTurnOutcome, ShutdownActorOutcome,
91 ShutdownDurableOutcome, ShutdownFinalizerId, ShutdownFinalizerOutcome,
92 ShutdownFinalizerRegistryError, ShutdownFinalizerReport, ShutdownOptions, ShutdownOptionsError,
93 ShutdownPlanId, ShutdownReport,
94};
95use shutdown::{RuntimeFinalizer, RuntimeFinalizerRegistry, ShutdownCoordinator};
96
97struct BackgroundJobsRuntimeFinalizer {
98 handle: talos_agent::session::BackgroundJobFinalizerHandle,
99}
100
101impl RuntimeFinalizer for BackgroundJobsRuntimeFinalizer {
102 fn identifier(&self) -> ShutdownFinalizerId {
103 ShutdownFinalizerId::new("background_jobs")
104 }
105
106 fn order(&self) -> u16 {
107 100
108 }
109
110 fn cap(&self) -> std::time::Duration {
111 std::time::Duration::from_secs(3)
112 }
113
114 fn finalize(&self) -> shutdown::RuntimeFinalizerFuture {
115 let handle = self.handle.clone();
116 Box::pin(async move {
117 handle
118 .finalize()
119 .await
120 .map_err(|_| shutdown::RuntimeFinalizerError)
121 })
122 }
123}
124
125#[cfg(feature = "shared-composition")]
126#[doc(hidden)]
127pub mod composition;
128
129pub use talos_agent::evaluator::{
130 EvaluatorAdmission, EvaluatorAssessor, EvaluatorError, EvaluatorFailure, EvaluatorOutcome,
131 EvaluatorRequest, IndependentEvaluator, ProviderEvaluatorAssessor, ValidationEvidence,
132 ValidationEvidenceStatus,
133};
134pub use talos_agent::{SandboxFallbackContext, SandboxFallbackDecision, SandboxFallbackPolicy};
135pub use talos_core::message::{AgentEvent, MessageToolResult, StopReason, ToolCall, Usage};
136pub use talos_core::provider::{ProviderError, ToolDefinition};
137pub use talos_core::session::TurnCompletionStatus as RuntimeTurnCompletionStatus;
138pub use talos_core::tool::{ToolNature, ToolProvenance};
139pub use talos_plugin::HookRegistry as RuntimeHookRegistry;
140pub use talos_skill::SkillIndex as RuntimeSkillIndex;
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum RuntimePreset {
145 Coding,
147}
148
149impl RuntimePreset {
150 #[must_use]
152 pub const fn coding() -> Self {
153 Self::Coding
154 }
155}
156
157#[derive(Debug, Error)]
159#[non_exhaustive]
160pub enum RuntimeError {
161 #[error("runtime provider is required")]
163 MissingProvider,
164
165 #[error("runtime command channel is closed")]
167 CommandChannelClosed,
168
169 #[error("runtime is closing")]
171 RuntimeClosing,
172
173 #[error("runtime construction requires an active Tokio runtime")]
175 AsyncRuntimeUnavailable,
176
177 #[error("runtime actor failed: {0}")]
179 ActorJoin(#[from] tokio::task::JoinError),
180
181 #[error("agent error: {0}")]
183 Agent(#[from] AgentError),
184
185 #[error("RuntimePreset::coding() requires the `shared-composition` feature")]
187 CodingPresetRequiresFeature,
188
189 #[error("durable session error: {0}")]
191 Session(#[from] talos_session::SessionError),
192
193 #[error("runtime shutdown did not complete")]
195 ShutdownIncomplete {
196 report: ShutdownReport,
198 },
199
200 #[error("invalid runtime shutdown finalizer registry: {0}")]
202 InvalidShutdownFinalizerRegistry(#[from] ShutdownFinalizerRegistryError),
203}
204
205pub type RuntimeResult<T> = Result<T, RuntimeError>;
207
208#[async_trait]
215pub trait ApprovalHandler: Send + Sync {
216 async fn request_approval(
219 &self,
220 tool_name: &str,
221 arguments: &Value,
222 summary_fields: &[String],
223 ) -> ApprovalChoice;
224
225 async fn request_scoped_approval(
229 &self,
230 tool_name: &str,
231 arguments: &Value,
232 summary_fields: &[String],
233 _preview: &GrantPreview,
234 ) -> ApprovalChoice {
235 self.request_approval(tool_name, arguments, summary_fields)
236 .await
237 }
238
239 async fn request_sandbox_fallback(
243 &self,
244 _context: &SandboxFallbackContext,
245 ) -> SandboxFallbackDecision {
246 SandboxFallbackDecision::Deny
247 }
248}
249
250struct RuntimeSandboxFallbackHandler {
251 inner: Arc<dyn ApprovalHandler>,
252}
253
254struct RuntimeApprovalResolver {
255 inner: Arc<dyn ApprovalHandler>,
256}
257
258#[async_trait]
259impl ApprovalResolver for RuntimeApprovalResolver {
260 async fn resolve(
261 &self,
262 request: PermissionApprovalRequest,
263 _remaining: std::time::Duration,
264 ) -> Result<ApprovalChoice, ApprovalResolverError> {
265 Ok(self
266 .inner
267 .request_scoped_approval(
268 &request.tool_name,
269 &request.arguments,
270 &request.summary_fields,
271 &request.preview,
272 )
273 .await)
274 }
275
276 fn grant_source(&self) -> GrantSource {
277 GrantSource::SdkHostApproval
278 }
279}
280
281struct RuntimeActorExitGuard {
282 admission: RuntimeAdmissionControl,
283}
284
285impl Drop for RuntimeActorExitGuard {
286 fn drop(&mut self) {
287 self.admission.record_actor_stopped();
288 }
289}
290
291#[async_trait]
292impl SandboxFallbackHandler for RuntimeSandboxFallbackHandler {
293 async fn request_fallback(&self, context: SandboxFallbackContext) -> SandboxFallbackDecision {
294 self.inner.request_sandbox_fallback(&context).await
295 }
296}
297
298pub struct RuntimeBuilder {
304 provider: Option<Arc<dyn LanguageModel>>,
305 tools: Vec<Arc<dyn AgentTool>>,
306 workspace_root: PathBuf,
307 permission_rules: Vec<PermissionRule>,
308 sandbox: Option<Box<dyn SandboxProvider>>,
309 sandbox_fallback_policy: SandboxFallbackPolicy,
310 preset: Option<RuntimePreset>,
311 initial_history: Vec<Message>,
312 model_context_limit: u32,
313 approval_handler: Option<Arc<dyn ApprovalHandler>>,
314 custom_prompt: Option<String>,
315 append_prompt: Option<String>,
316 hook_registry: Option<Arc<HookRegistry>>,
317 skill_index: Vec<SkillIndex>,
318 durable_session: Option<(DurableSession, PersistencePolicy)>,
319 shutdown_finalizers: Vec<Arc<dyn RuntimeFinalizer>>,
320 #[cfg(feature = "shared-composition")]
321 atomic_create_capability: Option<talos_core::tool::SharedAtomicCreateCapability>,
322}
323
324impl RuntimeBuilder {
325 #[must_use]
328 pub fn new() -> Self {
329 Self {
330 provider: None,
331 tools: Vec::new(),
332 workspace_root: PathBuf::from("."),
333 permission_rules: Vec::new(),
334 sandbox: None,
335 sandbox_fallback_policy: SandboxFallbackPolicy::Deny,
336 preset: None,
337 initial_history: Vec::new(),
338 model_context_limit: 128_000,
339 approval_handler: None,
340 custom_prompt: None,
341 append_prompt: None,
342 hook_registry: None,
343 skill_index: Vec::new(),
344 durable_session: None,
345 shutdown_finalizers: Vec::new(),
346 #[cfg(feature = "shared-composition")]
347 atomic_create_capability: None,
348 }
349 }
350
351 #[must_use]
353 pub fn provider(mut self, provider: Arc<dyn LanguageModel>) -> Self {
354 self.provider = Some(provider);
355 self
356 }
357
358 #[must_use]
360 pub fn workspace_root(mut self, root: impl Into<PathBuf>) -> Self {
361 self.workspace_root = root.into();
362 self
363 }
364
365 #[must_use]
367 pub fn tool(mut self, tool: Arc<dyn AgentTool>) -> Self {
368 self.tools.push(tool);
369 self
370 }
371
372 #[cfg(feature = "shared-composition")]
378 #[must_use]
379 pub fn shared_tools(mut self) -> Self {
380 self.tools.extend(
381 composition::tool_contributions_with_capability(
382 SharedToolProfile::Runtime,
383 self.workspace_root.clone(),
384 self.atomic_create_capability.clone(),
385 )
386 .into_iter()
387 .map(|contribution| contribution.tool().clone()),
388 );
389 self
390 }
391
392 #[cfg(feature = "shared-composition")]
394 #[must_use]
395 pub fn atomic_create_capability(
396 mut self,
397 capability: talos_core::tool::SharedAtomicCreateCapability,
398 ) -> Self {
399 self.atomic_create_capability = Some(capability);
400 self
401 }
402
403 #[must_use]
410 pub fn permission_rule(mut self, rule: PermissionRule) -> Self {
411 self.permission_rules.push(rule);
412 self
413 }
414
415 #[must_use]
417 pub fn sandbox(mut self, sandbox: Box<dyn SandboxProvider>) -> Self {
418 self.sandbox = Some(sandbox);
419 self
420 }
421
422 #[must_use]
424 pub fn sandbox_fallback_policy(mut self, policy: SandboxFallbackPolicy) -> Self {
425 self.sandbox_fallback_policy = policy;
426 self
427 }
428
429 #[must_use]
431 pub fn sandbox_fallback(self, policy: SandboxFallbackPolicy) -> Self {
432 self.sandbox_fallback_policy(policy)
433 }
434
435 #[must_use]
437 pub fn preset(mut self, preset: RuntimePreset) -> Self {
438 self.preset = Some(preset);
439 self
440 }
441
442 #[must_use]
444 pub fn coding_preset(self) -> Self {
445 self.preset(RuntimePreset::coding())
446 }
447
448 #[must_use]
450 pub fn initial_history(mut self, history: Vec<Message>) -> Self {
451 self.initial_history = history;
452 self
453 }
454
455 #[must_use]
457 pub fn model_context_limit(mut self, limit: u32) -> Self {
458 self.model_context_limit = limit;
459 self
460 }
461
462 #[must_use]
469 pub fn approval_handler(mut self, handler: Arc<dyn ApprovalHandler>) -> Self {
470 self.approval_handler = Some(handler);
471 self
472 }
473
474 #[must_use]
480 pub fn custom_prompt(mut self, prompt: impl Into<String>) -> Self {
481 self.custom_prompt = Some(prompt.into());
482 self
483 }
484
485 #[must_use]
487 pub fn append_prompt(mut self, prompt: impl Into<String>) -> Self {
488 self.append_prompt = Some(prompt.into());
489 self
490 }
491
492 #[must_use]
500 pub fn durable_session(mut self, session: DurableSession) -> Self {
501 self.durable_session = Some((session, PersistencePolicy::default()));
502 self
503 }
504
505 #[must_use]
507 pub fn durable_session_with_policy(
508 mut self,
509 session: DurableSession,
510 policy: PersistencePolicy,
511 ) -> Self {
512 self.durable_session = Some((session, policy));
513 self
514 }
515
516 #[must_use]
524 pub fn hook_registry(mut self, registry: Arc<HookRegistry>) -> Self {
525 self.hook_registry = Some(registry);
526 self
527 }
528
529 #[must_use]
537 pub fn skill_index(mut self, skills: Vec<SkillIndex>) -> Self {
538 self.skill_index = skills;
539 self
540 }
541
542 #[cfg(test)]
543 fn runtime_finalizer(mut self, finalizer: Arc<dyn RuntimeFinalizer>) -> Self {
544 self.shutdown_finalizers.push(finalizer);
545 self
546 }
547
548 pub fn build(mut self) -> RuntimeResult<RuntimeHandle> {
555 let provider = self.provider.ok_or(RuntimeError::MissingProvider)?;
556 #[allow(unused_mut)]
557 let mut tools = self.tools;
558 if matches!(self.preset, Some(RuntimePreset::Coding)) {
559 #[cfg(feature = "shared-composition")]
560 {
561 let caller_tool_names = tools
564 .iter()
565 .map(|tool| tool.name().to_owned())
566 .collect::<HashSet<_>>();
567 tools.extend(
568 composition::runtime_tool_contributions(self.workspace_root.clone())
569 .into_iter()
570 .filter(|contribution| !caller_tool_names.contains(contribution.name()))
571 .map(|contribution| contribution.tool().clone()),
572 );
573 }
574 #[cfg(not(feature = "shared-composition"))]
575 return Err(RuntimeError::CodingPresetRequiresFeature);
576 }
577 let agent_engine = Arc::new(build_permission_engine(
578 self.workspace_root.clone(),
579 &self.permission_rules,
580 ));
581 let mut registry = ToolRegistry::new();
582 let approval_handler = self.approval_handler.clone();
583 for tool in tools {
584 registry.register(tool);
585 }
586
587 let fallback_handler = self.approval_handler.as_ref().map(|handler| {
588 Arc::new(RuntimeSandboxFallbackHandler {
589 inner: handler.clone(),
590 }) as Arc<dyn SandboxFallbackHandler>
591 });
592 let mut agent = if let Some(hooks) = self.hook_registry {
593 Agent::with_security_and_hooks_and_sandbox_fallback(
594 provider,
595 registry,
596 None,
597 self.sandbox,
598 self.workspace_root.clone(),
599 hooks,
600 self.sandbox_fallback_policy,
601 fallback_handler,
602 )
603 } else {
604 Agent::with_security_and_sandbox_fallback(
605 provider,
606 registry,
607 None,
608 self.sandbox,
609 self.workspace_root.clone(),
610 self.sandbox_fallback_policy,
611 fallback_handler,
612 )
613 };
614 let resolver = approval_handler.map(|handler| {
615 Arc::new(RuntimeApprovalResolver { inner: handler }) as Arc<dyn ApprovalResolver>
616 });
617 agent = agent.with_permission_pipeline(
618 Arc::new(PermissionSessionState::new((*agent_engine).clone())),
619 PermissionContext::new(
620 PermissionMode::Headless,
621 if resolver.is_some() {
622 InteractionCapability::Available
623 } else {
624 InteractionCapability::Unavailable
625 },
626 ),
627 resolver,
628 );
629 if let Some(prompt) = self.custom_prompt {
630 agent.set_custom_prompt(prompt);
631 }
632 if let Some(prompt) = self.append_prompt {
633 agent.set_append_prompt(prompt);
634 }
635 if !self.skill_index.is_empty() {
636 agent.set_skill_index(self.skill_index);
637 }
638 let initial_history = if let Some((session, _)) = &self.durable_session {
639 session.read_messages()?
640 } else {
641 self.initial_history
642 };
643 let config = SessionConfig {
644 runtime_policy: RuntimePolicy::headless_deny(),
645 workspace_root: self.workspace_root,
646 initial_history,
647 model_context_limit: self.model_context_limit,
648 };
649 let (handle, mut actor) = AppServerSession::new(agent, config);
650 self.shutdown_finalizers
651 .push(Arc::new(BackgroundJobsRuntimeFinalizer {
652 handle: actor.background_job_finalizer(),
653 }));
654 let finalizers = RuntimeFinalizerRegistry::freeze(self.shutdown_finalizers)?;
655 let admission = talos_agent::session::RuntimeAdmissionControl::new();
656 actor.set_runtime_admission(admission.clone());
657 if let Some((session, policy)) = self.durable_session {
658 actor.set_durable_persistence(session, policy);
659 }
660 let runtime = tokio::runtime::Handle::try_current()
661 .map_err(|_| RuntimeError::AsyncRuntimeUnavailable)?;
662 let actor_exit_guard = RuntimeActorExitGuard {
663 admission: admission.clone(),
664 };
665 let actor_task = runtime.spawn(async move {
666 let _actor_exit_guard = actor_exit_guard;
667 actor.run().await;
668 });
669 let coordinator = ShutdownCoordinator::new(
670 admission,
671 handle.sq_tx.clone(),
672 actor_task,
673 runtime,
674 finalizers,
675 );
676
677 Ok(RuntimeHandle {
678 command_tx: handle.sq_tx,
679 event_rx: handle.eq_rx,
680 coordinator,
681 primary_drop_armed: true,
682 })
683 }
684}
685
686impl Default for RuntimeBuilder {
687 fn default() -> Self {
688 Self::new()
689 }
690}
691
692pub struct RuntimeHandle {
694 command_tx: mpsc::Sender<SessionOp>,
695 event_rx: mpsc::UnboundedReceiver<SessionEvent>,
696 coordinator: Arc<ShutdownCoordinator>,
697 primary_drop_armed: bool,
698}
699
700impl RuntimeHandle {
701 pub async fn submit(&self, message: impl Into<String>) -> RuntimeResult<()> {
703 if !self.coordinator.is_admission_open() {
704 return Err(RuntimeError::RuntimeClosing);
705 }
706 let permit = self.command_tx.reserve().await.map_err(|_| {
707 if self.coordinator.is_admission_open() {
708 RuntimeError::CommandChannelClosed
709 } else {
710 RuntimeError::RuntimeClosing
711 }
712 })?;
713 self.coordinator
714 .commit_reserved(
715 permit,
716 SessionOp::Submit {
717 message: message.into(),
718 },
719 )
720 .map_err(|_| RuntimeError::RuntimeClosing)
721 }
722
723 pub async fn preview_request(&self, message: impl Into<String>) -> RuntimeResult<()> {
725 if !self.coordinator.is_admission_open() {
726 return Err(RuntimeError::RuntimeClosing);
727 }
728 let permit = self.command_tx.reserve().await.map_err(|_| {
729 if self.coordinator.is_admission_open() {
730 RuntimeError::CommandChannelClosed
731 } else {
732 RuntimeError::RuntimeClosing
733 }
734 })?;
735 self.coordinator
736 .commit_reserved(
737 permit,
738 SessionOp::PreviewRequest {
739 message: message.into(),
740 },
741 )
742 .map_err(|_| RuntimeError::RuntimeClosing)
743 }
744
745 pub async fn interrupt(&self) -> RuntimeResult<()> {
747 self.command_tx
748 .send(SessionOp::Interrupt)
749 .await
750 .map_err(|_| RuntimeError::CommandChannelClosed)
751 }
752
753 pub async fn next_event(&mut self) -> Option<SessionEvent> {
755 self.event_rx.recv().await
756 }
757
758 #[must_use]
760 pub fn shutdown_controller(&self) -> RuntimeShutdownHandle {
761 RuntimeShutdownHandle {
762 coordinator: self.coordinator.clone(),
763 }
764 }
765
766 pub async fn shutdown_with(&self, options: ShutdownOptions) -> RuntimeResult<ShutdownReport> {
768 self.coordinator.shutdown(options).await
769 }
770
771 pub async fn shutdown(mut self) -> RuntimeResult<()> {
773 self.primary_drop_armed = false;
774 let report = self
775 .coordinator
776 .shutdown(ShutdownOptions::legacy_default())
777 .await?;
778 if let Some(error) = self.coordinator.take_actor_join_error() {
779 return Err(RuntimeError::ActorJoin(error));
780 }
781 if report.is_complete() {
782 Ok(())
783 } else {
784 Err(RuntimeError::ShutdownIncomplete { report })
785 }
786 }
787}
788
789impl Drop for RuntimeHandle {
790 fn drop(&mut self) {
791 if self.primary_drop_armed {
792 self.primary_drop_armed = false;
793 self.coordinator.initiate_default();
794 }
795 }
796}
797
798fn build_permission_engine(root: PathBuf, rules: &[PermissionRule]) -> PermissionEngine {
799 let mut engine = PermissionEngine::from_rules(rules.to_vec());
800 engine.set_workspace_root(root);
801 engine
802}
803
804#[cfg(test)]
805struct RuntimePermissionAwareTool {
806 inner: Arc<dyn AgentTool>,
807 permission_state: Arc<PermissionSessionState>,
808 approval_handler: Option<Arc<dyn ApprovalHandler>>,
809}
810
811#[cfg(test)]
812impl RuntimePermissionAwareTool {
813 async fn authorize(
814 &self,
815 input: &Value,
816 profile: &[ToolPermissionFacet],
817 ) -> Result<Vec<ToolExecutionAuthorization>, ToolResult> {
818 let interaction = if self.approval_handler.is_some() {
819 InteractionCapability::Available
820 } else {
821 InteractionCapability::Unavailable
822 };
823 let context = PermissionContext::new(PermissionMode::Headless, interaction);
824 let provenance = self.inner.provenance();
825 let request = PermissionRequest::new(self.inner.name(), provenance, profile, input);
826 let evaluation = self
827 .permission_state
828 .evaluate(&request, &context)
829 .map_err(permission_error)?;
830
831 let pending = match evaluation.decision() {
832 PermissionDecision::Allow => self
833 .permission_state
834 .prepare_authorized(&request, &context)
835 .map_err(permission_error)?
836 .ok_or_else(|| permission_denied("request is not authorized"))?,
837 PermissionDecision::Deny(reason) => {
838 return Err(permission_denied(&reason));
839 }
840 PermissionDecision::Ask => {
841 let Some(handler) = &self.approval_handler else {
842 return Err(permission_denied(
843 "approval required but no runtime approval handler is configured",
844 ));
845 };
846 let once = self
847 .permission_state
848 .propose(&request, &context, GrantScope::Once)
849 .map_err(permission_error)?;
850 let session = self
851 .permission_state
852 .propose(&request, &context, GrantScope::Session)
853 .map_err(permission_error)?;
854 let summary_fields = self
855 .inner
856 .summary_fields()
857 .iter()
858 .map(|field| (*field).to_string())
859 .collect::<Vec<_>>();
860 match handler
861 .request_scoped_approval(
862 self.inner.name(),
863 &self.inner.project_input(input),
864 &summary_fields,
865 session.preview(),
866 )
867 .await
868 {
869 ApprovalChoice::ApproveOnce => self
870 .permission_state
871 .approve_once(once, &request, &context)
872 .map_err(permission_error)?,
873 ApprovalChoice::AlwaysApprove => self
874 .permission_state
875 .approve_session(session, &request, &context, GrantSource::SdkHostApproval)
876 .map_err(permission_error)?,
877 ApprovalChoice::Deny => return Err(permission_denied("User denied")),
878 }
879 }
880 };
881
882 self.permission_state
883 .admit(pending, &request, &context)
884 .map_err(permission_error)
885 }
886}
887
888#[cfg(test)]
889fn permission_error(error: impl std::fmt::Display) -> ToolResult {
890 permission_denied(&error.to_string())
891}
892
893#[cfg(test)]
894fn permission_denied(reason: &str) -> ToolResult {
895 ToolResult::error(format!("Permission denied: {reason}"))
896}
897
898#[async_trait]
899#[cfg(test)]
900impl AgentTool for RuntimePermissionAwareTool {
901 fn name(&self) -> &str {
902 self.inner.name()
903 }
904
905 fn description(&self) -> &str {
906 self.inner.description()
907 }
908
909 fn parameters(&self) -> Value {
910 self.inner.parameters()
911 }
912
913 async fn execute(&self, input: Value) -> ToolResult {
914 let profile = self.inner.permission_profile(&input);
915 let authorizations = match self.authorize(&input, &profile).await {
916 Ok(authorizations) => authorizations,
917 Err(error) => return error,
918 };
919 self.inner.execute_authorized(input, &authorizations).await
920 }
921
922 async fn execute_with_output(&self, input: Value) -> ToolExecutionOutput {
923 let profile = self.inner.permission_profile(&input);
924 let authorizations = match self.authorize(&input, &profile).await {
925 Ok(authorizations) => authorizations,
926 Err(error) => return ToolExecutionOutput::from_result(error),
927 };
928 self.inner
929 .execute_authorized_with_output(input, &authorizations)
930 .await
931 }
932
933 fn is_read_only(&self) -> bool {
934 self.inner.is_read_only()
935 }
936
937 fn nature(&self) -> talos_core::tool::ToolNature {
938 self.inner.nature()
939 }
940
941 fn family(&self) -> talos_core::tool::ToolFamily {
942 self.inner.family()
943 }
944
945 fn is_always_on(&self) -> bool {
946 self.inner.is_always_on()
947 }
948
949 fn permission_profile(&self, input: &Value) -> Vec<talos_core::tool::ToolPermissionFacet> {
950 self.inner.permission_profile(input)
951 }
952
953 fn summary_fields(&self) -> &'static [&'static str] {
954 self.inner.summary_fields()
955 }
956
957 fn project_input(&self, input: &Value) -> Value {
958 self.inner.project_input(input)
959 }
960
961 fn project_result(&self, result: &ToolResult) -> talos_core::tool::ToolResultProjection {
962 self.inner.project_result(result)
963 }
964
965 fn provenance(&self) -> talos_core::tool::ToolProvenance {
966 self.inner.provenance()
967 }
968}
969
970pub async fn collect_until_turn_completed(
975 runtime: &mut RuntimeHandle,
976) -> Option<TurnCompletionStatus> {
977 while let Some(event) = runtime.next_event().await {
978 if let SessionEvent::TurnEvent {
979 payload: talos_core::session::TurnEventPayload::Completed { status },
980 ..
981 } = event
982 {
983 return Some(status);
984 }
985 }
986 None
987}
988
989#[cfg(test)]
990mod tests {
991 use std::sync::Mutex as StdMutex;
992 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
993 use std::time::Duration;
994
995 use talos_core::message::Message;
996 use talos_core::provider::ProviderResult;
997 use talos_core::tool::{ToolNature, ToolPermissionFacet, ToolResourceKind};
998 use talos_permission::PermissionDecision;
999 use talos_provider::mock::MockProvider;
1000 use talos_session::SessionManager;
1001 use talos_tools::{ReadTool, snapshot_aware_file_tools};
1002 use tokio::sync::Notify;
1003
1004 use super::*;
1005
1006 struct RecordingWriteTool {
1007 executions: Arc<AtomicUsize>,
1008 }
1009
1010 struct RecordingHybridTool {
1011 executions: Arc<AtomicUsize>,
1012 }
1013
1014 struct PrivateInputWriteTool;
1015
1016 struct PrivateResultReadTool;
1017
1018 struct GatedModel {
1019 entered: Arc<Notify>,
1020 release: Arc<Notify>,
1021 }
1022
1023 #[derive(Clone)]
1024 enum TestFinalizerBehavior {
1025 Complete,
1026 Fail,
1027 Panic,
1028 Pending(Arc<AtomicBool>),
1029 }
1030
1031 struct TestFinalizer {
1032 identifier: ShutdownFinalizerId,
1033 order: u16,
1034 cap: Duration,
1035 behavior: TestFinalizerBehavior,
1036 starts: Arc<StdMutex<Vec<&'static str>>>,
1037 }
1038
1039 impl RuntimeFinalizer for TestFinalizer {
1040 fn identifier(&self) -> ShutdownFinalizerId {
1041 self.identifier
1042 }
1043
1044 fn order(&self) -> u16 {
1045 self.order
1046 }
1047
1048 fn cap(&self) -> Duration {
1049 self.cap
1050 }
1051
1052 fn finalize(&self) -> shutdown::RuntimeFinalizerFuture {
1053 let identifier = self.identifier.as_str();
1054 let starts = self.starts.clone();
1055 let behavior = self.behavior.clone();
1056 Box::pin(async move {
1057 starts
1058 .lock()
1059 .unwrap_or_else(std::sync::PoisonError::into_inner)
1060 .push(identifier);
1061 match behavior {
1062 TestFinalizerBehavior::Complete => Ok(()),
1063 TestFinalizerBehavior::Fail => Err(shutdown::RuntimeFinalizerError),
1064 TestFinalizerBehavior::Panic => panic!("intentional runtime finalizer panic"),
1065 TestFinalizerBehavior::Pending(cancelled) => {
1066 struct CancellationMarker(Arc<AtomicBool>);
1067 impl Drop for CancellationMarker {
1068 fn drop(&mut self) {
1069 self.0.store(true, Ordering::SeqCst);
1070 }
1071 }
1072 let _marker = CancellationMarker(cancelled);
1073 std::future::pending::<()>().await;
1074 Ok(())
1075 }
1076 }
1077 })
1078 }
1079 }
1080
1081 fn test_finalizer(
1082 identifier: &'static str,
1083 order: u16,
1084 cap: Duration,
1085 behavior: TestFinalizerBehavior,
1086 starts: Arc<StdMutex<Vec<&'static str>>>,
1087 ) -> Arc<dyn RuntimeFinalizer> {
1088 Arc::new(TestFinalizer {
1089 identifier: ShutdownFinalizerId::new(identifier),
1090 order,
1091 cap,
1092 behavior,
1093 starts,
1094 })
1095 }
1096
1097 #[async_trait]
1098 impl LanguageModel for GatedModel {
1099 async fn stream(
1100 &self,
1101 _messages: &[Message],
1102 ) -> ProviderResult<mpsc::Receiver<AgentEvent>> {
1103 let (tx, rx) = mpsc::channel(8);
1104 let release = self.release.clone();
1105 self.entered.notify_one();
1106 tokio::spawn(async move {
1107 release.notified().await;
1108 let events = [
1109 AgentEvent::TurnStart,
1110 AgentEvent::TextDelta {
1111 delta: "finished".into(),
1112 },
1113 AgentEvent::TurnEnd {
1114 stop_reason: StopReason::EndTurn,
1115 usage: Usage::default(),
1116 },
1117 ];
1118 for event in events {
1119 if tx.send(event).await.is_err() {
1120 break;
1121 }
1122 }
1123 });
1124 Ok(rx)
1125 }
1126 }
1127
1128 async fn gated_runtime() -> (RuntimeHandle, Arc<Notify>, Arc<Notify>) {
1129 let entered = Arc::new(Notify::new());
1130 let release = Arc::new(Notify::new());
1131 let runtime = RuntimeBuilder::new()
1132 .provider(Arc::new(GatedModel {
1133 entered: entered.clone(),
1134 release: release.clone(),
1135 }))
1136 .build()
1137 .expect("gated runtime builds");
1138 (runtime, entered, release)
1139 }
1140
1141 #[test]
1142 fn shutdown_options_reject_invalid_drafts_before_runtime_access() {
1143 assert_eq!(
1144 ShutdownOptions::interrupt(Duration::ZERO),
1145 Err(ShutdownOptionsError::ZeroTotalTimeout)
1146 );
1147 assert_eq!(
1148 ShutdownOptions::finish_current(Duration::from_secs(1), Duration::from_secs(1)),
1149 Err(ShutdownOptionsError::FinishGraceNotLessThanTotal)
1150 );
1151 assert_eq!(
1152 ShutdownOptions::interrupt(Duration::MAX),
1153 Err(ShutdownOptionsError::TotalTimeoutOutOfRange)
1154 );
1155 }
1156
1157 #[test]
1158 fn runtime_build_freezes_and_validates_finalizer_identity_and_order() {
1159 let starts = Arc::new(StdMutex::new(Vec::new()));
1160 let duplicate_identifier = RuntimeBuilder::new()
1161 .provider(Arc::new(MockProvider::new()))
1162 .runtime_finalizer(test_finalizer(
1163 "test.duplicate",
1164 10,
1165 Duration::from_secs(1),
1166 TestFinalizerBehavior::Complete,
1167 starts.clone(),
1168 ))
1169 .runtime_finalizer(test_finalizer(
1170 "test.duplicate",
1171 20,
1172 Duration::from_secs(1),
1173 TestFinalizerBehavior::Complete,
1174 starts.clone(),
1175 ))
1176 .build();
1177 assert!(matches!(
1178 duplicate_identifier,
1179 Err(RuntimeError::InvalidShutdownFinalizerRegistry(
1180 ShutdownFinalizerRegistryError::DuplicateIdentifier
1181 ))
1182 ));
1183
1184 let duplicate_order = RuntimeBuilder::new()
1185 .provider(Arc::new(MockProvider::new()))
1186 .runtime_finalizer(test_finalizer(
1187 "test.first",
1188 10,
1189 Duration::from_secs(1),
1190 TestFinalizerBehavior::Complete,
1191 starts.clone(),
1192 ))
1193 .runtime_finalizer(test_finalizer(
1194 "test.second",
1195 10,
1196 Duration::from_secs(1),
1197 TestFinalizerBehavior::Complete,
1198 starts.clone(),
1199 ))
1200 .build();
1201 assert!(matches!(
1202 duplicate_order,
1203 Err(RuntimeError::InvalidShutdownFinalizerRegistry(
1204 ShutdownFinalizerRegistryError::DuplicateOrder
1205 ))
1206 ));
1207
1208 let zero_cap = RuntimeBuilder::new()
1209 .provider(Arc::new(MockProvider::new()))
1210 .runtime_finalizer(test_finalizer(
1211 "test.zero-cap",
1212 10,
1213 Duration::ZERO,
1214 TestFinalizerBehavior::Complete,
1215 starts,
1216 ))
1217 .build();
1218 assert!(matches!(
1219 zero_cap,
1220 Err(RuntimeError::InvalidShutdownFinalizerRegistry(
1221 ShutdownFinalizerRegistryError::ZeroCap
1222 ))
1223 ));
1224 }
1225
1226 #[tokio::test]
1227 async fn durable_reconciliation_precedes_ordered_finalizers() {
1228 let admission = talos_agent::session::RuntimeAdmissionControl::new();
1229 let actor_admission = admission.clone();
1230 let (command_tx, mut command_rx) = mpsc::channel(1);
1231 let order = Arc::new(StdMutex::new(Vec::new()));
1232 let actor_order = order.clone();
1233 let actor_task = tokio::spawn(async move {
1234 assert!(matches!(command_rx.recv().await, Some(SessionOp::Shutdown)));
1235 actor_order
1236 .lock()
1237 .unwrap_or_else(std::sync::PoisonError::into_inner)
1238 .push("durable");
1239 actor_admission.record_reconciliation(0, true);
1240 });
1241 let registry = RuntimeFinalizerRegistry::freeze(vec![test_finalizer(
1242 "test.finalizer",
1243 10,
1244 Duration::from_secs(1),
1245 TestFinalizerBehavior::Complete,
1246 order.clone(),
1247 )])
1248 .expect("registry is valid");
1249 let coordinator = ShutdownCoordinator::new(
1250 admission,
1251 command_tx,
1252 actor_task,
1253 tokio::runtime::Handle::current(),
1254 registry,
1255 );
1256
1257 let report = coordinator
1258 .shutdown(ShutdownOptions::interrupt(Duration::from_secs(1)).expect("valid options"))
1259 .await
1260 .expect("shutdown report");
1261
1262 assert_eq!(
1263 *order
1264 .lock()
1265 .unwrap_or_else(std::sync::PoisonError::into_inner),
1266 vec!["durable", "test.finalizer"]
1267 );
1268 assert_eq!(report.finalizers().len(), 1);
1269 assert_eq!(
1270 report.finalizers()[0].identifier(),
1271 ShutdownFinalizerId::new("test.finalizer")
1272 );
1273 assert_eq!(
1274 report.finalizers()[0].outcome(),
1275 ShutdownFinalizerOutcome::Completed
1276 );
1277 assert!(report.is_complete());
1278 }
1279
1280 #[tokio::test]
1281 async fn finalizer_failure_and_panic_are_typed_and_do_not_stop_later_entries() {
1282 let starts = Arc::new(StdMutex::new(Vec::new()));
1283 let runtime = RuntimeBuilder::new()
1284 .provider(Arc::new(MockProvider::new()))
1285 .runtime_finalizer(test_finalizer(
1286 "test.third",
1287 30,
1288 Duration::from_secs(1),
1289 TestFinalizerBehavior::Complete,
1290 starts.clone(),
1291 ))
1292 .runtime_finalizer(test_finalizer(
1293 "test.first",
1294 10,
1295 Duration::from_secs(1),
1296 TestFinalizerBehavior::Fail,
1297 starts.clone(),
1298 ))
1299 .runtime_finalizer(test_finalizer(
1300 "test.second",
1301 20,
1302 Duration::from_secs(1),
1303 TestFinalizerBehavior::Panic,
1304 starts.clone(),
1305 ))
1306 .build()
1307 .expect("runtime builds");
1308
1309 let report = runtime
1310 .shutdown_with(
1311 ShutdownOptions::interrupt(Duration::from_secs(1)).expect("valid options"),
1312 )
1313 .await
1314 .expect("structured report");
1315
1316 assert_eq!(
1317 *starts
1318 .lock()
1319 .unwrap_or_else(std::sync::PoisonError::into_inner),
1320 vec!["test.first", "test.second", "test.third"]
1321 );
1322 assert_eq!(
1323 report
1324 .finalizers()
1325 .iter()
1326 .map(ShutdownFinalizerReport::outcome)
1327 .collect::<Vec<_>>(),
1328 vec![
1329 ShutdownFinalizerOutcome::Failed,
1330 ShutdownFinalizerOutcome::Panicked,
1331 ShutdownFinalizerOutcome::Completed,
1332 ShutdownFinalizerOutcome::Completed,
1333 ]
1334 );
1335 assert!(!report.is_complete());
1336 assert!(matches!(
1337 runtime.shutdown().await,
1338 Err(RuntimeError::ShutdownIncomplete { .. })
1339 ));
1340 assert_eq!(
1341 *starts
1342 .lock()
1343 .unwrap_or_else(std::sync::PoisonError::into_inner),
1344 vec!["test.first", "test.second", "test.third"]
1345 );
1346 }
1347
1348 #[tokio::test]
1349 async fn finalizer_cap_contains_timeout_and_allows_later_entry() {
1350 let starts = Arc::new(StdMutex::new(Vec::new()));
1351 let cancelled = Arc::new(AtomicBool::new(false));
1352 let runtime = RuntimeBuilder::new()
1353 .provider(Arc::new(MockProvider::new()))
1354 .runtime_finalizer(test_finalizer(
1355 "test.timeout",
1356 10,
1357 Duration::from_millis(20),
1358 TestFinalizerBehavior::Pending(cancelled.clone()),
1359 starts.clone(),
1360 ))
1361 .runtime_finalizer(test_finalizer(
1362 "test.after-timeout",
1363 20,
1364 Duration::from_secs(1),
1365 TestFinalizerBehavior::Complete,
1366 starts.clone(),
1367 ))
1368 .build()
1369 .expect("runtime builds");
1370
1371 let report = runtime
1372 .shutdown_with(
1373 ShutdownOptions::interrupt(Duration::from_secs(1)).expect("valid options"),
1374 )
1375 .await
1376 .expect("structured report");
1377 tokio::task::yield_now().await;
1378
1379 assert!(cancelled.load(Ordering::SeqCst));
1380 assert_eq!(
1381 *starts
1382 .lock()
1383 .unwrap_or_else(std::sync::PoisonError::into_inner),
1384 vec!["test.timeout", "test.after-timeout"]
1385 );
1386 assert_eq!(
1387 report
1388 .finalizers()
1389 .iter()
1390 .map(ShutdownFinalizerReport::outcome)
1391 .collect::<Vec<_>>(),
1392 vec![
1393 ShutdownFinalizerOutcome::TimedOut,
1394 ShutdownFinalizerOutcome::Completed,
1395 ShutdownFinalizerOutcome::Completed,
1396 ]
1397 );
1398 assert!(!report.deadline_exhausted());
1399 }
1400
1401 #[tokio::test]
1402 async fn invalid_options_leave_the_primary_runtime_usable() {
1403 let mut runtime = RuntimeBuilder::new()
1404 .provider(Arc::new(MockProvider::new().with_response("still open")))
1405 .build()
1406 .expect("runtime builds");
1407 assert!(ShutdownOptions::interrupt(Duration::ZERO).is_err());
1408
1409 runtime
1410 .submit("continue")
1411 .await
1412 .expect("submit still succeeds");
1413 assert!(matches!(
1414 collect_until_turn_completed(&mut runtime).await,
1415 Some(TurnCompletionStatus::Success { .. })
1416 ));
1417 runtime.shutdown().await.expect("shutdown succeeds");
1418 }
1419
1420 #[tokio::test]
1421 async fn concurrent_shutdown_callers_share_one_cached_report() {
1422 let runtime = RuntimeBuilder::new()
1423 .provider(Arc::new(MockProvider::new().with_response("unused")))
1424 .build()
1425 .expect("runtime builds");
1426 let first = runtime.shutdown_controller();
1427 let second = first.clone();
1428 let first_task = tokio::spawn(async move {
1429 first
1430 .shutdown(
1431 ShutdownOptions::interrupt(Duration::from_secs(1)).expect("valid options"),
1432 )
1433 .await
1434 });
1435 let second_task = tokio::spawn(async move {
1436 second
1437 .shutdown(
1438 ShutdownOptions::finish_current(
1439 Duration::from_secs(2),
1440 Duration::from_millis(10),
1441 )
1442 .expect("valid options"),
1443 )
1444 .await
1445 });
1446 let first_report = first_task.await.expect("caller joins").expect("report");
1447 let second_report = second_task.await.expect("caller joins").expect("report");
1448
1449 assert_eq!(first_report, second_report);
1450 assert!(first_report.is_complete());
1451 assert_eq!(first_report.active_turn(), ShutdownActiveTurnOutcome::Idle);
1452 runtime
1453 .shutdown()
1454 .await
1455 .expect("legacy caller joins cached result");
1456 }
1457
1458 #[tokio::test]
1459 async fn interrupt_closes_admission_and_finalizes_the_active_turn() {
1460 let (runtime, entered, _release) = gated_runtime().await;
1461 let entered_wait = entered.notified();
1462 runtime.submit("block").await.expect("submit succeeds");
1463 entered_wait.await;
1464
1465 let report = runtime
1466 .shutdown_with(
1467 ShutdownOptions::interrupt(Duration::from_secs(1)).expect("valid options"),
1468 )
1469 .await
1470 .expect("structured report");
1471 assert_eq!(
1472 report.active_turn(),
1473 ShutdownActiveTurnOutcome::InterruptedAndFinalized
1474 );
1475 assert!(report.is_complete());
1476 assert!(matches!(
1477 runtime.submit("too late").await,
1478 Err(RuntimeError::RuntimeClosing)
1479 ));
1480 runtime
1481 .shutdown()
1482 .await
1483 .expect("legacy wrapper joins report");
1484 }
1485
1486 #[tokio::test]
1487 async fn finish_current_uses_grace_without_starting_post_fence_work() {
1488 let (runtime, entered, release) = gated_runtime().await;
1489 let entered_wait = entered.notified();
1490 runtime.submit("block").await.expect("submit succeeds");
1491 entered_wait.await;
1492 let controller = runtime.shutdown_controller();
1493 let shutdown = tokio::spawn(async move {
1494 controller
1495 .shutdown(
1496 ShutdownOptions::finish_current(
1497 Duration::from_secs(1),
1498 Duration::from_millis(500),
1499 )
1500 .expect("valid options"),
1501 )
1502 .await
1503 });
1504 tokio::task::yield_now().await;
1505 assert!(matches!(
1506 runtime.submit("post-fence").await,
1507 Err(RuntimeError::RuntimeClosing)
1508 ));
1509 release.notify_one();
1510 let report = shutdown.await.expect("caller joins").expect("report");
1511
1512 assert_eq!(report.active_turn(), ShutdownActiveTurnOutcome::Finished);
1513 assert!(report.is_complete());
1514 runtime
1515 .shutdown()
1516 .await
1517 .expect("legacy wrapper joins report");
1518 }
1519
1520 #[tokio::test]
1521 async fn finish_current_grace_expiry_uses_actor_owned_interrupt() {
1522 let (runtime, entered, _release) = gated_runtime().await;
1523 let entered_wait = entered.notified();
1524 runtime.submit("block").await.expect("submit succeeds");
1525 entered_wait.await;
1526
1527 let report = runtime
1528 .shutdown_with(
1529 ShutdownOptions::finish_current(Duration::from_secs(1), Duration::from_millis(10))
1530 .expect("valid options"),
1531 )
1532 .await
1533 .expect("structured report");
1534 assert_eq!(
1535 report.active_turn(),
1536 ShutdownActiveTurnOutcome::InterruptedAndFinalized
1537 );
1538 assert!(report.is_complete());
1539 runtime
1540 .shutdown()
1541 .await
1542 .expect("legacy wrapper joins report");
1543 }
1544
1545 #[tokio::test]
1546 async fn finish_current_never_starts_pre_fence_pending_work() {
1547 let entered = Arc::new(Notify::new());
1548 let release = Arc::new(Notify::new());
1549 let calls = Arc::new(AtomicUsize::new(0));
1550 struct CountingGatedModel {
1551 entered: Arc<Notify>,
1552 release: Arc<Notify>,
1553 calls: Arc<AtomicUsize>,
1554 }
1555 #[async_trait]
1556 impl LanguageModel for CountingGatedModel {
1557 async fn stream(
1558 &self,
1559 _messages: &[Message],
1560 ) -> ProviderResult<mpsc::Receiver<AgentEvent>> {
1561 self.calls.fetch_add(1, Ordering::SeqCst);
1562 let (tx, rx) = mpsc::channel(8);
1563 let release = self.release.clone();
1564 self.entered.notify_one();
1565 tokio::spawn(async move {
1566 release.notified().await;
1567 for event in [
1568 AgentEvent::TurnStart,
1569 AgentEvent::TurnEnd {
1570 stop_reason: StopReason::EndTurn,
1571 usage: Usage::default(),
1572 },
1573 ] {
1574 let _ = tx.send(event).await;
1575 }
1576 });
1577 Ok(rx)
1578 }
1579 }
1580 let runtime = RuntimeBuilder::new()
1581 .provider(Arc::new(CountingGatedModel {
1582 entered: entered.clone(),
1583 release: release.clone(),
1584 calls: calls.clone(),
1585 }))
1586 .build()
1587 .expect("runtime builds");
1588 let entered_wait = entered.notified();
1589 runtime.submit("active").await.expect("first submit");
1590 entered_wait.await;
1591 runtime.submit("pending").await.expect("pre-fence submit");
1592 let controller = runtime.shutdown_controller();
1593 let shutdown = tokio::spawn(async move {
1594 controller
1595 .shutdown(
1596 ShutdownOptions::finish_current(
1597 Duration::from_secs(1),
1598 Duration::from_millis(500),
1599 )
1600 .expect("valid options"),
1601 )
1602 .await
1603 });
1604 tokio::task::yield_now().await;
1605 release.notify_one();
1606 let report = shutdown.await.expect("caller joins").expect("report");
1607
1608 assert_eq!(calls.load(Ordering::SeqCst), 1);
1609 assert_eq!(report.active_turn(), ShutdownActiveTurnOutcome::Finished);
1610 runtime
1611 .shutdown()
1612 .await
1613 .expect("legacy wrapper joins report");
1614 }
1615
1616 #[tokio::test]
1617 async fn cancelling_one_waiter_does_not_cancel_the_runtime_driver() {
1618 let (runtime, entered, _release) = gated_runtime().await;
1619 let entered_wait = entered.notified();
1620 runtime.submit("block").await.expect("submit succeeds");
1621 entered_wait.await;
1622 let first = runtime.shutdown_controller();
1623 let later = first.clone();
1624 let waiter = tokio::spawn(async move {
1625 first
1626 .shutdown(
1627 ShutdownOptions::interrupt(Duration::from_secs(1)).expect("valid options"),
1628 )
1629 .await
1630 });
1631 loop {
1632 if matches!(
1633 runtime.submit("fence probe").await,
1634 Err(RuntimeError::RuntimeClosing)
1635 ) {
1636 break;
1637 }
1638 tokio::task::yield_now().await;
1639 }
1640 waiter.abort();
1641
1642 let report = later
1643 .shutdown(
1644 ShutdownOptions::finish_current(Duration::from_secs(2), Duration::from_millis(100))
1645 .expect("valid options"),
1646 )
1647 .await
1648 .expect("later caller receives cached report");
1649 assert_eq!(report.active_turn_policy(), ActiveTurnPolicy::Interrupt);
1650 assert!(report.is_complete());
1651 runtime
1652 .shutdown()
1653 .await
1654 .expect("legacy wrapper joins report");
1655 }
1656
1657 #[tokio::test]
1658 async fn primary_drop_initiates_default_plan_and_controller_drop_is_inert() {
1659 let (runtime, entered, _release) = gated_runtime().await;
1660 let entered_wait = entered.notified();
1661 runtime.submit("block").await.expect("submit succeeds");
1662 entered_wait.await;
1663 let controller = runtime.shutdown_controller();
1664 drop(runtime.shutdown_controller());
1665 drop(runtime);
1666
1667 let report = controller
1668 .shutdown(
1669 ShutdownOptions::finish_current(Duration::from_secs(2), Duration::from_millis(100))
1670 .expect("valid options"),
1671 )
1672 .await
1673 .expect("controller joins Drop-initiated report");
1674 assert_eq!(report.active_turn_policy(), ActiveTurnPolicy::Interrupt);
1675 assert!(report.is_complete());
1676 }
1677
1678 #[tokio::test]
1679 async fn primary_drop_cannot_replace_an_explicit_winning_plan() {
1680 let (runtime, entered, release) = gated_runtime().await;
1681 let entered_wait = entered.notified();
1682 runtime.submit("block").await.expect("submit succeeds");
1683 entered_wait.await;
1684 let controller = runtime.shutdown_controller();
1685 let observer = controller.clone();
1686 let shutdown = tokio::spawn(async move {
1687 controller
1688 .shutdown(
1689 ShutdownOptions::finish_current(
1690 Duration::from_secs(1),
1691 Duration::from_millis(500),
1692 )
1693 .expect("valid options"),
1694 )
1695 .await
1696 });
1697 loop {
1698 if matches!(
1699 runtime.submit("fence probe").await,
1700 Err(RuntimeError::RuntimeClosing)
1701 ) {
1702 break;
1703 }
1704 tokio::task::yield_now().await;
1705 }
1706 drop(runtime);
1707 release.notify_one();
1708
1709 let report = shutdown.await.expect("caller joins").expect("report");
1710 let cached = observer
1711 .shutdown(ShutdownOptions::interrupt(Duration::from_secs(2)).expect("valid options"))
1712 .await
1713 .expect("observer joins cached report");
1714 assert_eq!(report, cached);
1715 assert!(matches!(
1716 report.active_turn_policy(),
1717 ActiveTurnPolicy::FinishCurrent { .. }
1718 ));
1719 }
1720
1721 #[tokio::test]
1722 async fn legacy_shutdown_preserves_actor_join_errors() {
1723 let admission = talos_agent::session::RuntimeAdmissionControl::new();
1724 let (command_tx, command_rx) = mpsc::channel(1);
1725 drop(command_rx);
1726 let (_event_tx, event_rx) = mpsc::unbounded_channel();
1727 let actor_exit_guard = RuntimeActorExitGuard {
1728 admission: admission.clone(),
1729 };
1730 let actor_task = tokio::spawn(async move {
1731 let _actor_exit_guard = actor_exit_guard;
1732 panic!("intentional actor join failure");
1733 });
1734 let runtime_handle = tokio::runtime::Handle::current();
1735 let coordinator = ShutdownCoordinator::new(
1736 admission,
1737 command_tx.clone(),
1738 actor_task,
1739 runtime_handle,
1740 RuntimeFinalizerRegistry::freeze(Vec::new()).expect("empty registry is valid"),
1741 );
1742 let runtime = RuntimeHandle {
1743 command_tx,
1744 event_rx,
1745 coordinator,
1746 primary_drop_armed: true,
1747 };
1748
1749 assert!(matches!(
1750 runtime.shutdown().await,
1751 Err(RuntimeError::ActorJoin(_))
1752 ));
1753 }
1754
1755 #[tokio::test]
1756 async fn exhausted_total_deadline_returns_a_redacted_incomplete_report() {
1757 let runtime = RuntimeBuilder::new()
1758 .provider(Arc::new(MockProvider::new().with_response("unused")))
1759 .build()
1760 .expect("runtime builds");
1761 let report = runtime
1762 .shutdown_with(
1763 ShutdownOptions::interrupt(Duration::from_nanos(1)).expect("valid options"),
1764 )
1765 .await
1766 .expect("structured timeout remains observable");
1767
1768 assert!(report.deadline_exhausted());
1769 assert!(!report.is_complete());
1770 assert!(matches!(
1771 runtime.shutdown().await,
1772 Err(RuntimeError::ShutdownIncomplete { .. })
1773 ));
1774 }
1775
1776 #[tokio::test]
1777 async fn durable_reconciliation_failure_is_typed_and_incomplete() {
1778 let blocked_root = tempfile::NamedTempFile::new().expect("temporary file");
1779 let runtime = RuntimeBuilder::new()
1780 .provider(Arc::new(MockProvider::new().with_response("unused")))
1781 .workspace_root(blocked_root.path())
1782 .build()
1783 .expect("runtime construction is lazy over pending custody");
1784 let report = runtime
1785 .shutdown_with(
1786 ShutdownOptions::interrupt(Duration::from_secs(1)).expect("valid options"),
1787 )
1788 .await
1789 .expect("structured report");
1790
1791 assert!(matches!(
1792 report.durable_reconciliation(),
1793 ShutdownDurableOutcome::Failed { .. }
1794 ));
1795 assert!(!report.is_complete());
1796 assert!(matches!(
1797 runtime.shutdown().await,
1798 Err(RuntimeError::ShutdownIncomplete { .. })
1799 ));
1800 }
1801
1802 #[tokio::test]
1803 async fn shutdown_report_never_contains_submitted_content() {
1804 let (runtime, entered, _release) = gated_runtime().await;
1805 let entered_wait = entered.notified();
1806 runtime
1807 .submit("secret-prompt-and-credential")
1808 .await
1809 .expect("submit succeeds");
1810 entered_wait.await;
1811 let report = runtime
1812 .shutdown_with(
1813 ShutdownOptions::interrupt(Duration::from_secs(1)).expect("valid options"),
1814 )
1815 .await
1816 .expect("structured report");
1817
1818 let projection = format!("{report:?}");
1819 assert!(!projection.contains("secret-prompt-and-credential"));
1820 assert!(!projection.contains("GatedModel"));
1821 runtime
1822 .shutdown()
1823 .await
1824 .expect("legacy wrapper joins report");
1825 }
1826
1827 #[cfg(feature = "shared-composition")]
1828 struct PresetOverrideTool {
1829 executions: Arc<AtomicUsize>,
1830 }
1831
1832 struct SnapshotEditingModel {
1833 step: AtomicUsize,
1834 observed_snapshot: Arc<StdMutex<Option<String>>>,
1835 }
1836
1837 impl SnapshotEditingModel {
1838 fn new(observed_snapshot: Arc<StdMutex<Option<String>>>) -> Self {
1839 Self {
1840 step: AtomicUsize::new(0),
1841 observed_snapshot,
1842 }
1843 }
1844 }
1845
1846 #[async_trait]
1847 impl LanguageModel for SnapshotEditingModel {
1848 async fn stream(&self, messages: &[Message]) -> ProviderResult<mpsc::Receiver<AgentEvent>> {
1849 let step = self.step.fetch_add(1, Ordering::SeqCst);
1850 let events = if step == 0 {
1851 vec![
1852 AgentEvent::TurnStart,
1853 AgentEvent::ToolCall {
1854 call: ToolCall {
1855 id: "snapshot-read".into(),
1856 name: "read".into(),
1857 input: serde_json::json!({"path": "source.txt"}),
1858 },
1859 provenance: ToolProvenance::default(),
1860 summary_fields: Vec::new(),
1861 },
1862 AgentEvent::TurnEnd {
1863 stop_reason: StopReason::ToolUse,
1864 usage: Usage::default(),
1865 },
1866 ]
1867 } else if step == 1 {
1868 let content = messages
1869 .iter()
1870 .rev()
1871 .find_map(|message| match message {
1872 Message::Tool { result } => Some(result.content.as_str()),
1873 _ => None,
1874 })
1875 .expect("model receives read result");
1876 let mut lines = content.lines();
1877 let snapshot_id = lines
1878 .next()
1879 .and_then(|line| line.strip_prefix("[snapshot:"))
1880 .and_then(|line| line.strip_suffix(']'))
1881 .expect("model receives snapshot handle")
1882 .to_string();
1883 let target = lines
1884 .next()
1885 .and_then(|line| line.split_once('|'))
1886 .map(|(reference, _)| reference.to_string())
1887 .expect("model receives line reference");
1888 *self
1889 .observed_snapshot
1890 .lock()
1891 .expect("snapshot capture lock") = Some(snapshot_id.clone());
1892 vec![
1893 AgentEvent::TurnStart,
1894 AgentEvent::ToolCall {
1895 call: ToolCall {
1896 id: "snapshot-edit".into(),
1897 name: "edit".into(),
1898 input: serde_json::json!({
1899 "path": "source.txt",
1900 "snapshot_id": snapshot_id,
1901 "operations": [{
1902 "op": "replace",
1903 "target": target,
1904 "content": "updated"
1905 }]
1906 }),
1907 },
1908 provenance: ToolProvenance::default(),
1909 summary_fields: Vec::new(),
1910 },
1911 AgentEvent::TurnEnd {
1912 stop_reason: StopReason::ToolUse,
1913 usage: Usage::default(),
1914 },
1915 ]
1916 } else {
1917 vec![
1918 AgentEvent::TurnStart,
1919 AgentEvent::TextDelta {
1920 delta: "done".into(),
1921 },
1922 AgentEvent::TurnEnd {
1923 stop_reason: StopReason::EndTurn,
1924 usage: Usage::default(),
1925 },
1926 ]
1927 };
1928 let (tx, rx) = mpsc::channel(8);
1929 for event in events {
1930 tx.send(event).await.expect("runtime receiver remains open");
1931 }
1932 Ok(rx)
1933 }
1934 }
1935
1936 #[derive(Debug, Clone, PartialEq, Eq)]
1937 struct ApprovalRecord {
1938 tool_name: String,
1939 arguments: Value,
1940 summary_fields: Vec<String>,
1941 }
1942
1943 struct RecordingApprovalHandler {
1944 choice: ApprovalChoice,
1945 records: Arc<StdMutex<Vec<ApprovalRecord>>>,
1946 }
1947
1948 impl RecordingApprovalHandler {
1949 fn new(choice: ApprovalChoice, records: Arc<StdMutex<Vec<ApprovalRecord>>>) -> Self {
1950 Self { choice, records }
1951 }
1952 }
1953
1954 #[async_trait]
1955 impl ApprovalHandler for RecordingApprovalHandler {
1956 async fn request_approval(
1957 &self,
1958 tool_name: &str,
1959 arguments: &Value,
1960 summary_fields: &[String],
1961 ) -> ApprovalChoice {
1962 self.records
1963 .lock()
1964 .expect("records lock is available")
1965 .push(ApprovalRecord {
1966 tool_name: tool_name.to_string(),
1967 arguments: arguments.clone(),
1968 summary_fields: summary_fields.to_vec(),
1969 });
1970 self.choice.clone()
1971 }
1972 }
1973
1974 #[cfg(feature = "shared-composition")]
1975 #[test]
1976 fn shared_tools_are_explicit_and_keep_a_unique_inventory() {
1977 let builder = RuntimeBuilder::new()
1978 .workspace_root("workspace")
1979 .shared_tools();
1980 let mut names = builder
1981 .tools
1982 .iter()
1983 .map(|tool| tool.name().to_string())
1984 .collect::<Vec<_>>();
1985 names.sort();
1986 names.dedup();
1987
1988 assert_eq!(names.len(), builder.tools.len());
1989 assert!(names.iter().any(|name| name == "read"));
1990 assert!(
1991 names
1992 .iter()
1993 .any(|name| name == "bash" || name == "powershell")
1994 );
1995 assert!(names.iter().any(|name| name == "document_extract"));
1996 assert!(names.iter().any(|name| name == "read_image"));
1997 }
1998
1999 #[cfg(feature = "shared-composition")]
2000 #[tokio::test]
2001 async fn coding_preset_is_explicit_and_builds_shared_inventory() {
2002 let builder = RuntimeBuilder::new()
2003 .provider(Arc::new(MockProvider::new().with_response("done")))
2004 .workspace_root("workspace")
2005 .coding_preset();
2006 assert_eq!(builder.preset, Some(RuntimePreset::Coding));
2007 assert!(builder.tools.is_empty());
2008 let mut runtime = builder.build().expect("coding preset builds");
2009 runtime.submit("hello").await.expect("submit succeeds");
2010 let status = collect_until_turn_completed(&mut runtime)
2011 .await
2012 .expect("turn completes");
2013 assert!(matches!(status, TurnCompletionStatus::Success { .. }));
2014 runtime.shutdown().await.expect("shutdown succeeds");
2015 }
2016
2017 #[cfg(feature = "shared-composition")]
2018 #[tokio::test]
2019 async fn coding_preset_preserves_caller_tool_overrides() {
2020 let executions = Arc::new(AtomicUsize::new(0));
2021 let provider = MockProvider::new()
2022 .with_tool_call("bash", serde_json::json!({"command": "echo custom"}))
2023 .with_response("done");
2024 let mut runtime = RuntimeBuilder::new()
2025 .provider(Arc::new(provider))
2026 .tool(Arc::new(PresetOverrideTool {
2027 executions: executions.clone(),
2028 }))
2029 .permission_rule(PermissionRule {
2030 tool_name: "bash".into(),
2031 path_pattern: None,
2032 decision: PermissionDecision::Allow,
2033 nature: None,
2034 resource: None,
2035 resource_kind: None,
2036 })
2037 .coding_preset()
2038 .build()
2039 .expect("coding preset builds with caller override");
2040
2041 runtime
2042 .submit("run the custom tool")
2043 .await
2044 .expect("submit succeeds");
2045 let status = collect_until_turn_completed(&mut runtime)
2046 .await
2047 .expect("turn completes");
2048 assert!(matches!(status, TurnCompletionStatus::Success { .. }));
2049 assert_eq!(executions.load(Ordering::SeqCst), 1);
2050 runtime.shutdown().await.expect("shutdown succeeds");
2051 }
2052
2053 #[cfg(not(feature = "shared-composition"))]
2054 #[test]
2055 fn coding_preset_requires_opt_in_feature() {
2056 let result = RuntimeBuilder::new()
2057 .provider(Arc::new(MockProvider::new()))
2058 .coding_preset()
2059 .build();
2060 assert!(matches!(
2061 result,
2062 Err(RuntimeError::CodingPresetRequiresFeature)
2063 ));
2064 }
2065
2066 #[async_trait]
2067 impl AgentTool for RecordingWriteTool {
2068 fn name(&self) -> &str {
2069 "record_write"
2070 }
2071
2072 fn description(&self) -> &str {
2073 "Records a write-like operation"
2074 }
2075
2076 fn parameters(&self) -> Value {
2077 serde_json::json!({
2078 "type": "object",
2079 "properties": {
2080 "message": { "type": "string" }
2081 },
2082 "required": ["message"]
2083 })
2084 }
2085
2086 async fn execute(&self, input: Value) -> ToolResult {
2087 self.executions.fetch_add(1, Ordering::SeqCst);
2088 let message = input
2089 .get("message")
2090 .and_then(Value::as_str)
2091 .unwrap_or_default();
2092 ToolResult::success(format!("recorded: {message}"))
2093 }
2094
2095 fn nature(&self) -> ToolNature {
2096 ToolNature::Write
2097 }
2098
2099 fn permission_profile(&self, _input: &Value) -> Vec<ToolPermissionFacet> {
2100 vec![ToolPermissionFacet::with_resource(
2101 ToolNature::Write,
2102 ".talos-runtime-record",
2103 ToolResourceKind::Path,
2104 )]
2105 }
2106
2107 fn summary_fields(&self) -> &'static [&'static str] {
2108 &["message"]
2109 }
2110 }
2111
2112 #[cfg(feature = "shared-composition")]
2113 #[async_trait]
2114 impl AgentTool for PresetOverrideTool {
2115 fn name(&self) -> &str {
2116 "bash"
2117 }
2118
2119 fn description(&self) -> &str {
2120 "Test-only caller-provided bash override"
2121 }
2122
2123 fn parameters(&self) -> Value {
2124 serde_json::json!({
2125 "type": "object",
2126 "properties": {"command": {"type": "string"}},
2127 "required": ["command"]
2128 })
2129 }
2130
2131 async fn execute(&self, _input: Value) -> ToolResult {
2132 self.executions.fetch_add(1, Ordering::SeqCst);
2133 ToolResult::success("custom bash")
2134 }
2135
2136 fn nature(&self) -> ToolNature {
2137 ToolNature::Execute
2138 }
2139 }
2140
2141 #[async_trait]
2142 impl AgentTool for RecordingHybridTool {
2143 fn name(&self) -> &str {
2144 "record_hybrid"
2145 }
2146
2147 fn description(&self) -> &str {
2148 "Records a network plus write operation"
2149 }
2150
2151 fn parameters(&self) -> Value {
2152 serde_json::json!({
2153 "type": "object",
2154 "properties": {
2155 "url": { "type": "string" },
2156 "destination": { "type": "string" }
2157 },
2158 "required": ["url", "destination"]
2159 })
2160 }
2161
2162 async fn execute(&self, _input: Value) -> ToolResult {
2163 self.executions.fetch_add(1, Ordering::SeqCst);
2164 ToolResult::success("hybrid executed")
2165 }
2166
2167 fn nature(&self) -> ToolNature {
2168 ToolNature::Write
2169 }
2170
2171 fn permission_profile(&self, _input: &Value) -> Vec<ToolPermissionFacet> {
2172 vec![
2173 ToolPermissionFacet::with_resource(
2174 ToolNature::Network,
2175 "example.com",
2176 ToolResourceKind::Domain,
2177 ),
2178 ToolPermissionFacet::with_resource(
2179 ToolNature::Write,
2180 "blocked/output.txt",
2181 ToolResourceKind::Path,
2182 ),
2183 ]
2184 }
2185 }
2186
2187 #[async_trait]
2188 impl AgentTool for PrivateInputWriteTool {
2189 fn name(&self) -> &str {
2190 "private_write"
2191 }
2192
2193 fn description(&self) -> &str {
2194 "Projection test write"
2195 }
2196
2197 fn parameters(&self) -> Value {
2198 serde_json::json!({"type": "object"})
2199 }
2200
2201 async fn execute(&self, _input: Value) -> ToolResult {
2202 ToolResult::success("written")
2203 }
2204
2205 fn nature(&self) -> ToolNature {
2206 ToolNature::Write
2207 }
2208
2209 fn permission_profile(&self, input: &Value) -> Vec<ToolPermissionFacet> {
2210 input
2211 .get("path")
2212 .and_then(Value::as_str)
2213 .map(|path| {
2214 vec![ToolPermissionFacet::with_resource(
2215 ToolNature::Write,
2216 path,
2217 ToolResourceKind::Path,
2218 )]
2219 })
2220 .unwrap_or_else(|| vec![ToolPermissionFacet::new(ToolNature::Write)])
2221 }
2222
2223 fn project_input(&self, input: &Value) -> Value {
2224 let mut input = input.clone();
2225 if let Some(object) = input.as_object_mut() {
2226 object.remove("snapshot_id");
2227 }
2228 input
2229 }
2230 }
2231
2232 #[async_trait]
2233 impl AgentTool for PrivateResultReadTool {
2234 fn name(&self) -> &str {
2235 "private_read"
2236 }
2237
2238 fn description(&self) -> &str {
2239 "Projection test read"
2240 }
2241
2242 fn parameters(&self) -> Value {
2243 serde_json::json!({"type": "object"})
2244 }
2245
2246 async fn execute(&self, _input: Value) -> ToolResult {
2247 ToolResult::success("[snapshot:s1]\n1:aa|private line")
2248 }
2249
2250 fn is_read_only(&self) -> bool {
2251 true
2252 }
2253
2254 fn project_result(&self, result: &ToolResult) -> talos_core::tool::ToolResultProjection {
2255 talos_core::tool::ToolResultProjection {
2256 model_content: result.content.clone(),
2257 display_content: "read 1 line".into(),
2258 persistence_content: "read 1 line".into(),
2259 }
2260 }
2261 }
2262
2263 #[tokio::test]
2264 async fn runtime_streams_mock_response() {
2265 let provider = Arc::new(MockProvider::new().with_response("hello from runtime"));
2266 let mut runtime = RuntimeBuilder::new()
2267 .provider(provider)
2268 .workspace_root(".")
2269 .build()
2270 .expect("runtime builds");
2271
2272 runtime.submit("hello").await.expect("submit succeeds");
2273 let status = collect_until_turn_completed(&mut runtime)
2274 .await
2275 .expect("turn completes");
2276
2277 match status {
2278 TurnCompletionStatus::Success { final_text, .. } => {
2279 assert_eq!(final_text, "hello from runtime");
2280 }
2281 other => panic!("unexpected status: {other:?}"),
2282 }
2283
2284 runtime.shutdown().await.expect("shutdown succeeds");
2285 }
2286
2287 #[tokio::test]
2288 async fn runtime_denies_ask_tools_by_default() {
2289 let provider = Arc::new(
2290 MockProvider::new()
2291 .with_tool_call("record_write", serde_json::json!({"message": "secret"}))
2292 .with_response("done"),
2293 );
2294 let executions = Arc::new(AtomicUsize::new(0));
2295 let tool = Arc::new(RecordingWriteTool {
2296 executions: executions.clone(),
2297 });
2298 let mut runtime = RuntimeBuilder::new()
2299 .provider(provider)
2300 .workspace_root(".")
2301 .tool(tool)
2302 .build()
2303 .expect("runtime builds");
2304
2305 runtime
2306 .submit("write something")
2307 .await
2308 .expect("submit succeeds");
2309 let status = collect_until_turn_completed(&mut runtime)
2310 .await
2311 .expect("turn completes");
2312
2313 assert!(matches!(
2314 status,
2315 TurnCompletionStatus::Success { final_text, .. } if final_text == "done"
2316 ));
2317 assert_eq!(executions.load(Ordering::SeqCst), 0);
2318
2319 runtime.shutdown().await.expect("shutdown succeeds");
2320 }
2321
2322 #[tokio::test]
2323 async fn runtime_allows_tool_when_rule_allows_write() {
2324 let provider = Arc::new(
2325 MockProvider::new()
2326 .with_tool_call("record_write", serde_json::json!({"message": "allowed"}))
2327 .with_response("done"),
2328 );
2329 let executions = Arc::new(AtomicUsize::new(0));
2330 let tool = Arc::new(RecordingWriteTool {
2331 executions: executions.clone(),
2332 });
2333 let mut runtime = RuntimeBuilder::new()
2334 .provider(provider)
2335 .workspace_root(".")
2336 .permission_rule(PermissionRule::new_nature(
2337 ToolNature::Write,
2338 None,
2339 None,
2340 PermissionDecision::Allow,
2341 ))
2342 .tool(tool)
2343 .build()
2344 .expect("runtime builds");
2345
2346 runtime
2347 .submit("write something")
2348 .await
2349 .expect("submit succeeds");
2350 let status = collect_until_turn_completed(&mut runtime)
2351 .await
2352 .expect("turn completes");
2353
2354 assert!(matches!(
2355 status,
2356 TurnCompletionStatus::Success { final_text, .. } if final_text == "done"
2357 ));
2358 assert_eq!(executions.load(Ordering::SeqCst), 1);
2359
2360 runtime.shutdown().await.expect("shutdown succeeds");
2361 }
2362
2363 #[tokio::test]
2364 async fn runtime_approval_handler_can_approve_ask_tool() {
2365 let provider = Arc::new(
2366 MockProvider::new()
2367 .with_tool_call("record_write", serde_json::json!({"message": "approved"}))
2368 .with_response("done"),
2369 );
2370 let executions = Arc::new(AtomicUsize::new(0));
2371 let approval_records = Arc::new(StdMutex::new(Vec::new()));
2372 let tool = Arc::new(RecordingWriteTool {
2373 executions: executions.clone(),
2374 });
2375 let approval_handler = Arc::new(RecordingApprovalHandler::new(
2376 ApprovalChoice::ApproveOnce,
2377 approval_records.clone(),
2378 ));
2379 let mut runtime = RuntimeBuilder::new()
2380 .provider(provider)
2381 .workspace_root(".")
2382 .approval_handler(approval_handler)
2383 .tool(tool)
2384 .build()
2385 .expect("runtime builds");
2386
2387 runtime
2388 .submit("write something")
2389 .await
2390 .expect("submit succeeds");
2391 let status = collect_until_turn_completed(&mut runtime)
2392 .await
2393 .expect("turn completes");
2394
2395 assert!(matches!(
2396 status,
2397 TurnCompletionStatus::Success { final_text, .. } if final_text == "done"
2398 ));
2399 assert_eq!(executions.load(Ordering::SeqCst), 1);
2400 {
2401 let records = approval_records.lock().expect("records lock is available");
2402 assert_eq!(records.len(), 1);
2403 assert_eq!(records[0].tool_name, "record_write");
2404 assert_eq!(
2405 records[0].arguments,
2406 serde_json::json!({"message": "approved"})
2407 );
2408 assert_eq!(records[0].summary_fields, vec!["message"]);
2409 }
2410
2411 runtime.shutdown().await.expect("shutdown succeeds");
2412 }
2413
2414 #[tokio::test]
2415 async fn external_read_requires_approval_and_receives_exact_path_authorization() {
2416 let workspace = tempfile::tempdir().expect("workspace");
2417 let external = tempfile::NamedTempFile::new().expect("external file");
2418 std::fs::write(external.path(), "external content").expect("write fixture");
2419 let records = Arc::new(StdMutex::new(Vec::new()));
2420 let handler = Arc::new(RecordingApprovalHandler::new(
2421 ApprovalChoice::ApproveOnce,
2422 records.clone(),
2423 ));
2424 let tool = RuntimePermissionAwareTool {
2425 inner: Arc::new(ReadTool::new(workspace.path().to_path_buf())),
2426 permission_state: Arc::new(PermissionSessionState::new(
2427 PermissionEngine::with_workspace_root(workspace.path().to_path_buf()),
2428 )),
2429 approval_handler: Some(handler),
2430 };
2431
2432 let result = tool
2433 .execute(serde_json::json!({"path": external.path().to_string_lossy()}))
2434 .await;
2435
2436 assert!(!result.is_error, "{}", result.content);
2437 assert!(result.content.contains("external content"));
2438 assert_eq!(records.lock().expect("records lock").len(), 1);
2439 }
2440
2441 #[tokio::test]
2442 async fn external_read_without_handler_fails_closed() {
2443 let workspace = tempfile::tempdir().expect("workspace");
2444 let external = tempfile::NamedTempFile::new().expect("external file");
2445 std::fs::write(external.path(), "must not be read").expect("write fixture");
2446 let tool = RuntimePermissionAwareTool {
2447 inner: Arc::new(ReadTool::new(workspace.path().to_path_buf())),
2448 permission_state: Arc::new(PermissionSessionState::new(
2449 PermissionEngine::with_workspace_root(workspace.path().to_path_buf()),
2450 )),
2451 approval_handler: None,
2452 };
2453
2454 let result = tool
2455 .execute(serde_json::json!({"path": external.path().to_string_lossy()}))
2456 .await;
2457
2458 assert!(result.is_error);
2459 assert!(result.content.contains("approval required"));
2460 assert!(!result.content.contains("must not be read"));
2461 }
2462
2463 #[tokio::test]
2464 async fn external_read_explicit_denial_fails_closed() {
2465 let workspace = tempfile::tempdir().expect("workspace");
2466 let external = tempfile::NamedTempFile::new().expect("external file");
2467 std::fs::write(external.path(), "private").expect("write fixture");
2468 let handler = Arc::new(RecordingApprovalHandler::new(
2469 ApprovalChoice::Deny,
2470 Arc::new(StdMutex::new(Vec::new())),
2471 ));
2472 let tool = RuntimePermissionAwareTool {
2473 inner: Arc::new(ReadTool::new(workspace.path().to_path_buf())),
2474 permission_state: Arc::new(PermissionSessionState::new(
2475 PermissionEngine::with_workspace_root(workspace.path().to_path_buf()),
2476 )),
2477 approval_handler: Some(handler),
2478 };
2479
2480 let result = tool
2481 .execute(serde_json::json!({"path": external.path().to_string_lossy()}))
2482 .await;
2483
2484 assert!(result.is_error);
2485 assert!(result.content.contains("User denied"));
2486 assert!(!result.content.contains("private"));
2487 }
2488
2489 #[tokio::test]
2490 async fn external_read_always_approve_reuses_exact_session_grant_without_second_prompt() {
2491 let workspace = tempfile::tempdir().expect("workspace");
2492 let external = tempfile::NamedTempFile::new().expect("external file");
2493 std::fs::write(external.path(), "external content").expect("write fixture");
2494 let records = Arc::new(StdMutex::new(Vec::new()));
2495 let handler = Arc::new(RecordingApprovalHandler::new(
2496 ApprovalChoice::AlwaysApprove,
2497 records.clone(),
2498 ));
2499 let tool = RuntimePermissionAwareTool {
2500 inner: Arc::new(ReadTool::new(workspace.path().to_path_buf())),
2501 permission_state: Arc::new(PermissionSessionState::new(
2502 PermissionEngine::with_workspace_root(workspace.path().to_path_buf()),
2503 )),
2504 approval_handler: Some(handler),
2505 };
2506 let input = serde_json::json!({"path": external.path().to_string_lossy()});
2507
2508 let first = tool.execute(input.clone()).await;
2509 let second = tool.execute(input).await;
2510
2511 assert!(!first.is_error, "{}", first.content);
2512 assert!(!second.is_error, "{}", second.content);
2513 assert_eq!(
2514 records.lock().expect("records lock").len(),
2515 1,
2516 "exact Session grant must suppress the second prompt"
2517 );
2518 }
2519
2520 #[tokio::test]
2521 async fn runtime_approval_receives_projected_input_without_private_snapshot_id() {
2522 let provider = Arc::new(
2523 MockProvider::new()
2524 .with_tool_call(
2525 "private_write",
2526 serde_json::json!({"path": "src/lib.rs", "snapshot_id": "s1"}),
2527 )
2528 .with_response("done"),
2529 );
2530 let approval_records = Arc::new(StdMutex::new(Vec::new()));
2531 let approval_handler = Arc::new(RecordingApprovalHandler::new(
2532 ApprovalChoice::ApproveOnce,
2533 approval_records.clone(),
2534 ));
2535 let mut runtime = RuntimeBuilder::new()
2536 .provider(provider)
2537 .workspace_root(".")
2538 .approval_handler(approval_handler)
2539 .tool(Arc::new(PrivateInputWriteTool))
2540 .build()
2541 .expect("runtime builds");
2542
2543 runtime.submit("write").await.expect("submit succeeds");
2544 let status = collect_until_turn_completed(&mut runtime)
2545 .await
2546 .expect("turn completes");
2547 assert!(matches!(status, TurnCompletionStatus::Success { .. }));
2548 {
2549 let records = approval_records.lock().expect("records lock");
2550 assert_eq!(records.len(), 1);
2551 assert_eq!(
2552 records[0].arguments,
2553 serde_json::json!({"path": "src/lib.rs"})
2554 );
2555 }
2556 runtime.shutdown().await.expect("shutdown succeeds");
2557 }
2558
2559 #[tokio::test]
2560 async fn runtime_always_approve_installs_in_memory_session_grant() {
2561 let provider = Arc::new(
2562 MockProvider::new()
2563 .with_tool_call("record_write", serde_json::json!({"message": "first"}))
2564 .with_response("first done")
2565 .with_tool_call("record_write", serde_json::json!({"message": "second"}))
2566 .with_response("second done"),
2567 );
2568 let executions = Arc::new(AtomicUsize::new(0));
2569 let approval_records = Arc::new(StdMutex::new(Vec::new()));
2570 let tool = Arc::new(RecordingWriteTool {
2571 executions: executions.clone(),
2572 });
2573 let approval_handler = Arc::new(RecordingApprovalHandler::new(
2574 ApprovalChoice::AlwaysApprove,
2575 approval_records.clone(),
2576 ));
2577 let mut runtime = RuntimeBuilder::new()
2578 .provider(provider)
2579 .workspace_root(".")
2580 .approval_handler(approval_handler)
2581 .tool(tool)
2582 .build()
2583 .expect("runtime builds");
2584
2585 runtime
2586 .submit("write first")
2587 .await
2588 .expect("first submit succeeds");
2589 let first_status = collect_until_turn_completed(&mut runtime)
2590 .await
2591 .expect("first turn completes");
2592 runtime
2593 .submit("write second")
2594 .await
2595 .expect("second submit succeeds");
2596 let second_status = collect_until_turn_completed(&mut runtime)
2597 .await
2598 .expect("second turn completes");
2599
2600 assert!(matches!(
2601 first_status,
2602 TurnCompletionStatus::Success { final_text, .. } if final_text == "first done"
2603 ));
2604 assert!(matches!(
2605 second_status,
2606 TurnCompletionStatus::Success { final_text, .. } if final_text == "second done"
2607 ));
2608 assert_eq!(executions.load(Ordering::SeqCst), 2);
2609 assert_eq!(
2610 approval_records
2611 .lock()
2612 .expect("records lock is available")
2613 .len(),
2614 1
2615 );
2616
2617 runtime.shutdown().await.expect("shutdown succeeds");
2618 }
2619
2620 #[tokio::test]
2621 async fn runtime_denies_hybrid_tool_when_write_facet_is_denied() {
2622 let provider = Arc::new(
2623 MockProvider::new()
2624 .with_tool_call(
2625 "record_hybrid",
2626 serde_json::json!({
2627 "url": "https://example.com/file",
2628 "destination": "blocked/output.txt"
2629 }),
2630 )
2631 .with_response("done"),
2632 );
2633 let executions = Arc::new(AtomicUsize::new(0));
2634 let tool = Arc::new(RecordingHybridTool {
2635 executions: executions.clone(),
2636 });
2637 let mut runtime = RuntimeBuilder::new()
2638 .provider(provider)
2639 .workspace_root(".")
2640 .permission_rule(PermissionRule::new_nature(
2641 ToolNature::Network,
2642 Some("example.com".to_string()),
2643 Some(talos_permission::ResourceKind::Domain),
2644 PermissionDecision::Allow,
2645 ))
2646 .permission_rule(PermissionRule::new_nature(
2647 ToolNature::Write,
2648 Some("blocked/**".to_string()),
2649 Some(talos_permission::ResourceKind::Path),
2650 PermissionDecision::Deny("write blocked".to_string()),
2651 ))
2652 .tool(tool)
2653 .build()
2654 .expect("runtime builds");
2655
2656 runtime
2657 .submit("fetch and save")
2658 .await
2659 .expect("submit succeeds");
2660 let status = collect_until_turn_completed(&mut runtime)
2661 .await
2662 .expect("turn completes");
2663
2664 assert!(matches!(
2665 status,
2666 TurnCompletionStatus::Success { final_text, .. } if final_text == "done"
2667 ));
2668 assert_eq!(executions.load(Ordering::SeqCst), 0);
2669
2670 runtime.shutdown().await.expect("shutdown succeeds");
2671 }
2672
2673 #[tokio::test]
2674 async fn runtime_accepts_initial_history() {
2675 let provider = Arc::new(MockProvider::new().with_response("continued"));
2676 let mut runtime = RuntimeBuilder::new()
2677 .provider(provider)
2678 .initial_history(vec![Message::User {
2679 content: "earlier".into(),
2680 }])
2681 .build()
2682 .expect("runtime builds");
2683
2684 runtime.submit("continue").await.expect("submit succeeds");
2685 let status = collect_until_turn_completed(&mut runtime)
2686 .await
2687 .expect("turn completes");
2688 assert!(matches!(
2689 status,
2690 TurnCompletionStatus::Success { final_text, .. } if final_text == "continued"
2691 ));
2692
2693 runtime.shutdown().await.expect("shutdown succeeds");
2694 }
2695
2696 #[tokio::test]
2697 async fn durable_runtime_restores_history_and_reports_committed_entries() {
2698 let directory = std::env::temp_dir().join(format!(
2699 "talos-runtime-durable-{}",
2700 std::time::SystemTime::now()
2701 .duration_since(std::time::UNIX_EPOCH)
2702 .expect("clock after epoch")
2703 .as_nanos()
2704 ));
2705 let manager = SessionManager::with_dir(directory.clone());
2706 let session = manager
2707 .create_or_open_session("task:durable-runtime")
2708 .expect("durable session");
2709 let mut runtime = RuntimeBuilder::new()
2710 .provider(Arc::new(
2711 MockProvider::new().with_response("persisted answer"),
2712 ))
2713 .durable_session(session.clone())
2714 .build()
2715 .expect("runtime builds");
2716
2717 runtime
2718 .submit("persist this user turn")
2719 .await
2720 .expect("submit");
2721 let mut committed = false;
2722 while let Some(event) = runtime.next_event().await {
2723 if let SessionEvent::EntriesCommitted { entry_ids, .. } = &event {
2724 committed = !entry_ids.is_empty();
2725 }
2726 if matches!(
2727 event,
2728 SessionEvent::TurnEvent {
2729 payload: talos_core::session::TurnEventPayload::Completed { .. },
2730 ..
2731 }
2732 ) {
2733 break;
2734 }
2735 }
2736 assert!(committed, "durable success must report committed entry IDs");
2737 runtime.shutdown().await.expect("shutdown");
2738
2739 let restored = manager
2740 .get_session_by_external_id("task:durable-runtime")
2741 .expect("lookup")
2742 .expect("binding exists");
2743 let history = restored.read_messages().expect("history");
2744 assert!(history.iter().any(|message| matches!(message, Message::User { content } if content == "persist this user turn")));
2745 assert!(history.iter().any(|message| matches!(message, Message::Assistant { content, .. } if content == "persisted answer")));
2746 std::fs::remove_dir_all(directory).expect("cleanup");
2747 }
2748
2749 #[tokio::test]
2750 async fn durable_runtime_never_persists_model_private_tool_result() {
2751 let directory = std::env::temp_dir().join(format!(
2752 "talos-runtime-private-projection-{}",
2753 std::process::id()
2754 ));
2755 let _ = std::fs::remove_dir_all(&directory);
2756 let manager = SessionManager::with_dir(directory.clone());
2757 let session = manager
2758 .create_or_open_session("private-projection")
2759 .expect("durable session");
2760 let provider = Arc::new(
2761 MockProvider::new()
2762 .with_tool_call("private_read", serde_json::json!({"path": "src/lib.rs"}))
2763 .with_response("done"),
2764 );
2765 let mut runtime = RuntimeBuilder::new()
2766 .provider(provider)
2767 .tool(Arc::new(PrivateResultReadTool))
2768 .durable_session(session.clone())
2769 .build()
2770 .expect("runtime builds");
2771
2772 runtime.submit("read").await.expect("submit");
2773 let status = collect_until_turn_completed(&mut runtime)
2774 .await
2775 .expect("turn completes");
2776 assert!(matches!(status, TurnCompletionStatus::Success { .. }));
2777 runtime.shutdown().await.expect("shutdown");
2778
2779 let messages = session.read_messages().expect("messages");
2780 let serialized = serde_json::to_string(&messages).expect("serialize messages");
2781 assert!(serialized.contains("read 1 line"));
2782 assert!(!serialized.contains("snapshot:s1"));
2783 assert!(!serialized.contains("1:aa|"));
2784 for entry in std::fs::read_dir(&directory).expect("session directory") {
2785 let path = entry.expect("directory entry").path();
2786 if path.extension().and_then(|value| value.to_str()) == Some("tlog") {
2787 let bytes = std::fs::read(&path).expect("tlog bytes");
2788 let text = String::from_utf8_lossy(&bytes);
2789 assert!(!text.contains("snapshot:s1"));
2790 assert!(!text.contains("1:aa|"));
2791 }
2792 }
2793 std::fs::remove_dir_all(directory).expect("cleanup");
2794 }
2795
2796 #[cfg(feature = "shared-composition")]
2797 #[tokio::test]
2798 async fn shared_composition_runtime_executes_read_tool() {
2799 let workspace = tempfile::tempdir().expect("workspace");
2800 std::fs::write(workspace.path().join("note.txt"), "shared runtime\n").expect("fixture");
2801 let provider = Arc::new(
2802 MockProvider::new()
2803 .with_tool_call("read", serde_json::json!({"path": "note.txt"}))
2804 .with_response("read complete"),
2805 );
2806 let mut runtime = RuntimeBuilder::new()
2807 .provider(provider)
2808 .workspace_root(workspace.path())
2809 .shared_tools()
2810 .build()
2811 .expect("shared runtime builds");
2812
2813 runtime.submit("read note").await.expect("submit");
2814 let status = collect_until_turn_completed(&mut runtime)
2815 .await
2816 .expect("turn completes");
2817 assert!(matches!(status, TurnCompletionStatus::Success { .. }));
2818 runtime.shutdown().await.expect("shutdown");
2819 }
2820
2821 #[tokio::test]
2822 async fn real_snapshot_read_to_edit_is_atomic_permission_gated_and_never_durable() {
2823 let workspace = tempfile::tempdir().expect("workspace");
2824 let session_root = tempfile::tempdir().expect("session root");
2825 let source = workspace.path().join("source.txt");
2826 std::fs::write(&source, "original\n").expect("fixture write");
2827 let manager = SessionManager::with_dir(session_root.path().join("messages"));
2828 let session = manager
2829 .create_or_open_session("snapshot:e2e")
2830 .expect("durable session");
2831 let observed_snapshot = Arc::new(StdMutex::new(None));
2832 let provider = Arc::new(SnapshotEditingModel::new(observed_snapshot.clone()));
2833 let (read, write, edit, delete) = snapshot_aware_file_tools(workspace.path().to_path_buf());
2834 let mut runtime = RuntimeBuilder::new()
2835 .provider(provider)
2836 .workspace_root(workspace.path())
2837 .permission_rule(PermissionRule::new_nature(
2838 ToolNature::Write,
2839 None,
2840 None,
2841 PermissionDecision::Allow,
2842 ))
2843 .tool(Arc::new(read))
2844 .tool(Arc::new(write))
2845 .tool(Arc::new(edit))
2846 .tool(Arc::new(delete))
2847 .durable_session(session.clone())
2848 .build()
2849 .expect("runtime builds");
2850
2851 runtime
2852 .submit("update the first line")
2853 .await
2854 .expect("submit");
2855 let mut serialized_events = Vec::new();
2856 while let Some(event) = runtime.next_event().await {
2857 serialized_events.push(serde_json::to_string(&event).expect("serialize event"));
2858 if matches!(
2859 event,
2860 SessionEvent::TurnEvent {
2861 payload: talos_core::session::TurnEventPayload::Completed { .. },
2862 ..
2863 }
2864 ) {
2865 break;
2866 }
2867 }
2868 runtime.shutdown().await.expect("shutdown");
2869
2870 assert_eq!(
2871 std::fs::read_to_string(&source).expect("read source"),
2872 "updated\n"
2873 );
2874 let snapshot_id = observed_snapshot
2875 .lock()
2876 .expect("snapshot lock")
2877 .clone()
2878 .expect("model observed snapshot");
2879 for event in &serialized_events {
2880 assert!(!event.contains(&snapshot_id));
2881 assert!(!event.contains("snapshot_id"));
2882 }
2883 let messages = session.read_messages().expect("durable messages");
2884 let serialized = serde_json::to_string(&messages).expect("serialize messages");
2885 assert!(!serialized.contains(&snapshot_id));
2886 assert!(!serialized.contains("snapshot_id"));
2887 assert!(serialized.contains("1: original"));
2888 for entry in
2889 std::fs::read_dir(session_root.path().join("messages")).expect("session directory")
2890 {
2891 let path = entry.expect("directory entry").path();
2892 if path.extension().and_then(|value| value.to_str()) == Some("tlog") {
2893 let bytes = std::fs::read(path).expect("tlog bytes");
2894 let text = String::from_utf8_lossy(&bytes);
2895 assert!(!text.contains(&snapshot_id));
2896 assert!(!text.contains("snapshot_id"));
2897 }
2898 }
2899 }
2900
2901 #[tokio::test]
2902 async fn denied_real_snapshot_edit_leaves_the_file_unchanged() {
2903 let workspace = tempfile::tempdir().expect("workspace");
2904 let source = workspace.path().join("source.txt");
2905 std::fs::write(&source, "original\n").expect("fixture write");
2906 let observed_snapshot = Arc::new(StdMutex::new(None));
2907 let provider = Arc::new(SnapshotEditingModel::new(observed_snapshot.clone()));
2908 let (read, _, edit, _) = snapshot_aware_file_tools(workspace.path().to_path_buf());
2909 let mut runtime = RuntimeBuilder::new()
2910 .provider(provider)
2911 .workspace_root(workspace.path())
2912 .tool(Arc::new(read))
2913 .tool(Arc::new(edit))
2914 .build()
2915 .expect("runtime builds");
2916
2917 runtime
2918 .submit("update the first line")
2919 .await
2920 .expect("submit");
2921 let status = collect_until_turn_completed(&mut runtime)
2922 .await
2923 .expect("turn completes");
2924 runtime.shutdown().await.expect("shutdown");
2925
2926 assert!(matches!(status, TurnCompletionStatus::Success { .. }));
2927 assert!(observed_snapshot.lock().expect("snapshot lock").is_some());
2928 assert_eq!(
2929 std::fs::read_to_string(source).expect("read source"),
2930 "original\n"
2931 );
2932 }
2933
2934 #[tokio::test]
2935 async fn runtime_previews_request_without_submit_magic_string() {
2936 let provider = Arc::new(MockProvider::new().with_request_debug_builder(|messages| {
2937 serde_json::to_string(messages).expect("messages serialize")
2938 }));
2939 let mut runtime = RuntimeBuilder::new()
2940 .provider(provider)
2941 .build()
2942 .expect("runtime builds");
2943
2944 runtime
2945 .preview_request("inspect request")
2946 .await
2947 .expect("preview request succeeds");
2948 let status = collect_until_turn_completed(&mut runtime)
2949 .await
2950 .expect("turn completes");
2951
2952 match status {
2953 TurnCompletionStatus::Success { final_text, .. } => {
2954 assert!(final_text.contains("Request preview (no API call made)"));
2955 assert!(final_text.contains("inspect request"));
2956 }
2957 other => panic!("unexpected status: {other:?}"),
2958 }
2959
2960 runtime.shutdown().await.expect("shutdown succeeds");
2961 }
2962
2963 #[tokio::test]
2964 async fn runtime_builder_custom_prompt_replaces_default_identity() {
2965 let provider = Arc::new(MockProvider::new().with_request_debug_builder(|messages| {
2966 serde_json::to_string(messages).expect("messages serialize")
2967 }));
2968 let mut runtime = RuntimeBuilder::new()
2969 .provider(provider)
2970 .custom_prompt("You are Obei Buddy, a zh-CN office assistant.")
2971 .append_prompt("Answer in concise business Chinese.")
2972 .build()
2973 .expect("runtime builds");
2974
2975 runtime
2976 .preview_request("inspect request")
2977 .await
2978 .expect("preview request succeeds");
2979 let status = collect_until_turn_completed(&mut runtime)
2980 .await
2981 .expect("turn completes");
2982
2983 match status {
2984 TurnCompletionStatus::Success { final_text, .. } => {
2985 assert!(final_text.contains("You are Obei Buddy"));
2986 assert!(final_text.contains("Answer in concise business Chinese."));
2987 assert!(
2988 !final_text.contains("You are Talos, an AI coding assistant"),
2989 "custom prompt should replace the default Talos identity"
2990 );
2991 }
2992 other => panic!("unexpected status: {other:?}"),
2993 }
2994
2995 runtime.shutdown().await.expect("shutdown succeeds");
2996 }
2997}