1use async_trait::async_trait;
2use futures::stream::{Stream, StreamExt};
3use parking_lot::RwLock;
4use serde_json::Value;
5use std::collections::{HashMap, HashSet};
6use std::future::Future;
7use std::pin::Pin;
8use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
9use std::sync::{Arc, Weak};
10use std::time::Instant;
11use tracing::{debug, error, info, instrument, warn};
12
13pub(crate) type ToolResourceLocks = Arc<RwLock<HashMap<String, Weak<tokio::sync::Mutex<()>>>>>;
15
16struct ToolResourceGuards {
20 guards: Vec<tokio::sync::OwnedMutexGuard<()>>,
21 locks: ToolResourceLocks,
22}
23
24#[derive(Clone)]
25struct StoredSessionRestore {
26 snapshot: AgentSnapshot,
27 metadata: Option<ai_agents_core::SessionMetadata>,
28}
29
30struct RuntimeSessionRestorePoint {
31 snapshot: AgentSnapshot,
32 metadata: ai_agents_core::SessionMetadata,
33 actor_id: Option<String>,
34 session_id: Option<String>,
35}
36
37impl Drop for ToolResourceGuards {
38 fn drop(&mut self) {
39 self.guards.clear();
40 self.locks.write().retain(|_, lock| lock.strong_count() > 0);
41 }
42}
43
44#[derive(Clone)]
48struct RuntimeSafetySnapshot {
49 version: u64,
50 emergency_deny: bool,
51 tool_security: ToolSecurityEngine,
52 tool_scope_override: Option<Vec<String>>,
53}
54
55#[derive(Clone, Copy)]
59struct ToolDecisionVersions {
60 policy: u64,
61 registry: u64,
62 runtime_control: u64,
63 state: Option<u64>,
64}
65
66struct AvailableToolIdsSnapshot {
70 tool_ids: Vec<String>,
71 state_generation: Option<u64>,
72}
73
74#[derive(Clone)]
78struct ToolApprovalBinding {
79 canonical_id: String,
80 arguments: Value,
81 confirmation_required: bool,
82 policy_version: u64,
83 runtime_control_version: u64,
84 state_generation: Option<u64>,
85 reviewed_tool: Arc<dyn ai_agents_core::Tool>,
86}
87
88fn merge_approved_record(record: &mut Option<ToolApprovalRecord>) {
92 if record
93 .as_ref()
94 .is_some_and(|record| matches!(record.status, ToolApprovalStatus::Modified))
95 {
96 return;
97 }
98 *record = Some(ToolApprovalRecord {
99 status: ToolApprovalStatus::Approved,
100 reason: None,
101 modified_arguments: None,
102 });
103}
104
105impl ToolApprovalBinding {
106 fn is_stale(
108 &self,
109 canonical_id: &str,
110 arguments: &Value,
111 confirmation_required: bool,
112 versions: ToolDecisionVersions,
113 resolved_tool: &Arc<dyn ai_agents_core::Tool>,
114 ) -> bool {
115 self.canonical_id != canonical_id
116 || self.arguments != *arguments
117 || self.confirmation_required != confirmation_required
118 || self.policy_version != versions.policy
119 || self.runtime_control_version != versions.runtime_control
120 || self.state_generation != versions.state
121 || !Arc::ptr_eq(&self.reviewed_tool, resolved_tool)
122 }
123}
124
125use crate::turn_context::{current_turn_actor_context, scope_actor_context};
126
127use ai_agents_context::{ContextManager, ContextProvider, TemplateRenderer};
128use ai_agents_core::traits::storage::StorageCapability;
129use ai_agents_core::{
130 AgentError, AgentSnapshot, AgentStorage, ChatMessage, FinishReason, LLMError, LLMProvider,
131 LLMResponse, LLMToolDefinition, LLMToolRequest, PermissionOutcome, Result, ToolActorContext,
132 ToolApprovalRecord, ToolApprovalStatus, ToolCallSource, ToolCancellationToken, ToolChoice,
133 ToolExecutionContext, ToolExecutionRecord, ToolExecutionRequest, ToolInvoker,
134 ToolPolicyDecisionRecord, ToolResult,
135};
136use ai_agents_disambiguation::{
137 ClarificationObserver, ClarificationParseFuture, ClarificationQuestionFuture,
138 DisambiguationConfig, DisambiguationContext, DisambiguationManager, DisambiguationResult,
139};
140use ai_agents_hitl::{
141 ApprovalHandler, ApprovalResolvedOutcome, ApprovalResult, ApprovalTrigger, HITLCheckResult,
142 HITLEngine, RejectAllHandler, TimeoutAction,
143};
144use ai_agents_hooks::{AgentHooks, NoopHooks};
145use ai_agents_llm::LLMRegistry;
146use ai_agents_memory::{
147 CompressResult, EvictionReason, Memory, MemoryBudgetEvent, MemoryCompressEvent,
148 MemoryEvictEvent, MemoryTokenBudget, OverflowStrategy,
149};
150use ai_agents_observability::{
151 EventStatus, EventType, ObservabilityManager, ObservationPurpose, SpanContext,
152 current_observation_context, new_session_id as new_observation_session_id,
153 resolve_language_from_context, with_observation_context, with_observation_purpose,
154};
155use ai_agents_process::{
156 ProcessData, ProcessProcessor, ProcessPurposeHint, ProcessStageFuture, ProcessStageObserver,
157};
158use ai_agents_reasoning::{
159 CriterionResult, EvaluationResult, Plan, PlanAction, PlanStatus, PlanStep, ReasoningConfig,
160 ReasoningMetadata, ReasoningMode, ReasoningOutput, ReflectionAttempt, ReflectionConfig,
161 ReflectionMetadata, StepFailureAction,
162};
163use ai_agents_recovery::{
164 ByRoleFilter, ContextOverflowAction, FilterConfig, IntoClassifiedError, KeepRecentFilter,
165 LLMFailureAction, MessageFilter, RecoveryManager, SkipPatternFilter, ToolFailureAction,
166};
167use ai_agents_relationships::RelationshipManager;
168use ai_agents_skills::{SkillDefinition, SkillExecutor, SkillRouter};
169use ai_agents_state::{
170 PromptMode, StateAction, StateMachine, StateMachineSnapshot, StateTransitionEvent, Transition,
171 TransitionContext, TransitionEvaluator, TransitionTiming, evaluate_guard,
172};
173use ai_agents_storage::{StorageConfig as StorageStorageConfig, create_storage};
174use ai_agents_tools::{
175 CommandRunner, ConditionEvaluator, DiagnosticsProvider, EvaluationContext, LLMGetter,
176 QuestionHandler, SecurityCheckResult, TodoItem, ToolCallRecord, ToolRegistry,
177 ToolSecurityConfig, ToolSecurityEngine,
178};
179
180use super::{
181 Agent, AgentInfo, AgentResponse, ParallelToolsConfig, StreamChunk, StreamingConfig, ToolCall,
182};
183use crate::optimization::{
184 AwaitBeforeNextTurn, BackgroundMaintenanceQueue, BackgroundOverflowPolicy, MainResponseDraft,
185 MaintenanceMode, MaintenanceSequenceKey, RuntimeBranch, RuntimeBranchResult,
186 RuntimeBranchStatus, RuntimeCommitBehavior, RuntimeConfig, RuntimeOptimizationKind,
187 RuntimeTaskPriority, RuntimeTaskPurpose, ScheduledBranchSet, SkillCandidate,
188 StreamingDraftResult, TransitionCandidate, TurnBranchScheduler, TurnOptimizationContext,
189};
190use crate::spec::StorageConfig;
191
192enum ToolCallOutcome {
194 Continue,
196 TransitionFired,
198 Rejected(AgentResponse),
200}
201
202#[derive(Clone)]
203struct MainToolProtocol {
204 choice: Option<ToolChoice>,
205 tool_ids: Vec<String>,
206 definitions: Vec<LLMToolDefinition>,
207}
208
209struct MainProviderResponse {
210 response: LLMResponse,
211 used_native_tools: bool,
212}
213
214struct CommittedTextResponse<'a> {
218 processed_input: &'a str,
219 input_context: &'a HashMap<String, Value>,
220 answer: String,
221 reasoning_mode: ReasoningMode,
222 auto_detected: bool,
223 iterations: u32,
224 thinking_content: Option<String>,
225 all_tool_calls: Vec<ToolCall>,
226}
227
228struct AgentResponseParts {
232 content: String,
233 all_tool_calls: Vec<ToolCall>,
234 reasoning_mode: ReasoningMode,
235 auto_detected: bool,
236 iterations: u32,
237 thinking: Option<String>,
238 reflection_metadata: Option<ReflectionMetadata>,
239}
240
241enum SkillRouteResult {
243 NoMatch,
245 Response { skill_id: String, content: String },
247 NeedsClarification(AgentResponse),
249}
250
251enum ParallelTransitionSelection {
253 Candidate(TransitionCandidate),
255 NoMatch,
257 ReservationExhausted,
259}
260
261enum PostLoopResult {
263 NoTransition(String),
265 Transitioned(String),
267 NeedsRedispatch,
270}
271
272struct RootTurnCleanup<'a> {
273 agent: &'a RuntimeAgent,
274}
275
276impl<'a> RootTurnCleanup<'a> {
277 fn new(agent: &'a RuntimeAgent) -> Self {
278 Self { agent }
279 }
280}
281
282impl Drop for RootTurnCleanup<'_> {
283 fn drop(&mut self) {
284 self.agent.end_root_turn();
285 }
286}
287
288#[derive(Debug)]
290struct RuntimeControlState {
291 snapshot_guard: RwLock<()>,
293 version: AtomicU64,
295 emergency_deny: Arc<AtomicBool>,
297 tool_security_override: RwLock<Option<ToolSecurityEngine>>,
299 tool_scope_override: RwLock<Option<Vec<String>>>,
301}
302
303impl Default for RuntimeControlState {
304 fn default() -> Self {
305 Self {
306 snapshot_guard: RwLock::new(()),
307 version: AtomicU64::new(1),
308 emergency_deny: Arc::new(AtomicBool::new(false)),
309 tool_security_override: RwLock::new(None),
310 tool_scope_override: RwLock::new(None),
311 }
312 }
313}
314
315#[derive(Clone)]
317pub struct RuntimeControlHandle {
318 state: Arc<RuntimeControlState>,
319}
320
321impl RuntimeControlHandle {
322 pub fn version(&self) -> u64 {
324 self.state.version.load(Ordering::SeqCst)
325 }
326
327 fn bump(&self) -> u64 {
328 self.state.version.fetch_add(1, Ordering::SeqCst) + 1
329 }
330
331 pub fn set_tool_security(&self, config: ToolSecurityConfig) -> u64 {
333 self.try_set_tool_security(config)
334 .expect("invalid tool security configuration")
335 }
336
337 pub fn try_set_tool_security(&self, config: ToolSecurityConfig) -> Result<u64> {
339 config.validate()?;
340 let _guard = self.state.snapshot_guard.write();
341 let generation = self.bump();
342 *self.state.tool_security_override.write() = Some(
343 ToolSecurityEngine::new_with_policy_version(config, generation),
344 );
345 Ok(generation)
346 }
347
348 pub fn clear_tool_security_override(&self) -> u64 {
350 let _guard = self.state.snapshot_guard.write();
351 *self.state.tool_security_override.write() = None;
352 self.bump()
353 }
354
355 pub fn set_tool_scope(&self, tool_ids: Vec<String>) -> u64 {
357 let _guard = self.state.snapshot_guard.write();
358 *self.state.tool_scope_override.write() = Some(tool_ids);
359 self.bump()
360 }
361
362 pub fn clear_tool_scope_override(&self) -> u64 {
364 let _guard = self.state.snapshot_guard.write();
365 *self.state.tool_scope_override.write() = None;
366 self.bump()
367 }
368
369 pub fn set_emergency_deny(&self, enabled: bool) -> u64 {
371 let _guard = self.state.snapshot_guard.write();
372 self.state.emergency_deny.store(enabled, Ordering::SeqCst);
373 self.bump()
374 }
375
376 pub fn cancel_all(&self) -> u64 {
378 self.set_emergency_deny(true)
379 }
380}
381
382pub struct RuntimeAgent {
383 info: AgentInfo,
384 llm_registry: Arc<LLMRegistry>,
385 memory: Arc<dyn Memory>,
386 tools: Arc<ToolRegistry>,
387 skills: Vec<SkillDefinition>,
388 skill_router: Option<SkillRouter>,
389 skill_executor: Option<SkillExecutor>,
390 base_system_prompt: String,
391 max_iterations: u32,
392 iteration_count: RwLock<u32>,
393 max_context_tokens: u32,
394 memory_token_budget: Option<MemoryTokenBudget>,
395 recovery_manager: RecoveryManager,
396 tool_security: ToolSecurityEngine,
397 process_processor: Option<ProcessProcessor>,
398 message_filters: RwLock<HashMap<String, Arc<dyn MessageFilter>>>,
399 state_machine: Option<Arc<StateMachine>>,
400 transition_evaluator: Option<Arc<dyn TransitionEvaluator>>,
401 context_manager: Arc<ContextManager>,
402 template_renderer: TemplateRenderer,
403 tool_call_history: RwLock<Vec<ToolCallRecord>>,
404 parallel_tools: ParallelToolsConfig,
405 streaming: StreamingConfig,
406 hooks: Arc<dyn AgentHooks>,
407 hitl_engine: Option<HITLEngine>,
408 approval_handler: Arc<dyn ApprovalHandler>,
409 storage_config: StorageConfig,
410 storage: RwLock<Option<Arc<dyn AgentStorage>>>,
411 storage_init: tokio::sync::Mutex<()>,
412 reasoning_config: ReasoningConfig,
413 reflection_config: ReflectionConfig,
414 disambiguation_manager: Option<DisambiguationManager>,
415 persona_manager: Option<Arc<ai_agents_persona::PersonaManager>>,
417 pending_skill_id: RwLock<Option<String>>,
421 current_plan: RwLock<Option<Plan>>,
422 declared_tool_ids: Option<Vec<String>>,
424 context_initialized: AtomicBool,
426 spawner: Option<Arc<crate::spawner::AgentSpawner>>,
428 spawner_registry: Option<Arc<crate::spawner::AgentRegistry>>,
430 redispatch_depth: RwLock<u32>,
433 active_turn_context: RwLock<Option<TurnOptimizationContext>>,
435 root_user_message_committed: AtomicBool,
437 actor_id: RwLock<Option<String>>,
439 fact_store: RwLock<Option<Arc<ai_agents_facts::FactStore>>>,
441 fact_extractor: RwLock<Option<Arc<dyn ai_agents_facts::FactExtractor>>>,
444 actor_facts_cache: Arc<RwLock<HashMap<String, Vec<ai_agents_core::KeyFact>>>>,
446 messages_since_extraction: Arc<RwLock<usize>>,
448 actor_memory_config: Option<ai_agents_facts::ActorMemoryConfig>,
450 facts_config: Option<ai_agents_facts::FactsConfig>,
452 session_metadata: RwLock<ai_agents_core::SessionMetadata>,
454 current_session_id: RwLock<Option<String>>,
456 relationship_manager: Option<Arc<RelationshipManager>>,
458 observability_manager: Option<Arc<ObservabilityManager>>,
460 runtime_config: RuntimeConfig,
462 background_maintenance: Arc<BackgroundMaintenanceQueue>,
464 resource_locks: ToolResourceLocks,
466 runtime_control: Arc<RuntimeControlState>,
468}
469
470impl std::fmt::Debug for RuntimeAgent {
471 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
472 f.debug_struct("RuntimeAgent")
473 .field("info", &self.info)
474 .field("base_system_prompt", &self.base_system_prompt)
475 .field("max_iterations", &self.max_iterations)
476 .field("skills_count", &self.skills.len())
477 .field("max_context_tokens", &self.max_context_tokens)
478 .field("has_state_machine", &self.state_machine.is_some())
479 .field("parallel_tools", &self.parallel_tools)
480 .field("streaming", &self.streaming)
481 .field("has_hooks", &true)
482 .field("has_hitl", &self.hitl_engine.is_some())
483 .field("storage_type", &self.storage_config.storage_type())
484 .field("reasoning_mode", &self.reasoning_config.mode)
485 .field("reflection_enabled", &self.reflection_config.enabled)
486 .field("declared_tool_ids", &self.declared_tool_ids)
487 .field("has_persona", &self.persona_manager.is_some())
488 .field("has_observability", &self.observability_manager.is_some())
489 .finish_non_exhaustive()
490 }
491}
492
493struct ObservabilityClarificationObserver;
494
495impl ClarificationObserver for ObservabilityClarificationObserver {
496 fn observe_question<'a>(
498 &'a self,
499 future: ClarificationQuestionFuture<'a>,
500 ) -> ClarificationQuestionFuture<'a> {
501 Box::pin(async move {
502 with_observation_purpose(ObservationPurpose::DisambiguationClarification, future).await
503 })
504 }
505
506 fn observe_parse<'a>(
508 &'a self,
509 future: ClarificationParseFuture<'a>,
510 ) -> ClarificationParseFuture<'a> {
511 Box::pin(async move {
512 with_observation_purpose(ObservationPurpose::DisambiguationClarification, future).await
513 })
514 }
515}
516
517struct ObservabilityProcessStageObserver;
518
519impl ProcessStageObserver for ObservabilityProcessStageObserver {
520 fn observe<'a>(
522 &'a self,
523 hint: ProcessPurposeHint,
524 future: ProcessStageFuture<'a>,
525 ) -> ProcessStageFuture<'a> {
526 Box::pin(async move {
527 with_observation_purpose(observation_purpose_for_process(hint), future).await
528 })
529 }
530}
531
532struct RegistryLLMGetter {
533 registry: Arc<LLMRegistry>,
534}
535
536impl LLMGetter for RegistryLLMGetter {
537 fn get_llm(&self, alias: &str) -> Option<Arc<dyn LLMProvider>> {
538 self.registry.get(alias).ok()
539 }
540}
541
542impl RuntimeAgent {
543 #[allow(clippy::too_many_arguments)]
544 pub fn new(
545 info: AgentInfo,
546 llm_registry: Arc<LLMRegistry>,
547 memory: Arc<dyn Memory>,
548 tools: Arc<ToolRegistry>,
549 skills: Vec<SkillDefinition>,
550 system_prompt: String,
551 max_iterations: u32,
552 ) -> Self {
553 let (skill_router, skill_executor) = if !skills.is_empty() {
554 let router_llm = llm_registry.router().ok();
555 let router = router_llm.map(|llm| SkillRouter::new(llm, skills.clone()));
556 let executor = SkillExecutor::new(llm_registry.clone(), tools.clone());
557 (router, Some(executor))
558 } else {
559 (None, None)
560 };
561
562 let context_manager =
563 ContextManager::new(HashMap::new(), info.name.clone(), info.version.clone());
564
565 Self {
566 info,
567 llm_registry,
568 memory,
569 tools,
570 skills,
571 skill_router,
572 skill_executor,
573 base_system_prompt: system_prompt,
574 max_iterations,
575 iteration_count: RwLock::new(0),
576 max_context_tokens: 128000,
577 memory_token_budget: None,
578 recovery_manager: RecoveryManager::default(),
579 tool_security: ToolSecurityEngine::default(),
580 process_processor: None,
581 message_filters: RwLock::new(HashMap::new()),
582 state_machine: None,
583 transition_evaluator: None,
584 context_manager: Arc::new(context_manager),
585 template_renderer: TemplateRenderer::new(),
586 tool_call_history: RwLock::new(Vec::new()),
587 parallel_tools: ParallelToolsConfig::default(),
588 streaming: StreamingConfig::default(),
589 hooks: Arc::new(NoopHooks),
590 hitl_engine: None,
591 approval_handler: Arc::new(RejectAllHandler::new()),
592 storage_config: StorageConfig::default(),
593 storage: RwLock::new(None),
594 storage_init: tokio::sync::Mutex::new(()),
595 reasoning_config: ReasoningConfig::default(),
596 reflection_config: ReflectionConfig::default(),
597 disambiguation_manager: None,
598 persona_manager: None,
599 pending_skill_id: RwLock::new(None),
600 current_plan: RwLock::new(None),
601 declared_tool_ids: None,
602 context_initialized: AtomicBool::new(false),
603 spawner: None,
604 spawner_registry: None,
605 redispatch_depth: RwLock::new(0),
606 active_turn_context: RwLock::new(None),
607 root_user_message_committed: AtomicBool::new(false),
608 actor_id: RwLock::new(None),
609 fact_store: RwLock::new(None),
610 fact_extractor: RwLock::new(None),
611 actor_facts_cache: Arc::new(RwLock::new(HashMap::new())),
612 messages_since_extraction: Arc::new(RwLock::new(0)),
613 actor_memory_config: None,
614 facts_config: None,
615 session_metadata: RwLock::new(ai_agents_core::SessionMetadata::default()),
616 current_session_id: RwLock::new(None),
617 relationship_manager: None,
618 observability_manager: None,
619 runtime_config: RuntimeConfig::default(),
620 background_maintenance: Arc::new(BackgroundMaintenanceQueue::default()),
621 resource_locks: new_tool_resource_locks(),
622 runtime_control: Arc::new(RuntimeControlState::default()),
623 }
624 }
625
626 pub fn with_declared_tool_ids(mut self, ids: Option<Vec<String>>) -> Self {
627 self.declared_tool_ids = ids;
628 self
629 }
630
631 pub fn with_storage_config(mut self, config: StorageConfig) -> Self {
632 self.storage_config = config;
633 self
634 }
635
636 pub fn with_storage(self, storage: Arc<dyn AgentStorage>) -> Self {
637 *self.storage.write() = Some(storage);
638 self
639 }
640
641 pub(crate) fn with_shared_resource_locks(mut self, locks: ToolResourceLocks) -> Self {
642 self.resource_locks = locks;
643 self
644 }
645
646 pub fn with_reasoning(mut self, config: ReasoningConfig) -> Self {
647 self.reasoning_config = config;
648 self
649 }
650
651 pub fn with_reflection(mut self, config: ReflectionConfig) -> Self {
652 self.reflection_config = config;
653 self
654 }
655
656 pub fn with_relationships(mut self, manager: Arc<RelationshipManager>) -> Self {
658 self.relationship_manager = Some(manager);
659 self
660 }
661
662 pub fn with_observability(mut self, manager: Arc<ObservabilityManager>) -> Self {
664 self.observability_manager = Some(manager);
665 self
666 }
667
668 pub fn with_runtime_config(mut self, config: RuntimeConfig) -> Self {
670 let max_tasks = config.optimization.post_turn.max_background_tasks;
671 self.background_maintenance = Arc::new(BackgroundMaintenanceQueue::new(max_tasks));
672 self.runtime_config = config;
673 self
674 }
675
676 pub fn runtime_config(&self) -> &RuntimeConfig {
678 &self.runtime_config
679 }
680
681 pub async fn flush_background_tasks(&self) -> Result<()> {
683 self.background_maintenance.flush_all().await
684 }
685
686 pub async fn flush_background_tasks_for_actor(&self, actor_id: &str) -> Result<()> {
688 self.background_maintenance.flush_scope(actor_id).await
689 }
690
691 pub async fn flush_background_tasks_for_purpose(
693 &self,
694 purpose: RuntimeTaskPurpose,
695 ) -> Result<()> {
696 self.background_maintenance.flush_purpose(purpose).await
697 }
698
699 pub async fn flush_background_tasks_for_actor_purpose(
701 &self,
702 actor_id: &str,
703 purpose: RuntimeTaskPurpose,
704 ) -> Result<()> {
705 self.background_maintenance
706 .flush_scope_purpose(actor_id, purpose)
707 .await
708 }
709
710 pub async fn shutdown_background_tasks(&self) -> Result<()> {
712 self.flush_background_tasks().await
713 }
714
715 pub fn observability(&self) -> Option<Arc<ObservabilityManager>> {
717 self.observability_manager.clone()
718 }
719
720 async fn export_observability_if_configured(&self) {
722 let Some(manager) = self.observability_manager.as_ref() else {
723 return;
724 };
725 let export = &manager.config().export;
726 if !export.write_report && !export.write_raw_events {
727 return;
728 }
729 if let Err(error) = manager.export().await {
730 warn!(error = %error, "Observability export failed");
731 }
732 }
733
734 pub fn relationship_manager(&self) -> Option<Arc<RelationshipManager>> {
736 self.relationship_manager.clone()
737 }
738
739 fn current_turn_actor_context(&self) -> Option<crate::TurnActorContext> {
740 current_turn_actor_context()
741 }
742
743 fn effective_actor_id(&self) -> Option<String> {
744 self.current_turn_actor_context()
745 .and_then(|ctx| ctx.effective_actor_id().map(|id| id.to_string()))
746 .or_else(|| self.actor_id.read().clone())
747 }
748
749 fn effective_origin_actor_id(&self) -> Option<String> {
750 self.current_turn_actor_context()
751 .and_then(|ctx| ctx.origin_actor_id.clone())
752 .or_else(|| self.actor_id.read().clone())
753 }
754
755 fn record_session_actor_if_needed(&self) {
756 if let Some(actor_id) = self.effective_origin_actor_id() {
757 let mut meta = self.session_metadata.write();
758 meta.actor_id = Some(actor_id.clone());
759 if !meta.actors.iter().any(|a| a == &actor_id) {
760 meta.actors.push(actor_id);
761 }
762 }
763 }
764
765 fn outbound_actor_context(&self) -> crate::TurnActorContext {
766 let mut context = self.current_turn_actor_context().unwrap_or_default();
767 if context.origin_actor_id.is_none() {
768 context.origin_actor_id = self.effective_origin_actor_id();
769 }
770 context.sender_agent_id = Some(self.info.id.clone());
771 context
772 }
773
774 fn observation_session_id(&self) -> Option<String> {
776 let mut current = self.current_session_id.write();
777 if current.is_none() {
778 *current = Some(new_observation_session_id());
779 }
780 current.clone()
781 }
782
783 fn build_observation_context(&self, actor_id: Option<String>) -> Option<SpanContext> {
785 let manager = self.observability_manager.as_ref()?;
786 let context = self.build_context_with_overlays();
787 let language = resolve_language_from_context(manager.config(), &context);
788 let context = current_observation_context()
789 .map(|parent| parent.child_for_agent(self.info.id.clone()).with_new_turn())
790 .unwrap_or_else(|| SpanContext::new_root(self.info.id.clone()));
791 Some(
792 context
793 .with_actor(actor_id.or_else(|| self.effective_actor_id()))
794 .with_session(self.observation_session_id())
795 .with_state(self.current_state())
796 .with_language(Some(language)),
797 )
798 }
799
800 fn current_runtime_observation_context(
802 &self,
803 purpose: ObservationPurpose,
804 ) -> Option<SpanContext> {
805 let manager = self.observability_manager.as_ref()?;
806 let context = self.build_context_with_overlays();
807 let language = resolve_language_from_context(manager.config(), &context);
808 let mut observation = current_observation_context()
809 .unwrap_or_else(|| SpanContext::new_root(self.info.id.clone()));
810 observation.agent_id = self.info.id.clone();
811 observation.actor_id = self.effective_actor_id();
812 observation.session_id = self.observation_session_id();
813 observation.state = self.current_state();
814 observation.language = Some(language);
815 observation.purpose = purpose;
816 Some(observation)
817 }
818
819 async fn observe_purpose<F, T>(&self, purpose: ObservationPurpose, future: F) -> T
821 where
822 F: Future<Output = T>,
823 {
824 if let Some(context) = self.current_runtime_observation_context(purpose) {
825 with_observation_context(context, future).await
826 } else {
827 future.await
828 }
829 }
830
831 fn chat_with_actor_context_boxed<'a>(
833 &'a self,
834 input: &'a str,
835 actor_context: crate::TurnActorContext,
836 ) -> Pin<Box<dyn Future<Output = Result<AgentResponse>> + Send + 'a>> {
837 Box::pin(async move {
838 let actor_id = actor_context.effective_actor_id().map(str::to_string);
839 let run = async move {
840 scope_actor_context(
841 actor_context,
842 Box::pin(async move { self.run_loop(input).await }),
843 )
844 .await
845 };
846 let result = if let Some(context) = self.build_observation_context(actor_id) {
847 with_observation_context(context, run).await
848 } else {
849 run.await
850 };
851 self.export_observability_if_configured().await;
852 result
853 })
854 }
855
856 pub async fn chat_with_actor_context(
860 &self,
861 input: &str,
862 actor_context: crate::TurnActorContext,
863 ) -> Result<AgentResponse> {
864 self.chat_with_actor_context_boxed(input, actor_context)
865 .await
866 }
867
868 pub async fn chat_as_actor(&self, actor_id: &str, input: &str) -> Result<AgentResponse> {
870 let actor_context = crate::TurnActorContext::new().with_origin_actor(actor_id);
871 self.chat_with_actor_context(input, actor_context).await
872 }
873
874 pub async fn load_actor_relationship(&self) -> Result<()> {
876 self.maybe_load_actor_relationship().await;
877 Ok(())
878 }
879
880 pub async fn update_relationship_dimension(
882 &self,
883 dimension: &str,
884 delta: f64,
885 reason: Option<&str>,
886 ) -> Result<ai_agents_relationships::DimensionChange> {
887 self.update_relationship_dimension_for_perspective(
888 ai_agents_relationships::RelationshipPerspective::AgentToActor,
889 dimension,
890 delta,
891 reason,
892 )
893 .await
894 }
895
896 pub async fn update_relationship_dimension_for_perspective(
900 &self,
901 perspective: ai_agents_relationships::RelationshipPerspective,
902 dimension: &str,
903 delta: f64,
904 reason: Option<&str>,
905 ) -> Result<ai_agents_relationships::DimensionChange> {
906 let manager = self
907 .relationship_manager
908 .as_ref()
909 .ok_or_else(|| AgentError::Config("Relationship memory is not configured".into()))?;
910 let actor_id = self.effective_actor_id().ok_or_else(|| {
911 AgentError::Config("No actor ID set. Use set_actor_id() first".into())
912 })?;
913 let change = manager.update_dimension_for_perspective(
914 &actor_id,
915 perspective,
916 dimension,
917 delta,
918 1.0,
919 reason.unwrap_or("manual relationship update"),
920 )?;
921 self.persist_actor_relationship(&actor_id).await?;
922 info!(
923 actor_id = %actor_id,
924 perspective = %change.perspective,
925 dimension = %change.dimension,
926 delta = change.delta,
927 current = change.current,
928 "relationship updated manually"
929 );
930 self.hooks
931 .on_relationship_change(&actor_id, std::slice::from_ref(&change))
932 .await;
933 Ok(change)
934 }
935
936 pub fn reasoning_config(&self) -> &ReasoningConfig {
937 &self.reasoning_config
938 }
939
940 pub fn reflection_config(&self) -> &ReflectionConfig {
941 &self.reflection_config
942 }
943
944 pub fn with_facts_config(
947 mut self,
948 actor_memory_config: Option<ai_agents_facts::ActorMemoryConfig>,
949 facts_config: Option<ai_agents_facts::FactsConfig>,
950 ) -> Self {
951 self.actor_memory_config = actor_memory_config;
952 self.facts_config = facts_config;
953 self
954 }
955
956 pub fn with_facts(
959 mut self,
960 store: Arc<ai_agents_facts::FactStore>,
961 extractor: Option<Arc<dyn ai_agents_facts::FactExtractor>>,
962 actor_memory_config: Option<ai_agents_facts::ActorMemoryConfig>,
963 facts_config: Option<ai_agents_facts::FactsConfig>,
964 ) -> Self {
965 *self.fact_store.write() = Some(store);
966 *self.fact_extractor.write() = extractor;
967 self.actor_memory_config = actor_memory_config;
968 self.facts_config = facts_config;
969 self
970 }
971
972 pub fn fact_store(&self) -> Option<Arc<ai_agents_facts::FactStore>> {
974 self.fact_store.read().clone()
975 }
976
977 pub fn actor_id(&self) -> Option<String> {
979 self.actor_id.read().clone()
980 }
981
982 pub fn set_actor_id(&self, actor_id: &str) -> ai_agents_core::Result<()> {
984 *self.actor_id.write() = Some(actor_id.to_string());
985 {
986 let mut meta = self.session_metadata.write();
987 meta.actor_id = Some(actor_id.to_string());
988 if !meta.actors.iter().any(|a| a == actor_id) {
989 meta.actors.push(actor_id.to_string());
990 }
991 }
992 Ok(())
993 }
994
995 pub fn clear_actor_id(&self) {
997 *self.actor_id.write() = None;
998 self.session_metadata.write().actor_id = None;
999 }
1000
1001 pub fn set_user_id(&self, user_id: &str) -> ai_agents_core::Result<()> {
1003 self.set_actor_id(user_id)
1004 }
1005
1006 pub async fn load_actor_memory(&self) -> ai_agents_core::Result<()> {
1008 let actor_id = match self.effective_actor_id() {
1009 Some(id) => id,
1010 None => return Ok(()),
1011 };
1012
1013 let store_opt = self.fact_store.read().clone();
1014 if let Some(store) = store_opt {
1015 let facts = store.get_facts(&actor_id).await?;
1016 let count = facts.len();
1017 self.actor_facts_cache
1018 .write()
1019 .insert(actor_id.clone(), facts);
1020 self.hooks.on_actor_memory_loaded(&actor_id, count).await;
1021 tracing::debug!("loaded {} facts for actor {}", count, actor_id);
1022 }
1023
1024 Ok(())
1025 }
1026
1027 async fn maybe_load_actor_memory(&self) {
1029 let Some(actor_id) = self.effective_actor_id() else {
1030 return;
1031 };
1032 if self.actor_facts_cache.read().contains_key(&actor_id) {
1033 return;
1034 }
1035 let _ = self.load_actor_memory().await;
1036 }
1037
1038 async fn pre_turn_session_lifecycle(&self) {
1040 if *self.redispatch_depth.read() > 0 {
1041 return;
1042 }
1043 self.resolve_actor_id_from_context();
1044 self.await_background_before_next_turn().await;
1045 self.record_session_actor_if_needed();
1046 self.maybe_load_actor_memory().await;
1047 self.maybe_load_actor_relationship().await;
1048 *self.messages_since_extraction.write() += 1;
1049 }
1050
1051 async fn post_turn_session_lifecycle(&self) -> Result<()> {
1053 if *self.redispatch_depth.read() > 0 {
1054 return Ok(());
1055 }
1056 *self.messages_since_extraction.write() += 1;
1057 self.run_post_turn_maintenance().await
1058 }
1059
1060 fn begin_root_turn(&self) {
1062 if *self.redispatch_depth.read() == 0 {
1063 let mut guard = self.active_turn_context.write();
1064 if guard.is_none() {
1065 self.root_user_message_committed
1066 .store(false, Ordering::SeqCst);
1067 let max_calls = self
1068 .runtime_config
1069 .optimization
1070 .max_speculative_llm_calls_per_turn;
1071 *guard = Some(TurnOptimizationContext::new(
1072 String::new(),
1073 HashMap::new(),
1074 max_calls,
1075 ));
1076 }
1077 }
1078 }
1079
1080 fn update_active_turn_context(
1081 &self,
1082 processed_input: &str,
1083 input_context: HashMap<String, Value>,
1084 ) {
1085 if *self.redispatch_depth.read() > 0 {
1086 return;
1087 }
1088 let max_calls = self
1089 .runtime_config
1090 .optimization
1091 .max_speculative_llm_calls_per_turn;
1092 let mut guard = self.active_turn_context.write();
1093 match guard.as_mut() {
1094 Some(context) => {
1095 context.processed_input = processed_input.to_string();
1096 context.input_context = input_context;
1097 context.max_speculative_llm_calls = max_calls;
1098 }
1099 None => {
1100 *guard = Some(TurnOptimizationContext::new(
1101 processed_input,
1102 input_context,
1103 max_calls,
1104 ));
1105 }
1106 }
1107 }
1108
1109 async fn commit_root_user_message(&self, processed_input: &str) -> Result<()> {
1111 if *self.redispatch_depth.read() > 0 {
1112 return Ok(());
1113 }
1114 if !self
1115 .root_user_message_committed
1116 .swap(true, Ordering::SeqCst)
1117 {
1118 self.memory
1119 .add_message(ChatMessage::user(processed_input))
1120 .await?;
1121 if let Some(context) = self.active_turn_context.write().as_mut() {
1122 context.mark_user_message_committed();
1123 }
1124 }
1125 Ok(())
1126 }
1127
1128 fn end_root_turn(&self) {
1130 if *self.redispatch_depth.read() == 0 {
1131 self.root_user_message_committed
1132 .store(false, Ordering::SeqCst);
1133 *self.active_turn_context.write() = None;
1134 }
1135 }
1136
1137 fn reserve_active_speculative_llm_call(&self, kind: RuntimeOptimizationKind) -> bool {
1138 self.begin_root_turn();
1139 let mut guard = self.active_turn_context.write();
1140 let Some(context) = guard.as_mut() else {
1141 return false;
1142 };
1143 context.reserve_speculative_llm_call_for(kind)
1144 }
1145
1146 fn branch_context_preview(&self) -> String {
1147 let context = self.build_context_with_overlays();
1148 let mut value = serde_json::to_string_pretty(&context).unwrap_or_else(|_| "{}".to_string());
1149 const MAX_CONTEXT_PREVIEW_CHARS: usize = 2048;
1150 if value.chars().count() > MAX_CONTEXT_PREVIEW_CHARS {
1151 value = value
1152 .chars()
1153 .take(MAX_CONTEXT_PREVIEW_CHARS)
1154 .collect::<String>();
1155 value.push_str("...");
1156 }
1157 value
1158 }
1159
1160 async fn await_background_before_next_turn(&self) {
1162 let optimization = &self.runtime_config.optimization;
1163 if !optimization.enabled {
1164 return;
1165 }
1166 let actor_id = self.effective_actor_id();
1167 let post = &optimization.post_turn;
1168 self.await_background_task(
1169 post.facts.await_before_next_turn,
1170 RuntimeTaskPurpose::PostTurnFacts,
1171 actor_id.as_deref(),
1172 "facts",
1173 )
1174 .await;
1175 self.await_background_task(
1176 post.relationships.await_before_next_turn,
1177 RuntimeTaskPurpose::PostTurnRelationship,
1178 actor_id.as_deref(),
1179 "relationships",
1180 )
1181 .await;
1182 }
1183
1184 async fn await_background_task(
1185 &self,
1186 policy: AwaitBeforeNextTurn,
1187 purpose: RuntimeTaskPurpose,
1188 actor_id: Option<&str>,
1189 label: &str,
1190 ) {
1191 match policy {
1192 AwaitBeforeNextTurn::Never => {}
1193 AwaitBeforeNextTurn::Always => {
1194 if let Err(error) = self.flush_background_tasks_for_purpose(purpose).await {
1195 warn!(label = label, error = %error, "background maintenance flush failed");
1196 }
1197 }
1198 AwaitBeforeNextTurn::SameActor => {
1199 if let Some(actor_id) = actor_id
1200 && let Err(error) = self
1201 .flush_background_tasks_for_actor_purpose(actor_id, purpose)
1202 .await
1203 {
1204 warn!(label = label, actor_id = %actor_id, error = %error, "actor background maintenance flush failed");
1205 }
1206 }
1207 }
1208 }
1209
1210 async fn run_post_turn_maintenance(&self) -> Result<()> {
1212 let optimization = &self.runtime_config.optimization;
1213 if !optimization.enabled {
1214 self.auto_extract_facts().await;
1215 self.auto_update_relationship().await;
1216 return Ok(());
1217 }
1218
1219 let facts_mode = effective_maintenance_mode(
1220 optimization.post_turn.facts.mode,
1221 optimization.parallel_post_turn_memory,
1222 );
1223 let relationships_mode = effective_maintenance_mode(
1224 optimization.post_turn.relationships.mode,
1225 optimization.parallel_post_turn_memory,
1226 );
1227
1228 match (facts_mode, relationships_mode) {
1229 (MaintenanceMode::InlineSerial, MaintenanceMode::InlineSerial) => {
1230 self.auto_extract_facts().await;
1231 self.auto_update_relationship().await;
1232 }
1233 (MaintenanceMode::InlineParallel, MaintenanceMode::InlineParallel) => {
1234 let facts = self.auto_extract_facts();
1235 let relationships = self.auto_update_relationship();
1236 tokio::join!(facts, relationships);
1237 }
1238 (MaintenanceMode::Background, MaintenanceMode::Background) => {
1239 self.schedule_facts_background().await?;
1240 self.schedule_relationship_background().await?;
1241 }
1242 (MaintenanceMode::Background, MaintenanceMode::InlineParallel)
1243 | (MaintenanceMode::Background, MaintenanceMode::InlineSerial) => {
1244 self.schedule_facts_background().await?;
1245 self.auto_update_relationship().await;
1246 }
1247 (MaintenanceMode::InlineParallel, MaintenanceMode::Background)
1248 | (MaintenanceMode::InlineSerial, MaintenanceMode::Background) => {
1249 self.auto_extract_facts().await;
1250 self.schedule_relationship_background().await?;
1251 }
1252 _ => {
1253 self.auto_extract_facts().await;
1254 self.auto_update_relationship().await;
1255 }
1256 }
1257 Ok(())
1258 }
1259
1260 async fn schedule_facts_background(&self) -> Result<()> {
1261 let policy = self.runtime_config.optimization.post_turn.facts.clone();
1262 let should_extract = self
1263 .facts_config
1264 .as_ref()
1265 .map(|c| c.enabled && c.auto_extract)
1266 .unwrap_or(false);
1267 if !should_extract {
1268 return Ok(());
1269 }
1270 let msgs_since = *self.messages_since_extraction.read();
1271 if msgs_since < 2 {
1272 return Ok(());
1273 }
1274 let Some(actor_id) = self.effective_actor_id() else {
1275 self.record_skipped_maintenance(
1276 "facts",
1277 ObservationPurpose::FactsExtraction,
1278 "missing_actor",
1279 Some(&policy),
1280 );
1281 return Ok(());
1282 };
1283 let Some(extractor) = self.fact_extractor.read().clone() else {
1284 return Ok(());
1285 };
1286 let messages = match self.memory.get_messages(None).await {
1287 Ok(messages) => messages,
1288 Err(error) => {
1289 warn!(error = %error, "failed to snapshot messages for fact extraction");
1290 return Ok(());
1291 }
1292 };
1293 let recent: Vec<_> = messages
1294 .iter()
1295 .rev()
1296 .take(msgs_since)
1297 .rev()
1298 .cloned()
1299 .collect();
1300 if recent.is_empty() {
1301 return Ok(());
1302 }
1303 let existing = self
1304 .actor_facts_cache
1305 .read()
1306 .get(&actor_id)
1307 .cloned()
1308 .unwrap_or_default();
1309 let categories = self
1310 .facts_config
1311 .as_ref()
1312 .map(|c| c.custom_categories.clone())
1313 .unwrap_or_default();
1314 let store = self.fact_store.read().clone();
1315 let cache = Arc::clone(&self.actor_facts_cache);
1316 let counter = Arc::clone(&self.messages_since_extraction);
1317 let hooks = Arc::clone(&self.hooks);
1318 let agent_id = self.info.id.clone();
1319 let observation = current_observation_context();
1320 let key = MaintenanceSequenceKey::actor(
1321 agent_id,
1322 actor_id.clone(),
1323 RuntimeTaskPurpose::PostTurnFacts,
1324 );
1325 let actor_for_task = actor_id.clone();
1326 let task = async move {
1327 let run = async move {
1328 let facts = extractor
1329 .extract(&recent, &existing, Some(&actor_for_task), &categories)
1330 .await?;
1331 if !facts.is_empty() {
1332 if let Some(store) = store {
1333 let authoritative = store.add_facts(&actor_for_task, facts.clone()).await?;
1334 cache.write().insert(actor_for_task.clone(), authoritative);
1335 } else {
1336 cache
1337 .write()
1338 .entry(actor_for_task.clone())
1339 .or_default()
1340 .extend(facts.clone());
1341 }
1342 {
1343 let mut count = counter.write();
1344 if *count <= msgs_since {
1345 *count = 0;
1346 } else {
1347 *count -= msgs_since;
1348 }
1349 }
1350 hooks.on_facts_extracted(&actor_for_task, &facts).await;
1351 }
1352 Ok(())
1353 };
1354 if let Some(context) = observation {
1355 with_observation_context(
1356 context.with_purpose(ObservationPurpose::FactsExtraction),
1357 run,
1358 )
1359 .await
1360 } else {
1361 run.await
1362 }
1363 };
1364 self.spawn_or_handle_background(Some(key), task, "facts", &policy)
1365 .await
1366 }
1367
1368 async fn schedule_relationship_background(&self) -> Result<()> {
1369 let policy = self
1370 .runtime_config
1371 .optimization
1372 .post_turn
1373 .relationships
1374 .clone();
1375 let Some(manager) = self.relationship_manager.as_ref().cloned() else {
1376 return Ok(());
1377 };
1378 let Some(actor_id) = self.effective_actor_id() else {
1379 self.record_skipped_maintenance(
1380 "relationships",
1381 ObservationPurpose::RelationshipUpdate,
1382 "missing_actor",
1383 Some(&policy),
1384 );
1385 return Ok(());
1386 };
1387 let recent_messages = manager.config().auto_update.recent_messages;
1388 let messages = match self.memory.get_messages(Some(recent_messages)).await {
1389 Ok(messages) => messages,
1390 Err(error) => {
1391 warn!(actor = %actor_id, error = %error, "failed to snapshot messages for relationship update");
1392 return Ok(());
1393 }
1394 };
1395 let storage = self.storage.read().clone();
1396 let hooks = Arc::clone(&self.hooks);
1397 let agent_id = self.info.id.clone();
1398 let observation = current_observation_context();
1399 let key = MaintenanceSequenceKey::actor(
1400 agent_id.clone(),
1401 actor_id.clone(),
1402 RuntimeTaskPurpose::PostTurnRelationship,
1403 );
1404 let actor_for_task = actor_id.clone();
1405 let task = async move {
1406 let run = async move {
1407 if manager.config().auto_update.enabled {
1408 let update = manager.auto_update(&actor_for_task, &messages).await?;
1409 if !update.changes.is_empty() {
1410 hooks
1411 .on_relationship_change(&actor_for_task, &update.changes)
1412 .await;
1413 }
1414 if let Some(ref event) = update.event {
1415 hooks.on_notable_event(&actor_for_task, event).await;
1416 }
1417 }
1418 if manager.config().persistence.enabled
1419 && let (Some(storage), Some(value)) =
1420 (storage, manager.relationship_as_value(&actor_for_task)?)
1421 {
1422 storage
1423 .save_relationship(&agent_id, &actor_for_task, &value)
1424 .await?;
1425 }
1426 Ok(())
1427 };
1428 if let Some(context) = observation {
1429 with_observation_context(
1430 context.with_purpose(ObservationPurpose::RelationshipUpdate),
1431 run,
1432 )
1433 .await
1434 } else {
1435 run.await
1436 }
1437 };
1438 self.spawn_or_handle_background(Some(key), task, "relationships", &policy)
1439 .await
1440 }
1441
1442 async fn spawn_or_handle_background<F>(
1444 &self,
1445 key: Option<MaintenanceSequenceKey>,
1446 task: F,
1447 label: &'static str,
1448 policy: &crate::optimization::config::MaintenanceTaskPolicy,
1449 ) -> Result<()>
1450 where
1451 F: Future<Output = Result<()>> + Send + 'static,
1452 {
1453 if self.background_maintenance.is_full() {
1454 match self
1455 .runtime_config
1456 .optimization
1457 .post_turn
1458 .on_background_overflow
1459 {
1460 BackgroundOverflowPolicy::RunInline => {
1461 record_background_maintenance_event(
1462 self.observability_manager.as_ref(),
1463 label,
1464 EventStatus::Success,
1465 0,
1466 "inline_overflow",
1467 None,
1468 Some(policy),
1469 );
1470 let start = Instant::now();
1471 match task.await {
1472 Ok(()) => record_background_maintenance_event(
1473 self.observability_manager.as_ref(),
1474 label,
1475 EventStatus::Success,
1476 start.elapsed().as_millis() as u64,
1477 "inline_completed",
1478 None,
1479 Some(policy),
1480 ),
1481 Err(error) => {
1482 warn!(label = label, error = %error, "inline maintenance fallback failed");
1483 record_background_maintenance_event(
1484 self.observability_manager.as_ref(),
1485 label,
1486 EventStatus::Error,
1487 start.elapsed().as_millis() as u64,
1488 "inline_failed",
1489 Some(error.to_string()),
1490 Some(policy),
1491 );
1492 return Err(error);
1493 }
1494 }
1495 }
1496 BackgroundOverflowPolicy::Drop => {
1497 self.record_skipped_maintenance(
1498 label,
1499 ObservationPurpose::Other(label.to_string()),
1500 "queue_full",
1501 Some(policy),
1502 );
1503 }
1504 BackgroundOverflowPolicy::Error => {
1505 record_background_maintenance_event(
1506 self.observability_manager.as_ref(),
1507 label,
1508 EventStatus::Error,
1509 0,
1510 "queue_full",
1511 None,
1512 Some(policy),
1513 );
1514 warn!(label = label, "background maintenance queue full");
1515 return Err(AgentError::Other(format!(
1516 "background maintenance queue is full for {}",
1517 label
1518 )));
1519 }
1520 }
1521 return Ok(());
1522 }
1523
1524 record_background_maintenance_event(
1525 self.observability_manager.as_ref(),
1526 label,
1527 EventStatus::Success,
1528 0,
1529 "scheduled",
1530 None,
1531 Some(policy),
1532 );
1533 let manager = self.observability_manager.clone();
1534 let policy_for_task = policy.clone();
1535 let observed_task = async move {
1536 let start = Instant::now();
1537 let result = task.await;
1538 match &result {
1539 Ok(()) => record_background_maintenance_event(
1540 manager.as_ref(),
1541 label,
1542 EventStatus::Success,
1543 start.elapsed().as_millis() as u64,
1544 "completed",
1545 None,
1546 Some(&policy_for_task),
1547 ),
1548 Err(error) => record_background_maintenance_event(
1549 manager.as_ref(),
1550 label,
1551 EventStatus::Error,
1552 start.elapsed().as_millis() as u64,
1553 "failed",
1554 Some(error.to_string()),
1555 Some(&policy_for_task),
1556 ),
1557 }
1558 result
1559 };
1560
1561 if let Err(error) = self.background_maintenance.spawn(key, observed_task) {
1562 record_background_maintenance_event(
1563 self.observability_manager.as_ref(),
1564 label,
1565 EventStatus::Error,
1566 0,
1567 "spawn_failed",
1568 Some(error.to_string()),
1569 Some(policy),
1570 );
1571 warn!(label = label, error = %error, "background maintenance spawn failed");
1572 return Err(error);
1573 }
1574 Ok(())
1575 }
1576
1577 fn record_skipped_maintenance(
1579 &self,
1580 label: &str,
1581 purpose: ObservationPurpose,
1582 reason: &str,
1583 policy: Option<&crate::optimization::config::MaintenanceTaskPolicy>,
1584 ) {
1585 if let Some(manager) = self.observability_manager.as_ref() {
1586 let mut tags = background_maintenance_tags(label, "skipped", Some(reason), policy);
1587 tags.insert("runtime.skip_reason".to_string(), reason.to_string());
1588 manager.record_lifecycle_event(
1589 EventType::MemoryOperation {
1590 operation: format!("{}_maintenance", label),
1591 },
1592 purpose,
1593 EventStatus::Skipped,
1594 0,
1595 tags,
1596 None,
1597 );
1598 }
1599 }
1600
1601 pub fn actor_facts(&self) -> Vec<ai_agents_core::KeyFact> {
1603 let Some(actor_id) = self.effective_actor_id() else {
1604 return Vec::new();
1605 };
1606 self.actor_facts_cache
1607 .read()
1608 .get(&actor_id)
1609 .cloned()
1610 .unwrap_or_default()
1611 }
1612
1613 pub fn relationship_memory_text(&self) -> Option<String> {
1615 self.format_relationship_for_context().map(|(_, text)| text)
1616 }
1617
1618 pub async fn extract_facts(
1620 &self,
1621 last_n: usize,
1622 ) -> ai_agents_core::Result<Vec<ai_agents_core::KeyFact>> {
1623 self.extract_facts_with_source(last_n, "manual").await
1624 }
1625
1626 async fn extract_facts_with_source(
1627 &self,
1628 last_n: usize,
1629 source: &'static str,
1630 ) -> ai_agents_core::Result<Vec<ai_agents_core::KeyFact>> {
1631 let extractor = match self.fact_extractor.read().clone() {
1632 Some(e) => e,
1633 None => return Ok(vec![]),
1634 };
1635
1636 let messages = self.memory.get_messages(None).await?;
1637 let recent: Vec<_> = messages.iter().rev().take(last_n).rev().cloned().collect();
1638
1639 if recent.is_empty() {
1640 return Ok(vec![]);
1641 }
1642
1643 let actor_id = self.effective_actor_id();
1644 let existing = actor_id
1645 .as_ref()
1646 .and_then(|aid| self.actor_facts_cache.read().get(aid).cloned())
1647 .unwrap_or_default();
1648
1649 let categories = self
1650 .facts_config
1651 .as_ref()
1652 .map(|c| c.custom_categories.clone())
1653 .unwrap_or_default();
1654
1655 let facts = self
1656 .observe_purpose(
1657 ObservationPurpose::FactsExtraction,
1658 extractor.extract(&recent, &existing, actor_id.as_deref(), &categories),
1659 )
1660 .await?;
1661
1662 if !facts.is_empty() {
1664 let fact_store_opt = self.fact_store.read().clone();
1665 let mut stored_total = 0usize;
1666 let mut cache_updated = false;
1667 if let (Some(store), Some(aid)) = (fact_store_opt, &actor_id) {
1668 let authoritative = store.add_facts(aid, facts.clone()).await?;
1670 stored_total = authoritative.len();
1671 self.actor_facts_cache
1672 .write()
1673 .insert(aid.clone(), authoritative);
1674 cache_updated = true;
1675 } else if let Some(aid) = &actor_id {
1676 let mut cache = self.actor_facts_cache.write();
1677 let entry = cache.entry(aid.clone()).or_default();
1678 entry.extend(facts.clone());
1679 stored_total = entry.len();
1680 cache_updated = true;
1681 }
1682
1683 info!(
1684 actor_id = %actor_id.as_deref().unwrap_or("<none>"),
1685 source = source,
1686 requested_messages = last_n,
1687 message_count = recent.len(),
1688 extracted_count = facts.len(),
1689 cache_updated = cache_updated,
1690 stored_total = stored_total,
1691 "facts extracted"
1692 );
1693
1694 if let Some(ref aid) = actor_id {
1695 self.hooks.on_facts_extracted(aid, &facts).await;
1696 }
1697 }
1698
1699 Ok(facts)
1700 }
1701
1702 fn resolve_actor_id_from_context(&self) {
1705 if self
1706 .current_turn_actor_context()
1707 .and_then(|ctx| ctx.effective_actor_id().map(str::to_string))
1708 .is_some()
1709 {
1710 return;
1711 }
1712
1713 if let Some(ref am_config) = self.actor_memory_config
1714 && am_config.identification.method == ai_agents_facts::IdentificationMethod::FromContext
1715 && let Some(ref path) = am_config.identification.context_path
1716 {
1717 let val = self
1719 .context_manager
1720 .get_path(path)
1721 .or_else(|| self.context_manager.get(path));
1722 if let Some(val) = val
1723 && let Some(id_str) = val.as_str()
1724 {
1725 let current = self.actor_id.read().clone();
1726 if current.as_deref() != Some(id_str) {
1727 *self.actor_id.write() = Some(id_str.to_string());
1728 let mut meta = self.session_metadata.write();
1729 meta.actor_id = Some(id_str.to_string());
1730 if !meta.actors.iter().any(|a| a == id_str) {
1731 meta.actors.push(id_str.to_string());
1732 }
1733 }
1734 }
1735 }
1736 }
1737
1738 fn format_actor_facts_for_context(&self) -> String {
1740 let should_inject = self
1742 .facts_config
1743 .as_ref()
1744 .map(|c| c.inject_in_context)
1745 .unwrap_or(true);
1746 if !should_inject {
1747 return String::new();
1748 }
1749
1750 let Some(actor_id) = self.effective_actor_id() else {
1751 return String::new();
1752 };
1753
1754 let facts = self
1755 .actor_facts_cache
1756 .read()
1757 .get(&actor_id)
1758 .cloned()
1759 .unwrap_or_default();
1760 if facts.is_empty() {
1761 return String::new();
1762 }
1763
1764 let am_config = self.actor_memory_config.as_ref();
1765 let facts_budget = self
1768 .memory_token_budget
1769 .as_ref()
1770 .map(|b| b.allocation.facts as usize)
1771 .filter(|n| *n > 0);
1772 let default_max = am_config.map(|c| c.injection.max_tokens).unwrap_or(800);
1773 let max_tokens = facts_budget.unwrap_or(default_max);
1774
1775 let filtered: Vec<ai_agents_core::KeyFact> = if let Some(cfg) = am_config {
1777 if cfg.injection.mode == ai_agents_facts::InjectionMode::OnDemand {
1778 return String::new();
1779 }
1780 if cfg.injection.mode == ai_agents_facts::InjectionMode::Category
1781 && !cfg.injection.categories.is_empty()
1782 {
1783 facts
1784 .iter()
1785 .filter(|f| {
1786 cfg.injection
1787 .categories
1788 .iter()
1789 .any(|c| f.category.to_string() == *c)
1790 })
1791 .cloned()
1792 .collect()
1793 } else {
1794 facts.clone()
1795 }
1796 } else {
1797 facts.clone()
1798 };
1799
1800 if filtered.is_empty() {
1801 return String::new();
1802 }
1803
1804 if let Some(store) = self.fact_store.read().clone() {
1805 store.format_for_context(&filtered, max_tokens)
1806 } else {
1807 String::new()
1808 }
1809 }
1810
1811 fn build_context_with_staged(&self, staged: &HashMap<String, Value>) -> HashMap<String, Value> {
1812 let context = self.build_context_with_overlays();
1813 let mut root = Value::Object(context.into_iter().collect());
1814 for (path, value) in staged {
1815 if let Ok(updated) = ai_agents_core::set_dot_path(root.clone(), path, value.clone()) {
1816 root = updated;
1817 }
1818 }
1819 match root {
1820 Value::Object(obj) => obj.into_iter().collect(),
1821 _ => HashMap::new(),
1822 }
1823 }
1824
1825 fn build_context_with_overlays(&self) -> HashMap<String, Value> {
1826 let mut context = self.context_manager.get_all();
1827 let mut root = Value::Object(context.clone().into_iter().collect());
1828
1829 if let Some(turn_ctx) = self.current_turn_actor_context() {
1830 if let Some(ref origin_actor_id) = turn_ctx.origin_actor_id
1831 && let Ok(updated) = ai_agents_core::set_dot_path(
1832 root.clone(),
1833 "interaction.origin_actor_id",
1834 serde_json::json!(origin_actor_id),
1835 )
1836 {
1837 root = updated;
1838 }
1839 if let Some(ref sender_agent_id) = turn_ctx.sender_agent_id
1840 && let Ok(updated) = ai_agents_core::set_dot_path(
1841 root.clone(),
1842 "interaction.sender_agent_id",
1843 serde_json::json!(sender_agent_id),
1844 )
1845 {
1846 root = updated;
1847 }
1848 }
1849
1850 if let Some(ref actor_id) = self.effective_actor_id()
1851 && let Ok(updated) = ai_agents_core::set_dot_path(
1852 root.clone(),
1853 "interaction.actor_id",
1854 serde_json::json!(actor_id),
1855 )
1856 {
1857 root = updated;
1858 }
1859
1860 if let Some(manager) = self.relationship_manager.as_ref()
1861 && let Some(actor_id) = self.effective_actor_id()
1862 && let Some(value) = manager.to_context_value(&actor_id)
1863 && let Ok(updated) = ai_agents_core::set_dot_path(
1864 root.clone(),
1865 &manager.config().injection.context_path,
1866 value,
1867 )
1868 {
1869 root = updated;
1870 }
1871
1872 if let Value::Object(obj) = root {
1873 context = obj.into_iter().collect();
1874 }
1875
1876 context
1877 }
1878
1879 fn resolve_actor_name_from_context(&self) -> Option<String> {
1880 for path in ["actor.name", "user.name", "player.name", "customer.name"] {
1881 if let Some(value) = self.context_manager.get_path(path)
1882 && let Some(name) = value.as_str()
1883 {
1884 return Some(name.to_string());
1885 }
1886 }
1887 None
1888 }
1889
1890 async fn maybe_load_actor_relationship(&self) {
1891 let Some(manager) = self.relationship_manager.as_ref() else {
1892 return;
1893 };
1894 let Some(actor_id) = self.effective_actor_id() else {
1895 return;
1896 };
1897
1898 let mut should_fire_loaded = false;
1899 if manager.get(&actor_id).is_none() {
1900 let mut loaded = false;
1901 if manager.config().persistence.enabled {
1902 let storage = self.storage.read().clone();
1903 if let Some(storage) = storage {
1904 match storage.load_relationship(&self.info.id, &actor_id).await {
1905 Ok(Some(value)) => match manager.insert_from_value(value) {
1906 Ok(_) => loaded = true,
1907 Err(e) => {
1908 warn!(actor = %actor_id, error = %e, "failed to restore relationship")
1909 }
1910 },
1911 Ok(None) => {}
1912 Err(e) => {
1913 warn!(actor = %actor_id, error = %e, "failed to load relationship")
1914 }
1915 }
1916 }
1917 }
1918
1919 if !loaded {
1920 manager.get_or_create(&actor_id, self.resolve_actor_name_from_context().as_deref());
1921 }
1922 should_fire_loaded = true;
1923 }
1924
1925 let actor_name = self.resolve_actor_name_from_context();
1926 let relationship = manager.touch_interaction(&actor_id, actor_name.as_deref());
1927 if should_fire_loaded {
1928 self.hooks
1929 .on_relationship_loaded(&actor_id, &relationship)
1930 .await;
1931 }
1932 }
1933
1934 fn format_relationship_for_context(&self) -> Option<(String, String)> {
1935 let manager = self.relationship_manager.as_ref()?;
1936 if !manager.config().injection.enabled {
1937 return None;
1938 }
1939 let actor_id = self.effective_actor_id()?;
1940 let relationship = manager.get(&actor_id)?;
1941 let local_cap = manager.config().injection.max_tokens;
1942 let global_cap = self
1943 .memory_token_budget
1944 .as_ref()
1945 .map(|b| b.allocation.relationships as usize)
1946 .filter(|n| *n > 0);
1947 let max_tokens = global_cap.map(|g| g.min(local_cap)).unwrap_or(local_cap);
1948 let text = ai_agents_relationships::format_relationship(
1949 &relationship,
1950 &manager.config().injection.format,
1951 max_tokens,
1952 );
1953 if text.is_empty() {
1954 None
1955 } else {
1956 Some((manager.config().injection.prompt_variable.clone(), text))
1957 }
1958 }
1959
1960 async fn persist_actor_relationship(&self, actor_id: &str) -> Result<()> {
1961 let Some(manager) = self.relationship_manager.as_ref() else {
1962 return Ok(());
1963 };
1964 if !manager.config().persistence.enabled {
1965 return Ok(());
1966 }
1967 let storage = self.storage.read().clone();
1968 let Some(storage) = storage else {
1969 return Ok(());
1970 };
1971 if let Some(value) = manager.relationship_as_value(actor_id)? {
1972 storage
1973 .save_relationship(&self.info.id, actor_id, &value)
1974 .await?;
1975 }
1976 Ok(())
1977 }
1978
1979 async fn auto_update_relationship(&self) {
1980 let Some(manager) = self.relationship_manager.as_ref() else {
1981 return;
1982 };
1983 let Some(actor_id) = self.effective_actor_id() else {
1984 return;
1985 };
1986 if !manager.config().auto_update.enabled {
1987 let _ = self.persist_actor_relationship(&actor_id).await;
1988 return;
1989 }
1990
1991 let recent_messages = manager.config().auto_update.recent_messages;
1992 let messages = match self.memory.get_messages(Some(recent_messages)).await {
1993 Ok(messages) => messages,
1994 Err(e) => {
1995 warn!(actor = %actor_id, error = %e, "failed to read messages for relationship update");
1996 return;
1997 }
1998 };
1999
2000 match self
2001 .observe_purpose(
2002 ObservationPurpose::RelationshipUpdate,
2003 manager.auto_update(&actor_id, &messages),
2004 )
2005 .await
2006 {
2007 Ok(update) => {
2008 if !update.changes.is_empty() {
2009 self.hooks
2010 .on_relationship_change(&actor_id, &update.changes)
2011 .await;
2012 }
2013 if let Some(ref event) = update.event {
2014 self.hooks.on_notable_event(&actor_id, event).await;
2015 }
2016 let persisted = match self.persist_actor_relationship(&actor_id).await {
2017 Ok(()) => true,
2018 Err(e) => {
2019 warn!(actor = %actor_id, error = %e, "failed to persist relationship");
2020 false
2021 }
2022 };
2023 if !update.changes.is_empty() || update.event.is_some() {
2024 let changed_dimensions: Vec<String> = update
2025 .changes
2026 .iter()
2027 .map(|change| format!("{}:{}", change.perspective, change.dimension))
2028 .collect();
2029 info!(
2030 actor_id = %actor_id,
2031 change_count = update.changes.len(),
2032 changed_dimensions = ?changed_dimensions,
2033 event_present = update.event.is_some(),
2034 persisted = persisted,
2035 "relationship updated"
2036 );
2037 } else {
2038 debug!(actor_id = %actor_id, persisted = persisted, "relationship evaluation ran but found no changes");
2039 }
2040 }
2041 Err(e) => warn!(actor = %actor_id, error = %e, "relationship update failed"),
2042 }
2043 }
2044
2045 async fn auto_extract_facts(&self) {
2047 let should_extract = self
2048 .facts_config
2049 .as_ref()
2050 .map(|c| c.enabled && c.auto_extract)
2051 .unwrap_or(false);
2052
2053 if !should_extract {
2054 debug!("fact extraction skipped because auto extraction is disabled");
2055 return;
2056 }
2057
2058 let msgs_since = *self.messages_since_extraction.read();
2059 if msgs_since < 2 {
2060 debug!(
2061 messages_since_extraction = msgs_since,
2062 "fact extraction skipped until threshold is reached"
2063 );
2064 return;
2065 }
2066
2067 match self.extract_facts_with_source(msgs_since, "auto").await {
2068 Ok(facts) => {
2069 if !facts.is_empty() {
2070 *self.messages_since_extraction.write() = 0;
2071 } else {
2072 debug!("fact extraction ran but found no new facts");
2073 }
2074 }
2075 Err(e) => {
2076 warn!("fact extraction failed: {}", e);
2077 }
2078 }
2079 }
2080
2081 pub fn with_persona(mut self, manager: Arc<ai_agents_persona::PersonaManager>) -> Self {
2082 self.persona_manager = Some(manager);
2083 self
2084 }
2085
2086 pub fn persona_manager(&self) -> Option<&Arc<ai_agents_persona::PersonaManager>> {
2087 self.persona_manager.as_ref()
2088 }
2089
2090 pub fn with_disambiguation(mut self, config: DisambiguationConfig) -> Self {
2091 if config.is_enabled() {
2092 let manager = DisambiguationManager::new(config, Arc::clone(&self.llm_registry))
2093 .with_clarification_observer(Arc::new(ObservabilityClarificationObserver));
2094 self.disambiguation_manager = Some(manager);
2095 }
2096 self
2097 }
2098
2099 pub fn disambiguation_manager(&self) -> Option<&DisambiguationManager> {
2100 self.disambiguation_manager.as_ref()
2101 }
2102
2103 pub fn has_disambiguation(&self) -> bool {
2104 self.disambiguation_manager
2105 .as_ref()
2106 .is_some_and(|m| m.is_enabled())
2107 }
2108
2109 pub async fn init_storage(&self) -> Result<()> {
2110 let _guard = self.storage_init.lock().await;
2114 let mut storage = self.storage.read().clone();
2115 if storage.is_none() && !self.storage_config.is_none() {
2116 let storage_config = self.convert_storage_config();
2117 storage = create_storage(&storage_config).await?;
2118 *self.storage.write() = storage.clone();
2119 }
2120
2121 self.validate_storage_requirements(storage.as_deref())?;
2122 self.complete_facts_init().await;
2123 Ok(())
2124 }
2125
2126 fn validate_storage_requirements(&self, storage: Option<&dyn AgentStorage>) -> Result<()> {
2127 let facts_required = self
2128 .facts_config
2129 .as_ref()
2130 .is_some_and(|config| config.enabled)
2131 || self
2132 .actor_memory_config
2133 .as_ref()
2134 .is_some_and(|config| config.enabled);
2135 let relationships_required = self
2136 .relationship_manager
2137 .as_ref()
2138 .is_some_and(|manager| manager.config().persistence.enabled);
2139
2140 let Some(storage) = storage else {
2141 let mut requirements = Vec::new();
2142 if facts_required {
2143 requirements.push("actor facts or actor memory");
2144 }
2145 if relationships_required {
2146 requirements.push("persistent relationships");
2147 }
2148 if requirements.is_empty() {
2149 return Ok(());
2150 }
2151 return Err(AgentError::Config(format!(
2152 "Storage is required for enabled {} but none is configured or injected",
2153 requirements.join(" and ")
2154 )));
2155 };
2156
2157 if facts_required && !storage.supports(StorageCapability::ActorFacts) {
2161 return Err(AgentError::UnsupportedStorageCapability(
2162 StorageCapability::ActorFacts,
2163 ));
2164 }
2165 if relationships_required && !storage.supports(StorageCapability::ActorRelationships) {
2166 return Err(AgentError::UnsupportedStorageCapability(
2167 StorageCapability::ActorRelationships,
2168 ));
2169 }
2170 Ok(())
2171 }
2172
2173 async fn complete_facts_init(&self) {
2176 if self.fact_store.read().is_some() {
2177 return;
2178 }
2179 let storage = match self.storage.read().clone() {
2180 Some(s) => s,
2181 None => return,
2182 };
2183
2184 let facts_enabled = self
2185 .facts_config
2186 .as_ref()
2187 .map(|f| f.enabled)
2188 .unwrap_or(false);
2189 let actor_memory_enabled = self
2190 .actor_memory_config
2191 .as_ref()
2192 .map(|a| a.enabled)
2193 .unwrap_or(false);
2194
2195 if !facts_enabled && !actor_memory_enabled {
2196 return;
2197 }
2198
2199 let fc = self.facts_config.clone().unwrap_or_default();
2200 let store = Arc::new(ai_agents_facts::FactStore::new(
2201 storage,
2202 self.info.id.clone(),
2203 fc.clone(),
2204 ));
2205
2206 let extractor: Option<Arc<dyn ai_agents_facts::FactExtractor>> = if facts_enabled {
2207 let extractor_llm = fc
2208 .extractor_llm
2209 .as_ref()
2210 .and_then(|alias| self.llm_registry.get(alias).ok())
2211 .or_else(|| self.llm_registry.router().ok())
2212 .or_else(|| self.llm_registry.default().ok());
2213 extractor_llm.map(|llm| {
2214 Arc::new(ai_agents_facts::LLMFactExtractor::new(llm, fc.clone()))
2215 as Arc<dyn ai_agents_facts::FactExtractor>
2216 })
2217 } else {
2218 None
2219 };
2220
2221 *self.fact_store.write() = Some(store);
2222 *self.fact_extractor.write() = extractor;
2223 debug!(
2224 agent = %self.info.id,
2225 facts_enabled,
2226 actor_memory_enabled,
2227 "facts storage initialized"
2228 );
2229 }
2230
2231 fn convert_storage_config(&self) -> StorageStorageConfig {
2232 crate::spec::storage::to_storage_config(&self.storage_config)
2233 }
2234
2235 pub fn storage(&self) -> Option<Arc<dyn AgentStorage>> {
2236 self.storage.read().clone()
2237 }
2238
2239 pub fn storage_config(&self) -> &StorageConfig {
2240 &self.storage_config
2241 }
2242
2243 pub fn spawner(&self) -> Option<&Arc<crate::spawner::AgentSpawner>> {
2245 self.spawner.as_ref()
2246 }
2247
2248 pub fn spawner_registry(&self) -> Option<&Arc<crate::spawner::AgentRegistry>> {
2250 self.spawner_registry.as_ref()
2251 }
2252
2253 pub fn has_spawner(&self) -> bool {
2254 self.spawner_registry.is_some()
2255 }
2256
2257 pub fn with_spawner_handles(
2258 mut self,
2259 spawner: Arc<crate::spawner::AgentSpawner>,
2260 registry: Arc<crate::spawner::AgentRegistry>,
2261 ) -> Self {
2262 self.spawner = Some(spawner);
2263 self.spawner_registry = Some(registry);
2264 self
2265 }
2266
2267 pub fn with_hooks(mut self, hooks: Arc<dyn AgentHooks>) -> Self {
2268 self.hooks = hooks;
2269 self
2270 }
2271
2272 pub fn with_parallel_tools(mut self, config: ParallelToolsConfig) -> Self {
2273 self.parallel_tools = config;
2274 self
2275 }
2276
2277 pub fn with_streaming(mut self, config: StreamingConfig) -> Self {
2278 self.streaming = config;
2279 self
2280 }
2281
2282 pub fn with_hitl(mut self, engine: HITLEngine, handler: Arc<dyn ApprovalHandler>) -> Self {
2283 self.hitl_engine = Some(engine);
2284 self.approval_handler = handler;
2285 self
2286 }
2287
2288 pub fn with_max_context_tokens(mut self, tokens: u32) -> Self {
2289 self.max_context_tokens = tokens;
2290 self
2291 }
2292
2293 pub fn with_memory_token_budget(mut self, budget: MemoryTokenBudget) -> Self {
2294 self.memory_token_budget = Some(budget);
2295 self
2296 }
2297
2298 pub fn with_recovery_manager(mut self, manager: RecoveryManager) -> Self {
2299 self.recovery_manager = manager;
2300 self
2301 }
2302
2303 pub fn with_tool_security(mut self, engine: ToolSecurityEngine) -> Self {
2304 self.tool_security = engine;
2305 self
2306 }
2307
2308 pub fn runtime_control(&self) -> RuntimeControlHandle {
2310 RuntimeControlHandle {
2311 state: Arc::clone(&self.runtime_control),
2312 }
2313 }
2314
2315 pub fn set_question_handler(&self, handler: Option<Arc<dyn QuestionHandler>>) {
2317 self.tools.set_question_handler(handler);
2318 }
2319
2320 pub fn set_diagnostics_provider(&self, provider: Arc<dyn DiagnosticsProvider>) {
2322 self.tools.set_diagnostics_provider(provider);
2323 }
2324
2325 pub fn set_command_runner(&self, runner: Arc<dyn CommandRunner>) {
2327 self.tools.set_command_runner(runner);
2328 }
2329
2330 pub fn set_web_search_provider(&self, provider: Arc<dyn ai_agents_tools::WebSearchProvider>) {
2332 self.tools.set_web_search_provider(provider);
2333 }
2334
2335 pub fn todos(&self) -> Vec<TodoItem> {
2337 self.tools.todos()
2338 }
2339
2340 fn active_tool_security(&self) -> ToolSecurityEngine {
2342 self.runtime_control
2343 .tool_security_override
2344 .read()
2345 .clone()
2346 .unwrap_or_else(|| self.tool_security.clone())
2347 }
2348
2349 fn runtime_safety_snapshot(&self) -> RuntimeSafetySnapshot {
2351 let _guard = self.runtime_control.snapshot_guard.read();
2352 RuntimeSafetySnapshot {
2353 version: self.runtime_control.version.load(Ordering::SeqCst),
2354 emergency_deny: self.runtime_control.emergency_deny.load(Ordering::SeqCst),
2355 tool_security: self
2356 .runtime_control
2357 .tool_security_override
2358 .read()
2359 .clone()
2360 .unwrap_or_else(|| self.tool_security.clone()),
2361 tool_scope_override: self.runtime_control.tool_scope_override.read().clone(),
2362 }
2363 }
2364
2365 fn admit_tool_execution(
2367 &self,
2368 expected_runtime_version: u64,
2369 expected_policy_version: u64,
2370 expected_state_generation: Option<u64>,
2371 canonical_id: &str,
2372 ) -> SecurityCheckResult {
2373 let _guard = self.runtime_control.snapshot_guard.read();
2374 if self.runtime_control.emergency_deny.load(Ordering::SeqCst) {
2375 return SecurityCheckResult::Block {
2376 reason: "runtime emergency deny is enabled".to_string(),
2377 };
2378 }
2379 let runtime_version = self.runtime_control.version.load(Ordering::SeqCst);
2380 let security_engine = self
2381 .runtime_control
2382 .tool_security_override
2383 .read()
2384 .clone()
2385 .unwrap_or_else(|| self.tool_security.clone());
2386 if runtime_version != expected_runtime_version
2387 || security_engine.policy_version() != expected_policy_version
2388 {
2389 return SecurityCheckResult::Block {
2390 reason: "runtime safety controls changed before admission".to_string(),
2391 };
2392 }
2393 let current_state_generation = self
2394 .state_machine
2395 .as_ref()
2396 .map(|state_machine| state_machine.generation());
2397 if current_state_generation != expected_state_generation {
2398 return SecurityCheckResult::Block {
2399 reason: "state scope changed before admission".to_string(),
2400 };
2401 }
2402 security_engine.admit_tool_execution(canonical_id)
2403 }
2404
2405 pub fn with_process_processor(mut self, processor: ProcessProcessor) -> Self {
2406 let processor = processor.with_stage_observer(Arc::new(ObservabilityProcessStageObserver));
2407 self.process_processor = Some(processor);
2408 self
2409 }
2410
2411 pub fn with_state_machine(
2412 mut self,
2413 state_machine: Arc<StateMachine>,
2414 evaluator: Arc<dyn TransitionEvaluator>,
2415 ) -> Self {
2416 self.state_machine = Some(state_machine);
2417 self.transition_evaluator = Some(evaluator);
2418 self
2419 }
2420
2421 pub fn with_context_manager(mut self, manager: Arc<ContextManager>) -> Self {
2422 self.context_manager = manager;
2423 self
2424 }
2425
2426 pub fn register_message_filter(&self, name: impl Into<String>, filter: Arc<dyn MessageFilter>) {
2427 self.message_filters.write().insert(name.into(), filter);
2428 }
2429
2430 pub fn set_context(&self, key: &str, value: Value) -> Result<()> {
2431 self.context_manager.update(key, value)
2432 }
2433
2434 pub fn update_context(&self, path: &str, value: Value) -> Result<()> {
2435 self.context_manager.update(path, value)
2436 }
2437
2438 pub fn get_context(&self) -> HashMap<String, Value> {
2439 self.build_context_with_overlays()
2440 }
2441
2442 pub fn remove_context(&self, key: &str) -> Option<Value> {
2443 self.context_manager.remove(key)
2444 }
2445
2446 pub async fn refresh_context(&self, key: &str) -> Result<()> {
2447 self.context_manager.refresh(key).await
2448 }
2449
2450 pub fn register_context_provider(&self, name: &str, provider: Arc<dyn ContextProvider>) {
2451 self.context_manager.register_provider(name, provider);
2452 }
2453
2454 pub fn current_state(&self) -> Option<String> {
2455 self.state_machine.as_ref().map(|sm| sm.current())
2456 }
2457
2458 pub async fn transition_to(&self, state: &str) -> Result<()> {
2459 if let Some(ref sm) = self.state_machine {
2460 let from_state = sm.current();
2461 let history_before = sm.history();
2462 self.execute_state_exit_actions(&from_state).await;
2463 sm.transition_to(state, "manual transition")?;
2464 let entered = sm.current();
2465 let is_reentry =
2466 Self::state_was_previously_entered(&entered, &from_state, &history_before);
2467 self.execute_state_enter_actions(&entered, is_reentry).await;
2468 info!(to = %entered, "Manual state transition");
2469 }
2470 Ok(())
2471 }
2472
2473 pub fn state_history(&self) -> Vec<StateTransitionEvent> {
2474 self.state_machine
2475 .as_ref()
2476 .map(|sm| sm.history())
2477 .unwrap_or_default()
2478 }
2479
2480 pub fn session_metadata(&self) -> ai_agents_core::SessionMetadata {
2482 self.session_metadata.read().clone()
2483 }
2484
2485 pub async fn delete_actor_data(&self, actor_id: &str) -> Result<()> {
2488 let allowed = self
2489 .actor_memory_config
2490 .as_ref()
2491 .map(|c| c.privacy.allow_deletion)
2492 .unwrap_or(true);
2493 if !allowed {
2494 return Err(AgentError::Config(
2495 "privacy.allow_deletion is false; actor data deletion is not permitted".into(),
2496 ));
2497 }
2498 let storage = self.storage.read().clone();
2499 if let Some(storage) = storage {
2500 if !storage.supports(StorageCapability::ActorDataDeletion) {
2504 return Err(AgentError::UnsupportedStorageCapability(
2505 StorageCapability::ActorDataDeletion,
2506 ));
2507 }
2508 storage.delete_actor_data(&self.info.id, actor_id).await?;
2509 } else {
2510 let store = { self.fact_store.read().clone() };
2514 if let Some(store) = store {
2515 store.delete_actor_data(actor_id).await?;
2516 }
2517 }
2518 if let Some(manager) = self.relationship_manager.as_ref() {
2519 manager.remove(actor_id);
2520 }
2521 self.actor_facts_cache.write().remove(actor_id);
2522 Ok(())
2523 }
2524
2525 pub fn set_session_metadata(&self, meta: ai_agents_core::SessionMetadata) {
2527 *self.session_metadata.write() = meta;
2528 }
2529
2530 pub async fn cleanup_expired_sessions(&self) -> Result<usize> {
2532 let storage = self.storage.read().clone();
2533 match storage {
2534 Some(s) => {
2535 let count = s.cleanup_expired().await?;
2536 if count > 0 {
2537 self.hooks.on_sessions_expired(count).await;
2538 }
2539 Ok(count)
2540 }
2541 None => Err(AgentError::Config(
2542 "No storage configured. Use with_storage_config() or with_storage() first".into(),
2543 )),
2544 }
2545 }
2546
2547 pub async fn list_sessions_filtered(
2549 &self,
2550 filter: &ai_agents_core::SessionFilter,
2551 ) -> Result<Vec<ai_agents_core::SessionSummary>> {
2552 let storage = self.storage.read().clone();
2553 match storage {
2554 Some(s) => s.list_sessions_filtered(filter).await,
2555 None => Err(AgentError::Config(
2556 "No storage configured. Use with_storage_config() or with_storage() first".into(),
2557 )),
2558 }
2559 }
2560
2561 pub async fn save_state(&self) -> Result<AgentSnapshot> {
2562 let memory_snapshot = self.memory.snapshot().await?;
2563 let state_machine_snapshot = self.state_machine.as_ref().map(|sm| sm.snapshot());
2564 let context_snapshot = self.context_manager.snapshot();
2565
2566 let mut snapshot = AgentSnapshot::new(self.info.id.clone())
2567 .with_memory(memory_snapshot)
2568 .with_context(context_snapshot)
2569 .with_state_machine(
2570 state_machine_snapshot.unwrap_or_else(|| StateMachineSnapshot {
2571 current_state: String::new(),
2572 previous_state: None,
2573 turn_count: 0,
2574 no_transition_count: 0,
2575 history: vec![],
2576 }),
2577 );
2578
2579 if let Some(ref persona) = self.persona_manager {
2580 snapshot.persona = Some(persona.snapshot_as_value()?);
2581 }
2582
2583 if let Some(ref relationships) = self.relationship_manager {
2584 snapshot.relationships = Some(relationships.snapshot_as_value()?);
2585 }
2586
2587 Ok(snapshot)
2588 }
2589
2590 pub async fn save_state_full(&self) -> Result<AgentSnapshot> {
2592 let mut snapshot = self.save_state().await?;
2593 if let Some(ref registry) = self.spawner_registry {
2594 let entries = registry.list_with_specs();
2595 if !entries.is_empty() {
2596 snapshot = snapshot.with_spawned_agents(entries);
2597 }
2598 }
2599 Ok(snapshot)
2600 }
2601
2602 pub async fn restore_state(&self, snapshot: AgentSnapshot) -> Result<()> {
2603 self.memory.restore(snapshot.memory).await?;
2604
2605 if let (Some(sm), Some(sm_snapshot)) = (&self.state_machine, snapshot.state_machine)
2606 && !sm_snapshot.current_state.is_empty()
2607 {
2608 sm.restore(sm_snapshot)?;
2609 }
2610
2611 self.context_manager.restore(snapshot.context);
2612
2613 if let (Some(persona_value), Some(persona_manager)) =
2614 (snapshot.persona, &self.persona_manager)
2615 {
2616 persona_manager.restore_from_value(persona_value)?;
2617 }
2618
2619 if let (Some(relationship_value), Some(relationship_manager)) =
2620 (snapshot.relationships, &self.relationship_manager)
2621 {
2622 relationship_manager.restore_from_value(relationship_value)?;
2623 }
2624
2625 info!(agent_id = %snapshot.agent_id, "State restored");
2626 Ok(())
2627 }
2628
2629 pub async fn save_to(&self, storage: &dyn AgentStorage, session_id: &str) -> Result<()> {
2630 let snapshot = self.save_state().await?;
2631 storage.save(session_id, &snapshot).await
2632 }
2633
2634 async fn load_session_restore(
2635 storage: &dyn AgentStorage,
2636 session_id: &str,
2637 ) -> Result<Option<StoredSessionRestore>> {
2638 let Some(snapshot) = storage.load(session_id).await? else {
2639 return Ok(None);
2640 };
2641 let metadata = if storage.supports(StorageCapability::SessionMetadata) {
2645 storage.load_metadata(session_id).await?
2646 } else {
2647 None
2648 };
2649 Ok(Some(StoredSessionRestore { snapshot, metadata }))
2650 }
2651
2652 async fn capture_session_restore_point(&self) -> Result<RuntimeSessionRestorePoint> {
2653 Ok(RuntimeSessionRestorePoint {
2654 snapshot: self.save_state().await?,
2655 metadata: self.session_metadata(),
2656 actor_id: self.actor_id(),
2657 session_id: self.current_session_id.read().clone(),
2658 })
2659 }
2660
2661 async fn apply_session_restore_unchecked(
2662 &self,
2663 session_id: &str,
2664 stored: StoredSessionRestore,
2665 ) -> Result<()> {
2666 self.restore_state(stored.snapshot).await?;
2667 let metadata = stored.metadata.unwrap_or_default();
2668 if let Some(actor_id) = metadata.actor_id.as_deref() {
2669 self.set_actor_id(actor_id)?;
2670 } else {
2671 self.clear_actor_id();
2672 }
2673 self.set_session_metadata(metadata);
2674 *self.current_session_id.write() = Some(session_id.to_string());
2675 Ok(())
2676 }
2677
2678 async fn restore_session_restore_point(
2679 &self,
2680 restore_point: &RuntimeSessionRestorePoint,
2681 ) -> Result<()> {
2682 self.restore_state(restore_point.snapshot.clone()).await?;
2683 if let Some(actor_id) = restore_point.actor_id.as_deref() {
2684 self.set_actor_id(actor_id)?;
2685 } else {
2686 self.clear_actor_id();
2687 }
2688 self.set_session_metadata(restore_point.metadata.clone());
2689 *self.current_session_id.write() = restore_point.session_id.clone();
2690 Ok(())
2691 }
2692
2693 async fn apply_session_restore(
2694 &self,
2695 session_id: &str,
2696 stored: StoredSessionRestore,
2697 ) -> Result<()> {
2698 let before = self.capture_session_restore_point().await?;
2699 if let Err(error) = self
2700 .apply_session_restore_unchecked(session_id, stored)
2701 .await
2702 {
2703 return match self.restore_session_restore_point(&before).await {
2704 Ok(()) => Err(error),
2705 Err(rollback_error) => Err(AgentError::Other(format!(
2706 "Session restore failed: {error}; rollback failed: {rollback_error}"
2707 ))),
2708 };
2709 }
2710 Ok(())
2711 }
2712
2713 async fn rollback_session_restore_set(
2714 parent: Option<(&RuntimeAgent, &RuntimeSessionRestorePoint)>,
2715 children: &[(String, Arc<RuntimeAgent>, RuntimeSessionRestorePoint)],
2716 ) -> Vec<String> {
2717 let mut errors = Vec::new();
2718 if let Some((agent, restore_point)) = parent
2719 && let Err(error) = agent.restore_session_restore_point(restore_point).await
2720 {
2721 errors.push(format!("parent: {error}"));
2722 }
2723 for (id, agent, restore_point) in children {
2724 if let Err(error) = agent.restore_session_restore_point(restore_point).await {
2725 errors.push(format!("child '{id}': {error}"));
2726 }
2727 }
2728 errors
2729 }
2730
2731 fn restore_failure(error: impl std::fmt::Display, rollback_errors: Vec<String>) -> AgentError {
2732 if rollback_errors.is_empty() {
2733 AgentError::Other(format!(
2734 "Session restore failed: {error}; runtime state was rolled back"
2735 ))
2736 } else {
2737 AgentError::Other(format!(
2738 "Session restore failed: {error}; rollback also failed for {}",
2739 rollback_errors.join(", ")
2740 ))
2741 }
2742 }
2743
2744 pub async fn load_from(&self, storage: &dyn AgentStorage, session_id: &str) -> Result<bool> {
2745 let Some(stored) = Self::load_session_restore(storage, session_id).await? else {
2746 return Ok(false);
2747 };
2748 self.apply_session_restore(session_id, stored).await?;
2749 Ok(true)
2750 }
2751
2752 pub async fn save_session(&self, session_id: &str) -> Result<()> {
2753 let storage = self.storage.read().clone();
2754 match storage {
2755 Some(s) => {
2756 let is_new = {
2758 let cur = self.current_session_id.read().clone();
2759 cur.as_deref() != Some(session_id)
2760 };
2761 if is_new {
2762 *self.current_session_id.write() = Some(session_id.to_string());
2763 self.hooks.on_session_created(session_id).await;
2764 }
2765
2766 {
2768 let now = chrono::Utc::now();
2769 let msg_count = self
2770 .memory
2771 .get_messages(None)
2772 .await
2773 .map(|v| v.len())
2774 .unwrap_or(0);
2775 let mut meta = self.session_metadata.write();
2776 meta.last_active = now;
2777 meta.message_count = msg_count;
2778 if meta.actor_id.is_none() {
2779 meta.actor_id = self.actor_id.read().clone();
2780 }
2781 }
2782
2783 let snapshot = self.save_state().await?;
2784 if s.supports(StorageCapability::SessionMetadata) {
2788 let metadata = self.session_metadata.read().clone();
2789 s.save_snapshot_with_metadata(session_id, &snapshot, &metadata)
2790 .await
2791 } else {
2792 s.save(session_id, &snapshot).await
2793 }
2794 }
2795 None => Err(AgentError::Config(
2796 "No storage configured. Use with_storage_config() or with_storage() first".into(),
2797 )),
2798 }
2799 }
2800
2801 pub async fn load_session(&self, session_id: &str) -> Result<bool> {
2802 let storage = self.storage.read().clone();
2803 match storage {
2804 Some(storage) => self.load_from(storage.as_ref(), session_id).await,
2805 None => Err(AgentError::Config(
2806 "No storage configured. Use with_storage_config() or with_storage() first".into(),
2807 )),
2808 }
2809 }
2810
2811 pub async fn restore_session_full(&self, session_id: &str) -> Result<usize> {
2813 self.init_storage().await?;
2814 let storage = self.storage.read().clone().ok_or_else(|| {
2815 AgentError::Config(
2816 "No storage configured. Use with_storage_config() or with_storage() first".into(),
2817 )
2818 })?;
2819 let target_parent = Self::load_session_restore(storage.as_ref(), session_id)
2820 .await?
2821 .ok_or_else(|| AgentError::Persistence(format!("Session not found: {session_id}")))?;
2822 let manifest = target_parent
2823 .snapshot
2824 .spawned_agents
2825 .clone()
2826 .unwrap_or_default();
2827
2828 let registry = self.spawner_registry.as_ref().cloned();
2829 let spawner = if manifest.is_empty() {
2830 self.spawner.as_ref().cloned()
2831 } else {
2832 Some(self.spawner.as_ref().cloned().ok_or_else(|| {
2833 AgentError::Config(
2834 "Saved session contains child agents but this runtime has no spawner".into(),
2835 )
2836 })?)
2837 };
2838 let registry = if manifest.is_empty() {
2839 registry
2840 } else {
2841 Some(registry.ok_or_else(|| {
2842 AgentError::Config(
2843 "Saved session contains child agents but this runtime has no registry".into(),
2844 )
2845 })?)
2846 };
2847
2848 let mut target_ids = HashSet::with_capacity(manifest.len());
2849 let mut prepared = Vec::with_capacity(manifest.len());
2850 for entry in manifest {
2851 if !target_ids.insert(entry.id.clone()) {
2852 return Err(AgentError::InvalidSpec(format!(
2853 "Saved child manifest contains duplicate ID: {}",
2854 entry.id
2855 )));
2856 }
2857 let spec = crate::spec::AgentSpec::from_yaml_strict(&entry.spec_yaml)?;
2858 spawner
2859 .as_ref()
2860 .expect("non-empty manifests require a spawner")
2861 .validate_explicit_child(&entry.id, &spec)?;
2862 prepared.push((entry.id, spec));
2863 }
2864
2865 let current_ids = registry
2866 .as_ref()
2867 .map(|registry| {
2868 registry
2869 .list()
2870 .into_iter()
2871 .map(|info| info.id)
2872 .collect::<HashSet<_>>()
2873 })
2874 .unwrap_or_default();
2875 let removal_count = current_ids.difference(&target_ids).count();
2876 let additions = prepared
2877 .iter()
2878 .filter(|(id, _)| !current_ids.contains(id))
2879 .cloned()
2880 .collect::<Vec<_>>();
2881
2882 let mut existing = Vec::new();
2883 if let Some(registry) = registry.as_ref() {
2884 for (id, _) in prepared.iter().filter(|(id, _)| current_ids.contains(id)) {
2885 let agent = registry.get(id).ok_or_else(|| {
2886 AgentError::Config(format!("Retained child disappeared during restore: {id}"))
2887 })?;
2888 let child_storage = agent.storage().ok_or_else(|| {
2889 AgentError::Config(format!("Child '{id}' has no storage for session restore"))
2890 })?;
2891 let stored = Self::load_session_restore(child_storage.as_ref(), session_id)
2892 .await?
2893 .ok_or_else(|| {
2894 AgentError::Persistence(format!(
2895 "Child '{id}' has no saved session '{session_id}'"
2896 ))
2897 })?;
2898 existing.push((id.clone(), agent, stored));
2899 }
2900 }
2901
2902 let mut staged = Vec::with_capacity(additions.len());
2903 if !additions.is_empty() {
2904 let spawner = spawner
2905 .as_ref()
2906 .expect("restored additions require a spawner");
2907 let reservations = spawner.reserve_restore_capacity(additions.len(), removal_count)?;
2908 for ((id, spec), reservation) in additions.into_iter().zip(reservations) {
2909 let spawned = spawner
2910 .spawn_with_reserved_capacity(id.clone(), spec, reservation)
2911 .await?;
2912 let child_storage = spawned.agent.storage().ok_or_else(|| {
2913 AgentError::Config(format!("Child '{id}' has no storage for session restore"))
2914 })?;
2915 let stored = Self::load_session_restore(child_storage.as_ref(), session_id)
2916 .await?
2917 .ok_or_else(|| {
2918 AgentError::Persistence(format!(
2919 "Child '{id}' has no saved session '{session_id}'"
2920 ))
2921 })?;
2922 staged.push((spawned, stored));
2923 }
2924 } else if let Some(spawner) = spawner.as_ref() {
2925 spawner.reserve_restore_capacity(0, removal_count)?;
2926 }
2927
2928 let parent_before = self.capture_session_restore_point().await?;
2929 let mut existing_before = Vec::with_capacity(existing.len());
2930 for (id, agent, _) in &existing {
2931 existing_before.push((
2932 id.clone(),
2933 Arc::clone(agent),
2934 agent.capture_session_restore_point().await?,
2935 ));
2936 }
2937
2938 for (_, agent, stored) in &existing {
2942 if let Err(error) = agent
2943 .apply_session_restore_unchecked(session_id, stored.clone())
2944 .await
2945 {
2946 drop(staged);
2947 let rollback_errors =
2948 Self::rollback_session_restore_set(None, &existing_before).await;
2949 return Err(Self::restore_failure(error, rollback_errors));
2950 }
2951 }
2952 for (spawned, stored) in &staged {
2953 if let Err(error) = spawned
2954 .agent
2955 .apply_session_restore_unchecked(session_id, stored.clone())
2956 .await
2957 {
2958 drop(staged);
2959 let rollback_errors =
2960 Self::rollback_session_restore_set(None, &existing_before).await;
2961 return Err(Self::restore_failure(error, rollback_errors));
2962 }
2963 }
2964 if let Err(error) = self
2965 .apply_session_restore_unchecked(session_id, target_parent)
2966 .await
2967 {
2968 drop(staged);
2969 let rollback_errors =
2970 Self::rollback_session_restore_set(Some((self, &parent_before)), &existing_before)
2971 .await;
2972 return Err(Self::restore_failure(error, rollback_errors));
2973 }
2974
2975 if let Some(registry) = registry.as_ref()
2976 && let Err(error) = registry
2977 .reconcile(
2978 &target_ids,
2979 staged.into_iter().map(|(spawned, _)| spawned).collect(),
2980 )
2981 .await
2982 {
2983 let rollback_errors =
2984 Self::rollback_session_restore_set(Some((self, &parent_before)), &existing_before)
2985 .await;
2986 return Err(Self::restore_failure(error, rollback_errors));
2987 }
2988
2989 Ok(target_ids.len())
2990 }
2991
2992 pub async fn delete_session(&self, session_id: &str) -> Result<()> {
2993 let storage = self.storage.read().clone();
2994 match storage {
2995 Some(s) => s.delete(session_id).await,
2996 None => Err(AgentError::Config(
2997 "No storage configured. Use with_storage_config() or with_storage() first".into(),
2998 )),
2999 }
3000 }
3001
3002 pub async fn list_sessions(&self) -> Result<Vec<String>> {
3003 let storage = self.storage.read().clone();
3004 match storage {
3005 Some(s) => s.list_sessions().await,
3006 None => Err(AgentError::Config(
3007 "No storage configured. Use with_storage_config() or with_storage() first".into(),
3008 )),
3009 }
3010 }
3011
3012 fn estimate_tokens(&self, text: &str) -> u32 {
3013 (text.len() as f32 / 4.0).ceil() as u32
3014 }
3015
3016 fn estimate_total_tokens(&self, messages: &[ChatMessage]) -> u32 {
3017 messages
3018 .iter()
3019 .map(|m| self.estimate_tokens(&m.content))
3020 .sum()
3021 }
3022
3023 fn truncate_context(&self, messages: &mut Vec<ChatMessage>, keep_recent: usize) {
3024 if messages.len() <= keep_recent + 1 {
3025 return;
3026 }
3027 let system_msg = messages.remove(0);
3028 let to_remove = messages.len().saturating_sub(keep_recent);
3029 messages.drain(..to_remove);
3030 messages.insert(0, system_msg);
3031 }
3032
3033 fn get_filter(&self, config: &FilterConfig) -> Arc<dyn MessageFilter> {
3034 match config {
3035 FilterConfig::KeepRecent(n) => Arc::new(KeepRecentFilter::new(*n)),
3036 FilterConfig::ByRole { keep_roles } => Arc::new(ByRoleFilter::new(keep_roles.clone())),
3037 FilterConfig::SkipPattern { skip_if_contains } => {
3038 Arc::new(SkipPatternFilter::new(skip_if_contains.clone()))
3039 }
3040 FilterConfig::Custom { name } => {
3041 let filters = self.message_filters.read();
3042 filters
3043 .get(name)
3044 .cloned()
3045 .unwrap_or_else(|| Arc::new(KeepRecentFilter::new(10)))
3046 }
3047 }
3048 }
3049
3050 async fn summarize_context(
3051 &self,
3052 messages: &mut Vec<ChatMessage>,
3053 summarizer_llm: Option<&str>,
3054 max_summary_tokens: u32,
3055 custom_prompt: Option<&str>,
3056 keep_recent: usize,
3057 filter: Option<&FilterConfig>,
3058 ) -> Result<()> {
3059 let system_msg = messages.remove(0);
3060
3061 let to_summarize_count = messages.len().saturating_sub(keep_recent);
3062 if to_summarize_count == 0 {
3063 messages.insert(0, system_msg);
3064 return Ok(());
3065 }
3066
3067 let recent_msgs: Vec<ChatMessage> = messages.drain(to_summarize_count..).collect();
3068 let mut to_summarize = std::mem::take(messages);
3069
3070 if let Some(filter_config) = filter {
3071 let filter = self.get_filter(filter_config);
3072 to_summarize = filter.filter(to_summarize);
3073 }
3074
3075 if to_summarize.is_empty() {
3076 *messages = recent_msgs;
3077 messages.insert(0, system_msg);
3078 return Ok(());
3079 }
3080
3081 let conversation_text = to_summarize
3082 .iter()
3083 .map(|m| format!("{:?}: {}", m.role, m.content))
3084 .collect::<Vec<_>>()
3085 .join("\n");
3086
3087 let default_prompt = format!(
3088 "Summarize the following conversation in under {} tokens, preserving key information:\n\n{}",
3089 max_summary_tokens, conversation_text
3090 );
3091
3092 let summary_prompt = custom_prompt
3093 .map(|p| format!("{}\n\n{}", p, conversation_text))
3094 .unwrap_or(default_prompt);
3095
3096 let summarizer = if let Some(alias) = summarizer_llm {
3097 self.llm_registry
3098 .get(alias)
3099 .map_err(|e| AgentError::Config(e.to_string()))?
3100 } else {
3101 self.llm_registry
3102 .router()
3103 .or_else(|_| self.llm_registry.default())
3104 .map_err(|e| AgentError::Config(e.to_string()))?
3105 };
3106
3107 let summary_msgs = vec![ChatMessage::user(&summary_prompt)];
3108 let response = self
3109 .observe_purpose(
3110 ObservationPurpose::Summarization,
3111 summarizer.complete(&summary_msgs, None),
3112 )
3113 .await?;
3114
3115 let summary_message = ChatMessage::system(format!(
3116 "[Previous conversation summary]\n{}",
3117 response.content
3118 ));
3119
3120 *messages = vec![system_msg, summary_message];
3121 messages.extend(recent_msgs);
3122
3123 debug!(
3124 summarized_count = to_summarize_count,
3125 kept_recent = keep_recent,
3126 "Context summarized"
3127 );
3128
3129 Ok(())
3130 }
3131
3132 fn render_system_prompt(&self) -> Result<String> {
3133 let mut context = self.build_context_with_overlays();
3134
3135 let facts_text = self.format_actor_facts_for_context();
3137 if !facts_text.is_empty() {
3138 context.insert(
3139 "actor_facts".to_string(),
3140 serde_json::Value::String(facts_text),
3141 );
3142 }
3143
3144 if let Some((key, text)) = self.format_relationship_for_context() {
3145 context.insert(key, serde_json::Value::String(text));
3146 }
3147
3148 self.template_renderer
3149 .render(&self.base_system_prompt, &context)
3150 }
3151
3152 fn canonical_unique_tool_ids(&self, ids: &[String]) -> Vec<String> {
3154 let mut seen = HashSet::new();
3155 ids.iter()
3156 .filter_map(|id| self.tools.canonical_id(id))
3157 .filter(|canonical_id| seen.insert(canonical_id.clone()))
3158 .collect()
3159 }
3160
3161 fn get_top_level_tool_ids_for_scope(&self, scope_override: Option<&[String]>) -> Vec<String> {
3163 let Some(declared) = self.declared_tool_ids.as_deref() else {
3164 return Vec::new();
3165 };
3166 let mut effective = self.canonical_unique_tool_ids(declared);
3167 if let Some(scope) = scope_override {
3168 let scope: HashSet<String> =
3169 self.canonical_unique_tool_ids(scope).into_iter().collect();
3170 effective.retain(|canonical_id| scope.contains(canonical_id));
3171 }
3172 effective
3173 }
3174
3175 async fn get_available_tool_ids(&self) -> Result<Vec<String>> {
3177 Ok(self.get_available_tool_ids_snapshot().await?.tool_ids)
3178 }
3179
3180 async fn get_available_tool_ids_snapshot(&self) -> Result<AvailableToolIdsSnapshot> {
3182 let scope_override = self.runtime_control.tool_scope_override.read().clone();
3183 self.get_available_tool_ids_snapshot_for_scope(scope_override.as_deref())
3184 .await
3185 }
3186
3187 async fn get_available_tool_ids_snapshot_for_scope(
3189 &self,
3190 scope_override: Option<&[String]>,
3191 ) -> Result<AvailableToolIdsSnapshot> {
3192 let mut available = self.get_top_level_tool_ids_for_scope(scope_override);
3193 let (state_generation, state_scopes) = self
3194 .state_machine
3195 .as_ref()
3196 .map(|state_machine| {
3197 let (generation, scopes) = state_machine.current_tool_scope_snapshot();
3198 (Some(generation), scopes)
3199 })
3200 .unwrap_or((None, Vec::new()));
3201
3202 if available.is_empty() || state_scopes.is_empty() {
3203 return Ok(AvailableToolIdsSnapshot {
3204 tool_ids: available,
3205 state_generation,
3206 });
3207 }
3208
3209 let eval_ctx = self.build_evaluation_context().await?;
3210 let llm_getter = RegistryLLMGetter {
3211 registry: self.llm_registry.clone(),
3212 };
3213 let evaluator = ConditionEvaluator::new(llm_getter);
3214
3215 for state_scope in state_scopes {
3216 if state_scope.is_empty() {
3217 available.clear();
3218 break;
3219 }
3220
3221 let mut allowed = HashSet::new();
3222 for tool_ref in &state_scope {
3223 let tool_id = tool_ref.id();
3224 let Some(canonical_id) = self.tools.canonical_id(tool_id) else {
3225 continue;
3226 };
3227 let condition_matches = if let Some(condition) = tool_ref.condition() {
3228 match evaluator.evaluate(condition, &eval_ctx).await {
3229 Ok(matches) => matches,
3230 Err(error) => {
3231 warn!(tool = tool_id, error = %error, "Error evaluating tool condition");
3232 false
3233 }
3234 }
3235 } else {
3236 true
3237 };
3238 if condition_matches {
3239 allowed.insert(canonical_id);
3240 } else {
3241 debug!(tool = tool_id, "Tool condition not met, skipping");
3242 }
3243 }
3244 available.retain(|canonical_id| allowed.contains(canonical_id));
3245 if available.is_empty() {
3246 break;
3247 }
3248 }
3249
3250 Ok(AvailableToolIdsSnapshot {
3251 tool_ids: available,
3252 state_generation,
3253 })
3254 }
3255
3256 async fn build_evaluation_context(&self) -> Result<EvaluationContext> {
3257 let context = self.build_context_with_overlays();
3258 let messages = self.memory.get_messages(Some(10)).await?;
3259 let tool_history = self.tool_call_history.read().clone();
3260
3261 let (state_name, turn_count, previous_state) = if let Some(ref sm) = self.state_machine {
3262 (Some(sm.current()), sm.turn_count(), sm.previous())
3263 } else {
3264 (None, 0, None)
3265 };
3266
3267 Ok(EvaluationContext::default()
3268 .with_context(context)
3269 .with_state(state_name, turn_count, previous_state)
3270 .with_called_tools(tool_history)
3271 .with_messages(messages))
3272 }
3273
3274 fn record_tool_call(&self, tool_id: &str, result: Value) {
3275 self.tool_call_history.write().push(ToolCallRecord {
3276 tool_id: tool_id.to_string(),
3277 result,
3278 timestamp: chrono::Utc::now(),
3279 });
3280 }
3281
3282 async fn get_effective_system_prompt_with_persona_hooks(
3283 &self,
3284 fire_persona_hooks: bool,
3285 include_tool_prompt: bool,
3286 ) -> Result<String> {
3287 let rendered_base = self.render_system_prompt()?;
3288
3289 let persona_prefix = if let Some(ref persona) = self.persona_manager {
3290 let context = self.build_context_with_overlays();
3291 if fire_persona_hooks {
3292 let render_result = persona.render_prompt(&context)?;
3293 for content in &render_result.newly_revealed {
3294 self.hooks.on_secret_revealed(content).await;
3295 }
3296 render_result.prompt
3297 } else {
3298 persona.render_prompt_preview(&context)?
3299 }
3300 } else {
3301 String::new()
3302 };
3303
3304 if let Some(ref sm) = self.state_machine
3305 && let Some(state_def) = sm.current_definition()
3306 {
3307 let state_prompt = if let Some(ref prompt) = state_def.prompt {
3308 let context = self.build_context_with_overlays();
3309 self.template_renderer.render_with_state(
3310 prompt,
3311 &context,
3312 &sm.current(),
3313 sm.previous().as_deref(),
3314 sm.turn_count(),
3315 state_def.max_turns,
3316 )?
3317 } else {
3318 String::new()
3319 };
3320
3321 let combined = match state_def.prompt_mode {
3322 PromptMode::Append => {
3323 if state_prompt.is_empty() {
3324 rendered_base
3325 } else {
3326 format!(
3327 "{}\n\n[Current State: {}]\n{}",
3328 rendered_base,
3329 sm.current(),
3330 state_prompt
3331 )
3332 }
3333 }
3334 PromptMode::Replace => {
3335 if state_prompt.is_empty() {
3336 rendered_base
3337 } else {
3338 state_prompt
3339 }
3340 }
3341 PromptMode::Prepend => {
3342 if state_prompt.is_empty() {
3343 rendered_base
3344 } else {
3345 format!("{}\n\n{}", state_prompt, rendered_base)
3346 }
3347 }
3348 };
3349
3350 let with_persona = if persona_prefix.is_empty() {
3352 combined
3353 } else {
3354 format!("{}\n\n{}", persona_prefix, combined)
3355 };
3356
3357 if include_tool_prompt {
3358 let available_tool_ids = self.get_available_tool_ids().await?;
3359 if !available_tool_ids.is_empty() {
3360 let tools_prompt = self.tools.generate_scoped_prompt_with_mode(
3361 &available_tool_ids,
3362 None,
3363 self.parallel_tools.enabled,
3364 self.runtime_config.tool_schema_prompt_mode,
3365 );
3366 if !tools_prompt.is_empty() {
3367 return Ok(format!("{}\n\n{}", with_persona, tools_prompt));
3368 }
3369 }
3370 }
3371 return Ok(with_persona);
3372 }
3373
3374 let with_persona = if persona_prefix.is_empty() {
3376 rendered_base
3377 } else {
3378 format!("{}\n\n{}", persona_prefix, rendered_base)
3379 };
3380
3381 if include_tool_prompt {
3382 let available_tool_ids = self.get_available_tool_ids().await?;
3383 let tools_prompt = self.tools.generate_scoped_prompt_with_mode(
3384 &available_tool_ids,
3385 None,
3386 self.parallel_tools.enabled,
3387 self.runtime_config.tool_schema_prompt_mode,
3388 );
3389 if !tools_prompt.is_empty() {
3390 return Ok(format!("{}\n\n{}", with_persona, tools_prompt));
3391 }
3392 }
3393 Ok(with_persona)
3394 }
3395
3396 fn get_state_llm(&self) -> Result<Arc<dyn LLMProvider>> {
3397 if let Some(ref sm) = self.state_machine
3398 && let Some(state_def) = sm.current_definition()
3399 && let Some(ref llm_alias) = state_def.llm
3400 {
3401 return self
3402 .llm_registry
3403 .get(llm_alias)
3404 .map_err(|e| AgentError::Config(e.to_string()));
3405 }
3406 self.llm_registry
3407 .default()
3408 .map_err(|e| AgentError::Config(e.to_string()))
3409 }
3410
3411 fn get_effective_reasoning_config(&self) -> ReasoningConfig {
3412 if let Some(ref sm) = self.state_machine
3413 && let Some(state_def) = sm.current_definition()
3414 && let Some(ref state_reasoning) = state_def.reasoning
3415 {
3416 return state_reasoning.clone();
3417 }
3418 self.reasoning_config.clone()
3419 }
3420
3421 fn get_effective_reflection_config(&self) -> ReflectionConfig {
3422 if let Some(ref sm) = self.state_machine
3423 && let Some(state_def) = sm.current_definition()
3424 && let Some(ref state_reflection) = state_def.reflection
3425 {
3426 return state_reflection.clone();
3427 }
3428 self.reflection_config.clone()
3429 }
3430
3431 fn get_skill_reasoning_config(&self, skill: &SkillDefinition) -> ReasoningConfig {
3432 skill
3433 .reasoning
3434 .clone()
3435 .unwrap_or_else(|| self.get_effective_reasoning_config())
3436 }
3437
3438 fn get_skill_reflection_config(&self, skill: &SkillDefinition) -> ReflectionConfig {
3439 skill
3440 .reflection
3441 .clone()
3442 .unwrap_or_else(|| self.get_effective_reflection_config())
3443 }
3444
3445 async fn build_disambiguation_context(&self) -> Result<DisambiguationContext> {
3446 let recent_messages: Vec<String> = self
3447 .memory
3448 .get_messages(Some(5))
3449 .await?
3450 .iter()
3451 .rev()
3452 .map(|m| format!("{:?}: {}", m.role, m.content))
3453 .collect();
3454
3455 let current_state = self.current_state().map(|s| s.to_string());
3456
3457 let state_prompt: Option<String> = self
3460 .state_machine
3461 .as_ref()
3462 .and_then(|sm| sm.current_definition())
3463 .and_then(|def| def.prompt.clone());
3464
3465 let available_tools: Vec<String> = self
3466 .get_available_tool_ids()
3467 .await
3468 .unwrap_or_else(|_| self.tools.list_ids());
3469
3470 let available_skills: Vec<String> = self.skills.iter().map(|s| s.id.clone()).collect();
3471
3472 let user_context = self.build_context_with_overlays();
3473
3474 let available_intents: Vec<String> = if let Some(ref sm) = self.state_machine {
3476 sm.current_definition()
3477 .map(|def| {
3478 def.transitions
3479 .iter()
3480 .filter_map(|t| t.intent.clone())
3481 .collect()
3482 })
3483 .unwrap_or_default()
3484 } else {
3485 Vec::new()
3486 };
3487
3488 Ok(DisambiguationContext::from_agent_state(
3489 recent_messages,
3490 current_state,
3491 state_prompt,
3492 available_tools,
3493 available_skills,
3494 available_intents,
3495 user_context,
3496 ))
3497 }
3498
3499 fn get_available_skills(&self) -> Vec<&SkillDefinition> {
3500 if let Some(ref sm) = self.state_machine
3501 && let Some(state_def) = sm.current_definition()
3502 {
3503 let parent_def = sm.get_parent_definition();
3504 let effective_skills = state_def.get_effective_skills(parent_def.as_ref());
3505 if !effective_skills.is_empty() {
3506 return self
3507 .skills
3508 .iter()
3509 .filter(|s| effective_skills.contains(&&s.id))
3510 .collect();
3511 }
3512 }
3513 self.skills.iter().collect()
3514 }
3515
3516 async fn build_messages(&self) -> Result<Vec<ChatMessage>> {
3517 self.build_messages_internal(true, None, true).await
3518 }
3519
3520 async fn build_messages_for_draft(&self, user_message: &str) -> Result<Vec<ChatMessage>> {
3521 self.build_messages_internal(false, Some(user_message), true)
3522 .await
3523 }
3524
3525 async fn build_messages_internal(
3526 &self,
3527 fire_persona_hooks: bool,
3528 ephemeral_user_message: Option<&str>,
3529 include_tool_prompt: bool,
3530 ) -> Result<Vec<ChatMessage>> {
3531 let system_prompt = self
3532 .get_effective_system_prompt_with_persona_hooks(fire_persona_hooks, include_tool_prompt)
3533 .await?;
3534 let mut messages = vec![ChatMessage::system(&system_prompt)];
3535
3536 let context = self.memory.get_context().await?;
3537 let history = if let Some(ref budget) = self.memory_token_budget {
3538 context.to_llm_messages_with_allocation(&budget.allocation)
3539 } else {
3540 context.to_llm_messages()
3541 };
3542 messages.extend(history);
3543 if let Some(user_message) = ephemeral_user_message {
3544 messages.push(ChatMessage::user(user_message));
3545 }
3546
3547 let total_tokens = self.estimate_total_tokens(&messages);
3548
3549 if total_tokens > self.max_context_tokens {
3550 debug!(
3551 total = total_tokens,
3552 limit = self.max_context_tokens,
3553 "Context overflow"
3554 );
3555
3556 match &self.recovery_manager.config().llm.on_context_overflow {
3557 ContextOverflowAction::Error => {
3558 return Err(AgentError::LLM(format!(
3559 "Context overflow: {} tokens > {} limit",
3560 total_tokens, self.max_context_tokens
3561 )));
3562 }
3563 ContextOverflowAction::Truncate { keep_recent } => {
3564 self.truncate_context(&mut messages, *keep_recent);
3565 }
3566 ContextOverflowAction::Summarize {
3567 summarizer_llm,
3568 max_summary_tokens,
3569 custom_prompt,
3570 keep_recent,
3571 filter,
3572 } => {
3573 self.summarize_context(
3574 &mut messages,
3575 summarizer_llm.as_deref(),
3576 *max_summary_tokens,
3577 custom_prompt.as_deref(),
3578 *keep_recent,
3579 filter.as_ref(),
3580 )
3581 .await?;
3582 }
3583 }
3584 }
3585
3586 Ok(messages)
3587 }
3588
3589 async fn main_tool_protocol(
3590 &self,
3591 llm: &dyn LLMProvider,
3592 ephemeral_new_turn: bool,
3593 ) -> Result<MainToolProtocol> {
3594 let mut choice = llm.configured_tool_choice();
3595 if matches!(choice.as_ref(), Some(ToolChoice::None)) {
3596 return Ok(MainToolProtocol {
3597 choice,
3598 tool_ids: Vec::new(),
3599 definitions: Vec::new(),
3600 });
3601 }
3602
3603 let mut tool_ids = self.get_available_tool_ids().await?;
3604 tool_ids.sort();
3605 tool_ids.dedup();
3606 if let Some(ToolChoice::Specific(expected)) = choice.as_ref() {
3607 let canonical = self.tools.canonical_id(expected).ok_or_else(|| {
3608 AgentError::Config(format!(
3609 "specific tool choice '{expected}' is not registered"
3610 ))
3611 })?;
3612 if canonical != *expected {
3613 return Err(AgentError::Config(format!(
3614 "specific tool choice must use canonical ID '{canonical}', not '{expected}'"
3615 )));
3616 }
3617 if !tool_ids.iter().any(|tool_id| tool_id == expected) {
3618 return Err(AgentError::Config(format!(
3619 "specific tool choice '{expected}' is outside the effective tool grant"
3620 )));
3621 }
3622 }
3623 if matches!(
3624 choice.as_ref(),
3625 Some(ToolChoice::Required | ToolChoice::Specific(_))
3626 ) && tool_ids.is_empty()
3627 {
3628 return Err(AgentError::Config(
3629 "required tool choice has no tool inside the effective grant".to_string(),
3630 ));
3631 }
3632 if !ephemeral_new_turn
3633 && let Some(configured_choice) = choice.as_ref()
3634 && matches!(
3635 configured_choice,
3636 ToolChoice::Required | ToolChoice::Specific(_)
3637 )
3638 && self
3639 .tool_choice_satisfied_in_current_turn(configured_choice, &tool_ids)
3640 .await?
3641 {
3642 choice = Some(ToolChoice::Auto);
3643 }
3644 if let Some(ToolChoice::Specific(expected)) = choice.as_ref() {
3645 tool_ids.retain(|tool_id| tool_id == expected);
3646 }
3647
3648 let definitions = tool_ids
3649 .iter()
3650 .map(|tool_id| {
3651 let tool = self.tools.get(tool_id).ok_or_else(|| {
3652 AgentError::Config(format!(
3653 "effective tool '{tool_id}' disappeared before provider exposure"
3654 ))
3655 })?;
3656 Ok(LLMToolDefinition {
3657 name: tool_id.clone(),
3658 description: tool.description().to_string(),
3659 input_schema: tool.input_schema(),
3660 })
3661 })
3662 .collect::<Result<Vec<_>>>()?;
3663
3664 Ok(MainToolProtocol {
3668 choice,
3669 tool_ids,
3670 definitions,
3671 })
3672 }
3673
3674 async fn tool_choice_satisfied_in_current_turn(
3675 &self,
3676 choice: &ToolChoice,
3677 effective_tool_ids: &[String],
3678 ) -> Result<bool> {
3679 let messages = self.memory.get_messages(None).await?;
3680 let mut saw_tool_result = false;
3681 for message in messages.iter().rev() {
3682 match message.role {
3683 ai_agents_core::Role::Tool | ai_agents_core::Role::Function => {
3684 saw_tool_result = true;
3685 }
3686 ai_agents_core::Role::Assistant if saw_tool_result => {
3687 let Some(calls) = self.parse_tool_calls(&message.content) else {
3688 continue;
3689 };
3690 let calls_are_effective = !calls.is_empty()
3691 && calls.iter().all(|call| {
3692 self.tools
3693 .canonical_id(&call.name)
3694 .is_some_and(|canonical| effective_tool_ids.contains(&canonical))
3695 });
3696 return Ok(calls_are_effective
3697 && match choice {
3698 ToolChoice::Required => true,
3699 ToolChoice::Specific(expected) => calls.iter().all(|call| {
3700 self.tools.canonical_id(&call.name).as_deref()
3701 == Some(expected.as_str())
3702 }),
3703 _ => false,
3704 });
3705 }
3706 ai_agents_core::Role::User => return Ok(false),
3707 _ => {}
3708 }
3709 }
3710 Ok(false)
3711 }
3712
3713 fn provider_can_use_native_tools(
3714 &self,
3715 llm: &dyn LLMProvider,
3716 protocol: &MainToolProtocol,
3717 ) -> bool {
3718 let Some(choice) = protocol.choice.as_ref() else {
3719 return false;
3720 };
3721 if matches!(choice, ToolChoice::None) || protocol.definitions.is_empty() {
3722 return false;
3723 }
3724 llm.supports_tool_choice(choice)
3725 && protocol.definitions.iter().all(|definition| {
3726 !definition.name.is_empty()
3727 && definition.name.len() <= 64
3728 && definition
3729 .name
3730 .bytes()
3731 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
3732 })
3733 }
3734
3735 fn prompt_messages_for_tool_protocol(
3736 &self,
3737 messages: &[ChatMessage],
3738 protocol: &MainToolProtocol,
3739 corrective: bool,
3740 ) -> Vec<ChatMessage> {
3741 let mut messages = messages.to_vec();
3742 let Some(choice) = protocol.choice.as_ref() else {
3743 return messages;
3744 };
3745 if matches!(choice, ToolChoice::None) || protocol.tool_ids.is_empty() {
3746 return messages;
3747 }
3748
3749 let mut tool_prompt = self.tools.generate_scoped_prompt_with_mode(
3750 &protocol.tool_ids,
3751 None,
3752 self.parallel_tools.enabled,
3753 self.runtime_config.tool_schema_prompt_mode,
3754 );
3755 match choice {
3756 ToolChoice::Required => tool_prompt.push_str(
3757 "\n\nYou must call at least one listed tool before giving a final answer.",
3758 ),
3759 ToolChoice::Specific(tool_id) => tool_prompt.push_str(&format!(
3760 "\n\nYou must call the '{tool_id}' tool before giving a final answer."
3761 )),
3762 ToolChoice::Auto => {}
3763 ToolChoice::None => return messages,
3764 _ => return messages,
3765 }
3766 if let Some(system) = messages
3767 .iter_mut()
3768 .find(|message| message.role == ai_agents_core::Role::System)
3769 {
3770 system.content.push_str("\n\n");
3771 system.content.push_str(&tool_prompt);
3772 } else {
3773 messages.insert(0, ChatMessage::system(tool_prompt));
3774 }
3775 if corrective {
3776 let instruction = match choice {
3777 ToolChoice::Required => {
3778 "Your previous response did not call a required tool. Call at least one listed tool now and return only the JSON tool call."
3779 }
3780 ToolChoice::Specific(tool_id) => {
3781 messages.push(ChatMessage::user(format!(
3782 "Your previous response did not call the required '{tool_id}' tool. Call it now and return only the JSON tool call."
3783 )));
3784 return messages;
3785 }
3786 _ => return messages,
3787 };
3788 messages.push(ChatMessage::user(instruction));
3789 }
3790 messages
3791 }
3792
3793 async fn invoke_main_provider(
3794 &self,
3795 llm: Arc<dyn LLMProvider>,
3796 messages: &[ChatMessage],
3797 protocol: &MainToolProtocol,
3798 corrective: bool,
3799 ) -> std::result::Result<MainProviderResponse, LLMError> {
3800 let use_native = self.provider_can_use_native_tools(llm.as_ref(), protocol);
3801 let response = if use_native {
3802 let request = LLMToolRequest {
3803 tools: protocol.definitions.clone(),
3804 choice: protocol
3805 .choice
3806 .clone()
3807 .expect("native tool requests require an explicit choice"),
3808 };
3809 self.observe_purpose(
3810 ObservationPurpose::MainResponse,
3811 llm.complete_with_tools(messages, None, &request),
3812 )
3813 .await?
3814 } else {
3815 let prompt_messages =
3816 self.prompt_messages_for_tool_protocol(messages, protocol, corrective);
3817 self.observe_purpose(
3818 ObservationPurpose::MainResponse,
3819 llm.complete(&prompt_messages, None),
3820 )
3821 .await?
3822 };
3823 Ok(MainProviderResponse {
3824 response,
3825 used_native_tools: use_native,
3826 })
3827 }
3828
3829 async fn complete_main_attempt_with_recovery(
3830 &self,
3831 llm: Arc<dyn LLMProvider>,
3832 messages: &[ChatMessage],
3833 protocol: &MainToolProtocol,
3834 corrective: bool,
3835 ) -> Result<MainProviderResponse> {
3836 let primary_result = if self.recovery_manager.config().default.max_retries > 0 {
3837 self.recovery_manager
3838 .with_retry("llm_call", None, || {
3839 let llm = Arc::clone(&llm);
3840 async move {
3841 self.invoke_main_provider(llm, messages, protocol, corrective)
3842 .await
3843 .map_err(|error| error.classify())
3844 }
3845 })
3846 .await
3847 .map_err(|error| AgentError::LLM(error.to_string()))
3848 } else {
3849 self.invoke_main_provider(Arc::clone(&llm), messages, protocol, corrective)
3850 .await
3851 .map_err(|error| AgentError::LLM(error.to_string()))
3852 };
3853
3854 match primary_result {
3855 Ok(response) => Ok(response),
3856 Err(primary_error) => match &self.recovery_manager.config().llm.on_failure {
3857 LLMFailureAction::FallbackLlm { fallback_llm } => {
3858 let fallback = self.llm_registry.get(fallback_llm).map_err(|error| {
3859 AgentError::Config(format!(
3860 "Fallback LLM '{fallback_llm}' not found: {error}"
3861 ))
3862 })?;
3863 self.invoke_main_provider(fallback, messages, protocol, corrective)
3864 .await
3865 .map_err(|error| AgentError::LLM(error.to_string()))
3866 }
3867 LLMFailureAction::FallbackResponse { message } => {
3868 if matches!(
3869 protocol.choice.as_ref(),
3870 Some(ToolChoice::Required | ToolChoice::Specific(_))
3871 ) {
3872 Err(AgentError::LLM(format!(
3873 "Required tool selection failed and cannot be satisfied by a static fallback response: {primary_error}"
3874 )))
3875 } else {
3876 Ok(MainProviderResponse {
3877 response: LLMResponse::new(message.clone(), FinishReason::Stop),
3878 used_native_tools: false,
3879 })
3880 }
3881 }
3882 LLMFailureAction::Error => Err(primary_error),
3883 },
3884 }
3885 }
3886
3887 fn normalize_main_provider_response(
3888 &self,
3889 mut response: LLMResponse,
3890 protocol: &MainToolProtocol,
3891 ) -> Result<(LLMResponse, bool)> {
3892 let native_calls = response
3893 .tool_calls()
3894 .map_err(|error| AgentError::LLM(error.to_string()))?;
3895 let calls = match native_calls {
3896 Some(calls) => {
3897 let markers = calls
3898 .iter()
3899 .map(|call| {
3900 serde_json::json!({
3901 "_ai_agents_native_tool_call": true,
3902 "id": call.id,
3903 "tool": call.name,
3904 "arguments": call.arguments,
3905 })
3906 })
3907 .collect::<Vec<_>>();
3908 response.content = if markers.len() == 1 {
3909 markers[0].to_string()
3910 } else {
3911 serde_json::Value::Array(markers).to_string()
3912 };
3913 Some(calls)
3914 }
3915 None if !matches!(protocol.choice.as_ref(), Some(ToolChoice::None)) => {
3916 self.parse_tool_calls(response.content.trim())
3917 }
3918 None => None,
3919 };
3920
3921 if protocol.choice.is_some()
3922 && let Some(calls) = calls.as_ref()
3923 && calls.iter().any(|call| {
3924 self.tools
3925 .canonical_id(&call.name)
3926 .is_none_or(|canonical| !protocol.tool_ids.contains(&canonical))
3927 })
3928 {
3929 return Err(AgentError::LLM(
3930 "Provider returned a tool call outside the effective grant".to_string(),
3931 ));
3932 }
3933
3934 let compliant = match protocol.choice.as_ref() {
3935 Some(ToolChoice::Required) => calls.as_ref().is_some_and(|calls| !calls.is_empty()),
3936 Some(ToolChoice::Specific(expected)) => calls.as_ref().is_some_and(|calls| {
3937 !calls.is_empty()
3938 && calls.iter().all(|call| {
3939 self.tools.canonical_id(&call.name).as_deref() == Some(expected.as_str())
3940 })
3941 }),
3942 _ => true,
3943 };
3944 Ok((response, compliant))
3945 }
3946
3947 async fn complete_main_llm_with_recovery(
3948 &self,
3949 llm: Arc<dyn LLMProvider>,
3950 messages: &[ChatMessage],
3951 protocol: &MainToolProtocol,
3952 ) -> Result<LLMResponse> {
3953 let first = self
3954 .complete_main_attempt_with_recovery(Arc::clone(&llm), messages, protocol, false)
3955 .await?;
3956 let (response, compliant) =
3957 self.normalize_main_provider_response(first.response, protocol)?;
3958 if compliant {
3959 return Ok(response);
3960 }
3961 if first.used_native_tools {
3962 return Err(AgentError::LLM(
3963 "Provider returned no compliant native call for required tool choice".to_string(),
3964 ));
3965 }
3966
3967 let corrected = self
3968 .complete_main_attempt_with_recovery(llm, messages, protocol, true)
3969 .await?;
3970 let (response, compliant) =
3971 self.normalize_main_provider_response(corrected.response, protocol)?;
3972 if compliant {
3973 return Ok(response);
3974 }
3975 Err(AgentError::LLM(
3976 "Provider returned no compliant tool call after one corrective retry".to_string(),
3977 ))
3978 }
3979
3980 fn is_native_tool_call_content(content: &str) -> bool {
3981 let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
3982 return false;
3983 };
3984 match value {
3985 serde_json::Value::Array(values) => {
3986 !values.is_empty()
3987 && values.iter().all(|value| {
3988 value
3989 .get("_ai_agents_native_tool_call")
3990 .and_then(|marker| marker.as_bool())
3991 == Some(true)
3992 })
3993 }
3994 serde_json::Value::Object(map) => {
3995 map.get("_ai_agents_native_tool_call")
3996 .and_then(|marker| marker.as_bool())
3997 == Some(true)
3998 }
3999 _ => false,
4000 }
4001 }
4002
4003 fn tool_result_message(
4004 tool_call: &ToolCall,
4005 output: &str,
4006 native_tool_call: bool,
4007 ) -> ChatMessage {
4008 if !native_tool_call {
4009 return ChatMessage::function(&tool_call.name, output);
4010 }
4011 let output = serde_json::from_str::<serde_json::Value>(output)
4012 .unwrap_or_else(|_| serde_json::Value::String(output.to_string()));
4013 ChatMessage::function(
4014 &tool_call.name,
4015 serde_json::json!({
4016 "_ai_agents_native_tool_result": true,
4017 "id": tool_call.id,
4018 "tool": tool_call.name,
4019 "output": output,
4020 })
4021 .to_string(),
4022 )
4023 }
4024
4025 fn parse_main_tool_calls(
4026 &self,
4027 content: &str,
4028 protocol: &MainToolProtocol,
4029 ) -> Option<Vec<ToolCall>> {
4030 if matches!(protocol.choice.as_ref(), Some(ToolChoice::None)) {
4031 None
4032 } else {
4033 self.parse_tool_calls(content)
4034 }
4035 }
4036
4037 fn parse_tool_calls(&self, content: &str) -> Option<Vec<ToolCall>> {
4038 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) {
4040 if let Some(arr) = parsed.as_array() {
4042 let calls: Vec<ToolCall> = arr
4043 .iter()
4044 .filter_map(|v| self.extract_tool_call_from_value(v))
4045 .collect();
4046 if !calls.is_empty() {
4047 return Some(calls);
4048 }
4049 }
4050 if let Some(tool_call) = self.extract_tool_call_from_value(&parsed) {
4052 return Some(vec![tool_call]);
4053 }
4054 }
4055
4056 if let Some(json_str) = self.extract_json_from_content(content)
4058 && let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&json_str)
4059 {
4060 if let Some(arr) = parsed.as_array() {
4062 let calls: Vec<ToolCall> = arr
4063 .iter()
4064 .filter_map(|v| self.extract_tool_call_from_value(v))
4065 .collect();
4066 if !calls.is_empty() {
4067 return Some(calls);
4068 }
4069 }
4070 if let Some(tool_call) = self.extract_tool_call_from_value(&parsed) {
4072 return Some(vec![tool_call]);
4073 }
4074 }
4075
4076 None
4077 }
4078
4079 fn extract_tool_call_from_value(&self, parsed: &serde_json::Value) -> Option<ToolCall> {
4080 if let Some(tool_name) = parsed.get("tool").and_then(|v| v.as_str()) {
4081 let arguments = parsed
4082 .get("arguments")
4083 .cloned()
4084 .unwrap_or(serde_json::json!({}));
4085 return Some(ToolCall {
4086 id: parsed
4087 .get("id")
4088 .and_then(|value| value.as_str())
4089 .filter(|id| !id.is_empty())
4090 .map(str::to_string)
4091 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
4092 name: tool_name.to_string(),
4093 arguments,
4094 });
4095 }
4096 None
4097 }
4098
4099 fn extract_json_from_content(&self, content: &str) -> Option<String> {
4101 if let Some(result) = self.extract_json_array_from_content(content) {
4103 return Some(result);
4104 }
4105 self.extract_json_object_from_content(content)
4106 }
4107
4108 fn extract_json_array_from_content(&self, content: &str) -> Option<String> {
4110 let start = content.find('[')?;
4111 let content_from_start = &content[start..];
4112
4113 let mut depth = 0;
4114 let mut end = 0;
4115 for (i, ch) in content_from_start.char_indices() {
4116 match ch {
4117 '[' => depth += 1,
4118 ']' => {
4119 depth -= 1;
4120 if depth == 0 {
4121 end = i + 1;
4122 break;
4123 }
4124 }
4125 _ => {}
4126 }
4127 }
4128
4129 if end > 0 {
4130 let json_str = &content_from_start[..end];
4131 if json_str.contains("\"tool\"") {
4133 return Some(json_str.to_string());
4134 }
4135 }
4136
4137 None
4138 }
4139
4140 fn extract_json_object_from_content(&self, content: &str) -> Option<String> {
4142 let start = content.find('{')?;
4143 let content_from_start = &content[start..];
4144
4145 let mut depth = 0;
4147 let mut end = 0;
4148 for (i, ch) in content_from_start.char_indices() {
4149 match ch {
4150 '{' => depth += 1,
4151 '}' => {
4152 depth -= 1;
4153 if depth == 0 {
4154 end = i + 1;
4155 break;
4156 }
4157 }
4158 _ => {}
4159 }
4160 }
4161
4162 if end > 0 {
4163 let json_str = &content_from_start[..end];
4164 if json_str.contains("\"tool\"") {
4166 return Some(json_str.to_string());
4167 }
4168 }
4169
4170 None
4171 }
4172
4173 #[allow(clippy::too_many_arguments)]
4177 fn record_from_parts(
4178 &self,
4179 request: &ToolExecutionRequest,
4180 canonical_id: String,
4181 executed_arguments: Value,
4182 started_at: chrono::DateTime<chrono::Utc>,
4183 start: Instant,
4184 executed: bool,
4185 success: bool,
4186 output: String,
4187 metadata: HashMap<String, Value>,
4188 policy: ToolPolicyDecisionRecord,
4189 approval: Option<ToolApprovalRecord>,
4190 timed_out: bool,
4191 output_truncated: bool,
4192 ) -> ToolExecutionRecord {
4193 let versions = ToolDecisionVersions {
4194 policy: self.active_tool_security().policy_version(),
4195 registry: self.tools.version(),
4196 runtime_control: self.runtime_control.version.load(Ordering::SeqCst),
4197 state: self
4198 .state_machine
4199 .as_ref()
4200 .map(|state_machine| state_machine.generation()),
4201 };
4202 self.record_from_parts_at(
4203 request,
4204 canonical_id,
4205 executed_arguments,
4206 started_at,
4207 start,
4208 executed,
4209 success,
4210 output,
4211 metadata,
4212 policy,
4213 approval,
4214 timed_out,
4215 output_truncated,
4216 versions,
4217 )
4218 }
4219
4220 #[allow(clippy::too_many_arguments)]
4222 fn record_from_parts_at(
4223 &self,
4224 request: &ToolExecutionRequest,
4225 canonical_id: String,
4226 executed_arguments: Value,
4227 started_at: chrono::DateTime<chrono::Utc>,
4228 start: Instant,
4229 executed: bool,
4230 success: bool,
4231 output: String,
4232 metadata: HashMap<String, Value>,
4233 policy: ToolPolicyDecisionRecord,
4234 approval: Option<ToolApprovalRecord>,
4235 timed_out: bool,
4236 output_truncated: bool,
4237 versions: ToolDecisionVersions,
4238 ) -> ToolExecutionRecord {
4239 ToolExecutionRecord {
4240 call_id: request.call_id.clone(),
4241 requested_name: request.requested_name.clone(),
4242 canonical_id,
4243 source: request.source.clone(),
4244 arguments: request.arguments.clone(),
4245 executed_arguments,
4246 policy_version: versions.policy,
4247 registry_version: versions.registry,
4248 runtime_config_version: versions.runtime_control,
4249 executed,
4250 success,
4251 output,
4252 metadata,
4253 policy,
4254 approval,
4255 started_at,
4256 duration_ms: start.elapsed().as_millis() as u64,
4257 timed_out,
4258 cancelled: false,
4259 cancellation_reason: None,
4260 output_truncated,
4261 }
4262 }
4263
4264 async fn finish_tool_record(&self, record: &ToolExecutionRecord) {
4266 let result = ToolResult {
4267 success: record.success,
4268 output: record.model_output_string(),
4269 metadata: if record.metadata.is_empty() {
4270 None
4271 } else {
4272 Some(record.metadata.clone())
4273 },
4274 };
4275 self.hooks
4276 .on_tool_complete(&record.canonical_id, &result, record.duration_ms)
4277 .await;
4278 self.hooks.on_tool_execution_record(record).await;
4279 self.record_tool_call(&record.canonical_id, record.model_output_value());
4280 if !record.success {
4281 self.hooks
4282 .on_error(&AgentError::Tool(record.output.clone()))
4283 .await;
4284 }
4285 }
4286
4287 async fn finish_tool_record_after_resource_guards(
4289 &self,
4290 resource_guards: ToolResourceGuards,
4291 record: &ToolExecutionRecord,
4292 ) {
4293 drop(resource_guards);
4294 self.finish_tool_record(record).await;
4295 }
4296
4297 async fn execute_resolved_tool_once(
4299 &self,
4300 tool: Arc<dyn ai_agents_core::Tool>,
4301 args: Value,
4302 ctx: ToolExecutionContext,
4303 timeout_ms: u64,
4304 ) -> Result<(ToolResult, bool, bool, bool)> {
4305 if self.runtime_control.emergency_deny.load(Ordering::SeqCst) {
4306 return Ok((
4307 ToolResult::error("Tool execution cancelled by runtime control"),
4308 false,
4309 true,
4310 false,
4311 ));
4312 }
4313 let invoked = Arc::new(AtomicBool::new(false));
4317 let invoked_by_future = Arc::clone(&invoked);
4318 let actor_context = current_turn_actor_context();
4319 let future = async move {
4320 invoked_by_future.store(true, Ordering::SeqCst);
4321 if let Some(actor_context) = actor_context {
4322 scope_actor_context(actor_context, tool.execute(args, ctx)).await
4323 } else {
4324 tool.execute(args, ctx).await
4325 }
4326 };
4327 tokio::pin!(future);
4328 let timeout = tokio::time::sleep(std::time::Duration::from_millis(timeout_ms));
4329 tokio::pin!(timeout);
4330 let mut cancel_tick = tokio::time::interval(std::time::Duration::from_millis(50));
4331
4332 loop {
4333 tokio::select! {
4334 result = &mut future => return Ok((result, false, false, true)),
4335 _ = &mut timeout => {
4336 return Ok((
4337 ToolResult::error("Tool execution timed out"),
4338 true,
4339 false,
4340 invoked.load(Ordering::SeqCst),
4341 ));
4342 }
4343 _ = cancel_tick.tick() => {
4344 if self.runtime_control.emergency_deny.load(Ordering::SeqCst) {
4345 return Ok((
4346 ToolResult::error("Tool execution cancelled by runtime control"),
4347 false,
4348 true,
4349 invoked.load(Ordering::SeqCst),
4350 ));
4351 }
4352 }
4353 }
4354 }
4355 }
4356
4357 fn truncate_tool_output(output: String, max_chars: Option<usize>) -> (String, bool) {
4359 let Some(max_chars) = max_chars else {
4360 return (output, false);
4361 };
4362 let mut chars = output.chars();
4363 let truncated: String = chars.by_ref().take(max_chars).collect();
4364 if chars.next().is_some() {
4365 (truncated, true)
4366 } else {
4367 (output, false)
4368 }
4369 }
4370
4371 async fn acquire_tool_resource_locks(&self, keys: &[String]) -> Option<ToolResourceGuards> {
4373 let locks = {
4374 let mut table = self.resource_locks.write();
4375 table.retain(|_, lock| lock.strong_count() > 0);
4376 keys.iter()
4377 .map(|key| {
4378 if let Some(lock) = table.get(key).and_then(Weak::upgrade) {
4379 lock
4380 } else {
4381 let lock = Arc::new(tokio::sync::Mutex::new(()));
4382 table.insert(key.clone(), Arc::downgrade(&lock));
4383 lock
4384 }
4385 })
4386 .collect::<Vec<_>>()
4387 };
4388 let mut resource_guards = ToolResourceGuards {
4389 guards: Vec::with_capacity(locks.len()),
4390 locks: Arc::clone(&self.resource_locks),
4391 };
4392 let mut locks = locks.into_iter();
4393 while let Some(lock) = locks.next() {
4394 let mut lock = Box::pin(lock.lock_owned());
4395 loop {
4396 tokio::select! {
4397 guard = &mut lock => {
4398 resource_guards.guards.push(guard);
4399 break;
4400 }
4401 _ = tokio::time::sleep(std::time::Duration::from_millis(10)) => {
4402 if self.runtime_control.emergency_deny.load(Ordering::SeqCst) {
4403 drop(lock);
4404 drop(locks);
4405 drop(resource_guards);
4406 return None;
4407 }
4408 }
4409 }
4410 }
4411 }
4412 Some(resource_guards)
4413 }
4414
4415 async fn run_tool_with_retries(
4417 &self,
4418 canonical_id: &str,
4419 tool: Arc<dyn ai_agents_core::Tool>,
4420 args: Value,
4421 ctx: ToolExecutionContext,
4422 timeout_ms: u64,
4423 max_retries: u32,
4424 ) -> Result<(ToolResult, bool, bool, bool)> {
4425 let max_retries = if ctx.classification.safely_retryable {
4426 max_retries
4427 } else {
4428 0
4429 };
4430 let mut attempts = 0;
4431 let mut invoked = false;
4432 loop {
4433 let (result, timed_out, cancelled, attempt_invoked) = self
4434 .execute_resolved_tool_once(tool.clone(), args.clone(), ctx.clone(), timeout_ms)
4435 .await?;
4436 invoked |= attempt_invoked;
4437 if result.success || timed_out || cancelled || attempts >= max_retries {
4438 return Ok((result, timed_out, cancelled, invoked));
4439 }
4440 attempts += 1;
4441 warn!(tool = %canonical_id, attempt = attempts, error = %result.output, "Retrying failed tool call");
4442 }
4443 }
4444
4445 fn execute_tool_record(
4447 &self,
4448 request: ToolExecutionRequest,
4449 ) -> Pin<Box<dyn Future<Output = Result<ToolExecutionRecord>> + Send + '_>> {
4450 Box::pin(self.execute_tool_record_inner(request))
4451 }
4452
4453 async fn execute_tool_record_inner(
4454 &self,
4455 request: ToolExecutionRequest,
4456 ) -> Result<ToolExecutionRecord> {
4457 let started_at = chrono::Utc::now();
4458 let start = Instant::now();
4459 info!(tool = %request.requested_name, args = %request.arguments, "Executing tool");
4460
4461 if self.runtime_control.emergency_deny.load(Ordering::SeqCst) {
4462 let record = self.record_from_parts(
4463 &request,
4464 request.requested_name.clone(),
4465 request.arguments.clone(),
4466 started_at,
4467 start,
4468 false,
4469 false,
4470 "Tool execution is disabled by runtime control".to_string(),
4471 HashMap::new(),
4472 ToolPolicyDecisionRecord::deny("runtime emergency deny is enabled"),
4473 None,
4474 false,
4475 false,
4476 );
4477 self.finish_tool_record(&record).await;
4478 return Ok(record);
4479 }
4480
4481 let Some(resolved) = self.tools.resolve(&request.requested_name) else {
4482 let record = self.record_from_parts(
4483 &request,
4484 request.requested_name.clone(),
4485 request.arguments.clone(),
4486 started_at,
4487 start,
4488 false,
4489 false,
4490 format!("Tool '{}' is unavailable", request.requested_name),
4491 HashMap::new(),
4492 ToolPolicyDecisionRecord::unavailable(format!(
4493 "Tool '{}' is not registered",
4494 request.requested_name
4495 )),
4496 None,
4497 false,
4498 false,
4499 );
4500 self.finish_tool_record(&record).await;
4501 return Ok(record);
4502 };
4503
4504 let canonical_id = resolved.identity.canonical_id.clone();
4505
4506 let initial_scope_snapshot = self.get_available_tool_ids_snapshot().await?;
4507 if !initial_scope_snapshot
4508 .tool_ids
4509 .iter()
4510 .any(|id| id == &canonical_id)
4511 {
4512 let record = self.record_from_parts(
4513 &request,
4514 canonical_id.clone(),
4515 request.arguments.clone(),
4516 started_at,
4517 start,
4518 false,
4519 false,
4520 format!(
4521 "Tool '{}' is not available in the current scope",
4522 canonical_id
4523 ),
4524 HashMap::new(),
4525 ToolPolicyDecisionRecord::deny(format!(
4526 "Tool '{}' is not granted by the current top-level and state tool scope",
4527 canonical_id
4528 )),
4529 None,
4530 false,
4531 false,
4532 );
4533 self.finish_tool_record(&record).await;
4534 return Ok(record);
4535 }
4536
4537 let approval_control_snapshot = self.runtime_safety_snapshot();
4538 let security_engine = approval_control_snapshot.tool_security.clone();
4539 let bindings = resolved.tool.policy_bindings();
4540 let mut executed_arguments = security_engine.prepare_tool_arguments_with_bindings(
4541 &canonical_id,
4542 &request.arguments,
4543 &bindings,
4544 );
4545 self.hooks
4546 .on_tool_start(&canonical_id, &executed_arguments)
4547 .await;
4548
4549 let mut metadata = HashMap::new();
4550 let safety = resolved.tool.safety_metadata();
4551 let classification = resolved.tool.classify_call(&executed_arguments);
4552 let limits = security_engine.effective_limits(&canonical_id, &safety, &classification);
4553 metadata.insert(
4554 "classification".to_string(),
4555 serde_json::to_value(&classification).unwrap_or(Value::Null),
4556 );
4557 metadata.insert(
4558 "effective_limits".to_string(),
4559 serde_json::to_value(&limits).unwrap_or(Value::Null),
4560 );
4561 let policy_snapshot = security_engine.policy_snapshot(&canonical_id);
4562 if !policy_snapshot.is_null() {
4563 metadata.insert("policy_snapshot".to_string(), policy_snapshot.clone());
4564 }
4565
4566 let mut approval_record = Some(ToolApprovalRecord {
4567 status: ToolApprovalStatus::NotRequired,
4568 reason: None,
4569 modified_arguments: None,
4570 });
4571
4572 let mut security_result = security_engine
4573 .validate_tool_execution_with_bindings(&canonical_id, &executed_arguments, &bindings)
4574 .await?;
4575 match &security_result {
4576 SecurityCheckResult::Allow => {}
4577 SecurityCheckResult::Warn { message } => {
4578 warn!(tool = %canonical_id, message = %message, "Tool security warning");
4579 }
4580 SecurityCheckResult::Block { reason } => {
4581 let record = self.record_from_parts(
4582 &request,
4583 canonical_id,
4584 executed_arguments,
4585 started_at,
4586 start,
4587 false,
4588 false,
4589 format!("Denied: {}", reason),
4590 metadata,
4591 ToolPolicyDecisionRecord::deny(reason.clone()),
4592 approval_record,
4593 false,
4594 false,
4595 );
4596 self.finish_tool_record(&record).await;
4597 return Ok(record);
4598 }
4599 SecurityCheckResult::Unavailable { reason } => {
4600 let record = self.record_from_parts(
4601 &request,
4602 canonical_id,
4603 executed_arguments,
4604 started_at,
4605 start,
4606 false,
4607 false,
4608 format!("Unavailable: {}", reason),
4609 metadata,
4610 ToolPolicyDecisionRecord::unavailable(reason.clone()),
4611 approval_record,
4612 false,
4613 false,
4614 );
4615 self.finish_tool_record(&record).await;
4616 return Ok(record);
4617 }
4618 SecurityCheckResult::RequireConfirmation { message } => {
4619 if self.hitl_engine.is_none() {
4620 approval_record = Some(ToolApprovalRecord {
4621 status: ToolApprovalStatus::Unavailable,
4622 reason: Some("No HITL engine configured".to_string()),
4623 modified_arguments: None,
4624 });
4625 let record = self.record_from_parts(
4626 &request,
4627 canonical_id,
4628 executed_arguments,
4629 started_at,
4630 start,
4631 false,
4632 false,
4633 format!("Approval unavailable: {}", message),
4634 metadata,
4635 ToolPolicyDecisionRecord::approval(message.clone()),
4636 approval_record,
4637 false,
4638 false,
4639 );
4640 self.finish_tool_record(&record).await;
4641 return Ok(record);
4642 }
4643
4644 let check_result = HITLCheckResult::required(
4645 ApprovalTrigger::tool(&canonical_id, executed_arguments.clone()),
4646 HashMap::new(),
4647 message.clone(),
4648 None,
4649 );
4650 match self.request_hitl_approval(check_result).await? {
4651 ApprovalResult::Approved => {
4652 merge_approved_record(&mut approval_record);
4653 }
4654 ApprovalResult::Modified { changes } => {
4655 if let Some(obj) = executed_arguments.as_object_mut() {
4656 for (key, value) in changes {
4657 obj.insert(key, value);
4658 }
4659 }
4660 security_result = security_engine
4661 .validate_tool_execution_with_bindings(
4662 &canonical_id,
4663 &executed_arguments,
4664 &bindings,
4665 )
4666 .await?;
4667 if !matches!(
4668 security_result,
4669 SecurityCheckResult::Allow
4670 | SecurityCheckResult::Warn { .. }
4671 | SecurityCheckResult::RequireConfirmation { .. }
4672 ) {
4673 let reason = security_result
4674 .reason()
4675 .unwrap_or("modified arguments failed policy")
4676 .to_string();
4677 let record = self.record_from_parts(
4678 &request,
4679 canonical_id,
4680 executed_arguments.clone(),
4681 started_at,
4682 start,
4683 false,
4684 false,
4685 reason.clone(),
4686 metadata,
4687 ToolPolicyDecisionRecord::deny(reason),
4688 Some(ToolApprovalRecord {
4689 status: ToolApprovalStatus::Modified,
4690 reason: None,
4691 modified_arguments: Some(executed_arguments),
4692 }),
4693 false,
4694 false,
4695 );
4696 self.finish_tool_record(&record).await;
4697 return Ok(record);
4698 }
4699 approval_record = Some(ToolApprovalRecord {
4700 status: ToolApprovalStatus::Modified,
4701 reason: None,
4702 modified_arguments: Some(executed_arguments.clone()),
4703 });
4704 }
4705 ApprovalResult::Rejected { reason } => {
4706 let reason = reason.unwrap_or_else(|| "rejected".to_string());
4707 approval_record = Some(ToolApprovalRecord {
4708 status: ToolApprovalStatus::Rejected,
4709 reason: Some(reason.clone()),
4710 modified_arguments: None,
4711 });
4712 let record = self.record_from_parts(
4713 &request,
4714 canonical_id,
4715 executed_arguments,
4716 started_at,
4717 start,
4718 false,
4719 false,
4720 format!("Approval rejected: {}", reason),
4721 metadata,
4722 ToolPolicyDecisionRecord::approval(reason),
4723 approval_record,
4724 false,
4725 false,
4726 );
4727 self.finish_tool_record(&record).await;
4728 return Ok(record);
4729 }
4730 ApprovalResult::Timeout => {
4731 approval_record = Some(ToolApprovalRecord {
4732 status: ToolApprovalStatus::Timeout,
4733 reason: Some("approval timeout".to_string()),
4734 modified_arguments: None,
4735 });
4736 let record = self.record_from_parts(
4737 &request,
4738 canonical_id,
4739 executed_arguments,
4740 started_at,
4741 start,
4742 false,
4743 false,
4744 "Approval timed out".to_string(),
4745 metadata,
4746 ToolPolicyDecisionRecord::approval("approval timeout"),
4747 approval_record,
4748 false,
4749 false,
4750 );
4751 self.finish_tool_record(&record).await;
4752 return Ok(record);
4753 }
4754 }
4755 }
4756 }
4757
4758 if canonical_id == "command" && !self.tools.command_runner_available() {
4759 let record = self.record_from_parts(
4760 &request,
4761 canonical_id.clone(),
4762 executed_arguments.clone(),
4763 started_at,
4764 start,
4765 false,
4766 false,
4767 "Command runner is unavailable".to_string(),
4768 metadata,
4769 ToolPolicyDecisionRecord::unavailable("command runner is unavailable"),
4770 Some(ToolApprovalRecord {
4771 status: ToolApprovalStatus::Unavailable,
4772 reason: Some("command runner is unavailable".to_string()),
4773 modified_arguments: None,
4774 }),
4775 false,
4776 false,
4777 );
4778 self.finish_tool_record(&record).await;
4779 return Ok(record);
4780 }
4781
4782 if approval_record
4783 .as_ref()
4784 .is_some_and(|record| matches!(record.status, ToolApprovalStatus::NotRequired))
4785 && let Some(message) =
4786 security_engine.classification_approval_message(&canonical_id, &classification)
4787 {
4788 if self.hitl_engine.is_none() {
4789 approval_record = Some(ToolApprovalRecord {
4790 status: ToolApprovalStatus::Unavailable,
4791 reason: Some("No HITL engine configured".to_string()),
4792 modified_arguments: None,
4793 });
4794 let record = self.record_from_parts(
4795 &request,
4796 canonical_id,
4797 executed_arguments,
4798 started_at,
4799 start,
4800 false,
4801 false,
4802 format!("Approval unavailable: {}", message),
4803 metadata,
4804 ToolPolicyDecisionRecord::approval(message),
4805 approval_record,
4806 false,
4807 false,
4808 );
4809 self.finish_tool_record(&record).await;
4810 return Ok(record);
4811 }
4812 let check_result = HITLCheckResult::required(
4813 ApprovalTrigger::tool(&canonical_id, executed_arguments.clone()),
4814 HashMap::new(),
4815 message.clone(),
4816 None,
4817 );
4818 match self.request_hitl_approval(check_result).await? {
4819 ApprovalResult::Approved => {
4820 merge_approved_record(&mut approval_record);
4821 }
4822 ApprovalResult::Modified { changes } => {
4823 if let Some(obj) = executed_arguments.as_object_mut() {
4824 for (key, value) in changes {
4825 obj.insert(key, value);
4826 }
4827 }
4828 let modified_security = security_engine
4829 .validate_tool_execution_with_bindings(
4830 &canonical_id,
4831 &executed_arguments,
4832 &bindings,
4833 )
4834 .await?;
4835 if !matches!(
4836 modified_security,
4837 SecurityCheckResult::Allow | SecurityCheckResult::Warn { .. }
4838 ) {
4839 let reason = modified_security
4840 .reason()
4841 .unwrap_or("modified arguments failed policy")
4842 .to_string();
4843 let record = self.record_from_parts(
4844 &request,
4845 canonical_id,
4846 executed_arguments.clone(),
4847 started_at,
4848 start,
4849 false,
4850 false,
4851 reason.clone(),
4852 metadata,
4853 ToolPolicyDecisionRecord::deny(reason),
4854 Some(ToolApprovalRecord {
4855 status: ToolApprovalStatus::Modified,
4856 reason: None,
4857 modified_arguments: Some(executed_arguments),
4858 }),
4859 false,
4860 false,
4861 );
4862 self.finish_tool_record(&record).await;
4863 return Ok(record);
4864 }
4865 approval_record = Some(ToolApprovalRecord {
4866 status: ToolApprovalStatus::Modified,
4867 reason: None,
4868 modified_arguments: Some(executed_arguments.clone()),
4869 });
4870 }
4871 ApprovalResult::Rejected { reason } => {
4872 let reason = reason.unwrap_or_else(|| "rejected".to_string());
4873 let record = self.record_from_parts(
4874 &request,
4875 canonical_id,
4876 executed_arguments,
4877 started_at,
4878 start,
4879 false,
4880 false,
4881 format!("Approval rejected: {}", reason),
4882 metadata,
4883 ToolPolicyDecisionRecord::approval(reason.clone()),
4884 Some(ToolApprovalRecord {
4885 status: ToolApprovalStatus::Rejected,
4886 reason: Some(reason),
4887 modified_arguments: None,
4888 }),
4889 false,
4890 false,
4891 );
4892 self.finish_tool_record(&record).await;
4893 return Ok(record);
4894 }
4895 ApprovalResult::Timeout => {
4896 let record = self.record_from_parts(
4897 &request,
4898 canonical_id,
4899 executed_arguments,
4900 started_at,
4901 start,
4902 false,
4903 false,
4904 "Approval timed out".to_string(),
4905 metadata,
4906 ToolPolicyDecisionRecord::approval("approval timeout"),
4907 Some(ToolApprovalRecord {
4908 status: ToolApprovalStatus::Timeout,
4909 reason: Some("approval timeout".to_string()),
4910 modified_arguments: None,
4911 }),
4912 false,
4913 false,
4914 );
4915 self.finish_tool_record(&record).await;
4916 return Ok(record);
4917 }
4918 }
4919 }
4920
4921 if canonical_id == "diagnostics" && !self.tools.diagnostics_available() {
4922 let record = self.record_from_parts(
4923 &request,
4924 canonical_id.clone(),
4925 executed_arguments.clone(),
4926 started_at,
4927 start,
4928 false,
4929 false,
4930 "Diagnostics provider is unavailable".to_string(),
4931 metadata,
4932 ToolPolicyDecisionRecord::unavailable("diagnostics provider is unavailable"),
4933 Some(ToolApprovalRecord {
4934 status: ToolApprovalStatus::Unavailable,
4935 reason: Some("diagnostics provider is unavailable".to_string()),
4936 modified_arguments: None,
4937 }),
4938 false,
4939 false,
4940 );
4941 self.finish_tool_record(&record).await;
4942 return Ok(record);
4943 }
4944
4945 if canonical_id == "web_search" && !self.tools.web_search_available() {
4946 let record = self.record_from_parts(
4947 &request,
4948 canonical_id.clone(),
4949 executed_arguments.clone(),
4950 started_at,
4951 start,
4952 false,
4953 false,
4954 "Web search provider is unavailable".to_string(),
4955 metadata,
4956 ToolPolicyDecisionRecord::unavailable("web search provider is unavailable"),
4957 Some(ToolApprovalRecord {
4958 status: ToolApprovalStatus::Unavailable,
4959 reason: Some("web search provider is unavailable".to_string()),
4960 modified_arguments: None,
4961 }),
4962 false,
4963 false,
4964 );
4965 self.finish_tool_record(&record).await;
4966 return Ok(record);
4967 }
4968
4969 let hitl_lang_ctx = self.build_hitl_language_context();
4970 if let Some(ref hitl_engine) = self.hitl_engine {
4971 let check_result = self
4972 .observe_purpose(
4973 ObservationPurpose::HitlLocalization,
4974 hitl_engine.check_tool_with_localization(
4975 &canonical_id,
4976 &executed_arguments,
4977 &hitl_lang_ctx,
4978 self.approval_handler.as_ref(),
4979 Some(&self.llm_registry),
4980 ),
4981 )
4982 .await?;
4983 if check_result.is_required() {
4984 match self.request_hitl_approval(check_result).await? {
4985 ApprovalResult::Approved => {
4986 merge_approved_record(&mut approval_record);
4987 }
4988 ApprovalResult::Modified { changes } => {
4989 if let Some(obj) = executed_arguments.as_object_mut() {
4990 for (key, value) in changes {
4991 obj.insert(key, value);
4992 }
4993 }
4994 let modified_security = security_engine
4995 .validate_tool_execution_with_bindings(
4996 &canonical_id,
4997 &executed_arguments,
4998 &bindings,
4999 )
5000 .await?;
5001 if !matches!(
5002 modified_security,
5003 SecurityCheckResult::Allow | SecurityCheckResult::Warn { .. }
5004 ) {
5005 let reason = modified_security
5006 .reason()
5007 .unwrap_or("modified arguments failed policy")
5008 .to_string();
5009 let record = self.record_from_parts(
5010 &request,
5011 canonical_id,
5012 executed_arguments.clone(),
5013 started_at,
5014 start,
5015 false,
5016 false,
5017 reason.clone(),
5018 metadata,
5019 ToolPolicyDecisionRecord::deny(reason),
5020 Some(ToolApprovalRecord {
5021 status: ToolApprovalStatus::Modified,
5022 reason: None,
5023 modified_arguments: Some(executed_arguments),
5024 }),
5025 false,
5026 false,
5027 );
5028 self.finish_tool_record(&record).await;
5029 return Ok(record);
5030 }
5031 approval_record = Some(ToolApprovalRecord {
5032 status: ToolApprovalStatus::Modified,
5033 reason: None,
5034 modified_arguments: Some(executed_arguments.clone()),
5035 });
5036 }
5037 ApprovalResult::Rejected { reason } => {
5038 let reason = reason.unwrap_or_else(|| "rejected".to_string());
5039 let record = self.record_from_parts(
5040 &request,
5041 canonical_id,
5042 executed_arguments,
5043 started_at,
5044 start,
5045 false,
5046 false,
5047 format!("Approval rejected: {}", reason),
5048 metadata,
5049 ToolPolicyDecisionRecord::approval(reason.clone()),
5050 Some(ToolApprovalRecord {
5051 status: ToolApprovalStatus::Rejected,
5052 reason: Some(reason),
5053 modified_arguments: None,
5054 }),
5055 false,
5056 false,
5057 );
5058 self.finish_tool_record(&record).await;
5059 return Ok(record);
5060 }
5061 ApprovalResult::Timeout => {
5062 let record = self.record_from_parts(
5063 &request,
5064 canonical_id,
5065 executed_arguments,
5066 started_at,
5067 start,
5068 false,
5069 false,
5070 "Approval timed out".to_string(),
5071 metadata,
5072 ToolPolicyDecisionRecord::approval("approval timeout"),
5073 Some(ToolApprovalRecord {
5074 status: ToolApprovalStatus::Timeout,
5075 reason: Some("approval timeout".to_string()),
5076 modified_arguments: None,
5077 }),
5078 false,
5079 false,
5080 );
5081 self.finish_tool_record(&record).await;
5082 return Ok(record);
5083 }
5084 }
5085 }
5086
5087 let condition_check = self
5088 .observe_purpose(
5089 ObservationPurpose::HitlLocalization,
5090 hitl_engine.check_conditions_with_localization(
5091 &executed_arguments,
5092 &hitl_lang_ctx,
5093 self.approval_handler.as_ref(),
5094 Some(&self.llm_registry),
5095 ),
5096 )
5097 .await?;
5098 if condition_check.is_required() {
5099 match self.request_hitl_approval(condition_check).await? {
5100 ApprovalResult::Approved => {
5101 merge_approved_record(&mut approval_record);
5102 }
5103 ApprovalResult::Modified { changes } => {
5104 if let Some(obj) = executed_arguments.as_object_mut() {
5105 for (key, value) in changes {
5106 obj.insert(key, value);
5107 }
5108 }
5109 let modified_security = security_engine
5110 .validate_tool_execution_with_bindings(
5111 &canonical_id,
5112 &executed_arguments,
5113 &bindings,
5114 )
5115 .await?;
5116 if !matches!(
5117 modified_security,
5118 SecurityCheckResult::Allow | SecurityCheckResult::Warn { .. }
5119 ) {
5120 let reason = modified_security
5121 .reason()
5122 .unwrap_or("modified arguments failed policy")
5123 .to_string();
5124 let record = self.record_from_parts(
5125 &request,
5126 canonical_id,
5127 executed_arguments,
5128 started_at,
5129 start,
5130 false,
5131 false,
5132 reason.clone(),
5133 metadata,
5134 ToolPolicyDecisionRecord::deny(reason),
5135 approval_record,
5136 false,
5137 false,
5138 );
5139 self.finish_tool_record(&record).await;
5140 return Ok(record);
5141 }
5142 approval_record = Some(ToolApprovalRecord {
5143 status: ToolApprovalStatus::Modified,
5144 reason: None,
5145 modified_arguments: Some(executed_arguments.clone()),
5146 });
5147 }
5148 ApprovalResult::Rejected { reason } => {
5149 let reason = reason.unwrap_or_else(|| "rejected".to_string());
5150 let record = self.record_from_parts(
5151 &request,
5152 canonical_id,
5153 executed_arguments,
5154 started_at,
5155 start,
5156 false,
5157 false,
5158 format!("Approval rejected: {}", reason),
5159 metadata,
5160 ToolPolicyDecisionRecord::approval(reason.clone()),
5161 Some(ToolApprovalRecord {
5162 status: ToolApprovalStatus::Rejected,
5163 reason: Some(reason),
5164 modified_arguments: None,
5165 }),
5166 false,
5167 false,
5168 );
5169 self.finish_tool_record(&record).await;
5170 return Ok(record);
5171 }
5172 ApprovalResult::Timeout => {
5173 let record = self.record_from_parts(
5174 &request,
5175 canonical_id,
5176 executed_arguments,
5177 started_at,
5178 start,
5179 false,
5180 false,
5181 "Approval timed out".to_string(),
5182 metadata,
5183 ToolPolicyDecisionRecord::approval("approval timeout"),
5184 Some(ToolApprovalRecord {
5185 status: ToolApprovalStatus::Timeout,
5186 reason: Some("approval timeout".to_string()),
5187 modified_arguments: None,
5188 }),
5189 false,
5190 false,
5191 );
5192 self.finish_tool_record(&record).await;
5193 return Ok(record);
5194 }
5195 }
5196 }
5197 }
5198
5199 executed_arguments = security_engine.prepare_tool_arguments_with_bindings(
5204 &canonical_id,
5205 &executed_arguments,
5206 &bindings,
5207 );
5208 if let Some(record) = approval_record.as_mut()
5209 && matches!(record.status, ToolApprovalStatus::Modified)
5210 {
5211 record.modified_arguments = Some(executed_arguments.clone());
5212 }
5213 let binding_security_result = security_engine
5214 .validate_tool_execution_with_bindings(&canonical_id, &executed_arguments, &bindings)
5215 .await?;
5216 let approval_confirmation_required = matches!(
5217 binding_security_result,
5218 SecurityCheckResult::RequireConfirmation { .. }
5219 ) || security_engine
5220 .classification_approval_message(
5221 &canonical_id,
5222 &resolved.tool.classify_call(&executed_arguments),
5223 )
5224 .is_some();
5225 let approval_binding = approval_record.as_ref().and_then(|record| {
5226 matches!(
5227 record.status,
5228 ToolApprovalStatus::Approved | ToolApprovalStatus::Modified
5229 )
5230 .then(|| ToolApprovalBinding {
5231 canonical_id: canonical_id.clone(),
5232 arguments: executed_arguments.clone(),
5233 confirmation_required: approval_confirmation_required,
5234 policy_version: security_engine.policy_version(),
5235 runtime_control_version: approval_control_snapshot.version,
5236 state_generation: initial_scope_snapshot.state_generation,
5237 reviewed_tool: Arc::clone(&resolved.tool),
5238 })
5239 });
5240
5241 let control_snapshot = self.runtime_safety_snapshot();
5246 let resolved = self.tools.resolve(&request.requested_name);
5247 let registry_version = self.tools.version();
5248 let mut versions = ToolDecisionVersions {
5249 policy: control_snapshot.tool_security.policy_version(),
5250 registry: registry_version,
5251 runtime_control: control_snapshot.version,
5252 state: None,
5253 };
5254 metadata.insert(
5255 "runtime_scope_snapshot".to_string(),
5256 serde_json::to_value(&control_snapshot.tool_scope_override).unwrap_or(Value::Null),
5257 );
5258 let resolved = match resolved {
5259 Some(resolved) => resolved,
5260 None => {
5261 let reason = format!(
5262 "Tool '{}' became unavailable after approval",
5263 request.requested_name
5264 );
5265 let record = self.record_from_parts_at(
5266 &request,
5267 request.requested_name.clone(),
5268 executed_arguments,
5269 started_at,
5270 start,
5271 false,
5272 false,
5273 reason.clone(),
5274 metadata,
5275 ToolPolicyDecisionRecord::unavailable(reason),
5276 approval_record,
5277 false,
5278 false,
5279 versions,
5280 );
5281 self.finish_tool_record(&record).await;
5282 return Ok(record);
5283 }
5284 };
5285
5286 let canonical_id = resolved.identity.canonical_id.clone();
5287 let bindings = resolved.tool.policy_bindings();
5288 let final_arguments = control_snapshot
5289 .tool_security
5290 .prepare_tool_arguments_with_bindings(&canonical_id, &executed_arguments, &bindings);
5291 if let Some(record) = approval_record.as_mut()
5292 && matches!(record.status, ToolApprovalStatus::Modified)
5293 {
5294 record.modified_arguments = Some(final_arguments.clone());
5295 }
5296 let classification = resolved.tool.classify_call(&final_arguments);
5297 let safety = resolved.tool.safety_metadata();
5298 let security_engine = control_snapshot.tool_security;
5299 let limits = security_engine.effective_limits(&canonical_id, &safety, &classification);
5300 let policy_snapshot = security_engine.policy_snapshot(&canonical_id);
5301 let resource_lock_keys =
5302 tool_resource_lock_keys(&canonical_id, &final_arguments, &bindings, &classification);
5303 metadata.insert(
5304 "classification".to_string(),
5305 serde_json::to_value(&classification).unwrap_or(Value::Null),
5306 );
5307 metadata.insert(
5308 "effective_limits".to_string(),
5309 serde_json::to_value(&limits).unwrap_or(Value::Null),
5310 );
5311 metadata.insert(
5312 "resource_lock_keys".to_string(),
5313 serde_json::to_value(&resource_lock_keys).unwrap_or(Value::Null),
5314 );
5315 if policy_snapshot.is_null() {
5316 metadata.remove("policy_snapshot");
5317 } else {
5318 metadata.insert("policy_snapshot".to_string(), policy_snapshot.clone());
5319 }
5320
5321 let final_denial = |canonical_id: String,
5322 output: String,
5323 policy: ToolPolicyDecisionRecord,
5324 metadata: HashMap<String, Value>,
5325 decision_versions: ToolDecisionVersions| {
5326 self.record_from_parts_at(
5327 &request,
5328 canonical_id,
5329 final_arguments.clone(),
5330 started_at,
5331 start,
5332 false,
5333 false,
5334 output,
5335 metadata,
5336 policy,
5337 approval_record.clone(),
5338 false,
5339 false,
5340 decision_versions,
5341 )
5342 };
5343
5344 if control_snapshot.emergency_deny {
5345 let reason = "Tool execution is disabled by runtime control".to_string();
5346 let record = final_denial(
5347 canonical_id,
5348 reason.clone(),
5349 ToolPolicyDecisionRecord::deny(reason),
5350 metadata,
5351 versions,
5352 );
5353 self.finish_tool_record(&record).await;
5354 return Ok(record);
5355 }
5356
5357 let available_snapshot = self
5362 .get_available_tool_ids_snapshot_for_scope(
5363 control_snapshot.tool_scope_override.as_deref(),
5364 )
5365 .await?;
5366 versions.state = available_snapshot.state_generation;
5367 metadata.insert(
5368 "available_tool_ids_snapshot".to_string(),
5369 serde_json::to_value(&available_snapshot.tool_ids).unwrap_or(Value::Null),
5370 );
5371 metadata.insert(
5372 "state_generation_snapshot".to_string(),
5373 serde_json::to_value(available_snapshot.state_generation).unwrap_or(Value::Null),
5374 );
5375 if !available_snapshot
5376 .tool_ids
5377 .iter()
5378 .any(|tool_id| tool_id == &canonical_id)
5379 {
5380 let reason = format!(
5381 "Tool '{}' is not available in the final runtime scope",
5382 canonical_id
5383 );
5384 let record = final_denial(
5385 canonical_id,
5386 reason.clone(),
5387 ToolPolicyDecisionRecord::deny(reason),
5388 metadata,
5389 versions,
5390 );
5391 self.finish_tool_record(&record).await;
5392 return Ok(record);
5393 }
5394
5395 let final_security_result = security_engine
5400 .validate_tool_execution_with_bindings(&canonical_id, &final_arguments, &bindings)
5401 .await?;
5402 match &final_security_result {
5403 SecurityCheckResult::Block { reason } => {
5404 let record = final_denial(
5405 canonical_id,
5406 format!("Denied: {}", reason),
5407 ToolPolicyDecisionRecord::deny(reason.clone()),
5408 metadata,
5409 versions,
5410 );
5411 self.finish_tool_record(&record).await;
5412 return Ok(record);
5413 }
5414 SecurityCheckResult::Unavailable { reason } => {
5415 let record = final_denial(
5416 canonical_id,
5417 format!("Unavailable: {}", reason),
5418 ToolPolicyDecisionRecord::unavailable(reason.clone()),
5419 metadata,
5420 versions,
5421 );
5422 self.finish_tool_record(&record).await;
5423 return Ok(record);
5424 }
5425 SecurityCheckResult::Warn { message } => {
5426 warn!(tool = %canonical_id, message = %message, "Tool security warning after approval");
5427 }
5428 SecurityCheckResult::Allow | SecurityCheckResult::RequireConfirmation { .. } => {}
5429 }
5430 let final_confirmation_required = matches!(
5431 final_security_result,
5432 SecurityCheckResult::RequireConfirmation { .. }
5433 ) || security_engine
5434 .classification_approval_message(&canonical_id, &classification)
5435 .is_some();
5436 let stale_approval = approval_binding.as_ref().is_some_and(|binding| {
5437 binding.is_stale(
5438 &canonical_id,
5439 &final_arguments,
5440 final_confirmation_required,
5441 versions,
5442 &resolved.tool,
5443 )
5444 });
5445 if stale_approval {
5446 let reason = "Approval became stale before final admission".to_string();
5447 let record = final_denial(
5448 canonical_id,
5449 reason.clone(),
5450 ToolPolicyDecisionRecord::deny(reason),
5451 metadata,
5452 versions,
5453 );
5454 self.finish_tool_record(&record).await;
5455 return Ok(record);
5456 }
5457 if final_confirmation_required && approval_binding.is_none() {
5458 let reason = "Final policy requires fresh approval".to_string();
5459 let record = final_denial(
5460 canonical_id,
5461 reason.clone(),
5462 ToolPolicyDecisionRecord::approval(reason),
5463 metadata,
5464 versions,
5465 );
5466 self.finish_tool_record(&record).await;
5467 return Ok(record);
5468 }
5469
5470 let unavailable_reason = match canonical_id.as_str() {
5471 "command" if !self.tools.command_runner_available() => {
5472 Some("command runner is unavailable")
5473 }
5474 "diagnostics" if !self.tools.diagnostics_available() => {
5475 Some("diagnostics provider is unavailable")
5476 }
5477 "web_search" if !self.tools.web_search_available() => {
5478 Some("web search provider is unavailable")
5479 }
5480 _ => None,
5481 };
5482 if let Some(reason) = unavailable_reason {
5483 let record = final_denial(
5484 canonical_id,
5485 reason.to_string(),
5486 ToolPolicyDecisionRecord::unavailable(reason),
5487 metadata,
5488 versions,
5489 );
5490 self.finish_tool_record(&record).await;
5491 return Ok(record);
5492 }
5493
5494 let Some(resource_guards) = self.acquire_tool_resource_locks(&resource_lock_keys).await
5499 else {
5500 let reason = "Tool execution cancelled while waiting for resource locks".to_string();
5501 let record = final_denial(
5502 canonical_id,
5503 reason.clone(),
5504 ToolPolicyDecisionRecord::deny(reason),
5505 metadata,
5506 versions,
5507 );
5508 self.finish_tool_record(&record).await;
5509 return Ok(record);
5510 };
5511
5512 let admission = self.admit_tool_execution(
5517 versions.runtime_control,
5518 versions.policy,
5519 versions.state,
5520 &canonical_id,
5521 );
5522 if !matches!(admission, SecurityCheckResult::Allow) {
5523 let latest_control = self.runtime_safety_snapshot();
5524 let reason = admission
5525 .reason()
5526 .unwrap_or("tool admission was denied")
5527 .to_string();
5528 let policy = if admission.is_unavailable() {
5529 ToolPolicyDecisionRecord::unavailable(reason.clone())
5530 } else {
5531 ToolPolicyDecisionRecord::deny(reason.clone())
5532 };
5533 let record = self.record_from_parts_at(
5534 &request,
5535 canonical_id,
5536 final_arguments,
5537 started_at,
5538 start,
5539 false,
5540 false,
5541 reason,
5542 metadata,
5543 policy,
5544 approval_record,
5545 false,
5546 false,
5547 ToolDecisionVersions {
5548 policy: latest_control.tool_security.policy_version(),
5549 registry: versions.registry,
5550 runtime_control: latest_control.version,
5551 state: self
5552 .state_machine
5553 .as_ref()
5554 .map(|state_machine| state_machine.generation()),
5555 },
5556 );
5557 self.finish_tool_record_after_resource_guards(resource_guards, &record)
5558 .await;
5559 return Ok(record);
5560 }
5561 let executed_arguments = final_arguments;
5562
5563 let tool_config = self.recovery_manager.get_tool_config(&canonical_id);
5564 let timeout_ms = limits
5565 .timeout_ms
5566 .unwrap_or_else(|| security_engine.get_tool_timeout(&canonical_id));
5567 let deadline = Some(started_at + chrono::Duration::milliseconds(timeout_ms as i64));
5568 let turn_actor = current_turn_actor_context();
5569 let actor = ToolActorContext {
5570 actor_id: turn_actor
5571 .as_ref()
5572 .and_then(|context| context.effective_actor_id().map(str::to_string))
5573 .or_else(|| self.actor_id()),
5574 origin_actor_id: turn_actor
5575 .as_ref()
5576 .and_then(|context| context.origin_actor_id.clone()),
5577 sender_agent_id: turn_actor
5578 .as_ref()
5579 .and_then(|context| context.sender_agent_id.clone()),
5580 };
5581 let tool_context = ToolExecutionContext {
5582 requested_name: request.requested_name.clone(),
5583 canonical_id: canonical_id.clone(),
5584 display_name: resolved.identity.display_name.clone(),
5585 provider_id: resolved.identity.provider_id.clone(),
5586 registry_version: versions.registry,
5587 policy_version: versions.policy,
5588 runtime_control_version: versions.runtime_control,
5589 call_id: request.call_id.clone(),
5590 source: request.source.clone(),
5591 actor,
5592 cancellation: ToolCancellationToken::new(
5593 Arc::clone(&self.runtime_control.emergency_deny),
5594 Some("runtime control cancellation".to_string()),
5595 ),
5596 started_at,
5597 deadline,
5598 permission: ToolPolicyDecisionRecord::allow(),
5599 approval: approval_record.clone(),
5600 classification: classification.clone(),
5601 safety,
5602 limits: limits.clone(),
5603 policy_snapshot,
5604 custom_config: security_engine.custom_config(&canonical_id),
5605 };
5606 let (mut result, timed_out, cancelled, invoked) = self
5607 .run_tool_with_retries(
5608 &canonical_id,
5609 resolved.tool.clone(),
5610 executed_arguments.clone(),
5611 tool_context,
5612 timeout_ms,
5613 tool_config.max_retries,
5614 )
5615 .await?;
5616
5617 if !result.success {
5618 match &tool_config.on_failure {
5619 ToolFailureAction::Skip => {
5620 result = ToolResult::ok(format!(
5621 "{{\"skipped\": true, \"reason\": \"Tool '{}' was skipped after failure\"}}",
5622 canonical_id
5623 ));
5624 }
5625 ToolFailureAction::Fallback { fallback_tool } => {
5626 drop(resource_guards);
5627 let fallback_request = ToolExecutionRequest::new(
5628 request.call_id.clone(),
5629 fallback_tool.clone(),
5630 executed_arguments,
5631 ToolCallSource::Fallback {
5632 original_tool: canonical_id,
5633 },
5634 );
5635 return Box::pin(self.execute_tool_record(fallback_request)).await;
5636 }
5637 ToolFailureAction::ReportError => {}
5638 }
5639 }
5640
5641 let output_cap = limits.max_output_chars;
5642 let (output, output_truncated) =
5643 Self::truncate_tool_output(result.output.clone(), output_cap);
5644 if let Some(result_metadata) = result.metadata {
5645 metadata.extend(result_metadata);
5646 }
5647 let mut record = self.record_from_parts_at(
5648 &request,
5649 canonical_id,
5650 executed_arguments,
5651 started_at,
5652 start,
5653 invoked,
5654 result.success,
5655 output,
5656 metadata,
5657 ToolPolicyDecisionRecord::allow(),
5658 approval_record,
5659 timed_out,
5660 output_truncated,
5661 versions,
5662 );
5663 record.cancelled = cancelled;
5664 if cancelled {
5665 record.cancellation_reason = Some("runtime control cancellation".to_string());
5666 }
5667 self.finish_tool_record_after_resource_guards(resource_guards, &record)
5668 .await;
5669 Ok(record)
5670 }
5671
5672 #[instrument(skip(self, tool_call), fields(tool = %tool_call.name))]
5673 async fn execute_tool_smart(&self, tool_call: &ToolCall) -> Result<String> {
5674 let record = self
5675 .execute_tool_record(ToolExecutionRequest::new(
5676 tool_call.id.clone(),
5677 tool_call.name.clone(),
5678 tool_call.arguments.clone(),
5679 ToolCallSource::Model,
5680 ))
5681 .await?;
5682 if record.success {
5683 Ok(record.model_output_string())
5684 } else if matches!(record.policy.outcome, PermissionOutcome::RequiresApproval) {
5685 Err(AgentError::HITLRejected(record.model_output_string()))
5686 } else {
5687 Err(AgentError::Tool(record.model_output_string()))
5688 }
5689 }
5690
5691 async fn select_skill_candidate(&self, input: &str) -> Result<Option<SkillCandidate>> {
5697 let Some(ref router) = self.skill_router else {
5698 return Ok(None);
5699 };
5700 let available_skills = self.get_available_skills();
5701 if available_skills.is_empty() {
5702 return Ok(None);
5703 }
5704 let skill_ids: Vec<&str> = available_skills.iter().map(|s| s.id.as_str()).collect();
5705 let Some(skill_id) = self
5706 .observe_purpose(
5707 ObservationPurpose::SkillRouting,
5708 router.select_skill_filtered(input, &skill_ids),
5709 )
5710 .await?
5711 else {
5712 return Ok(None);
5713 };
5714 let skill = router
5715 .get_skill(&skill_id)
5716 .cloned()
5717 .ok_or_else(|| AgentError::Skill(format!("Skill not found: {}", skill_id)))?;
5718 info!(skill_id = %skill_id, "Skill selected");
5719 Ok(Some(SkillCandidate::new(skill_id, skill)))
5720 }
5721
5722 async fn commit_skill_candidate_route_result(
5727 &self,
5728 candidate: SkillCandidate,
5729 input: &str,
5730 ) -> Result<SkillRouteResult> {
5731 let skill_id = candidate.skill_id;
5732 let skill = candidate.skill;
5733 if let Some(ref skill_disambig) = skill.disambiguation
5734 && skill_disambig.enabled.unwrap_or(false)
5735 && let Some(ref disambiguator) = self.disambiguation_manager
5736 {
5737 let context = self.build_disambiguation_context().await?;
5738 let state_override = self
5739 .state_machine
5740 .as_ref()
5741 .and_then(|sm| sm.current_definition())
5742 .and_then(|def| def.disambiguation.clone());
5743
5744 match self
5745 .observe_purpose(
5746 ObservationPurpose::DisambiguationDetection,
5747 disambiguator.process_input_with_override(
5748 input,
5749 &context,
5750 state_override.as_ref(),
5751 Some(skill_disambig),
5752 ),
5753 )
5754 .await?
5755 {
5756 DisambiguationResult::Clear => {
5757 debug!(skill_id = %skill_id, "Skill disambiguation: clear");
5758 }
5759 DisambiguationResult::NeedsClarification {
5760 question,
5761 detection,
5762 } => {
5763 info!(
5764 skill_id = %skill_id,
5765 ambiguity_type = ?detection.ambiguity_type,
5766 confidence = detection.confidence,
5767 "Skill requires clarification before execution"
5768 );
5769 *self.pending_skill_id.write() = Some(skill_id.clone());
5770 return Ok(SkillRouteResult::NeedsClarification(
5771 AgentResponse::new(&question.question).with_metadata(
5772 "disambiguation",
5773 serde_json::json!({
5774 "status": "awaiting_clarification",
5775 "skill_id": skill_id,
5776 "options": question.options,
5777 "clarifying": question.clarifying,
5778 "detection": {
5779 "type": detection.ambiguity_type,
5780 "confidence": detection.confidence,
5781 "what_is_unclear": detection.what_is_unclear,
5782 }
5783 }),
5784 ),
5785 ));
5786 }
5787 DisambiguationResult::Clarified { enriched_input, .. } => {
5788 info!(skill_id = %skill_id, enriched = %enriched_input, "Skill disambiguation clarified");
5789 return Ok(SkillRouteResult::Response {
5790 skill_id,
5791 content: self.execute_skill(&skill, &enriched_input).await?,
5792 });
5793 }
5794 DisambiguationResult::ProceedWithBestGuess { enriched_input } => {
5795 info!(skill_id = %skill_id, "Skill disambiguation best guess");
5796 return Ok(SkillRouteResult::Response {
5797 skill_id,
5798 content: self.execute_skill(&skill, &enriched_input).await?,
5799 });
5800 }
5801 DisambiguationResult::GiveUp { reason } => {
5802 warn!(skill_id = %skill_id, reason = %reason, "Skill disambiguation gave up");
5803 let apology = self
5804 .generate_localized_apology(
5805 "Generate a brief, polite apology saying you couldn't understand the request. Be concise.",
5806 &reason,
5807 )
5808 .await
5809 .unwrap_or_else(|_| {
5810 format!("I'm sorry, I couldn't understand your request: {}", reason)
5811 });
5812 return Ok(SkillRouteResult::NeedsClarification(AgentResponse::new(
5813 &apology,
5814 )));
5815 }
5816 DisambiguationResult::Escalate { reason } => {
5817 info!(skill_id = %skill_id, reason = %reason, "Skill disambiguation escalating");
5818 let apology = self
5819 .generate_localized_apology(
5820 "Explain briefly that you're transferring the user to a human agent for help.",
5821 &reason,
5822 )
5823 .await
5824 .unwrap_or_else(|_| {
5825 format!("I need human assistance to help with your request: {}", reason)
5826 });
5827 return Ok(SkillRouteResult::NeedsClarification(AgentResponse::new(
5828 &apology,
5829 )));
5830 }
5831 DisambiguationResult::Abandoned { .. } => {
5832 debug!(skill_id = %skill_id, "Skill disambiguation abandoned");
5833 return Ok(SkillRouteResult::NoMatch);
5834 }
5835 }
5836 }
5837 Ok(SkillRouteResult::Response {
5838 skill_id,
5839 content: self.execute_skill(&skill, input).await?,
5840 })
5841 }
5842
5843 async fn try_skill_route(&self, input: &str) -> Result<SkillRouteResult> {
5845 if let Some(candidate) = self.select_skill_candidate(input).await? {
5846 self.commit_skill_candidate_route_result(candidate, input)
5847 .await
5848 } else {
5849 Ok(SkillRouteResult::NoMatch)
5850 }
5851 }
5852
5853 async fn execute_skill(&self, skill: &SkillDefinition, input: &str) -> Result<String> {
5855 if let Some(ref executor) = self.skill_executor {
5856 let skill_reasoning = self.get_skill_reasoning_config(skill);
5857 let skill_reflection = self.get_skill_reflection_config(skill);
5858
5859 debug!(
5860 skill_id = %skill.id,
5861 reasoning_mode = ?skill_reasoning.mode,
5862 reflection_enabled = ?skill_reflection.enabled,
5863 "Skill reasoning/reflection config"
5864 );
5865
5866 let response = self
5867 .observe_purpose(
5868 ObservationPurpose::SkillPrompt,
5869 executor.execute_with_invoker(skill, input, serde_json::json!({}), self),
5870 )
5871 .await?;
5872
5873 if skill_reflection.requires_evaluation() && skill_reflection.is_enabled() {
5874 let should_reflect = self
5875 .should_reflect_with_config(input, &response, &skill_reflection)
5876 .await?;
5877 if should_reflect {
5878 let evaluated = self
5879 .evaluate_and_retry_with_config(input, response, &skill_reflection)
5880 .await?;
5881 return Ok(evaluated);
5882 }
5883 }
5884
5885 return Ok(response);
5886 }
5887 Err(AgentError::Skill(
5888 "No skill executor configured".to_string(),
5889 ))
5890 }
5891
5892 async fn execute_skill_by_id(&self, skill_id: &str, input: &str) -> Result<String> {
5895 let skill = self
5896 .skill_router
5897 .as_ref()
5898 .and_then(|r| r.get_skill(skill_id).cloned())
5899 .ok_or_else(|| AgentError::Skill(format!("Skill not found: {}", skill_id)))?;
5900 self.execute_skill(&skill, input).await
5901 }
5902
5903 async fn should_reflect_with_config(
5904 &self,
5905 input: &str,
5906 response: &str,
5907 config: &ReflectionConfig,
5908 ) -> Result<bool> {
5909 if !config.requires_evaluation() {
5910 return Ok(false);
5911 }
5912
5913 if config.is_enabled() {
5914 return Ok(true);
5915 }
5916
5917 let evaluator_llm = config
5918 .evaluator_llm
5919 .as_ref()
5920 .and_then(|alias| self.llm_registry.get(alias).ok())
5921 .or_else(|| self.llm_registry.router().ok())
5922 .or_else(|| self.llm_registry.default().ok());
5923
5924 let Some(llm) = evaluator_llm else {
5925 return Ok(false);
5926 };
5927
5928 let response_preview: String = response.chars().take(500).collect();
5929 let prompt = format!(
5930 r#"Should this response be evaluated for quality? Consider if it's a complex or important response.
5931
5932User query: "{}"
5933Response: "{}"
5934
5935Answer YES or NO only."#,
5936 input, response_preview
5937 );
5938
5939 let messages = vec![ChatMessage::user(&prompt)];
5940 let result = self
5941 .observe_purpose(
5942 ObservationPurpose::ReflectionDecision,
5943 llm.complete(&messages, None),
5944 )
5945 .await;
5946
5947 match result {
5948 Ok(resp) => Ok(resp.content.trim().to_uppercase().contains("YES")),
5949 Err(_) => Ok(false),
5950 }
5951 }
5952
5953 async fn evaluate_and_retry_with_config(
5954 &self,
5955 input: &str,
5956 mut response: String,
5957 config: &ReflectionConfig,
5958 ) -> Result<String> {
5959 let llm = self.get_state_llm()?;
5960 let mut attempts = 0u32;
5961 let max_retries = config.max_retries;
5962
5963 loop {
5964 let evaluation = self
5965 .evaluate_response_with_config(input, &response, config)
5966 .await?;
5967
5968 if evaluation.passed || attempts >= max_retries {
5969 info!(
5970 passed = evaluation.passed,
5971 confidence = evaluation.confidence,
5972 attempts = attempts + 1,
5973 "Skill reflection evaluation complete"
5974 );
5975 return Ok(response);
5976 }
5977
5978 debug!(
5979 attempt = attempts + 1,
5980 failed_criteria = evaluation.failed_criteria().count(),
5981 "Skill response did not meet criteria, retrying"
5982 );
5983
5984 let feedback: Vec<String> = evaluation
5985 .failed_criteria()
5986 .map(|c| format!("- {}", c.criterion))
5987 .collect();
5988
5989 let retry_prompt = format!(
5990 "Your previous response did not meet these criteria:\n{}\n\nPlease provide an improved response to: {}",
5991 feedback.join("\n"),
5992 input
5993 );
5994
5995 let messages = vec![ChatMessage::user(&retry_prompt)];
5996 let retry_response = self
5997 .observe_purpose(
5998 ObservationPurpose::ReflectionEvaluation,
5999 llm.complete(&messages, None),
6000 )
6001 .await
6002 .map_err(|e| AgentError::LLM(e.to_string()))?;
6003
6004 response = retry_response.content.trim().to_string();
6005 attempts += 1;
6006 }
6007 }
6008
6009 async fn evaluate_response_with_config(
6010 &self,
6011 input: &str,
6012 response: &str,
6013 config: &ReflectionConfig,
6014 ) -> Result<EvaluationResult> {
6015 let evaluator_llm = config
6016 .evaluator_llm
6017 .as_ref()
6018 .and_then(|alias| self.llm_registry.get(alias).ok())
6019 .or_else(|| self.llm_registry.router().ok())
6020 .or_else(|| self.llm_registry.default().ok())
6021 .ok_or_else(|| AgentError::Config("No LLM available for evaluation".into()))?;
6022
6023 let criteria = &config.criteria;
6024 let criteria_list = criteria
6025 .iter()
6026 .enumerate()
6027 .map(|(i, c)| format!("{}. {}", i + 1, c))
6028 .collect::<Vec<_>>()
6029 .join("\n");
6030
6031 let prompt = format!(
6032 r#"Evaluate this response against the criteria.
6033
6034User query: "{}"
6035
6036Response to evaluate: "{}"
6037
6038Criteria:
6039{}
6040
6041For each criterion, respond with:
6042- criterion number
6043- PASS or FAIL
6044- brief reason
6045
6046Then provide overall confidence (0.0 to 1.0) and whether it passes overall.
6047
6048Format:
60491. PASS/FAIL - reason
60502. PASS/FAIL - reason
6051...
6052CONFIDENCE: 0.X
6053OVERALL: PASS/FAIL"#,
6054 input, response, criteria_list
6055 );
6056
6057 let messages = vec![ChatMessage::user(&prompt)];
6058 let eval_response = self
6059 .observe_purpose(
6060 ObservationPurpose::ReflectionEvaluation,
6061 evaluator_llm.complete(&messages, None),
6062 )
6063 .await
6064 .map_err(|e| AgentError::LLM(format!("Evaluation failed: {}", e)))?;
6065
6066 let content = eval_response.content.to_uppercase();
6067 let llm_pass = content.contains("OVERALL: PASS");
6068
6069 let confidence = content
6070 .lines()
6071 .find(|l| l.contains("CONFIDENCE:"))
6072 .and_then(|l| {
6073 l.split(':')
6074 .nth(1)
6075 .and_then(|v| v.trim().parse::<f32>().ok())
6076 })
6077 .unwrap_or(if llm_pass { 0.8 } else { 0.4 });
6078
6079 let overall_pass = llm_pass && confidence >= config.pass_threshold;
6082
6083 let mut criteria_results = Vec::new();
6084 for (i, criterion) in criteria.iter().enumerate() {
6085 let line_marker = format!("{}.", i + 1);
6086 let passed = eval_response
6087 .content
6088 .lines()
6089 .find(|l| l.contains(&line_marker))
6090 .map(|l| l.to_uppercase().contains("PASS"))
6091 .unwrap_or(overall_pass);
6092
6093 if passed {
6094 criteria_results.push(CriterionResult::pass(criterion));
6095 } else {
6096 criteria_results.push(CriterionResult::fail(criterion, "Did not meet criterion"));
6097 }
6098 }
6099
6100 Ok(EvaluationResult::new(overall_pass, confidence).with_criteria(criteria_results))
6101 }
6102
6103 async fn process_input(&self, input: &str) -> Result<ProcessData> {
6105 if let Some(processor) = self.get_state_process_processor() {
6106 let purpose = observation_purpose_for_process(processor.input_purpose_hint());
6107 return self
6108 .observe_purpose(purpose, processor.process_input(input))
6109 .await;
6110 }
6111 if let Some(ref processor) = self.process_processor {
6112 let purpose = observation_purpose_for_process(processor.input_purpose_hint());
6113 self.observe_purpose(purpose, processor.process_input(input))
6114 .await
6115 } else {
6116 Ok(ProcessData::new(input))
6117 }
6118 }
6119
6120 async fn process_output(
6122 &self,
6123 output: &str,
6124 input_context: &std::collections::HashMap<String, serde_json::Value>,
6125 ) -> Result<ProcessData> {
6126 if let Some(processor) = self.get_state_process_processor() {
6127 let purpose = observation_purpose_for_process(processor.output_purpose_hint());
6128 return self
6129 .observe_purpose(purpose, processor.process_output(output, input_context))
6130 .await;
6131 }
6132 if let Some(ref processor) = self.process_processor {
6133 let purpose = observation_purpose_for_process(processor.output_purpose_hint());
6134 self.observe_purpose(purpose, processor.process_output(output, input_context))
6135 .await
6136 } else {
6137 Ok(ProcessData::new(output))
6138 }
6139 }
6140
6141 fn get_state_process_processor(&self) -> Option<ProcessProcessor> {
6143 let sm = self.state_machine.as_ref()?;
6144 let def = sm.current_definition()?;
6145 let config = def.process.as_ref()?;
6146 let mut processor = ProcessProcessor::new(config.clone());
6147 if let Some(ref registry) = Some(self.llm_registry.clone()) {
6148 processor = processor.with_llm_registry(registry.clone());
6149 }
6150 processor = processor.with_stage_observer(Arc::new(ObservabilityProcessStageObserver));
6151 Some(processor)
6152 }
6153
6154 async fn check_turn_timeout(&self) -> Result<()> {
6155 if let Some(ref sm) = self.state_machine
6156 && let Some(timeout_state) = sm.check_timeout()
6157 {
6158 let from_state = sm.current();
6159 let history_before = sm.history();
6160 self.execute_state_exit_actions(&from_state).await;
6161 sm.transition_to(&timeout_state, "max_turns exceeded")?;
6162 let entered = sm.current();
6163 let is_reentry =
6164 Self::state_was_previously_entered(&entered, &from_state, &history_before);
6165 self.execute_state_enter_actions(&entered, is_reentry).await;
6166 info!(to = %entered, "Timeout transition");
6167 }
6168 Ok(())
6169 }
6170
6171 fn increment_turn(&self) {
6172 if let Some(ref sm) = self.state_machine {
6173 sm.increment_turn();
6174 }
6175 }
6176
6177 fn transitions_available_for_commit(&self) -> Option<(Vec<Transition>, String)> {
6178 let sm = self.state_machine.as_ref()?;
6179 let current = sm.current();
6180 let transitions: Vec<_> = sm
6181 .auto_transitions()
6182 .into_iter()
6183 .filter(|t| match t.cooldown_turns {
6184 Some(cd) if cd > 0 => {
6185 let resolved = sm.config().resolve_full_path(¤t, &t.to);
6186 !sm.is_on_cooldown(&resolved, cd)
6187 }
6188 _ => true,
6189 })
6190 .collect();
6191 Some((transitions, current))
6192 }
6193
6194 fn transition_reason(transition: &Transition) -> String {
6195 if transition.when.is_empty() {
6196 "guard condition met".to_string()
6197 } else {
6198 transition.when.clone()
6199 }
6200 }
6201
6202 fn build_transition_context(
6204 &self,
6205 user_message: &str,
6206 response: &str,
6207 current_state: &str,
6208 staged: Option<&HashMap<String, Value>>,
6209 ) -> TransitionContext {
6210 let context_map = staged
6211 .map(|writes| self.build_context_with_staged(writes))
6212 .unwrap_or_else(|| self.build_context_with_overlays());
6213 TransitionContext::new(user_message, response, current_state).with_context(context_map)
6214 }
6215
6216 async fn select_transition_candidate(
6218 &self,
6219 user_message: &str,
6220 response: &str,
6221 ) -> Result<Option<TransitionCandidate>> {
6222 let Some((transitions, current_state)) = self.transitions_available_for_commit() else {
6223 return Ok(None);
6224 };
6225 let transitions: Vec<Transition> = transitions
6226 .into_iter()
6227 .filter(|transition| matches!(transition.timing, TransitionTiming::PostResponse))
6228 .collect();
6229 if transitions.is_empty() {
6230 return Ok(None);
6231 }
6232 let Some(evaluator) = self.transition_evaluator.as_ref() else {
6233 return Ok(None);
6234 };
6235 let context = self.build_transition_context(user_message, response, ¤t_state, None);
6236 let selected = self
6237 .observe_purpose(
6238 ObservationPurpose::StateTransitionEvaluation,
6239 evaluator.select_transition(&transitions, &context),
6240 )
6241 .await?;
6242 Ok(selected.map(|index| {
6243 let transition = transitions[index].clone();
6244 TransitionCandidate::new(
6245 current_state,
6246 transition.clone(),
6247 Self::transition_reason(&transition),
6248 )
6249 }))
6250 }
6251
6252 fn select_deterministic_transition_candidate(
6254 &self,
6255 user_message: &str,
6256 current_state: &str,
6257 transitions: &[Transition],
6258 staged: &HashMap<String, Value>,
6259 ) -> Option<TransitionCandidate> {
6260 let context = self.build_transition_context(user_message, "", current_state, Some(staged));
6261
6262 for transition in transitions {
6263 if let Some(guard) = transition.guard.as_ref()
6264 && evaluate_guard(guard, &context)
6265 {
6266 return Some(TransitionCandidate::new(
6267 current_state,
6268 transition.clone(),
6269 Self::transition_reason(transition),
6270 ));
6271 }
6272 }
6273
6274 let resolved_intent = context
6275 .context
6276 .get("resolved_intent")
6277 .and_then(Value::as_str)
6278 .filter(|value| !value.is_empty());
6279 if let Some(resolved_intent) = resolved_intent {
6280 for transition in transitions {
6281 if transition.intent.as_deref() == Some(resolved_intent) {
6282 return Some(TransitionCandidate::new(
6283 current_state,
6284 transition.clone(),
6285 Self::transition_reason(transition),
6286 ));
6287 }
6288 }
6289 }
6290
6291 None
6292 }
6293
6294 async fn commit_transition_candidate(&self, candidate: &TransitionCandidate) -> Result<bool> {
6296 self.commit_transition_target(&candidate.from_state, candidate.target(), &candidate.reason)
6297 .await
6298 }
6299
6300 async fn approve_transition_target(&self, from_state: &str, target: &str) -> Result<bool> {
6302 let approved = self.check_state_hitl(Some(from_state), target).await?;
6303 if !approved {
6304 info!(to = %target, "State transition rejected by HITL");
6305 }
6306 Ok(approved)
6307 }
6308
6309 async fn apply_transition_target(
6311 &self,
6312 from_state: &str,
6313 target: &str,
6314 reason: &str,
6315 staged: Option<&HashMap<String, Value>>,
6316 ) -> Result<bool> {
6317 let Some(ref sm) = self.state_machine else {
6318 return Ok(false);
6319 };
6320
6321 let history_before = sm.history();
6322 self.execute_state_exit_actions(from_state).await;
6323 sm.transition_to(target, reason)?;
6324 sm.reset_no_transition();
6325 if let Some(staged) = staged {
6326 self.commit_staged_context_writes(staged).await;
6327 }
6328 let entered = sm.current();
6329 let is_reentry = Self::state_was_previously_entered(&entered, from_state, &history_before);
6330 self.execute_state_enter_actions(&entered, is_reentry).await;
6331 self.hooks
6332 .on_state_transition(Some(from_state), &entered, reason)
6333 .await;
6334 info!(from = %from_state, to = %entered, "State transition");
6335 Ok(true)
6336 }
6337
6338 async fn commit_transition_target(
6340 &self,
6341 from_state: &str,
6342 target: &str,
6343 reason: &str,
6344 ) -> Result<bool> {
6345 if !self.approve_transition_target(from_state, target).await? {
6346 return Ok(false);
6347 }
6348 self.apply_transition_target(from_state, target, reason, None)
6349 .await
6350 }
6351
6352 async fn apply_pre_response_transition_candidate(
6354 &self,
6355 candidate: &TransitionCandidate,
6356 staged: &HashMap<String, Value>,
6357 processed_input: &str,
6358 ) -> Result<bool> {
6359 self.commit_root_user_message(processed_input).await?;
6360 self.apply_transition_target(
6361 &candidate.from_state,
6362 candidate.target(),
6363 &candidate.reason,
6364 Some(staged),
6365 )
6366 .await
6367 }
6368
6369 async fn commit_pre_response_transition_candidate(
6371 &self,
6372 candidate: &TransitionCandidate,
6373 staged: &HashMap<String, Value>,
6374 processed_input: &str,
6375 ) -> Result<bool> {
6376 if !self
6377 .approve_transition_target(&candidate.from_state, candidate.target())
6378 .await?
6379 {
6380 return Ok(false);
6381 }
6382 self.apply_pre_response_transition_candidate(candidate, staged, processed_input)
6383 .await
6384 }
6385
6386 async fn handle_transition_miss(&self, current_state: &str) -> Result<bool> {
6388 let Some(ref sm) = self.state_machine else {
6389 return Ok(false);
6390 };
6391 sm.increment_no_transition();
6392 let Some(fallback) = sm.check_fallback() else {
6393 return Ok(false);
6394 };
6395 self.commit_transition_target(current_state, &fallback, "fallback after no transitions")
6396 .await
6397 }
6398
6399 async fn evaluate_transitions(&self, user_message: &str, response: &str) -> Result<bool> {
6401 let Some((transitions, current_state)) = self.transitions_available_for_commit() else {
6402 return Ok(false);
6403 };
6404 if transitions.is_empty() {
6405 return Ok(false);
6406 }
6407 if let Some(candidate) = self
6408 .select_transition_candidate(user_message, response)
6409 .await?
6410 {
6411 return self.commit_transition_candidate(&candidate).await;
6412 }
6413 self.handle_transition_miss(¤t_state).await
6414 }
6415
6416 async fn try_pre_response_transition(
6418 &self,
6419 processed_input: &str,
6420 ) -> Result<Option<AgentResponse>> {
6421 let optimization = &self.runtime_config.optimization;
6422 if !optimization.enabled || !optimization.pre_response_deterministic_transitions {
6423 return Ok(None);
6424 }
6425 let Some((transitions, current_state)) = self.transitions_available_for_commit() else {
6426 return Ok(None);
6427 };
6428 let eligible: Vec<Transition> = transitions
6429 .into_iter()
6430 .filter(|transition| !transition.requires_response)
6431 .filter(|transition| matches!(transition.timing, TransitionTiming::PreResponse))
6432 .collect();
6433 if eligible.is_empty() {
6434 return Ok(None);
6435 }
6436
6437 let empty_staged = HashMap::new();
6438 let mut extracted_staged: Option<HashMap<String, Value>> = None;
6439 let mut selected: Option<(TransitionCandidate, HashMap<String, Value>)> = None;
6440
6441 for transition in &eligible {
6442 let use_extractors = optimization.pre_response_extractors || transition.run_extractors;
6443 let staged_for_eval = if use_extractors {
6444 if extracted_staged.is_none() {
6445 extracted_staged =
6446 Some(self.run_context_extractors_staged(processed_input).await);
6447 }
6448 extracted_staged.as_ref().unwrap_or(&empty_staged)
6449 } else {
6450 &empty_staged
6451 };
6452
6453 if let Some(candidate) = self.select_deterministic_transition_candidate(
6454 processed_input,
6455 ¤t_state,
6456 std::slice::from_ref(transition),
6457 staged_for_eval,
6458 ) {
6459 let staged_for_commit = if use_extractors {
6460 staged_for_eval.clone()
6461 } else {
6462 HashMap::new()
6463 };
6464 selected = Some((candidate, staged_for_commit));
6465 break;
6466 }
6467 }
6468
6469 let Some((candidate, staged)) = selected else {
6470 return Ok(None);
6471 };
6472
6473 if !self
6474 .commit_pre_response_transition_candidate(&candidate, &staged, processed_input)
6475 .await?
6476 {
6477 return Ok(None);
6478 }
6479 self.redispatch_current_state(processed_input)
6480 .await
6481 .map(Some)
6482 }
6483
6484 async fn try_speculative_branches(
6489 &self,
6490 processed_input: &str,
6491 input_context: &HashMap<String, Value>,
6492 ) -> Result<Option<AgentResponse>> {
6493 let optimization = &self.runtime_config.optimization;
6494 if !optimization.enabled {
6495 return Ok(None);
6496 }
6497
6498 let effective_reasoning_mode = self.get_effective_reasoning_config().mode.clone();
6499 if !matches!(
6500 effective_reasoning_mode,
6501 ReasoningMode::None | ReasoningMode::Auto
6502 ) {
6503 return Ok(None);
6504 }
6505
6506 let mut transition_enabled =
6507 optimization.speculative_state_transitions && self.has_parallel_transition_candidates();
6508 let mut skill_enabled = optimization.speculative_skill_routing
6509 && self.skill_router.is_some()
6510 && self.pending_skill_id.read().is_none();
6511 let mut reasoning_enabled = optimization.speculative_reasoning_auto
6512 && matches!(effective_reasoning_mode, ReasoningMode::Auto);
6513
6514 if matches!(effective_reasoning_mode, ReasoningMode::Auto)
6515 && (!reasoning_enabled || optimization.max_speculative_llm_calls_per_turn < 2)
6516 {
6517 return Ok(None);
6518 }
6519
6520 if !transition_enabled && !skill_enabled && !reasoning_enabled {
6521 return Ok(None);
6522 }
6523
6524 let mut optional_slots = optimization.max_parallel_runtime_tasks.saturating_sub(1);
6525 let mut speculative_call_slots = optimization
6526 .max_speculative_llm_calls_per_turn
6527 .saturating_sub(1);
6528 if reasoning_enabled {
6529 if optional_slots == 0 || speculative_call_slots == 0 {
6530 return Ok(None);
6531 }
6532 optional_slots -= 1;
6533 speculative_call_slots -= 1;
6534 }
6535 if transition_enabled {
6536 if optional_slots == 0 {
6537 transition_enabled = false;
6538 } else {
6539 optional_slots -= 1;
6540 }
6541 }
6542 if skill_enabled && (optional_slots == 0 || speculative_call_slots == 0) {
6543 skill_enabled = false;
6544 }
6545
6546 if !transition_enabled && !skill_enabled && !reasoning_enabled {
6547 return Ok(None);
6548 }
6549
6550 let main_kind = if transition_enabled {
6551 RuntimeOptimizationKind::ParallelStateTransition
6552 } else if skill_enabled {
6553 RuntimeOptimizationKind::SpeculativeSkillRouting
6554 } else {
6555 RuntimeOptimizationKind::SpeculativeReasoningAuto
6556 };
6557 if !self.reserve_active_speculative_llm_call(main_kind) {
6558 return Ok(None);
6559 }
6560
6561 let mut branch_set = ScheduledBranchSet::new(optimization.max_parallel_runtime_tasks)?;
6562 let main_branch = RuntimeBranch::new(
6563 RuntimeTaskPurpose::MainResponse,
6564 main_kind,
6565 RuntimeTaskPriority::Normal,
6566 RuntimeCommitBehavior::FinalResponse,
6567 );
6568 let transition_branch = RuntimeBranch::new(
6569 RuntimeTaskPurpose::StateTransition,
6570 RuntimeOptimizationKind::ParallelStateTransition,
6571 RuntimeTaskPriority::Critical,
6572 RuntimeCommitBehavior::TransitionDecision,
6573 );
6574 let skill_branch = RuntimeBranch::new(
6575 RuntimeTaskPurpose::SkillRouting,
6576 RuntimeOptimizationKind::SpeculativeSkillRouting,
6577 RuntimeTaskPriority::High,
6578 RuntimeCommitBehavior::SkillSelection,
6579 );
6580 let reasoning_branch = RuntimeBranch::new(
6581 RuntimeTaskPurpose::ReasoningJudge,
6582 RuntimeOptimizationKind::SpeculativeReasoningAuto,
6583 RuntimeTaskPriority::Normal,
6584 RuntimeCommitBehavior::ReasoningDecision,
6585 );
6586 let main_id = main_branch.branch_id();
6587 let transition_id = transition_branch.branch_id();
6588 let skill_id = skill_branch.branch_id();
6589 let reasoning_id = reasoning_branch.branch_id();
6590
6591 let main_id_for_future = main_id.clone();
6592 if !branch_set.schedule(
6593 main_branch,
6594 Box::pin(async move {
6595 match crate::optimization::observability::with_branch_observation(
6596 &main_id_for_future,
6597 main_kind,
6598 RuntimeCommitBehavior::FinalResponse,
6599 self.generate_main_response_draft(processed_input, &ReasoningMode::None),
6600 )
6601 .await
6602 {
6603 Ok(draft) => RuntimeBranchResult::MainDraft(draft),
6604 Err(error) => RuntimeBranchResult::Failed(error),
6605 }
6606 }),
6607 ) {
6608 return Ok(None);
6609 }
6610
6611 if transition_enabled {
6612 let transition_id_for_future = transition_id.clone();
6613 if !branch_set.schedule(
6614 transition_branch,
6615 Box::pin(async move {
6616 match crate::optimization::observability::with_branch_observation(
6617 &transition_id_for_future,
6618 RuntimeOptimizationKind::ParallelStateTransition,
6619 RuntimeCommitBehavior::TransitionDecision,
6620 self.select_parallel_transition_candidate(processed_input),
6621 )
6622 .await
6623 {
6624 Ok(ParallelTransitionSelection::Candidate(candidate)) => {
6625 RuntimeBranchResult::Transition(Some(candidate))
6626 }
6627 Ok(ParallelTransitionSelection::NoMatch) => {
6628 RuntimeBranchResult::Transition(None)
6629 }
6630 Ok(ParallelTransitionSelection::ReservationExhausted) => {
6631 RuntimeBranchResult::Cancelled
6632 }
6633 Err(error) => RuntimeBranchResult::Failed(error),
6634 }
6635 }),
6636 ) {
6637 transition_enabled = false;
6638 }
6639 }
6640
6641 if skill_enabled {
6642 let skill_id_for_future = skill_id.clone();
6643 if !branch_set.schedule(
6644 skill_branch,
6645 Box::pin(async move {
6646 if !self.reserve_active_speculative_llm_call(
6647 RuntimeOptimizationKind::SpeculativeSkillRouting,
6648 ) {
6649 return RuntimeBranchResult::Cancelled;
6650 }
6651 match crate::optimization::observability::with_branch_observation(
6652 &skill_id_for_future,
6653 RuntimeOptimizationKind::SpeculativeSkillRouting,
6654 RuntimeCommitBehavior::SkillSelection,
6655 self.select_skill_candidate(processed_input),
6656 )
6657 .await
6658 {
6659 Ok(candidate) => RuntimeBranchResult::Skill(candidate),
6660 Err(error) => RuntimeBranchResult::Failed(error),
6661 }
6662 }),
6663 ) {
6664 skill_enabled = false;
6665 }
6666 }
6667
6668 if reasoning_enabled {
6669 let reasoning_id_for_future = reasoning_id.clone();
6670 if !branch_set.schedule(
6671 reasoning_branch,
6672 Box::pin(async move {
6673 if !self.reserve_active_speculative_llm_call(
6674 RuntimeOptimizationKind::SpeculativeReasoningAuto,
6675 ) {
6676 return RuntimeBranchResult::Cancelled;
6677 }
6678 match crate::optimization::observability::with_branch_observation(
6679 &reasoning_id_for_future,
6680 RuntimeOptimizationKind::SpeculativeReasoningAuto,
6681 RuntimeCommitBehavior::ReasoningDecision,
6682 self.determine_reasoning_mode_strict(processed_input),
6683 )
6684 .await
6685 {
6686 Ok(mode) => RuntimeBranchResult::Reasoning(mode),
6687 Err(error) => RuntimeBranchResult::Failed(error),
6688 }
6689 }),
6690 ) {
6691 reasoning_enabled = false;
6692 }
6693 }
6694
6695 if matches!(effective_reasoning_mode, ReasoningMode::Auto) && !reasoning_enabled {
6696 self.finalize_pending_branches(branch_set.cancel_pending());
6697 return Ok(None);
6698 }
6699
6700 if !transition_enabled && !skill_enabled && !reasoning_enabled {
6701 self.finalize_pending_branches(branch_set.cancel_pending());
6702 return Ok(None);
6703 }
6704
6705 let mut main_pending = true;
6706 let mut skill_pending = skill_enabled;
6707 let mut reasoning_pending = reasoning_enabled;
6708 let mut transition_finalized = !transition_enabled;
6709 let mut skill_finalized = !skill_enabled;
6710 let mut reasoning_finalized = !reasoning_enabled;
6711 let mut main_result: Option<Result<MainResponseDraft>> = None;
6712 let mut transition_candidate: Option<TransitionCandidate> = None;
6713 let mut skill_candidate: Option<SkillCandidate> = None;
6714 let mut reasoning_decision: Option<ReasoningMode> = None;
6715 let mut transition_fallback_required = false;
6716 let mut skill_fallback_required = false;
6717 let mut reasoning_fallback_required = false;
6718
6719 loop {
6720 if let Some(candidate) = transition_candidate.take() {
6721 if self
6722 .approve_transition_target(&candidate.from_state, candidate.target())
6723 .await?
6724 {
6725 self.finalize_pending_branches(branch_set.cancel_pending());
6727 if !main_pending {
6728 self.finalize_branch_loss(
6729 &main_id,
6730 main_kind,
6731 RuntimeCommitBehavior::FinalResponse,
6732 false,
6733 main_result.as_ref().map(|result| result.is_err()),
6734 );
6735 }
6736 if skill_enabled && !skill_pending {
6737 self.finalize_branch_loss(
6738 &skill_id,
6739 RuntimeOptimizationKind::SpeculativeSkillRouting,
6740 RuntimeCommitBehavior::SkillSelection,
6741 false,
6742 Some(false),
6743 );
6744 }
6745 if reasoning_enabled && !reasoning_pending {
6746 self.finalize_branch_loss(
6747 &reasoning_id,
6748 RuntimeOptimizationKind::SpeculativeReasoningAuto,
6749 RuntimeCommitBehavior::ReasoningDecision,
6750 false,
6751 Some(false),
6752 );
6753 }
6754 if !self
6755 .apply_pre_response_transition_candidate(
6756 &candidate,
6757 &HashMap::new(),
6758 processed_input,
6759 )
6760 .await?
6761 {
6762 self.finalize_optional_branch(
6763 &transition_id,
6764 RuntimeOptimizationKind::ParallelStateTransition,
6765 RuntimeCommitBehavior::TransitionDecision,
6766 "discarded",
6767 false,
6768 );
6769 return Ok(None);
6770 }
6771 self.finalize_optional_branch(
6772 &transition_id,
6773 RuntimeOptimizationKind::ParallelStateTransition,
6774 RuntimeCommitBehavior::TransitionDecision,
6775 "committed",
6776 true,
6777 );
6778 return self
6779 .redispatch_current_state(processed_input)
6780 .await
6781 .map(Some);
6782 }
6783 self.finalize_optional_branch(
6784 &transition_id,
6785 RuntimeOptimizationKind::ParallelStateTransition,
6786 RuntimeCommitBehavior::TransitionDecision,
6787 "discarded",
6788 false,
6789 );
6790 transition_finalized = true;
6791 }
6792
6793 if transition_finalized && skill_candidate.is_some() {
6794 let candidate = skill_candidate.take().unwrap();
6795 self.finalize_optional_branch(
6796 &skill_id,
6797 RuntimeOptimizationKind::SpeculativeSkillRouting,
6798 RuntimeCommitBehavior::SkillSelection,
6799 "committed",
6800 true,
6801 );
6802 if !main_pending {
6803 self.finalize_branch_loss(
6804 &main_id,
6805 main_kind,
6806 RuntimeCommitBehavior::FinalResponse,
6807 false,
6808 main_result.as_ref().map(|result| result.is_err()),
6809 );
6810 }
6811 if reasoning_enabled && !reasoning_pending {
6812 self.finalize_branch_loss(
6813 &reasoning_id,
6814 RuntimeOptimizationKind::SpeculativeReasoningAuto,
6815 RuntimeCommitBehavior::ReasoningDecision,
6816 false,
6817 Some(false),
6818 );
6819 }
6820 self.finalize_pending_branches(branch_set.cancel_pending());
6821 self.commit_root_user_message(processed_input).await?;
6822 return match self
6823 .commit_skill_candidate_route_result(candidate, processed_input)
6824 .await?
6825 {
6826 SkillRouteResult::Response { skill_id, content } => self
6827 .handle_skill_response(processed_input, &skill_id, content, input_context)
6828 .await
6829 .map(Some),
6830 SkillRouteResult::NeedsClarification(response) => {
6831 if response
6832 .metadata
6833 .as_ref()
6834 .and_then(|m| m.get("disambiguation"))
6835 .and_then(|d| d.get("status"))
6836 .and_then(|s| s.as_str())
6837 == Some("awaiting_clarification")
6838 {
6839 self.memory
6840 .add_message(ChatMessage::assistant(&response.content))
6841 .await?;
6842 }
6843 self.finish_turn_if_root(&response).await?;
6844 Ok(Some(response))
6845 }
6846 SkillRouteResult::NoMatch => Ok(None),
6847 };
6848 }
6849
6850 if transition_finalized
6851 && skill_finalized
6852 && let Some(reasoning_mode) = reasoning_decision.take()
6853 {
6854 if !matches!(reasoning_mode, ReasoningMode::None) {
6855 self.finalize_optional_branch(
6856 &reasoning_id,
6857 RuntimeOptimizationKind::SpeculativeReasoningAuto,
6858 RuntimeCommitBehavior::ReasoningDecision,
6859 "committed",
6860 true,
6861 );
6862 if !main_pending {
6863 self.finalize_branch_loss(
6864 &main_id,
6865 main_kind,
6866 RuntimeCommitBehavior::FinalResponse,
6867 false,
6868 main_result.as_ref().map(|result| result.is_err()),
6869 );
6870 }
6871 self.finalize_pending_branches(branch_set.cancel_pending());
6872 self.commit_root_user_message(processed_input).await?;
6873 return if matches!(reasoning_mode, ReasoningMode::PlanAndExecute) {
6874 self.handle_plan_and_execute(processed_input, input_context, true)
6875 .await
6876 .map(Some)
6877 } else {
6878 self.run_committed_response_loop_with_reasoning(
6879 processed_input,
6880 input_context,
6881 reasoning_mode,
6882 true,
6883 )
6884 .await
6885 .map(Some)
6886 };
6887 }
6888 self.finalize_optional_branch(
6889 &reasoning_id,
6890 RuntimeOptimizationKind::SpeculativeReasoningAuto,
6891 RuntimeCommitBehavior::ReasoningDecision,
6892 "committed",
6893 true,
6894 );
6895 reasoning_finalized = true;
6896 }
6897
6898 if transition_finalized && skill_finalized && reasoning_finalized {
6899 if transition_fallback_required
6900 || skill_fallback_required
6901 || reasoning_fallback_required
6902 {
6903 if !main_pending {
6904 self.finalize_branch_loss(
6905 &main_id,
6906 main_kind,
6907 RuntimeCommitBehavior::FinalResponse,
6908 false,
6909 main_result.as_ref().map(|result| result.is_err()),
6910 );
6911 }
6912 self.finalize_pending_branches(branch_set.cancel_pending());
6913 return Ok(None);
6914 }
6915
6916 if let Some(result) = main_result.take() {
6917 let draft = match result {
6918 Ok(draft) => draft,
6919 Err(error) => {
6920 self.finalize_optional_branch(
6921 &main_id,
6922 main_kind,
6923 RuntimeCommitBehavior::FinalResponse,
6924 "failed",
6925 false,
6926 );
6927 self.finalize_pending_branches(branch_set.cancel_pending());
6928 return Err(error);
6929 }
6930 };
6931 self.finalize_optional_branch(
6932 &main_id,
6933 main_kind,
6934 RuntimeCommitBehavior::FinalResponse,
6935 "committed",
6936 true,
6937 );
6938 self.finalize_pending_branches(branch_set.cancel_pending());
6939 return self
6940 .commit_main_response_draft(
6941 processed_input,
6942 input_context,
6943 draft,
6944 ReasoningMode::None,
6945 reasoning_enabled,
6946 )
6947 .await
6948 .map(Some);
6949 }
6950 }
6951
6952 if branch_set.is_empty() {
6953 return Ok(None);
6954 }
6955
6956 let Some(outcome) = branch_set.next_completed().await else {
6957 return Ok(None);
6958 };
6959 let branch_id = outcome.branch.branch_id();
6960 match outcome.result {
6961 RuntimeBranchResult::MainDraft(draft) => {
6962 main_pending = false;
6963 main_result = Some(Ok(draft));
6964 }
6965 RuntimeBranchResult::Transition(candidate) => {
6966 if let Some(candidate) = candidate {
6967 transition_candidate = Some(candidate);
6968 } else {
6969 self.finalize_optional_branch(
6970 &transition_id,
6971 RuntimeOptimizationKind::ParallelStateTransition,
6972 RuntimeCommitBehavior::TransitionDecision,
6973 "discarded",
6974 false,
6975 );
6976 transition_finalized = true;
6977 }
6978 }
6979 RuntimeBranchResult::Skill(candidate) => {
6980 skill_pending = false;
6981 if let Some(candidate) = candidate {
6982 skill_candidate = Some(candidate);
6983 } else {
6984 self.finalize_optional_branch(
6985 &skill_id,
6986 RuntimeOptimizationKind::SpeculativeSkillRouting,
6987 RuntimeCommitBehavior::SkillSelection,
6988 "discarded",
6989 false,
6990 );
6991 skill_finalized = true;
6992 }
6993 }
6994 RuntimeBranchResult::Reasoning(mode) => {
6995 reasoning_pending = false;
6996 reasoning_decision = Some(mode);
6997 }
6998 RuntimeBranchResult::Failed(error) => {
6999 if branch_id == main_id {
7000 main_pending = false;
7001 main_result = Some(Err(error));
7002 } else if branch_id == transition_id {
7003 self.finalize_optional_branch(
7004 &transition_id,
7005 RuntimeOptimizationKind::ParallelStateTransition,
7006 RuntimeCommitBehavior::TransitionDecision,
7007 "failed",
7008 false,
7009 );
7010 transition_finalized = true;
7011 } else if branch_id == skill_id {
7012 skill_pending = false;
7013 self.finalize_optional_branch(
7014 &skill_id,
7015 RuntimeOptimizationKind::SpeculativeSkillRouting,
7016 RuntimeCommitBehavior::SkillSelection,
7017 "failed",
7018 false,
7019 );
7020 skill_finalized = true;
7021 } else if branch_id == reasoning_id {
7022 reasoning_pending = false;
7023 self.finalize_optional_branch(
7024 &reasoning_id,
7025 RuntimeOptimizationKind::SpeculativeReasoningAuto,
7026 RuntimeCommitBehavior::ReasoningDecision,
7027 "failed",
7028 false,
7029 );
7030 reasoning_finalized = true;
7031 }
7032 }
7033 RuntimeBranchResult::Cancelled => {
7034 self.finalize_optional_branch(
7035 &branch_id,
7036 outcome.branch.optimization,
7037 outcome.branch.commit_behavior,
7038 "cancelled",
7039 false,
7040 );
7041 if branch_id == main_id {
7042 main_pending = false;
7043 main_result =
7044 Some(Err(AgentError::Other("main branch cancelled".to_string())));
7045 } else if branch_id == transition_id {
7046 transition_finalized = true;
7047 transition_fallback_required = true;
7048 } else if branch_id == skill_id {
7049 skill_pending = false;
7050 skill_finalized = true;
7051 skill_fallback_required = true;
7052 } else if branch_id == reasoning_id {
7053 reasoning_pending = false;
7054 reasoning_finalized = true;
7055 reasoning_fallback_required = true;
7056 }
7057 }
7058 }
7059 }
7060 }
7061
7062 fn finalize_pending_branches(&self, branches: Vec<RuntimeBranch>) {
7063 for branch in branches {
7064 self.finalize_optional_branch(
7065 &branch.branch_id(),
7066 branch.optimization,
7067 branch.commit_behavior,
7068 "cancelled",
7069 false,
7070 );
7071 }
7072 }
7073
7074 fn finalize_branch_loss(
7079 &self,
7080 branch_id: &str,
7081 optimization: RuntimeOptimizationKind,
7082 commit_behavior: RuntimeCommitBehavior,
7083 pending: bool,
7084 completed_failed: Option<bool>,
7085 ) {
7086 let status = if pending {
7087 "cancelled"
7088 } else if completed_failed.unwrap_or(false) {
7089 "failed"
7090 } else {
7091 "discarded"
7092 };
7093 self.finalize_optional_branch(branch_id, optimization, commit_behavior, status, false);
7094 }
7095
7096 fn finalize_optional_branch(
7101 &self,
7102 branch_id: &str,
7103 optimization: RuntimeOptimizationKind,
7104 commit_behavior: RuntimeCommitBehavior,
7105 status: &str,
7106 winner: bool,
7107 ) {
7108 crate::optimization::observability::finalize_branch(
7109 self.observability_manager.as_ref(),
7110 branch_id,
7111 status,
7112 winner,
7113 optimization,
7114 commit_behavior,
7115 );
7116 }
7117
7118 fn has_parallel_transition_candidates(&self) -> bool {
7123 self.transitions_available_for_commit()
7124 .map(|(transitions, _)| {
7125 transitions
7126 .iter()
7127 .any(|transition| matches!(transition.timing, TransitionTiming::Parallel))
7128 })
7129 .unwrap_or(false)
7130 }
7131
7132 async fn select_parallel_transition_candidate(
7137 &self,
7138 processed_input: &str,
7139 ) -> Result<ParallelTransitionSelection> {
7140 let Some((transitions, current_state)) = self.transitions_available_for_commit() else {
7141 return Ok(ParallelTransitionSelection::NoMatch);
7142 };
7143 let parallel: Vec<Transition> = transitions
7144 .into_iter()
7145 .filter(|transition| matches!(transition.timing, TransitionTiming::Parallel))
7146 .filter(|transition| !transition.requires_response)
7147 .collect();
7148 if parallel.is_empty() {
7149 return Ok(ParallelTransitionSelection::NoMatch);
7150 }
7151 let empty_staged = HashMap::new();
7152 if let Some(candidate) = self.select_deterministic_transition_candidate(
7153 processed_input,
7154 ¤t_state,
7155 ¶llel,
7156 &empty_staged,
7157 ) {
7158 return Ok(ParallelTransitionSelection::Candidate(candidate));
7159 }
7160 let when_transitions: Vec<(usize, &Transition)> = parallel
7161 .iter()
7162 .enumerate()
7163 .filter(|(_, transition)| !transition.when.trim().is_empty())
7164 .collect();
7165 if when_transitions.is_empty() {
7166 return Ok(ParallelTransitionSelection::NoMatch);
7167 }
7168 let llm = self
7169 .llm_registry
7170 .router()
7171 .or_else(|_| self.llm_registry.default())
7172 .map_err(|e| AgentError::Config(e.to_string()))?;
7173 let conditions = when_transitions
7174 .iter()
7175 .enumerate()
7176 .map(|(display_idx, (_, transition))| {
7177 format!("{}. {}", display_idx + 1, transition.when)
7178 })
7179 .collect::<Vec<_>>()
7180 .join("\n");
7181 if !self
7182 .reserve_active_speculative_llm_call(RuntimeOptimizationKind::ParallelStateTransition)
7183 {
7184 return Ok(ParallelTransitionSelection::ReservationExhausted);
7185 }
7186 let context_preview = self.branch_context_preview();
7187 let prompt = format!(
7188 "Based only on the current user message and context, which transition condition is met?\n\nCurrent state: {}\nUser message: {}\nContext:\n{}\n\nConditions:\n{}\n0. None of the above\n\nReply with ONLY the number (0-{}).",
7189 current_state,
7190 processed_input,
7191 context_preview,
7192 conditions,
7193 when_transitions.len()
7194 );
7195 let response = self
7196 .observe_purpose(
7197 ObservationPurpose::StateTransitionEvaluation,
7198 llm.complete(&[ChatMessage::user(prompt)], None),
7199 )
7200 .await
7201 .map_err(|e| AgentError::LLM(e.to_string()))?;
7202 let choice = response.content.trim().parse::<usize>().unwrap_or(0);
7203 if choice == 0 || choice > when_transitions.len() {
7204 return Ok(ParallelTransitionSelection::NoMatch);
7205 }
7206 let transition = when_transitions[choice - 1].1.clone();
7207 Ok(ParallelTransitionSelection::Candidate(
7208 TransitionCandidate::new(
7209 current_state,
7210 transition.clone(),
7211 Self::transition_reason(&transition),
7212 ),
7213 ))
7214 }
7215
7216 async fn redispatch_current_state(&self, processed_input: &str) -> Result<AgentResponse> {
7218 const MAX_REDISPATCH_DEPTH: u32 = 3;
7219 let current_depth = *self.redispatch_depth.read();
7220 if current_depth >= MAX_REDISPATCH_DEPTH {
7221 warn!(depth = current_depth, "Re-dispatch depth limit reached");
7222 let response = AgentResponse::new("");
7223 self.finish_turn_if_root(&response).await?;
7224 return Ok(response);
7225 }
7226 *self.redispatch_depth.write() += 1;
7227 if let Some(context) = self.active_turn_context.write().as_mut() {
7228 context.enter_redispatch();
7229 }
7230 let result = Box::pin(self.run_loop_internal(processed_input)).await;
7231 *self.redispatch_depth.write() -= 1;
7232 if let Some(context) = self.active_turn_context.write().as_mut() {
7233 context.exit_redispatch();
7234 }
7235 let response = result?;
7236 self.finish_turn_if_root(&response).await?;
7237 Ok(response)
7238 }
7239
7240 async fn finish_turn_if_root(&self, response: &AgentResponse) -> Result<()> {
7242 if *self.redispatch_depth.read() == 0 {
7243 self.post_turn_session_lifecycle().await?;
7244 if let Some(context) = self.active_turn_context.write().as_mut() {
7245 context.mark_post_turn_lifecycle_completed();
7246 }
7247 self.hooks.on_response(response).await;
7248 self.end_root_turn();
7249 }
7250 Ok(())
7251 }
7252
7253 async fn execute_state_exit_actions(&self, state_path: &str) {
7255 if let Some(ref sm) = self.state_machine
7256 && let Some(def) = sm.get_definition(state_path)
7257 && !def.on_exit.is_empty()
7258 {
7259 debug!(state = %state_path, count = def.on_exit.len(), "Executing on_exit actions");
7260 self.execute_state_actions(&def.on_exit).await;
7261 }
7262 }
7263
7264 fn state_was_previously_entered(
7266 state_path: &str,
7267 from_state: &str,
7268 history_before: &[StateTransitionEvent],
7269 ) -> bool {
7270 state_path == from_state
7271 || history_before
7272 .iter()
7273 .any(|event| event.from == state_path || event.to == state_path)
7274 }
7275
7276 async fn execute_state_enter_actions(&self, state_path: &str, is_reentry: bool) {
7278 if let Some(ref sm) = self.state_machine
7279 && let Some(def) = sm.get_definition(state_path)
7280 {
7281 if is_reentry && !def.on_reenter.is_empty() {
7282 debug!(state = %state_path, count = def.on_reenter.len(), "Executing on_reenter actions");
7283 self.execute_state_actions(&def.on_reenter).await;
7284 } else if !def.on_enter.is_empty() {
7285 debug!(state = %state_path, count = def.on_enter.len(), "Executing on_enter actions");
7286 self.execute_state_actions(&def.on_enter).await;
7287 }
7288 }
7289 }
7290
7291 async fn execute_state_actions(&self, actions: &[StateAction]) {
7293 for (action_index, action) in actions.iter().enumerate() {
7294 match action {
7295 StateAction::Tool { tool, args } => {
7296 let raw_args = args.clone().unwrap_or(Value::Object(Default::default()));
7297 let args_value = self.render_action_args(&raw_args);
7298 let state = self.state_machine.as_ref().map(|sm| sm.current());
7299 let request = ToolExecutionRequest::new(
7300 uuid::Uuid::new_v4().to_string(),
7301 tool.clone(),
7302 args_value,
7303 ToolCallSource::StateAction {
7304 state,
7305 action_index,
7306 },
7307 );
7308 match self.execute_tool_record(request).await {
7309 Ok(record) if record.success => {
7310 debug!(tool = %record.canonical_id, "State action: tool executed");
7311 let _ = self.context_manager.set(
7312 "last_tool_result",
7313 serde_json::Value::String(record.model_output_string()),
7314 );
7315 let _ = self.context_manager.set(
7316 "last_tool_record",
7317 serde_json::to_value(record).unwrap_or(Value::Null),
7318 );
7319 }
7320 Ok(record) => {
7321 warn!(tool = %record.canonical_id, error = %record.output, "State action: tool failed");
7322 }
7323 Err(e) => {
7324 warn!(tool = %tool, error = %e, "State action: tool failed")
7325 }
7326 }
7327 }
7328 StateAction::Skill { skill } => {
7329 if let Some(ref executor) = self.skill_executor {
7330 if let Some(def) = self.skills.iter().find(|s| s.id == *skill) {
7331 match executor
7332 .execute_with_invoker(def, "", serde_json::json!({}), self)
7333 .await
7334 {
7335 Ok(_) => debug!(skill = %skill, "State action: skill executed"),
7336 Err(e) => {
7337 warn!(skill = %skill, error = %e, "State action: skill failed")
7338 }
7339 }
7340 } else {
7341 warn!(skill = %skill, "State action: skill not found");
7342 }
7343 }
7344 }
7345 StateAction::SetContext { set_context } => {
7346 for (key, value) in set_context {
7347 if let Err(e) = self.context_manager.set(key, value.clone()) {
7348 warn!(key = %key, error = %e, "State action: set_context failed");
7349 } else {
7350 debug!(key = %key, "State action: context set");
7351 }
7352 }
7353 }
7354 StateAction::Prompt {
7355 prompt,
7356 llm,
7357 store_as,
7358 } => {
7359 let llm_result = if let Some(alias) = llm {
7360 self.llm_registry.get(alias)
7361 } else {
7362 self.llm_registry.default()
7363 };
7364 match llm_result {
7365 Ok(llm_provider) => {
7366 let context = self.build_context_with_overlays();
7368 let rendered_prompt = self
7369 .template_renderer
7370 .render(prompt, &context)
7371 .unwrap_or_else(|_| prompt.clone());
7372 let recent =
7373 self.memory.get_messages(Some(5)).await.unwrap_or_default();
7374 let mut messages: Vec<ChatMessage> = recent;
7375 messages.push(ChatMessage::user(&rendered_prompt));
7376 match self
7377 .observe_purpose(
7378 ObservationPurpose::StateAction,
7379 llm_provider.complete(&messages, None),
7380 )
7381 .await
7382 {
7383 Ok(response) => {
7384 if let Some(key) = store_as {
7385 let _ = self
7386 .context_manager
7387 .set(key, Value::String(response.content));
7388 debug!(key = %key, "State action: prompt result stored");
7389 }
7390 }
7391 Err(e) => {
7392 warn!(error = %e, "State action: prompt LLM call failed");
7393 }
7394 }
7395 }
7396 Err(e) => {
7397 warn!(error = %e, "State action: LLM not found for prompt");
7398 }
7399 }
7400 }
7401 }
7402 }
7403 }
7404
7405 async fn run_context_extractors_staged(&self, user_message: &str) -> HashMap<String, Value> {
7406 let extractors = match &self.state_machine {
7407 Some(sm) => match sm.current_definition() {
7408 Some(def) if !def.extract.is_empty() => def.extract.clone(),
7409 _ => return HashMap::new(),
7410 },
7411 None => return HashMap::new(),
7412 };
7413
7414 let mut staged = HashMap::new();
7415 for extractor in &extractors {
7416 let prompt = if let Some(ref custom) = extractor.llm_extract {
7417 format!(
7418 "User message:\n\"{}\"\n\nInstruction:\n{}",
7419 user_message, custom
7420 )
7421 } else if let Some(ref desc) = extractor.description {
7422 format!(
7423 "From the following message, extract: {}\n\n\
7424 Message: \"{}\"\n\n\
7425 If the information is present, return ONLY the extracted value.\n\
7426 If NOT present, return exactly: __NONE__",
7427 desc, user_message
7428 )
7429 } else {
7430 continue;
7431 };
7432
7433 let llm = match self
7434 .llm_registry
7435 .get(&extractor.llm)
7436 .or_else(|_| self.llm_registry.get("router"))
7437 .or_else(|_| self.llm_registry.get("default"))
7438 {
7439 Ok(llm) => llm,
7440 Err(e) => {
7441 warn!(key = %extractor.key, error = %e, "Extractor LLM not found");
7442 continue;
7443 }
7444 };
7445
7446 let messages = vec![ChatMessage::user(&prompt)];
7447 match self
7448 .observe_purpose(
7449 ObservationPurpose::ContextExtraction,
7450 llm.complete(&messages, None),
7451 )
7452 .await
7453 {
7454 Ok(response) => {
7455 let value = response.content.trim().to_string();
7456 if value != "__NONE__" && !value.is_empty() {
7457 staged.insert(
7458 extractor.key.clone(),
7459 serde_json::Value::String(value.clone()),
7460 );
7461 debug!(key = %extractor.key, value = %value, "Context extracted");
7462 } else if extractor.required {
7463 warn!(key = %extractor.key, "Required extraction returned no value");
7464 }
7465 }
7466 Err(e) => {
7467 warn!(key = %extractor.key, error = %e, "Context extraction LLM call failed");
7468 }
7469 }
7470 }
7471 staged
7472 }
7473
7474 async fn commit_staged_context_writes(&self, staged: &HashMap<String, Value>) {
7475 for (key, value) in staged {
7476 if let Err(error) = self.context_manager.update(key, value.clone()) {
7477 warn!(key = %key, error = %error, "staged context write failed");
7478 }
7479 }
7480 }
7481
7482 async fn run_context_extractors(&self, user_message: &str) {
7484 let staged = self.run_context_extractors_staged(user_message).await;
7485 self.commit_staged_context_writes(&staged).await;
7486 }
7487
7488 async fn check_memory_compression(&self) -> Result<()> {
7489 if self.memory.needs_compression() {
7490 let result = self.memory.compress(None).await?;
7491 if let CompressResult::Compressed {
7492 messages_summarized,
7493 new_summary_length,
7494 tokens_saved,
7495 } = result
7496 {
7497 let event = MemoryCompressEvent::new(
7498 messages_summarized,
7499 tokens_saved,
7500 new_summary_length as u32,
7501 );
7502 self.hooks.on_memory_compress(&event).await;
7503 debug!(
7504 messages = messages_summarized,
7505 tokens_saved = tokens_saved,
7506 "Memory compressed"
7507 );
7508 }
7509 }
7510
7511 self.handle_memory_overflow().await?;
7513 self.check_memory_budget().await;
7514
7515 Ok(())
7516 }
7517
7518 async fn check_memory_budget(&self) {
7519 let Some(ref budget) = self.memory_token_budget else {
7520 return;
7521 };
7522
7523 let context = match self.memory.get_context().await {
7524 Ok(ctx) => ctx,
7525 Err(_) => return,
7526 };
7527
7528 let used_tokens = context.estimated_tokens();
7530 if budget.is_over_warn_threshold(used_tokens) {
7531 let event = MemoryBudgetEvent::new("memory", used_tokens, budget.total);
7532 self.hooks.on_memory_budget_warning(&event).await;
7533 debug!(
7534 used = used_tokens,
7535 total = budget.total,
7536 percent = event.usage_percent,
7537 "Memory budget warning"
7538 );
7539 }
7540
7541 if let Some(ref summary) = context.summary {
7543 let summary_tokens = ai_agents_memory::estimate_tokens(summary);
7544 let summary_budget = budget.allocation.summary;
7545 if summary_budget > 0 {
7546 let warn_threshold =
7547 (summary_budget as f64 * budget.warn_at_percent as f64 / 100.0) as u32;
7548 if summary_tokens >= warn_threshold {
7549 let event = MemoryBudgetEvent::new("summary", summary_tokens, summary_budget);
7550 self.hooks.on_memory_budget_warning(&event).await;
7551 }
7552 }
7553 }
7554
7555 let recent_tokens: u32 = context
7557 .messages
7558 .iter()
7559 .map(ai_agents_memory::estimate_message_tokens)
7560 .sum();
7561 let recent_budget = budget.allocation.recent_messages;
7562 if recent_budget > 0 {
7563 let warn_threshold =
7564 (recent_budget as f64 * budget.warn_at_percent as f64 / 100.0) as u32;
7565 if recent_tokens >= warn_threshold {
7566 let event = MemoryBudgetEvent::new("recent_messages", recent_tokens, recent_budget);
7567 self.hooks.on_memory_budget_warning(&event).await;
7568 }
7569 }
7570
7571 let relationship_budget = budget.allocation.relationships;
7572 if relationship_budget > 0 {
7573 let relationship_tokens = self
7574 .relationship_memory_text()
7575 .map(|text| ai_agents_memory::estimate_tokens(&text))
7576 .unwrap_or(0);
7577 let warn_threshold =
7578 (relationship_budget as f64 * budget.warn_at_percent as f64 / 100.0) as u32;
7579 if relationship_tokens >= warn_threshold {
7580 let event = MemoryBudgetEvent::new(
7581 "relationships",
7582 relationship_tokens,
7583 relationship_budget,
7584 );
7585 self.hooks.on_memory_budget_warning(&event).await;
7586 }
7587 }
7588 }
7589
7590 async fn handle_memory_overflow(&self) -> Result<()> {
7591 let Some(ref budget) = self.memory_token_budget else {
7592 return Ok(());
7593 };
7594
7595 let context = self.memory.get_context().await?;
7596 let used_tokens = context.estimated_tokens();
7597
7598 if used_tokens <= budget.total {
7599 return Ok(());
7600 }
7601
7602 match budget.overflow_strategy {
7603 OverflowStrategy::TruncateOldest => {
7604 let tokens_to_free = used_tokens - budget.total;
7605 let messages_to_evict = self.calculate_eviction_count(tokens_to_free);
7606 if messages_to_evict > 0 {
7607 self.evict_messages(messages_to_evict, EvictionReason::TokenBudgetExceeded)
7608 .await?;
7609 }
7610 }
7611 OverflowStrategy::SummarizeMore => {
7612 let max_attempts = context.total_messages.max(1);
7613 for _ in 0..max_attempts {
7614 match self.memory.compress(None).await? {
7615 CompressResult::Compressed {
7616 messages_summarized,
7617 ..
7618 } if messages_summarized > 0 => {
7619 let context = self.memory.get_context().await?;
7620 if context.estimated_tokens() <= budget.total {
7621 return Ok(());
7622 }
7623 }
7624 _ => break,
7625 }
7626 }
7627 let context = self.memory.get_context().await?;
7628 let used_tokens = context.estimated_tokens();
7629 if used_tokens > budget.total {
7630 return Err(AgentError::MemoryBudgetExceeded {
7631 used: used_tokens,
7632 budget: budget.total,
7633 });
7634 }
7635 }
7636 OverflowStrategy::Error => {
7637 return Err(AgentError::MemoryBudgetExceeded {
7638 used: used_tokens,
7639 budget: budget.total,
7640 });
7641 }
7642 }
7643 Ok(())
7644 }
7645
7646 fn calculate_eviction_count(&self, tokens_to_free: u32) -> usize {
7647 ((tokens_to_free as f64 / 50.0).ceil() as usize).max(1)
7649 }
7650
7651 async fn evict_messages(&self, count: usize, reason: EvictionReason) -> Result<()> {
7652 let evicted = self.memory.evict_oldest(count).await?;
7653 if !evicted.is_empty() {
7654 let event = MemoryEvictEvent {
7655 reason,
7656 messages_evicted: evicted.len(),
7657 importance_scores: vec![],
7658 };
7659 self.hooks.on_memory_evict(&event).await;
7660 debug!(count = evicted.len(), "Messages evicted from memory");
7661 }
7662 Ok(())
7663 }
7664
7665 #[instrument(skip(self, input), fields(agent = %self.info.name))]
7666 async fn determine_reasoning_mode(&self, input: &str) -> Result<ReasoningMode> {
7667 match self.determine_reasoning_mode_strict(input).await {
7668 Ok(mode) => Ok(mode),
7669 Err(_) => Ok(ReasoningMode::None),
7670 }
7671 }
7672
7673 async fn determine_reasoning_mode_strict(&self, input: &str) -> Result<ReasoningMode> {
7674 let effective_config = self.get_effective_reasoning_config();
7675
7676 if !matches!(effective_config.mode, ReasoningMode::Auto) {
7677 return Ok(effective_config.mode.clone());
7678 }
7679
7680 let judge_llm = effective_config
7681 .judge_llm
7682 .as_ref()
7683 .and_then(|alias| self.llm_registry.get(alias).ok())
7684 .or_else(|| self.llm_registry.router().ok())
7685 .or_else(|| self.llm_registry.default().ok());
7686
7687 let Some(llm) = judge_llm else {
7688 return Ok(ReasoningMode::None);
7689 };
7690
7691 let prompt = format!(
7692 r#"Analyze this user request and determine the appropriate reasoning mode.
7693
7694User request: "{}"
7695
7696Choose ONE of these modes:
7697- none: Simple queries, greetings, direct answers (fastest)
7698- cot: Complex analysis, multi-step reasoning, math problems
7699- react: Tasks requiring multiple tool calls with observation
7700- plan_and_execute: Complex multi-step tasks requiring coordination
7701
7702Respond with ONLY the mode name (none, cot, react, or plan_and_execute)."#,
7703 input
7704 );
7705
7706 let messages = vec![ChatMessage::user(&prompt)];
7707 let response = self
7708 .observe_purpose(
7709 ObservationPurpose::ReflectionDecision,
7710 llm.complete(&messages, None),
7711 )
7712 .await
7713 .map_err(|e| AgentError::LLM(e.to_string()))?;
7714
7715 let mode_str = response.content.trim().to_lowercase();
7716 Ok(match mode_str.as_str() {
7717 "cot" => ReasoningMode::CoT,
7718 "react" => ReasoningMode::React,
7719 "plan_and_execute" => ReasoningMode::PlanAndExecute,
7720 _ => ReasoningMode::None,
7721 })
7722 }
7723
7724 async fn should_reflect(&self, input: &str, response: &str) -> Result<bool> {
7725 let effective_config = self.get_effective_reflection_config();
7726
7727 if !effective_config.requires_evaluation() {
7728 return Ok(false);
7729 }
7730
7731 if effective_config.is_enabled() {
7732 return Ok(true);
7733 }
7734
7735 let evaluator_llm = effective_config
7736 .evaluator_llm
7737 .as_ref()
7738 .and_then(|alias| self.llm_registry.get(alias).ok())
7739 .or_else(|| self.llm_registry.router().ok())
7740 .or_else(|| self.llm_registry.default().ok());
7741
7742 let Some(llm) = evaluator_llm else {
7743 return Ok(false);
7744 };
7745
7746 let response_preview: String = response.chars().take(500).collect();
7747 let prompt = format!(
7748 r#"Should this response be evaluated for quality? Consider if it's a complex or important response.
7749
7750User query: "{}"
7751Response: "{}"
7752
7753Answer YES or NO only."#,
7754 input, response_preview
7755 );
7756
7757 let messages = vec![ChatMessage::user(&prompt)];
7758 let result = self
7759 .observe_purpose(
7760 ObservationPurpose::ReflectionDecision,
7761 llm.complete(&messages, None),
7762 )
7763 .await;
7764
7765 match result {
7766 Ok(resp) => Ok(resp.content.trim().to_uppercase().contains("YES")),
7767 Err(_) => Ok(false),
7768 }
7769 }
7770
7771 fn build_cot_system_prompt(&self, base_prompt: &str) -> String {
7772 format!(
7773 "{}\n\n<instruction>\nThink through this step by step before answering:\n1. Understand what is being asked\n2. Break down the problem\n3. Work through each part\n4. Provide your final answer\n\nShow your thinking process, then give your final answer.\n</instruction>",
7774 base_prompt
7775 )
7776 }
7777
7778 fn build_react_system_prompt(&self, base_prompt: &str) -> String {
7779 format!(
7780 "{}\n\n<instruction>\nUse the Reason-Act-Observe pattern:\n1. Thought: Think about what to do\n2. Action: Use a tool if needed\n3. Observation: Analyze the result\n4. Repeat until you have the answer\n\nFormat your response showing Thought/Action/Observation steps.\n</instruction>",
7781 base_prompt
7782 )
7783 }
7784
7785 async fn generate_plan(&self, input: &str) -> Result<Plan> {
7786 let effective = self.get_effective_reasoning_config();
7787 let planning_config = effective.get_planning();
7788
7789 let planner_llm = planning_config
7790 .and_then(|c| c.planner_llm.as_ref())
7791 .and_then(|alias| self.llm_registry.get(alias).ok())
7792 .or_else(|| self.llm_registry.router().ok())
7793 .or_else(|| self.llm_registry.default().ok())
7794 .ok_or_else(|| AgentError::Config("No LLM available for planning".into()))?;
7795
7796 let mut available_tool_ids: Vec<String> = self
7797 .get_available_tool_ids()
7798 .await
7799 .unwrap_or_else(|_| self.tools.list_ids());
7800 let mut available_skills: Vec<String> = self.skills.iter().map(|s| s.id.clone()).collect();
7801
7802 if let Some(config) = planning_config {
7804 if !config.available.tools.is_all() {
7805 available_tool_ids.retain(|t| config.available.tools.allows(t));
7806 }
7807 if !config.available.skills.is_all() {
7808 available_skills.retain(|s| config.available.skills.allows(s));
7809 }
7810 }
7811
7812 let tool_descriptions: Vec<String> = available_tool_ids
7815 .iter()
7816 .filter_map(|id| {
7817 self.tools.get(id).map(|tool| {
7818 let schema = tool.input_schema();
7819 let args_desc = schema
7820 .get("properties")
7821 .and_then(|p| serde_json::to_string(p).ok())
7822 .unwrap_or_else(|| "{}".to_string());
7823 format!(
7824 "- {} ({}): {}\n Arguments: {}",
7825 id,
7826 tool.name(),
7827 tool.description(),
7828 args_desc
7829 )
7830 })
7831 })
7832 .collect();
7833
7834 let tools_section = if tool_descriptions.is_empty() {
7835 "Available tools: none".to_string()
7836 } else {
7837 format!("Available tools:\n{}", tool_descriptions.join("\n"))
7838 };
7839
7840 let skills_section = if available_skills.is_empty() {
7841 "Available skills: none".to_string()
7842 } else {
7843 format!("Available skills: {}", available_skills.join(", "))
7844 };
7845
7846 let prompt = format!(
7847 r#"Create a step-by-step plan to accomplish this goal.
7848
7849Goal: "{}"
7850
7851{}
7852
7853{}
7854
7855Create a plan with clear steps. For each step, specify:
7856- description: What this step accomplishes
7857- action_type: "tool", "skill", "think", or "respond"
7858- action_target: The tool/skill id (if applicable)
7859- args: The arguments object matching the tool's schema (if action_type is "tool")
7860- dependencies: List of step IDs this depends on (empty if none)
7861
7862Respond in JSON format:
7863{{
7864 "steps": [
7865 {{"id": "step1", "description": "...", "action_type": "tool", "action_target": "tool_id", "args": {{"required_field": "value"}}, "dependencies": []}},
7866 {{"id": "step2", "description": "...", "action_type": "think", "action_target": "...", "dependencies": ["step1"]}}
7867 ]
7868}}"#,
7869 input, tools_section, skills_section,
7870 );
7871
7872 let messages = vec![ChatMessage::user(&prompt)];
7873 let response = self
7874 .observe_purpose(
7875 ObservationPurpose::PlanGeneration,
7876 planner_llm.complete(&messages, None),
7877 )
7878 .await
7879 .map_err(|e| AgentError::LLM(format!("Planning failed: {}", e)))?;
7880
7881 let mut plan = Plan::new(input);
7882
7883 if let Some(json_start) = response.content.find('{')
7884 && let Some(json_end) = response.content.rfind('}')
7885 {
7886 let json_str = &response.content[json_start..=json_end];
7887 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json_str)
7888 && let Some(steps) = parsed.get("steps").and_then(|s| s.as_array())
7889 {
7890 for step_value in steps {
7891 let id = step_value
7892 .get("id")
7893 .and_then(|v| v.as_str())
7894 .unwrap_or("step");
7895 let desc = step_value
7896 .get("description")
7897 .and_then(|v| v.as_str())
7898 .unwrap_or("");
7899 let action_type = step_value
7900 .get("action_type")
7901 .and_then(|v| v.as_str())
7902 .unwrap_or("think");
7903 let action_target = step_value
7904 .get("action_target")
7905 .and_then(|v| v.as_str())
7906 .unwrap_or("");
7907 let args = step_value
7908 .get("args")
7909 .cloned()
7910 .unwrap_or(serde_json::json!({}));
7911 let deps: Vec<String> = step_value
7912 .get("dependencies")
7913 .and_then(|v| v.as_array())
7914 .map(|arr| {
7915 arr.iter()
7916 .filter_map(|v| v.as_str().map(String::from))
7917 .collect()
7918 })
7919 .unwrap_or_default();
7920
7921 let action = match action_type {
7922 "tool" => PlanAction::tool(action_target, args),
7923 "skill" => PlanAction::skill(action_target),
7924 "respond" => PlanAction::respond(action_target),
7925 _ => PlanAction::think(desc),
7926 };
7927
7928 let step = PlanStep::new(desc, action)
7929 .with_id(id)
7930 .with_dependencies(deps);
7931 plan.add_step(step);
7932 }
7933 }
7934 }
7935
7936 if plan.steps.is_empty() {
7937 plan.add_step(PlanStep::new(
7938 "Process the request",
7939 PlanAction::think(input),
7940 ));
7941 plan.add_step(PlanStep::new(
7942 "Provide response",
7943 PlanAction::respond("Answer based on analysis"),
7944 ));
7945 }
7946
7947 Ok(plan)
7948 }
7949
7950 async fn execute_plan(&self, plan: &mut Plan) -> Result<String> {
7951 let llm = self.get_state_llm()?;
7952 let mut results: HashMap<String, serde_json::Value> = HashMap::new();
7953 let effective = self.get_effective_reasoning_config();
7954 let max_steps = effective.get_planning().map(|c| c.max_steps).unwrap_or(10);
7955
7956 plan.status = PlanStatus::InProgress;
7957
7958 for step_idx in 0..plan.steps.len().min(max_steps as usize) {
7959 let step = &plan.steps[step_idx];
7960
7961 let deps_satisfied = step.dependencies.iter().all(|dep| {
7962 plan.steps
7963 .iter()
7964 .find(|s| &s.id == dep)
7965 .map(|s| s.status.is_completed())
7966 .unwrap_or(false)
7967 });
7968
7969 if !deps_satisfied {
7970 continue;
7971 }
7972
7973 plan.steps[step_idx].mark_running();
7974
7975 let result = match &plan.steps[step_idx].action {
7976 PlanAction::Tool { tool, args } => {
7977 let has_dep_results = plan.steps[step_idx]
7983 .dependencies
7984 .iter()
7985 .any(|dep| results.contains_key(dep));
7986
7987 let final_args = if has_dep_results {
7988 let dep_context: String = plan.steps[step_idx]
7989 .dependencies
7990 .iter()
7991 .filter_map(|dep| results.get(dep).map(|r| format!("{}: {}", dep, r)))
7992 .collect::<Vec<_>>()
7993 .join("\n");
7994
7995 let tool_schema = self
7996 .tools
7997 .get(tool)
7998 .map(|t| {
7999 let schema = t.input_schema();
8000 let props = schema
8001 .get("properties")
8002 .and_then(|p| serde_json::to_string(p).ok())
8003 .unwrap_or_else(|| "{}".to_string());
8004 format!(
8005 "{}: {}\nArguments schema: {}",
8006 t.id(),
8007 t.description(),
8008 props
8009 )
8010 })
8011 .unwrap_or_default();
8012
8013 let step_desc = &plan.steps[step_idx].description;
8014 let arg_prompt = format!(
8015 "Generate the JSON arguments for a tool call.\n\n\
8016 Tool: {}\n\n\
8017 Task: {}\n\n\
8018 Previous step results:\n{}\n\n\
8019 Planner's draft arguments: {}\n\n\
8020 Produce ONLY a valid JSON object with the correct argument values.\n\
8021 Use actual values from the previous step results, not template references.",
8022 tool_schema,
8023 step_desc,
8024 dep_context,
8025 serde_json::to_string(args).unwrap_or_default()
8026 );
8027 let messages = vec![ChatMessage::user(&arg_prompt)];
8028 match self
8029 .observe_purpose(
8030 ObservationPurpose::PlanStep,
8031 llm.complete(&messages, None),
8032 )
8033 .await
8034 {
8035 Ok(resp) => {
8036 let content = resp.content.trim();
8037 let json_start = content.find('{');
8039 let json_end = content.rfind('}');
8040 if let (Some(start), Some(end)) = (json_start, json_end) {
8041 serde_json::from_str(&content[start..=end])
8042 .unwrap_or_else(|_| args.clone())
8043 } else {
8044 args.clone()
8045 }
8046 }
8047 Err(_) => args.clone(),
8048 }
8049 } else {
8050 args.clone()
8051 };
8052
8053 let request = ToolExecutionRequest::new(
8054 uuid::Uuid::new_v4().to_string(),
8055 tool.clone(),
8056 final_args,
8057 ToolCallSource::Plan {
8058 step_index: step_idx,
8059 },
8060 );
8061 match self.execute_tool_record(request).await {
8062 Ok(record) if record.success => {
8063 serde_json::json!({ "output": record.model_output_string() })
8064 }
8065 Ok(record) => {
8066 plan.steps[step_idx].mark_failed(record.model_output_string());
8067 continue;
8068 }
8069 Err(e) => {
8070 plan.steps[step_idx].mark_failed(e.to_string());
8071 continue;
8072 }
8073 }
8074 }
8075 PlanAction::Skill { skill } => {
8076 if let Some(skill_def) = self.skills.iter().find(|s| &s.id == skill) {
8077 if let Some(ref executor) = self.skill_executor {
8078 match executor
8079 .execute_with_invoker(skill_def, "", serde_json::json!({}), self)
8080 .await
8081 {
8082 Ok(output) => serde_json::json!({ "output": output }),
8083 Err(e) => {
8084 plan.steps[step_idx].mark_failed(e.to_string());
8085 continue;
8086 }
8087 }
8088 } else {
8089 serde_json::json!({ "output": "Skill executor not available" })
8090 }
8091 } else {
8092 plan.steps[step_idx].mark_failed("Skill not found");
8093 continue;
8094 }
8095 }
8096 PlanAction::Think { prompt } => {
8097 let context: String = results
8098 .iter()
8099 .map(|(k, v)| format!("{}: {}", k, v))
8100 .collect::<Vec<_>>()
8101 .join("\n");
8102
8103 let think_prompt = format!("Context:\n{}\n\nTask: {}", context, prompt);
8104 let messages = vec![ChatMessage::user(&think_prompt)];
8105
8106 match self
8107 .observe_purpose(
8108 ObservationPurpose::PlanStep,
8109 llm.complete(&messages, None),
8110 )
8111 .await
8112 {
8113 Ok(resp) => serde_json::json!({ "output": resp.content }),
8114 Err(e) => {
8115 plan.steps[step_idx].mark_failed(e.to_string());
8116 continue;
8117 }
8118 }
8119 }
8120 PlanAction::Respond { template } => {
8121 let context: String = results
8122 .iter()
8123 .map(|(k, v)| format!("{}: {}", k, v))
8124 .collect::<Vec<_>>()
8125 .join("\n");
8126
8127 let respond_prompt = format!(
8128 "Based on this context:\n{}\n\nGenerate a response following this template/instruction: {}",
8129 context, template
8130 );
8131 let messages = vec![ChatMessage::user(&respond_prompt)];
8132
8133 match self
8134 .observe_purpose(
8135 ObservationPurpose::PlanStep,
8136 llm.complete(&messages, None),
8137 )
8138 .await
8139 {
8140 Ok(resp) => serde_json::json!({ "output": resp.content }),
8141 Err(e) => {
8142 plan.steps[step_idx].mark_failed(e.to_string());
8143 continue;
8144 }
8145 }
8146 }
8147 };
8148
8149 results.insert(plan.steps[step_idx].id.clone(), result.clone());
8150 plan.steps[step_idx].mark_completed(Some(result));
8151 }
8152
8153 let has_failures = plan.steps.iter().any(|s| s.status.is_failed());
8155 if has_failures {
8156 let failed_ids: Vec<String> = plan
8157 .steps
8158 .iter()
8159 .filter(|s| s.status.is_failed())
8160 .map(|s| s.id.clone())
8161 .collect();
8162 plan.status = PlanStatus::Failed {
8163 error: format!("Steps failed: {}", failed_ids.join(", ")),
8164 };
8165 } else {
8166 plan.status = PlanStatus::Completed;
8167 }
8168
8169 let all_outputs: Vec<String> = plan
8171 .steps
8172 .iter()
8173 .filter(|s| s.status.is_completed())
8174 .filter_map(|s| {
8175 s.result
8176 .as_ref()
8177 .and_then(|r| r.get("output"))
8178 .and_then(|o| o.as_str())
8179 .map(|o| format!("{}: {}", s.description, o))
8180 })
8181 .collect();
8182
8183 if all_outputs.is_empty() {
8184 return Ok("Plan execution completed but produced no results.".to_string());
8185 }
8186
8187 if all_outputs.len() == 1 {
8188 return Ok(all_outputs.into_iter().next().unwrap());
8189 }
8190
8191 let context = all_outputs.join("\n\n");
8193 let prompt = format!(
8194 "You completed a multi-step plan for: \"{}\"\n\nStep results:\n{}\n\nProvide a coherent final response that synthesizes these results.",
8195 plan.goal, context
8196 );
8197 let messages = vec![ChatMessage::user(&prompt)];
8198 match self
8199 .observe_purpose(ObservationPurpose::PlanStep, llm.complete(&messages, None))
8200 .await
8201 {
8202 Ok(resp) => Ok(resp.content.trim().to_string()),
8203 Err(_) => Ok(context),
8204 }
8205 }
8206
8207 async fn evaluate_response(&self, input: &str, response: &str) -> Result<EvaluationResult> {
8208 let effective_config = self.get_effective_reflection_config();
8209 self.evaluate_response_with_config(input, response, &effective_config)
8210 .await
8211 }
8212
8213 fn extract_thinking(&self, content: &str) -> (Option<String>, String) {
8214 if let Some(start) = content.find("<thinking>")
8215 && let Some(end) = content.find("</thinking>")
8216 {
8217 let thinking = content[start + 10..end].trim().to_string();
8218 let answer = content[end + 11..].trim().to_string();
8219 return (Some(thinking), answer);
8220 }
8221 (None, content.to_string())
8222 }
8223
8224 fn format_response_with_thinking(&self, thinking: Option<&str>, answer: &str) -> String {
8225 match self.get_effective_reasoning_config().output {
8226 ReasoningOutput::Hidden => answer.to_string(),
8227 ReasoningOutput::Visible => {
8228 if let Some(t) = thinking {
8229 format!("Thinking:\n{}\n\nAnswer:\n{}", t, answer)
8230 } else {
8231 answer.to_string()
8232 }
8233 }
8234 ReasoningOutput::Tagged => {
8235 if let Some(t) = thinking {
8236 format!("<thinking>{}</thinking>\n{}", t, answer)
8237 } else {
8238 answer.to_string()
8239 }
8240 }
8241 }
8242 }
8243
8244 async fn run_loop(&self, input: &str) -> Result<AgentResponse> {
8245 self.init_storage().await?;
8249 self.begin_root_turn();
8250 let _root_cleanup = RootTurnCleanup::new(self);
8251 info!(input_len = input.len(), "Starting chat");
8252
8253 self.hooks.on_message_received(input).await;
8254
8255 if !self.context_initialized.swap(true, Ordering::SeqCst) {
8259 self.context_manager.initialize().await?;
8260 debug!("Context manager initialized (defaults, env, builtins)");
8261 }
8262
8263 self.check_turn_timeout().await?;
8264 self.context_manager.refresh_per_turn().await?;
8265
8266 self.clear_disambiguation_context();
8269
8270 if let Some(ref disambiguator) = self.disambiguation_manager {
8272 let disambiguation_context = self.build_disambiguation_context().await?;
8273
8274 let state_override = self
8276 .state_machine
8277 .as_ref()
8278 .and_then(|sm| sm.current_definition())
8279 .and_then(|def| def.disambiguation.clone());
8280
8281 match self
8282 .observe_purpose(
8283 ObservationPurpose::DisambiguationDetection,
8284 disambiguator.process_input_with_override(
8285 input,
8286 &disambiguation_context,
8287 state_override.as_ref(),
8288 None,
8289 ),
8290 )
8291 .await?
8292 {
8293 DisambiguationResult::Clear => {
8294 debug!("Input is clear, proceeding normally");
8295 }
8296 DisambiguationResult::NeedsClarification {
8297 question,
8298 detection,
8299 } => {
8300 info!(
8301 ambiguity_type = ?detection.ambiguity_type,
8302 confidence = detection.confidence,
8303 "Input requires clarification"
8304 );
8305
8306 self.commit_root_user_message(input).await?;
8307 self.memory
8308 .add_message(ChatMessage::assistant(&question.question))
8309 .await?;
8310
8311 let response = AgentResponse::new(&question.question).with_metadata(
8312 "disambiguation",
8313 serde_json::json!({
8314 "status": "awaiting_clarification",
8315 "options": question.options,
8316 "clarifying": question.clarifying,
8317 "detection": {
8318 "type": detection.ambiguity_type,
8319 "confidence": detection.confidence,
8320 "what_is_unclear": detection.what_is_unclear,
8321 }
8322 }),
8323 );
8324 self.finish_turn_if_root(&response).await?;
8325 return Ok(response);
8326 }
8327 DisambiguationResult::Clarified {
8328 enriched_input,
8329 resolved,
8330 ..
8331 } => {
8332 info!(
8333 resolved_count = resolved.len(),
8334 enriched = %enriched_input,
8335 "Input clarified, injecting resolved intent into context"
8336 );
8337
8338 for (key, value) in &resolved {
8341 let context_key = format!("disambiguation.{}", key);
8342 let _ = self.context_manager.set(&context_key, value.clone());
8343 }
8344
8345 if let Some(intent) = resolved.get("intent") {
8346 let _ = self.context_manager.set("resolved_intent", intent.clone());
8347 }
8348
8349 let _ = self
8350 .context_manager
8351 .set("disambiguation.resolved", serde_json::Value::Bool(true));
8352
8353 let skill_id = self.pending_skill_id.read().clone();
8357 if let Some(skill_id) = skill_id {
8358 info!(skill_id = %skill_id, "Re-checking skill disambiguation on clarified input");
8359 return self
8360 .recheck_skill_disambiguation(&skill_id, &enriched_input)
8361 .await;
8362 }
8363
8364 return self.run_loop_internal(&enriched_input).await;
8365 }
8366 DisambiguationResult::ProceedWithBestGuess { enriched_input } => {
8367 info!("Proceeding with best guess interpretation");
8368
8369 let skill_id = self.pending_skill_id.read().clone();
8371 if let Some(skill_id) = skill_id {
8372 info!(skill_id = %skill_id, "Re-checking skill disambiguation on best-guess input");
8373 return self
8374 .recheck_skill_disambiguation(&skill_id, &enriched_input)
8375 .await;
8376 }
8377
8378 return self.run_loop_internal(&enriched_input).await;
8379 }
8380 DisambiguationResult::GiveUp { reason } => {
8381 *self.pending_skill_id.write() = None;
8382 warn!(reason = %reason, "Disambiguation gave up");
8383 let apology = self
8384 .generate_localized_apology(
8385 "Generate a brief, polite apology saying you couldn't understand the request. Be concise.",
8386 &reason,
8387 )
8388 .await
8389 .unwrap_or_else(|_| {
8390 format!("I'm sorry, I couldn't understand your request: {}", reason)
8391 });
8392 let response = AgentResponse::new(&apology);
8393 self.finish_turn_if_root(&response).await?;
8394 return Ok(response);
8395 }
8396 DisambiguationResult::Escalate { reason } => {
8397 *self.pending_skill_id.write() = None;
8398 info!(reason = %reason, "Escalating to human");
8399 if let Some(ref hitl) = self.hitl_engine {
8400 let trigger =
8401 ApprovalTrigger::condition("disambiguation_escalation", reason.clone());
8402 let mut context_map = HashMap::new();
8403 context_map.insert("original_input".to_string(), serde_json::json!(input));
8404 context_map.insert("reason".to_string(), serde_json::json!(&reason));
8405 let check_result = HITLCheckResult::required(
8406 trigger,
8407 context_map,
8408 format!("User request needs human assistance: {}", reason),
8409 Some(hitl.config().default_timeout_seconds),
8410 );
8411 let result = self.request_hitl_approval(check_result).await?;
8412 if matches!(
8413 result,
8414 ApprovalResult::Approved | ApprovalResult::Modified { .. }
8415 ) {
8416 return self.run_loop_internal(input).await;
8417 }
8418 }
8419 let apology = self
8420 .generate_localized_apology(
8421 "Explain briefly that you're transferring the user to a human agent for help.",
8422 &reason,
8423 )
8424 .await
8425 .unwrap_or_else(|_| {
8426 format!("I need human assistance to help with your request: {}", reason)
8427 });
8428 let response = AgentResponse::new(&apology);
8429 self.finish_turn_if_root(&response).await?;
8430 return Ok(response);
8431 }
8432 DisambiguationResult::Abandoned { new_input } => {
8433 *self.pending_skill_id.write() = None;
8434
8435 info!(
8436 has_new_input = new_input.is_some(),
8437 "Clarification abandoned by user"
8438 );
8439
8440 self.commit_root_user_message(input).await?;
8441
8442 match new_input {
8443 Some(fresh_input) => {
8444 return self.run_loop_internal(&fresh_input).await;
8447 }
8448 None => {
8449 let ack = self
8451 .generate_localized_apology(
8452 "The user changed their mind about their previous request. \
8453 Generate a brief, friendly acknowledgment (e.g. 'OK, no problem. What else can I help with?'). \
8454 Do NOT apologize excessively. Be concise.",
8455 "User abandoned clarification",
8456 )
8457 .await
8458 .unwrap_or_else(|_| {
8459 "OK, no problem. What else can I help with?".to_string()
8460 });
8461
8462 self.memory
8463 .add_message(ChatMessage::assistant(&ack))
8464 .await?;
8465
8466 let response = AgentResponse::new(&ack);
8467 self.finish_turn_if_root(&response).await?;
8468 return Ok(response);
8469 }
8470 }
8471 }
8472 }
8473 }
8474
8475 self.run_loop_internal(input).await
8476 }
8477
8478 async fn generate_localized_apology(&self, instruction: &str, reason: &str) -> Result<String> {
8480 let llm = self.llm_registry.router().map_err(|e| {
8481 AgentError::LLM(format!(
8482 "Router LLM not available for localized response: {}",
8483 e
8484 ))
8485 })?;
8486
8487 let recent: Vec<String> = self
8488 .memory
8489 .get_messages(Some(3))
8490 .await?
8491 .iter()
8492 .map(|m| m.content.clone())
8493 .collect();
8494
8495 let context_hint = if recent.is_empty() {
8496 String::new()
8497 } else {
8498 format!(
8499 "\nRecent conversation (detect the user's language from this):\n{}\n",
8500 recent.join("\n")
8501 )
8502 };
8503
8504 let prompt = format!(
8505 "{}\nReason: {}\n{}Respond in the same language as the user. Output ONLY the message, nothing else.",
8506 instruction, reason, context_hint
8507 );
8508
8509 let messages = vec![ChatMessage::user(&prompt)];
8510 let response = self
8511 .observe_purpose(
8512 ObservationPurpose::DisambiguationClarification,
8513 llm.complete(&messages, None),
8514 )
8515 .await
8516 .map_err(|e| AgentError::LLM(format!("Localized response generation failed: {}", e)))?;
8517
8518 Ok(response.content.trim().to_string())
8519 }
8520
8521 fn render_action_args(&self, args: &Value) -> Value {
8525 let context = self.build_context_with_overlays();
8526 match args {
8527 Value::Object(map) => {
8528 let mut rendered = serde_json::Map::new();
8529 for (k, v) in map {
8530 match v {
8531 Value::String(s) if s.contains("{{") => {
8532 match self.template_renderer.render(s, &context) {
8533 Ok(rendered_str) => {
8534 rendered.insert(k.clone(), Value::String(rendered_str));
8535 }
8536 Err(_) => {
8537 rendered.insert(k.clone(), v.clone());
8538 }
8539 }
8540 }
8541 _ => {
8542 rendered.insert(k.clone(), v.clone());
8543 }
8544 }
8545 }
8546 Value::Object(rendered)
8547 }
8548 _ => args.clone(),
8549 }
8550 }
8551
8552 fn clear_disambiguation_context(&self) {
8554 let _ = self
8555 .context_manager
8556 .set("resolved_intent", serde_json::Value::Null);
8557
8558 let all = self.context_manager.get_all();
8559 for key in all.keys() {
8560 if key.starts_with("disambiguation.") {
8561 let _ = self.context_manager.set(key, serde_json::Value::Null);
8562 }
8563 }
8564 }
8565
8566 async fn recheck_skill_disambiguation(
8572 &self,
8573 skill_id: &str,
8574 enriched_input: &str,
8575 ) -> Result<AgentResponse> {
8576 let skill = self
8577 .skill_router
8578 .as_ref()
8579 .and_then(|r| r.get_skill(skill_id).cloned());
8580
8581 if let Some(ref skill) = skill
8583 && let Some(ref skill_disambig) = skill.disambiguation
8584 && skill_disambig.enabled.unwrap_or(false)
8585 && let Some(ref disambiguator) = self.disambiguation_manager
8586 {
8587 let context = self.build_disambiguation_context().await?;
8588 let state_override = self
8589 .state_machine
8590 .as_ref()
8591 .and_then(|sm| sm.current_definition())
8592 .and_then(|def| def.disambiguation.clone());
8593
8594 match self
8595 .observe_purpose(
8596 ObservationPurpose::DisambiguationDetection,
8597 disambiguator.process_input_with_override(
8598 enriched_input,
8599 &context,
8600 state_override.as_ref(),
8601 Some(skill_disambig),
8602 ),
8603 )
8604 .await?
8605 {
8606 DisambiguationResult::Clear => {
8607 debug!(skill_id = %skill_id, "Skill re-check: all fields present");
8608 }
8609 DisambiguationResult::NeedsClarification {
8610 question,
8611 detection,
8612 } => {
8613 info!(
8614 skill_id = %skill_id,
8615 ambiguity_type = ?detection.ambiguity_type,
8616 what_is_unclear = ?detection.what_is_unclear,
8617 "Skill re-check: still missing fields, asking again"
8618 );
8619 self.memory
8623 .add_message(ChatMessage::user(enriched_input))
8624 .await?;
8625 self.memory
8626 .add_message(ChatMessage::assistant(&question.question))
8627 .await?;
8628
8629 let response = AgentResponse::new(&question.question).with_metadata(
8630 "disambiguation",
8631 serde_json::json!({
8632 "status": "awaiting_clarification",
8633 "skill_id": skill_id,
8634 "options": question.options,
8635 "clarifying": question.clarifying,
8636 "detection": {
8637 "type": detection.ambiguity_type,
8638 "confidence": detection.confidence,
8639 "what_is_unclear": detection.what_is_unclear,
8640 }
8641 }),
8642 );
8643 self.finish_turn_if_root(&response).await?;
8644 return Ok(response);
8645 }
8646 DisambiguationResult::Clarified {
8647 enriched_input: re_enriched,
8648 ..
8649 } => {
8650 debug!(skill_id = %skill_id, "Skill re-check: clarified immediately, executing");
8651 *self.pending_skill_id.write() = None;
8653 let skill_response = self.execute_skill_by_id(skill_id, &re_enriched).await?;
8654 self.memory
8655 .add_message(ChatMessage::user(&re_enriched))
8656 .await?;
8657 return self
8658 .handle_skill_response(
8659 &re_enriched,
8660 skill_id,
8661 skill_response,
8662 &HashMap::new(),
8663 )
8664 .await;
8665 }
8666 DisambiguationResult::ProceedWithBestGuess {
8667 enriched_input: re_enriched,
8668 } => {
8669 debug!(skill_id = %skill_id, "Skill re-check: proceeding with best guess");
8670 *self.pending_skill_id.write() = None;
8671 let skill_response = self.execute_skill_by_id(skill_id, &re_enriched).await?;
8672 self.memory
8673 .add_message(ChatMessage::user(&re_enriched))
8674 .await?;
8675 return self
8676 .handle_skill_response(
8677 &re_enriched,
8678 skill_id,
8679 skill_response,
8680 &HashMap::new(),
8681 )
8682 .await;
8683 }
8684 DisambiguationResult::GiveUp { reason } => {
8685 *self.pending_skill_id.write() = None;
8686 let apology = self
8687 .generate_localized_apology(
8688 "Generate a brief, polite apology saying you couldn't understand the request. Be concise.",
8689 &reason,
8690 )
8691 .await
8692 .unwrap_or_else(|_| {
8693 format!("I'm sorry, I couldn't understand your request: {}", reason)
8694 });
8695 let response = AgentResponse::new(&apology);
8696 self.finish_turn_if_root(&response).await?;
8697 return Ok(response);
8698 }
8699 DisambiguationResult::Escalate { reason } => {
8700 *self.pending_skill_id.write() = None;
8701 let apology = self
8702 .generate_localized_apology(
8703 "Explain briefly that you're transferring the user to a human agent for help.",
8704 &reason,
8705 )
8706 .await
8707 .unwrap_or_else(|_| {
8708 format!("I need human assistance to help with your request: {}", reason)
8709 });
8710 let response = AgentResponse::new(&apology);
8711 self.finish_turn_if_root(&response).await?;
8712 return Ok(response);
8713 }
8714 DisambiguationResult::Abandoned { new_input } => {
8715 *self.pending_skill_id.write() = None;
8718 debug!(skill_id = %skill_id, "Skill re-check: abandoned by user");
8719 if let Some(fresh) = new_input {
8720 return self.run_loop_internal(&fresh).await;
8721 }
8722 let ack = self
8723 .generate_localized_apology(
8724 "The user changed their mind about their previous request. \
8725 Generate a brief, friendly acknowledgment (e.g. 'OK, no problem. What else can I help with?'). \
8726 Do NOT apologize excessively. Be concise.",
8727 "User abandoned clarification",
8728 )
8729 .await
8730 .unwrap_or_else(|_| {
8731 "OK, no problem. What else can I help with?".to_string()
8732 });
8733 self.memory
8734 .add_message(ChatMessage::assistant(&ack))
8735 .await?;
8736 let response = AgentResponse::new(&ack);
8737 self.finish_turn_if_root(&response).await?;
8738 return Ok(response);
8739 }
8740 }
8741 }
8742
8743 *self.pending_skill_id.write() = None;
8745 let skill_response = self.execute_skill_by_id(skill_id, enriched_input).await?;
8746 self.memory
8747 .add_message(ChatMessage::user(enriched_input))
8748 .await?;
8749 self.handle_skill_response(enriched_input, skill_id, skill_response, &HashMap::new())
8750 .await
8751 }
8752
8753 async fn handle_skill_response(
8756 &self,
8757 processed_input: &str,
8758 skill_id: &str,
8759 skill_response: String,
8760 input_context: &HashMap<String, Value>,
8761 ) -> Result<AgentResponse> {
8762 let output_data = self.process_output(&skill_response, input_context).await?;
8763 let final_response = output_data.content;
8764
8765 self.memory
8766 .add_message(ChatMessage::assistant(&final_response))
8767 .await?;
8768
8769 self.check_memory_compression().await?;
8770
8771 self.increment_turn();
8772 self.evaluate_transitions(processed_input, &final_response)
8773 .await?;
8774
8775 let response = AgentResponse::new(final_response)
8776 .with_metadata("skill_id", serde_json::json!(skill_id));
8777 self.finish_turn_if_root(&response).await?;
8778 Ok(response)
8779 }
8780
8781 async fn handle_plan_and_execute(
8784 &self,
8785 processed_input: &str,
8786 input_context: &HashMap<String, Value>,
8787 auto_detected: bool,
8788 ) -> Result<AgentResponse> {
8789 let effective = self.get_effective_reasoning_config();
8790 let plan_reflection = effective
8791 .get_planning()
8792 .map(|c| c.reflection.clone())
8793 .unwrap_or_default();
8794
8795 let max_attempts = if plan_reflection.enabled {
8796 1 + plan_reflection.max_replans
8797 } else {
8798 1
8799 };
8800
8801 let mut plan = self.generate_plan(processed_input).await?;
8802 info!(
8803 plan_id = %plan.id,
8804 steps = plan.steps.len(),
8805 "Plan generated"
8806 );
8807
8808 let mut plan_result = String::new();
8809
8810 for attempt in 0..max_attempts {
8811 *self.current_plan.write() = Some(plan.clone());
8812 plan_result = self.execute_plan(&mut plan).await?;
8813
8814 info!(
8815 plan_status = ?plan.status,
8816 completed_steps = plan.completed_steps().count(),
8817 attempt = attempt + 1,
8818 "Plan execution completed"
8819 );
8820
8821 if !plan_reflection.enabled {
8822 break;
8823 }
8824
8825 let has_failures = plan.steps.iter().any(|s| s.status.is_failed());
8826 if !has_failures {
8827 break;
8828 }
8829
8830 if attempt + 1 >= max_attempts {
8831 break;
8832 }
8833
8834 match plan_reflection.on_step_failure {
8835 StepFailureAction::Replan => {
8836 info!(attempt = attempt + 1, "Plan had failures, replanning");
8837 plan = self.generate_plan(processed_input).await?;
8838 }
8839 StepFailureAction::Abort => {
8840 warn!("Plan step failed, aborting");
8841 break;
8842 }
8843 StepFailureAction::Skip | StepFailureAction::Continue => {
8844 break;
8845 }
8846 }
8847 }
8848
8849 *self.current_plan.write() = Some(plan);
8850
8851 let output_data = self.process_output(&plan_result, input_context).await?;
8852 let final_content = output_data.content;
8853
8854 self.memory
8855 .add_message(ChatMessage::assistant(&final_content))
8856 .await?;
8857
8858 self.check_memory_compression().await?;
8859 self.increment_turn();
8860 self.evaluate_transitions(processed_input, &final_content)
8861 .await?;
8862
8863 let reasoning_metadata =
8864 ReasoningMetadata::new(ReasoningMode::PlanAndExecute).with_auto_detected(auto_detected);
8865
8866 let response = AgentResponse::new(&final_content).with_metadata(
8867 "reasoning",
8868 serde_json::to_value(&reasoning_metadata).unwrap_or_default(),
8869 );
8870
8871 self.finish_turn_if_root(&response).await?;
8872 Ok(response)
8873 }
8874
8875 fn inject_reasoning_prompt(
8877 &self,
8878 messages: &mut [ChatMessage],
8879 reasoning_mode: &ReasoningMode,
8880 is_first_iteration: bool,
8881 ) {
8882 if !is_first_iteration {
8883 return;
8884 }
8885 match reasoning_mode {
8886 ReasoningMode::CoT => {
8887 if let Some(msg) = messages.first_mut()
8888 && matches!(msg.role, ai_agents_core::Role::System)
8889 {
8890 msg.content = self.build_cot_system_prompt(&msg.content);
8891 debug!("Applied Chain-of-Thought system prompt");
8892 }
8893 }
8894 ReasoningMode::React => {
8895 if let Some(msg) = messages.first_mut()
8896 && matches!(msg.role, ai_agents_core::Role::System)
8897 {
8898 msg.content = self.build_react_system_prompt(&msg.content);
8899 debug!("Applied ReAct system prompt");
8900 }
8901 }
8902 _ => {}
8903 }
8904 }
8905
8906 async fn generate_main_response_draft(
8911 &self,
8912 processed_input: &str,
8913 reasoning_mode: &ReasoningMode,
8914 ) -> Result<MainResponseDraft> {
8915 let llm = self.get_state_llm()?;
8916 let protocol = self.main_tool_protocol(llm.as_ref(), true).await?;
8917 let mut messages = self
8918 .build_messages_internal(false, Some(processed_input), protocol.choice.is_none())
8919 .await?;
8920 self.inject_reasoning_prompt(&mut messages, reasoning_mode, true);
8921 let response = self
8922 .complete_main_llm_with_recovery(llm, &messages, &protocol)
8923 .await?;
8924 let content = response.content.trim().to_string();
8925 let (thinking, answer) = self.extract_thinking(&content);
8926 if let Some(calls) = self.parse_main_tool_calls(&content, &protocol) {
8927 return Ok(MainResponseDraft::ToolCalls {
8928 raw_content: content,
8929 calls,
8930 thinking,
8931 });
8932 }
8933 Ok(MainResponseDraft::Text {
8934 raw_content: answer,
8935 thinking,
8936 })
8937 }
8938
8939 async fn commit_main_response_draft(
8944 &self,
8945 processed_input: &str,
8946 input_context: &HashMap<String, Value>,
8947 draft: MainResponseDraft,
8948 reasoning_mode: ReasoningMode,
8949 auto_detected: bool,
8950 ) -> Result<AgentResponse> {
8951 self.commit_root_user_message(processed_input).await?;
8952 match draft {
8953 MainResponseDraft::Text {
8954 raw_content,
8955 thinking,
8956 } => {
8957 self.finish_text_response_from_model(CommittedTextResponse {
8958 processed_input,
8959 input_context,
8960 answer: raw_content,
8961 reasoning_mode,
8962 auto_detected,
8963 iterations: 1,
8964 thinking_content: thinking,
8965 all_tool_calls: Vec::new(),
8966 })
8967 .await
8968 }
8969 MainResponseDraft::ToolCalls {
8970 raw_content,
8971 calls,
8972 thinking: _,
8973 } => {
8974 let mut all_tool_calls = Vec::new();
8975 match self
8976 .handle_tool_calls(processed_input, &raw_content, calls, &mut all_tool_calls)
8977 .await?
8978 {
8979 ToolCallOutcome::Rejected(response) => {
8980 self.finish_turn_if_root(&response).await?;
8981 Ok(response)
8982 }
8983 ToolCallOutcome::Continue | ToolCallOutcome::TransitionFired => {
8984 self.continue_after_committed_tool_draft(processed_input)
8985 .await
8986 }
8987 }
8988 }
8989 }
8990 }
8991
8992 async fn continue_after_committed_tool_draft(
8997 &self,
8998 processed_input: &str,
8999 ) -> Result<AgentResponse> {
9000 *self.redispatch_depth.write() += 1;
9001 if let Some(context) = self.active_turn_context.write().as_mut() {
9002 context.enter_redispatch();
9003 }
9004 let result = Box::pin(self.run_loop_internal(processed_input)).await;
9005 *self.redispatch_depth.write() -= 1;
9006 if let Some(context) = self.active_turn_context.write().as_mut() {
9007 context.exit_redispatch();
9008 }
9009 let response = result?;
9010 self.finish_turn_if_root(&response).await?;
9011 Ok(response)
9012 }
9013
9014 async fn finish_text_response_from_model(
9019 &self,
9020 response: CommittedTextResponse<'_>,
9021 ) -> Result<AgentResponse> {
9022 let CommittedTextResponse {
9023 processed_input,
9024 input_context,
9025 answer,
9026 reasoning_mode,
9027 auto_detected,
9028 iterations,
9029 thinking_content,
9030 all_tool_calls,
9031 } = response;
9032 let output_data = self.process_output(&answer, input_context).await?;
9033 let mut final_content = if output_data.metadata.rejected {
9034 output_data
9035 .metadata
9036 .rejection_reason
9037 .unwrap_or_else(|| answer.to_string())
9038 } else {
9039 output_data.content
9040 };
9041 let llm = self.get_state_llm()?;
9042 let reflection_metadata;
9043 (final_content, reflection_metadata) = self
9044 .run_reflection(&*llm, processed_input, final_content)
9045 .await?;
9046 final_content =
9047 self.format_response_with_thinking(thinking_content.as_deref(), &final_content);
9048 let final_content = {
9049 let result = self
9050 .post_loop_processing(processed_input, final_content)
9051 .await?;
9052 self.apply_post_loop_result(processed_input, result).await?
9053 };
9054 let response = self.build_agent_response(AgentResponseParts {
9055 content: final_content,
9056 all_tool_calls,
9057 reasoning_mode,
9058 auto_detected,
9059 iterations,
9060 thinking: thinking_content,
9061 reflection_metadata,
9062 });
9063 self.finish_turn_if_root(&response).await?;
9064 Ok(response)
9065 }
9066
9067 async fn run_committed_response_loop_with_reasoning(
9072 &self,
9073 processed_input: &str,
9074 input_context: &HashMap<String, Value>,
9075 reasoning_mode: ReasoningMode,
9076 auto_detected: bool,
9077 ) -> Result<AgentResponse> {
9078 self.commit_root_user_message(processed_input).await?;
9079 let llm = self.get_state_llm()?;
9080 let mut iterations = 0u32;
9081 let mut all_tool_calls = Vec::new();
9082 let mut thinking_content = None;
9083 loop {
9084 let effective_max = if reasoning_mode != ReasoningMode::None {
9085 let rc = self.get_effective_reasoning_config();
9086 self.max_iterations.min(rc.max_iterations)
9087 } else {
9088 self.max_iterations
9089 };
9090 if iterations >= effective_max {
9091 return Err(AgentError::Other(format!(
9092 "Max iterations ({}) exceeded",
9093 effective_max
9094 )));
9095 }
9096 iterations += 1;
9097 *self.iteration_count.write() = iterations;
9098 let protocol = self.main_tool_protocol(llm.as_ref(), false).await?;
9099 let mut messages = self
9100 .build_messages_internal(true, None, protocol.choice.is_none())
9101 .await?;
9102 self.inject_reasoning_prompt(&mut messages, &reasoning_mode, iterations == 1);
9103 self.hooks.on_llm_start(&messages).await;
9104 let llm_start = Instant::now();
9105 let response = self
9106 .complete_main_llm_with_recovery(Arc::clone(&llm), &messages, &protocol)
9107 .await?;
9108 let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
9109 self.hooks.on_llm_complete(&response, llm_duration_ms).await;
9110 let content = response.content.trim();
9111 if let Some(tool_calls) = self.parse_main_tool_calls(content, &protocol) {
9112 match self
9113 .handle_tool_calls(processed_input, content, tool_calls, &mut all_tool_calls)
9114 .await?
9115 {
9116 ToolCallOutcome::Continue | ToolCallOutcome::TransitionFired => continue,
9117 ToolCallOutcome::Rejected(resp) => {
9118 self.finish_turn_if_root(&resp).await?;
9119 return Ok(resp);
9120 }
9121 }
9122 }
9123 let (extracted_thinking, answer) = self.extract_thinking(content);
9124 if extracted_thinking.is_some() {
9125 thinking_content = extracted_thinking;
9126 }
9127 return self
9128 .finish_text_response_from_model(CommittedTextResponse {
9129 processed_input,
9130 input_context,
9131 answer,
9132 reasoning_mode,
9133 auto_detected,
9134 iterations,
9135 thinking_content,
9136 all_tool_calls,
9137 })
9138 .await;
9139 }
9140 }
9141
9142 async fn handle_tool_calls(
9144 &self,
9145 processed_input: &str,
9146 content: &str,
9147 tool_calls: Vec<ToolCall>,
9148 all_tool_calls: &mut Vec<ToolCall>,
9149 ) -> Result<ToolCallOutcome> {
9150 let transition_fired = self.evaluate_transitions(processed_input, content).await?;
9154 if transition_fired {
9155 self.memory
9156 .add_message(ChatMessage::assistant(
9157 "(Transitioned to new state — tool call handled by workflow)",
9158 ))
9159 .await?;
9160 return Ok(ToolCallOutcome::TransitionFired);
9161 }
9162
9163 self.memory
9165 .add_message(ChatMessage::assistant(content))
9166 .await?;
9167 let native_tool_call = Self::is_native_tool_call_content(content);
9168
9169 let results = self.execute_tools_parallel(&tool_calls).await;
9170
9171 for ((_id, result), tool_call) in results.into_iter().zip(tool_calls.iter()) {
9172 match result {
9173 Ok(output) => {
9174 self.memory
9175 .add_message(Self::tool_result_message(
9176 tool_call,
9177 &output,
9178 native_tool_call,
9179 ))
9180 .await?;
9181 }
9182 Err(e) => {
9183 if matches!(e, AgentError::HITLRejected(_)) {
9185 self.memory
9186 .add_message(ChatMessage::assistant(format!(
9187 "The operation was rejected by the approver: {}",
9188 e
9189 )))
9190 .await?;
9191 return Ok(ToolCallOutcome::Rejected(AgentResponse {
9193 content: format!("Operation cancelled: {}", e),
9194 metadata: None,
9195 tool_calls: Some(all_tool_calls.clone()),
9196 }));
9197 }
9198 self.memory
9199 .add_message(Self::tool_result_message(
9200 tool_call,
9201 &format!("Error: {}", e),
9202 native_tool_call,
9203 ))
9204 .await?;
9205 }
9206 }
9207 all_tool_calls.push(tool_call.clone());
9208 }
9209 Ok(ToolCallOutcome::Continue)
9210 }
9211
9212 async fn run_reflection(
9214 &self,
9215 llm: &dyn LLMProvider,
9216 processed_input: &str,
9217 mut content: String,
9218 ) -> Result<(String, Option<ReflectionMetadata>)> {
9219 let should_reflect = self.should_reflect(processed_input, &content).await?;
9220 if !should_reflect {
9221 return Ok((content, None));
9222 }
9223
9224 info!("Starting response reflection evaluation");
9225 let mut attempts = 0u32;
9226 let max_retries = self.reflection_config.max_retries;
9227 let mut history: Vec<ReflectionAttempt> = Vec::new();
9228
9229 loop {
9230 let evaluation = self.evaluate_response(processed_input, &content).await?;
9231
9232 if evaluation.passed || attempts >= max_retries {
9233 info!(
9234 passed = evaluation.passed,
9235 confidence = evaluation.confidence,
9236 attempts = attempts + 1,
9237 "Reflection evaluation complete"
9238 );
9239 let reflection_metadata = Some(
9240 ReflectionMetadata::new(evaluation)
9241 .with_attempts(attempts + 1)
9242 .with_history(history),
9243 );
9244 return Ok((content, reflection_metadata));
9245 }
9246
9247 debug!(
9248 attempt = attempts + 1,
9249 failed_criteria = evaluation.failed_criteria().count(),
9250 "Response did not meet criteria, retrying"
9251 );
9252
9253 history.push(
9254 ReflectionAttempt::new(&content, evaluation.clone())
9255 .with_feedback("Response did not meet quality criteria"),
9256 );
9257
9258 let feedback: Vec<String> = evaluation
9259 .failed_criteria()
9260 .map(|c| format!("- {}", c.criterion))
9261 .collect();
9262
9263 let retry_prompt = format!(
9264 "Your previous response did not meet these criteria:\n{}\n\nPlease provide an improved response.",
9265 feedback.join("\n")
9266 );
9267
9268 self.memory
9269 .add_message(ChatMessage::user(&retry_prompt))
9270 .await?;
9271
9272 let retry_messages = self.build_messages().await?;
9273 let retry_response = self
9274 .observe_purpose(
9275 ObservationPurpose::ReflectionEvaluation,
9276 llm.complete(&retry_messages, None),
9277 )
9278 .await
9279 .map_err(|e| AgentError::LLM(e.to_string()))?;
9280
9281 content = retry_response.content.trim().to_string();
9282 attempts += 1;
9283 }
9284 }
9285
9286 async fn post_loop_processing(
9289 &self,
9290 processed_input: &str,
9291 content: String,
9292 ) -> Result<PostLoopResult> {
9293 self.increment_turn();
9298
9299 self.run_context_extractors(processed_input).await;
9301
9302 let transitioned = self.evaluate_transitions(processed_input, &content).await?;
9303
9304 if !transitioned {
9305 self.memory
9306 .add_message(ChatMessage::assistant(&content))
9307 .await?;
9308 self.check_memory_compression().await?;
9309 return Ok(PostLoopResult::NoTransition(content));
9310 }
9311
9312 if !self.should_regenerate_after_transition() {
9314 self.memory
9315 .add_message(ChatMessage::assistant(&content))
9316 .await?;
9317 self.check_memory_compression().await?;
9318 return Ok(PostLoopResult::Transitioned(content));
9319 }
9320
9321 if self.needs_redispatch_for_new_state() {
9325 info!("Post-transition NeedsRedispatch: new state requires full dispatch");
9326 return Ok(PostLoopResult::NeedsRedispatch);
9329 }
9330
9331 self.memory
9334 .add_message(ChatMessage::assistant(&content))
9335 .await?;
9336 self.check_memory_compression().await?;
9337
9338 let new_llm = self.get_state_llm()?;
9344 let mut final_content;
9345
9346 for post_iter in 0..self.max_iterations {
9347 let protocol = self.main_tool_protocol(new_llm.as_ref(), false).await?;
9348 let new_messages = self
9349 .build_messages_internal(true, None, protocol.choice.is_none())
9350 .await?;
9351 if post_iter == 0
9352 && let Some(system_msg) = new_messages.first()
9353 && system_msg.role == ai_agents_core::Role::System
9354 {
9355 debug!(
9356 prompt_preview =
9357 &system_msg.content[system_msg.content.len().saturating_sub(200)..],
9358 "Post-transition system prompt (last 200 chars)"
9359 );
9360 }
9361
9362 let new_response = self
9363 .complete_main_llm_with_recovery(Arc::clone(&new_llm), &new_messages, &protocol)
9364 .await?;
9365 final_content = new_response.content.trim().to_string();
9366
9367 if let Some(tool_calls) = self.parse_main_tool_calls(&final_content, &protocol) {
9370 let native_tool_call = Self::is_native_tool_call_content(&final_content);
9371 debug!(
9372 post_iter = post_iter,
9373 tools = tool_calls.len(),
9374 "Post-transition tool call detected, executing"
9375 );
9376
9377 self.memory
9378 .add_message(ChatMessage::assistant(&final_content))
9379 .await?;
9380
9381 let results = self.execute_tools_parallel(&tool_calls).await;
9382 for ((_id, result), tool_call) in results.into_iter().zip(tool_calls.iter()) {
9383 match result {
9384 Ok(output) => {
9385 self.memory
9386 .add_message(Self::tool_result_message(
9387 tool_call,
9388 &output,
9389 native_tool_call,
9390 ))
9391 .await?;
9392 }
9393 Err(e) => {
9394 self.memory
9395 .add_message(Self::tool_result_message(
9396 tool_call,
9397 &format!("Error: {}", e),
9398 native_tool_call,
9399 ))
9400 .await?;
9401 }
9402 }
9403 }
9404 continue;
9406 }
9407
9408 self.memory
9410 .add_message(ChatMessage::assistant(&final_content))
9411 .await?;
9412 return Ok(PostLoopResult::Transitioned(final_content));
9413 }
9414
9415 final_content = "Post-transition processing completed.".to_string();
9417 self.memory
9418 .add_message(ChatMessage::assistant(&final_content))
9419 .await?;
9420
9421 Ok(PostLoopResult::Transitioned(final_content))
9422 }
9423
9424 fn should_regenerate_after_transition(&self) -> bool {
9427 if let Some(ref sm) = self.state_machine {
9428 if !sm.config().regenerate_on_transition {
9430 return false;
9431 }
9432 if let Some(def) = sm.current_definition()
9434 && let Some(regen) = def.regenerate_on_enter
9435 {
9436 return regen;
9437 }
9438 }
9439 true
9440 }
9441
9442 fn needs_redispatch_for_new_state(&self) -> bool {
9445 if let Some(ref sm) = self.state_machine
9446 && let Some(def) = sm.current_definition()
9447 {
9448 if def.concurrent.is_some()
9449 || def.group_chat.is_some()
9450 || def.pipeline.is_some()
9451 || def.handoff.is_some()
9452 || def.delegate.is_some()
9453 {
9454 return true;
9455 }
9456 let effective = self.get_effective_reasoning_config();
9458 if !matches!(effective.mode, ReasoningMode::None) {
9459 return true;
9460 }
9461 }
9462 false
9463 }
9464
9465 async fn apply_post_loop_result(
9468 &self,
9469 processed_input: &str,
9470 result: PostLoopResult,
9471 ) -> Result<String> {
9472 match result {
9473 PostLoopResult::NoTransition(content) | PostLoopResult::Transitioned(content) => {
9474 Ok(content)
9475 }
9476 PostLoopResult::NeedsRedispatch => {
9477 const MAX_REDISPATCH_DEPTH: u32 = 3;
9478 let current_depth = *self.redispatch_depth.read();
9479 if current_depth >= MAX_REDISPATCH_DEPTH {
9480 warn!(
9481 depth = current_depth,
9482 "Post-transition re-dispatch depth limit reached, returning empty response"
9483 );
9484 let content = String::new();
9485 self.memory
9486 .add_message(ChatMessage::assistant(&content))
9487 .await?;
9488 return Ok(content);
9489 }
9490 *self.redispatch_depth.write() += 1;
9491 if let Some(context) = self.active_turn_context.write().as_mut() {
9492 context.enter_redispatch();
9493 }
9494 info!(
9495 depth = current_depth + 1,
9496 "Re-dispatching for new state after transition"
9497 );
9498 let resp = Box::pin(self.run_loop_internal(processed_input)).await;
9499 *self.redispatch_depth.write() -= 1;
9500 if let Some(context) = self.active_turn_context.write().as_mut() {
9501 context.exit_redispatch();
9502 }
9503 resp.map(|r| r.content)
9504 }
9505 }
9506 }
9507
9508 fn build_agent_response(&self, parts: AgentResponseParts) -> AgentResponse {
9510 let AgentResponseParts {
9511 content,
9512 all_tool_calls,
9513 reasoning_mode,
9514 auto_detected,
9515 iterations,
9516 thinking,
9517 reflection_metadata,
9518 } = parts;
9519 let reasoning_metadata = ReasoningMetadata::new(reasoning_mode.clone())
9520 .with_thinking(thinking.clone().unwrap_or_default())
9521 .with_iterations(iterations)
9522 .with_auto_detected(auto_detected);
9523
9524 let mut response = AgentResponse::new(&content);
9525 if !all_tool_calls.is_empty() {
9526 response = response.with_tool_calls(all_tool_calls);
9527 }
9528
9529 if let Some(state) = self.current_state() {
9530 response = response.with_metadata("current_state", serde_json::json!(state));
9531 }
9532
9533 response = response.with_metadata(
9534 "reasoning",
9535 serde_json::to_value(&reasoning_metadata).unwrap_or_default(),
9536 );
9537
9538 if let Some(ref refl_meta) = reflection_metadata {
9539 response = response.with_metadata(
9540 "reflection",
9541 serde_json::to_value(refl_meta).unwrap_or_default(),
9542 );
9543 }
9544
9545 response
9546 }
9547
9548 async fn handle_delegated_state(
9550 &self,
9551 input: &str,
9552 delegate_id: &str,
9553 state_def: &ai_agents_state::StateDefinition,
9554 ) -> Result<AgentResponse> {
9555 use std::time::Instant;
9556
9557 let registry = self.spawner_registry.as_ref().ok_or_else(|| {
9558 AgentError::Config(format!(
9559 "State delegates to '{}' but no agent registry is configured. \
9560 Add a spawner section with auto_spawn to your YAML.",
9561 delegate_id
9562 ))
9563 })?;
9564
9565 let state_name = self
9566 .state_machine
9567 .as_ref()
9568 .map(|sm| sm.current())
9569 .unwrap_or_else(|| "unknown".to_string());
9570
9571 self.hooks.on_delegate_start(delegate_id, &state_name).await;
9572 let start = Instant::now();
9573
9574 let delegate = registry.get(delegate_id).ok_or_else(|| {
9575 AgentError::Other(format!(
9576 "State '{}' delegates to '{}' but no agent with that ID exists in the registry.",
9577 state_name, delegate_id
9578 ))
9579 })?;
9580
9581 let context_mode = state_def.delegate_context.clone().unwrap_or_default();
9583 let effective_input = self
9584 .observe_purpose(
9585 ObservationPurpose::OrchestrationRouting,
9586 crate::orchestration::context::prepare_delegate_input(
9587 input,
9588 &context_mode,
9589 &*self.memory,
9590 self.llm_registry.get("router").ok().as_deref(),
9591 ),
9592 )
9593 .await?;
9594
9595 let response = delegate
9596 .chat_with_actor_context(&effective_input, self.outbound_actor_context())
9597 .await?;
9598
9599 let duration_ms = start.elapsed().as_millis() as u64;
9600 self.hooks
9601 .on_delegate_complete(delegate_id, &state_name, duration_ms)
9602 .await;
9603
9604 let ctx_key = format!("delegation.{}.last_response", delegate_id);
9606 let _ = self.context_manager.set(
9607 &ctx_key,
9608 serde_json::Value::String(response.content.clone()),
9609 );
9610
9611 let _ = self.context_manager.set(
9613 "orchestration",
9614 serde_json::json!({
9615 "type": "delegate",
9616 "agent": delegate_id,
9617 "state": state_name,
9618 "response": response.content,
9619 "duration_ms": duration_ms,
9620 }),
9621 );
9622
9623 self.commit_root_user_message(input).await?;
9624
9625 let post_result = self
9628 .post_loop_processing(
9629 input,
9630 format!("[Delegated to {}]: {}", delegate_id, response.content),
9631 )
9632 .await?;
9633 let final_content = self.apply_post_loop_result(input, post_result).await?;
9634
9635 let mut result = AgentResponse::new(final_content);
9636
9637 let metadata = serde_json::json!({
9638 "orchestration": {
9639 "type": "delegate",
9640 "agent": delegate_id,
9641 "state": state_name,
9642 "response": response.content,
9643 "duration_ms": duration_ms,
9644 }
9645 });
9646 result.metadata = Some(
9647 serde_json::from_value::<std::collections::HashMap<String, serde_json::Value>>(
9648 metadata,
9649 )
9650 .unwrap_or_default(),
9651 );
9652
9653 self.finish_turn_if_root(&result).await?;
9654 Ok(result)
9655 }
9656
9657 async fn handle_concurrent_state(
9659 &self,
9660 input: &str,
9661 config: &ai_agents_state::ConcurrentStateConfig,
9662 ) -> Result<AgentResponse> {
9663 use std::time::Instant;
9664
9665 let registry = self.spawner_registry.as_ref().ok_or_else(|| {
9666 AgentError::Config(
9667 "Concurrent state requires an agent registry. Add a spawner section.".into(),
9668 )
9669 })?;
9670
9671 let context_mode = config.context_mode.clone().unwrap_or_default();
9676 let context_input = self
9677 .observe_purpose(
9678 ObservationPurpose::OrchestrationRouting,
9679 crate::orchestration::context::prepare_delegate_input(
9680 input,
9681 &context_mode,
9682 &*self.memory,
9683 self.llm_registry.get("router").ok().as_deref(),
9684 ),
9685 )
9686 .await?;
9687
9688 let effective_input = if let Some(ref tmpl) = config.input {
9689 render_concurrent_template(tmpl, &context_input, &self.build_context_with_overlays())
9690 .unwrap_or_else(|_| context_input.clone())
9691 } else {
9692 context_input
9693 };
9694
9695 let start = Instant::now();
9696
9697 let llm_name = config
9698 .aggregation
9699 .synthesizer_llm
9700 .as_deref()
9701 .unwrap_or("router");
9702 let llm_provider = self.llm_registry.get(llm_name).ok();
9703
9704 let vote_parallelism = if self.runtime_config.optimization.enabled
9705 && self
9706 .runtime_config
9707 .optimization
9708 .parallel_orchestration_vote_extraction
9709 {
9710 Some(self.runtime_config.optimization.max_parallel_runtime_tasks)
9711 } else {
9712 None
9713 };
9714
9715 let result = self
9716 .observe_purpose(
9717 ObservationPurpose::OrchestrationAggregation,
9718 scope_actor_context(
9719 self.outbound_actor_context(),
9720 crate::orchestration::concurrent(
9721 registry,
9722 &effective_input,
9723 &config.agents,
9724 &config.aggregation,
9725 llm_provider.as_deref(),
9726 config.min_required,
9727 config.timeout_ms,
9728 config.on_partial_failure.clone(),
9729 vote_parallelism,
9730 ),
9731 ),
9732 )
9733 .await?;
9734
9735 let duration_ms = start.elapsed().as_millis() as u64;
9736 let agent_ids: Vec<String> = config.agents.iter().map(|a| a.id().to_string()).collect();
9737 let strategy = format!("{:?}", config.aggregation.strategy);
9738 self.hooks
9739 .on_concurrent_complete(&agent_ids, &strategy, duration_ms)
9740 .await;
9741
9742 let _ = self.context_manager.set(
9744 "concurrent.result",
9745 serde_json::Value::String(result.response.content.clone()),
9746 );
9747
9748 let agents_json: Vec<serde_json::Value> = result
9750 .agent_results
9751 .iter()
9752 .map(|ar| {
9753 serde_json::json!({
9754 "id": ar.agent_id,
9755 "response": ar.response.as_ref().map(|r| r.content.as_str()),
9756 "success": ar.success,
9757 "error": ar.error,
9758 "duration_ms": ar.duration_ms,
9759 })
9760 })
9761 .collect();
9762
9763 let _ = self.context_manager.set(
9765 "orchestration",
9766 serde_json::json!({
9767 "type": "concurrent",
9768 "result": result.response.content,
9769 "strategy": strategy,
9770 "agents": agents_json,
9771 "duration_ms": duration_ms,
9772 }),
9773 );
9774
9775 self.commit_root_user_message(input).await?;
9776
9777 let post_result = self
9778 .post_loop_processing(input, result.response.content.clone())
9779 .await?;
9780 let final_content = self.apply_post_loop_result(input, post_result).await?;
9781
9782 let mut response = AgentResponse::new(final_content);
9783 let metadata = serde_json::json!({
9784 "orchestration": {
9785 "type": "concurrent",
9786 "result": result.response.content,
9787 "strategy": strategy,
9788 "agents": agents_json,
9789 "duration_ms": duration_ms,
9790 }
9791 });
9792 response.metadata = Some(
9793 serde_json::from_value::<std::collections::HashMap<String, serde_json::Value>>(
9794 metadata,
9795 )
9796 .unwrap_or_default(),
9797 );
9798
9799 self.finish_turn_if_root(&response).await?;
9800 Ok(response)
9801 }
9802
9803 async fn handle_group_chat_state(
9805 &self,
9806 input: &str,
9807 config: &ai_agents_state::GroupChatStateConfig,
9808 ) -> Result<AgentResponse> {
9809 use std::time::Instant;
9810
9811 let registry = self.spawner_registry.as_ref().ok_or_else(|| {
9812 AgentError::Config(
9813 "Group chat state requires an agent registry. Add a spawner section.".into(),
9814 )
9815 })?;
9816
9817 let start = Instant::now();
9818
9819 let llm_provider = self.llm_registry.get("router").ok();
9820
9821 let context_mode = config.context_mode.clone().unwrap_or_default();
9823 let context_input = self
9824 .observe_purpose(
9825 ObservationPurpose::OrchestrationRouting,
9826 crate::orchestration::context::prepare_delegate_input(
9827 input,
9828 &context_mode,
9829 &*self.memory,
9830 self.llm_registry.get("router").ok().as_deref(),
9831 ),
9832 )
9833 .await?;
9834
9835 let effective_topic = if let Some(ref tmpl) = config.input {
9837 render_concurrent_template(tmpl, &context_input, &self.build_context_with_overlays())
9838 .unwrap_or_else(|_| context_input.clone())
9839 } else {
9840 context_input
9841 };
9842
9843 let result = self
9844 .observe_purpose(
9845 ObservationPurpose::OrchestrationConversation,
9846 scope_actor_context(
9847 self.outbound_actor_context(),
9848 crate::orchestration::group_chat(
9849 registry,
9850 &effective_topic,
9851 config,
9852 llm_provider.as_deref(),
9853 Some(&*self.hooks),
9854 ),
9855 ),
9856 )
9857 .await?;
9858
9859 let duration_ms = start.elapsed().as_millis() as u64;
9860
9861 let _ = self.context_manager.set(
9863 "group_chat.conclusion",
9864 serde_json::Value::String(result.response.content.clone()),
9865 );
9866
9867 let transcript_json: Vec<serde_json::Value> = result
9869 .transcript
9870 .iter()
9871 .map(|t| {
9872 serde_json::json!({
9873 "speaker": t.speaker,
9874 "round": t.round,
9875 "content": t.content,
9876 })
9877 })
9878 .collect();
9879
9880 let _ = self.context_manager.set(
9882 "orchestration",
9883 serde_json::json!({
9884 "type": "group_chat",
9885 "conclusion": result.response.content,
9886 "transcript": transcript_json,
9887 "rounds": result.rounds_completed,
9888 "termination": result.termination_reason,
9889 "duration_ms": duration_ms,
9890 }),
9891 );
9892
9893 self.commit_root_user_message(input).await?;
9894
9895 let post_result = self
9896 .post_loop_processing(input, result.response.content.clone())
9897 .await?;
9898 let final_content = self.apply_post_loop_result(input, post_result).await?;
9899
9900 let mut response = AgentResponse::new(final_content);
9901 let metadata = serde_json::json!({
9902 "orchestration": {
9903 "type": "group_chat",
9904 "conclusion": result.response.content,
9905 "transcript": transcript_json,
9906 "rounds": result.rounds_completed,
9907 "termination": result.termination_reason,
9908 "duration_ms": duration_ms,
9909 }
9910 });
9911 response.metadata = Some(
9912 serde_json::from_value::<std::collections::HashMap<String, serde_json::Value>>(
9913 metadata,
9914 )
9915 .unwrap_or_default(),
9916 );
9917
9918 self.finish_turn_if_root(&response).await?;
9919 Ok(response)
9920 }
9921
9922 async fn handle_pipeline_state(
9924 &self,
9925 input: &str,
9926 config: &ai_agents_state::PipelineStateConfig,
9927 ) -> Result<AgentResponse> {
9928 use std::time::Instant;
9929
9930 let registry = self.spawner_registry.as_ref().ok_or_else(|| {
9931 AgentError::Config(
9932 "Pipeline state requires an agent registry. Add a spawner section.".into(),
9933 )
9934 })?;
9935
9936 let start = Instant::now();
9937
9938 let stages: Vec<crate::orchestration::PipelineStage> = config
9939 .stages
9940 .iter()
9941 .map(|entry| {
9942 let mut stage = crate::orchestration::PipelineStage::id(entry.id());
9943 if let Some(tmpl) = entry.input() {
9944 stage = stage.with_input(tmpl);
9945 }
9946 stage
9947 })
9948 .collect();
9949
9950 let context_mode = config.context_mode.clone().unwrap_or_default();
9952 let context_input = self
9953 .observe_purpose(
9954 ObservationPurpose::OrchestrationRouting,
9955 crate::orchestration::context::prepare_delegate_input(
9956 input,
9957 &context_mode,
9958 &*self.memory,
9959 self.llm_registry.get("router").ok().as_deref(),
9960 ),
9961 )
9962 .await?;
9963
9964 let context_values = self.build_context_with_overlays();
9965 let result = self
9966 .observe_purpose(
9967 ObservationPurpose::OrchestrationRouting,
9968 scope_actor_context(
9969 self.outbound_actor_context(),
9970 crate::orchestration::pipeline(
9971 registry,
9972 &context_input,
9973 &stages,
9974 config.timeout_ms,
9975 Some(&*self.hooks),
9976 Some(&context_values),
9977 ),
9978 ),
9979 )
9980 .await?;
9981
9982 let duration_ms = start.elapsed().as_millis() as u64;
9983
9984 let _ = self.context_manager.set(
9986 "pipeline.result",
9987 serde_json::Value::String(result.response.content.clone()),
9988 );
9989
9990 let stages_json: Vec<serde_json::Value> = result
9992 .stage_outputs
9993 .iter()
9994 .map(|s| {
9995 serde_json::json!({
9996 "agent_id": s.agent_id,
9997 "output": s.output,
9998 "duration_ms": s.duration_ms,
9999 "skipped": s.skipped,
10000 })
10001 })
10002 .collect();
10003
10004 let _ = self.context_manager.set(
10006 "orchestration",
10007 serde_json::json!({
10008 "type": "pipeline",
10009 "result": result.response.content,
10010 "stages": stages_json,
10011 "duration_ms": duration_ms,
10012 }),
10013 );
10014
10015 self.commit_root_user_message(input).await?;
10016
10017 let post_result = self
10018 .post_loop_processing(input, result.response.content.clone())
10019 .await?;
10020 let final_content = self.apply_post_loop_result(input, post_result).await?;
10021
10022 let mut response = AgentResponse::new(final_content);
10023 let metadata = serde_json::json!({
10024 "orchestration": {
10025 "type": "pipeline",
10026 "result": result.response.content,
10027 "stages": stages_json,
10028 "duration_ms": duration_ms,
10029 }
10030 });
10031 response.metadata = Some(
10032 serde_json::from_value::<std::collections::HashMap<String, serde_json::Value>>(
10033 metadata,
10034 )
10035 .unwrap_or_default(),
10036 );
10037
10038 self.finish_turn_if_root(&response).await?;
10039 Ok(response)
10040 }
10041
10042 async fn handle_handoff_state(
10044 &self,
10045 input: &str,
10046 config: &ai_agents_state::HandoffStateConfig,
10047 ) -> Result<AgentResponse> {
10048 use std::time::Instant;
10049
10050 let registry = self.spawner_registry.as_ref().ok_or_else(|| {
10051 AgentError::Config(
10052 "Handoff state requires an agent registry. Add a spawner section.".into(),
10053 )
10054 })?;
10055
10056 let llm = self
10057 .llm_registry
10058 .get("router")
10059 .map_err(|_| AgentError::Config("Handoff state requires a router LLM.".into()))?;
10060
10061 let start = Instant::now();
10062
10063 let context_mode = config.context_mode.clone().unwrap_or_default();
10065 let context_input = self
10066 .observe_purpose(
10067 ObservationPurpose::OrchestrationRouting,
10068 crate::orchestration::context::prepare_delegate_input(
10069 input,
10070 &context_mode,
10071 &*self.memory,
10072 self.llm_registry.get("router").ok().as_deref(),
10073 ),
10074 )
10075 .await?;
10076
10077 let effective_input = if let Some(ref tmpl) = config.input {
10079 render_concurrent_template(tmpl, &context_input, &self.build_context_with_overlays())
10080 .unwrap_or_else(|_| context_input.clone())
10081 } else {
10082 context_input
10083 };
10084
10085 let result = self
10086 .observe_purpose(
10087 ObservationPurpose::OrchestrationRouting,
10088 scope_actor_context(
10089 self.outbound_actor_context(),
10090 crate::orchestration::handoff(
10091 registry,
10092 &effective_input,
10093 &config.initial_agent,
10094 &config.available_agents,
10095 config.max_handoffs,
10096 llm.as_ref(),
10097 Some(&*self.hooks),
10098 ),
10099 ),
10100 )
10101 .await?;
10102
10103 let duration_ms = start.elapsed().as_millis() as u64;
10104
10105 let _ = self.context_manager.set(
10107 "handoff.result",
10108 serde_json::Value::String(result.response.content.clone()),
10109 );
10110
10111 let chain_json: Vec<serde_json::Value> = result
10113 .handoff_chain
10114 .iter()
10115 .map(|h| {
10116 serde_json::json!({
10117 "from": h.from_agent,
10118 "to": h.to_agent,
10119 "reason": h.reason,
10120 })
10121 })
10122 .collect();
10123
10124 let _ = self.context_manager.set(
10126 "orchestration",
10127 serde_json::json!({
10128 "type": "handoff",
10129 "result": result.response.content,
10130 "final_agent": result.final_agent,
10131 "handoff_chain": chain_json,
10132 "duration_ms": duration_ms,
10133 }),
10134 );
10135
10136 self.commit_root_user_message(input).await?;
10137
10138 let post_result = self
10139 .post_loop_processing(input, result.response.content.clone())
10140 .await?;
10141 let final_content = self.apply_post_loop_result(input, post_result).await?;
10142
10143 let mut response = AgentResponse::new(final_content);
10144 let metadata = serde_json::json!({
10145 "orchestration": {
10146 "type": "handoff",
10147 "result": result.response.content,
10148 "final_agent": result.final_agent,
10149 "handoff_chain": chain_json,
10150 "duration_ms": duration_ms,
10151 }
10152 });
10153 response.metadata = Some(
10154 serde_json::from_value::<std::collections::HashMap<String, serde_json::Value>>(
10155 metadata,
10156 )
10157 .unwrap_or_default(),
10158 );
10159
10160 self.finish_turn_if_root(&response).await?;
10161 Ok(response)
10162 }
10163
10164 async fn run_loop_internal(&self, input: &str) -> Result<AgentResponse> {
10166 self.begin_root_turn();
10167 self.pre_turn_session_lifecycle().await;
10169
10170 let input_data = self.process_input(input).await?;
10171 self.update_active_turn_context(&input_data.content, input_data.context.clone());
10172
10173 for (key, value) in &input_data.context {
10176 let _ = self.context_manager.set(key, value.clone());
10177 }
10178
10179 if input_data.metadata.rejected {
10180 let reason = input_data
10181 .metadata
10182 .rejection_reason
10183 .unwrap_or_else(|| "Input rejected".to_string());
10184 warn!(reason = %reason, "Input rejected");
10185 let response = AgentResponse::new(reason);
10186 self.finish_turn_if_root(&response).await?;
10187 return Ok(response);
10188 }
10189
10190 let processed_input = &input_data.content;
10191
10192 if let Some(response) = self.try_pre_response_transition(processed_input).await? {
10193 return Ok(response);
10194 }
10195
10196 if let Some(ref sm) = self.state_machine
10198 && let Some(def) = sm.current_definition()
10199 {
10200 if let Some(ref delegate_id) = def.delegate {
10201 return self
10202 .handle_delegated_state(processed_input, delegate_id, &def)
10203 .await;
10204 }
10205 if let Some(ref concurrent_config) = def.concurrent {
10206 return self
10207 .handle_concurrent_state(processed_input, concurrent_config)
10208 .await;
10209 }
10210 if let Some(ref group_chat_config) = def.group_chat {
10211 return self
10212 .handle_group_chat_state(processed_input, group_chat_config)
10213 .await;
10214 }
10215 if let Some(ref pipeline_config) = def.pipeline {
10216 return self
10217 .handle_pipeline_state(processed_input, pipeline_config)
10218 .await;
10219 }
10220 if let Some(ref handoff_config) = def.handoff {
10221 return self
10222 .handle_handoff_state(processed_input, handoff_config)
10223 .await;
10224 }
10225 }
10226
10227 if let Some(response) =
10232 Box::pin(self.try_speculative_branches(processed_input, &input_data.context)).await?
10233 {
10234 return Ok(response);
10235 }
10236
10237 match self.try_skill_route(processed_input).await? {
10238 SkillRouteResult::Response { skill_id, content } => {
10239 self.commit_root_user_message(processed_input).await?;
10240 return self
10241 .handle_skill_response(processed_input, &skill_id, content, &input_data.context)
10242 .await;
10243 }
10244 SkillRouteResult::NeedsClarification(response) => {
10245 self.commit_root_user_message(processed_input).await?;
10246 if let Some(q) = response
10247 .metadata
10248 .as_ref()
10249 .and_then(|m| m.get("disambiguation"))
10250 .and_then(|d| d.get("status"))
10251 .and_then(|s| s.as_str())
10252 && q == "awaiting_clarification"
10253 {
10254 self.memory
10257 .add_message(ChatMessage::assistant(&response.content))
10258 .await?;
10259 }
10260 self.finish_turn_if_root(&response).await?;
10261 return Ok(response);
10262 }
10263 SkillRouteResult::NoMatch => {} }
10265
10266 let effective_reasoning = self.get_effective_reasoning_config();
10267 let reasoning_mode = self.determine_reasoning_mode(processed_input).await?;
10268 let auto_detected = matches!(effective_reasoning.mode, ReasoningMode::Auto);
10269
10270 info!(
10271 reasoning_mode = ?reasoning_mode,
10272 auto_detected = auto_detected,
10273 reflection_enabled = ?self.reflection_config.enabled,
10274 "Reasoning mode determined"
10275 );
10276
10277 if matches!(reasoning_mode, ReasoningMode::PlanAndExecute) {
10278 self.commit_root_user_message(processed_input).await?;
10279 return self
10280 .handle_plan_and_execute(processed_input, &input_data.context, auto_detected)
10281 .await;
10282 }
10283
10284 self.commit_root_user_message(processed_input).await?;
10285
10286 let mut iterations = 0u32;
10287 let mut all_tool_calls: Vec<ToolCall> = Vec::new();
10288 let mut thinking_content: Option<String> = None;
10289
10290 let llm = self.get_state_llm()?;
10291
10292 loop {
10293 let effective_max = if reasoning_mode != ReasoningMode::None {
10295 let rc = self.get_effective_reasoning_config();
10296 self.max_iterations.min(rc.max_iterations)
10297 } else {
10298 self.max_iterations
10299 };
10300
10301 if iterations >= effective_max {
10302 let err = AgentError::Other(format!("Max iterations ({}) exceeded", effective_max));
10303 self.hooks.on_error(&err).await;
10304 error!(iterations = iterations, "Max iterations exceeded");
10305 return Err(err);
10306 }
10307 iterations += 1;
10308 *self.iteration_count.write() = iterations;
10309
10310 debug!(iteration = iterations, max = effective_max, "LLM call");
10311
10312 let protocol = self.main_tool_protocol(llm.as_ref(), false).await?;
10313 let mut messages = self
10314 .build_messages_internal(true, None, protocol.choice.is_none())
10315 .await?;
10316 self.inject_reasoning_prompt(&mut messages, &reasoning_mode, iterations == 1);
10317
10318 self.hooks.on_llm_start(&messages).await;
10319 let llm_start = Instant::now();
10320 let response = self
10321 .complete_main_llm_with_recovery(Arc::clone(&llm), &messages, &protocol)
10322 .await?;
10323
10324 let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
10325 self.hooks.on_llm_complete(&response, llm_duration_ms).await;
10326
10327 let content = response.content.trim();
10328
10329 if let Some(tool_calls) = self.parse_main_tool_calls(content, &protocol) {
10330 match self
10331 .handle_tool_calls(processed_input, content, tool_calls, &mut all_tool_calls)
10332 .await?
10333 {
10334 ToolCallOutcome::Continue | ToolCallOutcome::TransitionFired => continue,
10335 ToolCallOutcome::Rejected(resp) => {
10336 self.finish_turn_if_root(&resp).await?;
10337 return Ok(resp);
10338 }
10339 }
10340 }
10341
10342 let (extracted_thinking, answer) = self.extract_thinking(content);
10343 if extracted_thinking.is_some() {
10344 thinking_content = extracted_thinking;
10345 }
10346
10347 let output_data = self.process_output(&answer, &input_data.context).await?;
10348
10349 let mut final_content = if output_data.metadata.rejected {
10350 output_data
10351 .metadata
10352 .rejection_reason
10353 .unwrap_or_else(|| answer.to_string())
10354 } else {
10355 output_data.content
10356 };
10357
10358 let reflection_metadata;
10360 (final_content, reflection_metadata) = self
10361 .run_reflection(&*llm, processed_input, final_content)
10362 .await?;
10363
10364 final_content =
10365 self.format_response_with_thinking(thinking_content.as_deref(), &final_content);
10366
10367 let final_content = {
10371 let result = self
10372 .post_loop_processing(processed_input, final_content)
10373 .await?;
10374 self.apply_post_loop_result(processed_input, result).await?
10375 };
10376
10377 let reflected = reflection_metadata.is_some();
10378 let reasoning_mode_debug = format!("{:?}", reasoning_mode);
10379
10380 let response = self.build_agent_response(AgentResponseParts {
10381 content: final_content,
10382 all_tool_calls,
10383 reasoning_mode,
10384 auto_detected,
10385 iterations,
10386 thinking: thinking_content,
10387 reflection_metadata,
10388 });
10389
10390 self.finish_turn_if_root(&response).await?;
10391
10392 let tool_call_count = response.tool_calls.as_ref().map(|tc| tc.len()).unwrap_or(0);
10393 info!(
10394 tool_calls = tool_call_count,
10395 response_len = response.content.len(),
10396 reasoning_mode = %reasoning_mode_debug,
10397 reflected = reflected,
10398 "Chat completed"
10399 );
10400 return Ok(response);
10401 }
10402 }
10403
10404 async fn generate_buffered_streaming_draft(
10405 &self,
10406 processed_input: &str,
10407 routing_resolved: Arc<AtomicBool>,
10408 ) -> Result<StreamingDraftResult> {
10409 let llm = self.get_state_llm()?;
10410 if llm.configured_tool_choice().is_some() {
10411 let draft = self
10412 .generate_main_response_draft(processed_input, &ReasoningMode::None)
10413 .await?;
10414 return Ok(StreamingDraftResult::new(draft, Vec::new()));
10415 }
10416 let messages = self.build_messages_for_draft(processed_input).await?;
10417 let mut stream = self
10418 .observe_purpose(
10419 ObservationPurpose::MainResponse,
10420 llm.complete_stream(&messages, None),
10421 )
10422 .await
10423 .map_err(|e| AgentError::LLM(e.to_string()))?;
10424 let mut buffer = crate::optimization::StreamBranchBuffer::new(self.streaming.buffer_size)?;
10425 let mut chunks = Vec::new();
10426 let mut accumulated = String::new();
10427 while let Some(chunk_result) = stream.next().await {
10428 let chunk = chunk_result.map_err(|e| AgentError::LLM(e.to_string()))?;
10429 accumulated.push_str(&chunk.delta);
10430 let stream_chunk = StreamChunk::content(chunk.delta);
10431 if routing_resolved.load(Ordering::SeqCst) {
10432 chunks.push(stream_chunk);
10433 } else {
10434 buffer.push(stream_chunk)?;
10435 }
10436 }
10437 chunks.splice(0..0, buffer.drain());
10438 let content = accumulated.trim().to_string();
10439 let draft = if let Some(calls) = self.parse_tool_calls(&content) {
10440 MainResponseDraft::ToolCalls {
10441 raw_content: content,
10442 calls,
10443 thinking: None,
10444 }
10445 } else {
10446 MainResponseDraft::Text {
10447 raw_content: content,
10448 thinking: None,
10449 }
10450 };
10451 Ok(StreamingDraftResult::new(draft, chunks))
10452 }
10453
10454 async fn try_buffered_streaming_branches(
10455 &self,
10456 processed_input: &str,
10457 input_context: &HashMap<String, Value>,
10458 ) -> Result<Option<(AgentResponse, Vec<StreamChunk>)>> {
10459 let optimization = &self.runtime_config.optimization;
10460 if !optimization.enabled {
10461 return Ok(None);
10462 }
10463 let transition_enabled =
10464 optimization.speculative_state_transitions && self.has_parallel_transition_candidates();
10465 if !transition_enabled {
10466 return Ok(None);
10467 }
10468 let mut branch_scheduler =
10469 TurnBranchScheduler::new(optimization.max_parallel_runtime_tasks)?;
10470 if !branch_scheduler.reserve_task() {
10471 return Ok(None);
10472 }
10473 if !self
10474 .reserve_active_speculative_llm_call(RuntimeOptimizationKind::BufferedStreamingRouting)
10475 {
10476 branch_scheduler.release_task();
10477 return Ok(None);
10478 }
10479 if !branch_scheduler.reserve_task() {
10480 branch_scheduler.release_task();
10481 return Ok(None);
10482 }
10483 let mut main_branch = RuntimeBranch::new(
10484 RuntimeTaskPurpose::MainResponse,
10485 RuntimeOptimizationKind::BufferedStreamingRouting,
10486 RuntimeTaskPriority::Normal,
10487 RuntimeCommitBehavior::FinalResponse,
10488 );
10489 let mut transition_branch = RuntimeBranch::new(
10490 RuntimeTaskPurpose::StateTransition,
10491 RuntimeOptimizationKind::ParallelStateTransition,
10492 RuntimeTaskPriority::Critical,
10493 RuntimeCommitBehavior::TransitionDecision,
10494 );
10495 let main_id = main_branch.branch_id();
10496 let transition_id = transition_branch.branch_id();
10497 let routing_resolved = Arc::new(AtomicBool::new(false));
10498 let mut main_future =
10499 Box::pin(crate::optimization::observability::with_branch_observation(
10500 &main_id,
10501 RuntimeOptimizationKind::BufferedStreamingRouting,
10502 RuntimeCommitBehavior::FinalResponse,
10503 self.generate_buffered_streaming_draft(
10504 processed_input,
10505 Arc::clone(&routing_resolved),
10506 ),
10507 ));
10508 let mut transition_future =
10509 Box::pin(crate::optimization::observability::with_branch_observation(
10510 &transition_id,
10511 RuntimeOptimizationKind::ParallelStateTransition,
10512 RuntimeCommitBehavior::TransitionDecision,
10513 self.select_parallel_transition_candidate(processed_input),
10514 ));
10515 let mut main_pending = true;
10516 let mut transition_pending = true;
10517 let mut main_result: Option<Result<StreamingDraftResult>> = None;
10518 let mut transition_finalized = false;
10519 let mut transition_candidate: Option<TransitionCandidate> = None;
10520 loop {
10521 if let Some(candidate) = transition_candidate.take() {
10522 if self
10523 .approve_transition_target(&candidate.from_state, candidate.target())
10524 .await?
10525 {
10526 drop(main_future);
10528 drop(transition_future);
10529 self.finalize_branch_loss(
10530 &main_id,
10531 RuntimeOptimizationKind::BufferedStreamingRouting,
10532 RuntimeCommitBehavior::FinalResponse,
10533 main_pending,
10534 main_result.as_ref().map(|result| result.is_err()),
10535 );
10536 if !self
10537 .apply_pre_response_transition_candidate(
10538 &candidate,
10539 &HashMap::new(),
10540 processed_input,
10541 )
10542 .await?
10543 {
10544 self.finalize_optional_branch(
10545 &transition_id,
10546 RuntimeOptimizationKind::ParallelStateTransition,
10547 RuntimeCommitBehavior::TransitionDecision,
10548 "discarded",
10549 false,
10550 );
10551 return Ok(None);
10552 }
10553 self.finalize_optional_branch(
10554 &transition_id,
10555 RuntimeOptimizationKind::ParallelStateTransition,
10556 RuntimeCommitBehavior::TransitionDecision,
10557 "committed",
10558 true,
10559 );
10560 let response = self.redispatch_current_state(processed_input).await?;
10561 return Ok(Some((
10562 response.clone(),
10563 vec![StreamChunk::content(response.content)],
10564 )));
10565 }
10566 self.finalize_optional_branch(
10567 &transition_id,
10568 RuntimeOptimizationKind::ParallelStateTransition,
10569 RuntimeCommitBehavior::TransitionDecision,
10570 "discarded",
10571 false,
10572 );
10573 routing_resolved.store(true, Ordering::SeqCst);
10574 transition_finalized = true;
10575 }
10576 if transition_finalized && let Some(result) = main_result.take() {
10577 let stream_draft = match result {
10578 Ok(stream_draft) => stream_draft,
10579 Err(error) => {
10580 self.finalize_optional_branch(
10581 &main_id,
10582 RuntimeOptimizationKind::BufferedStreamingRouting,
10583 RuntimeCommitBehavior::FinalResponse,
10584 "failed",
10585 false,
10586 );
10587 return Err(error);
10588 }
10589 };
10590 let raw_draft_content = stream_draft.draft.raw_content().to_string();
10591 let buffered_chunks = stream_draft.chunks;
10592 self.finalize_optional_branch(
10593 &main_id,
10594 RuntimeOptimizationKind::BufferedStreamingRouting,
10595 RuntimeCommitBehavior::FinalResponse,
10596 "committed",
10597 true,
10598 );
10599 let response = self
10600 .commit_main_response_draft(
10601 processed_input,
10602 input_context,
10603 stream_draft.draft,
10604 ReasoningMode::None,
10605 false,
10606 )
10607 .await?;
10608 let chunks = if response.content == raw_draft_content {
10609 buffered_chunks
10610 } else {
10611 vec![StreamChunk::content(response.content.clone())]
10612 };
10613 return Ok(Some((response, chunks)));
10614 }
10615 tokio::select! {
10616 result = &mut main_future, if main_pending => {
10617 main_pending = false;
10618 main_branch.transition_to(RuntimeBranchStatus::Completed)?;
10619 main_result = Some(result);
10620 }
10621 result = &mut transition_future, if transition_pending => {
10622 transition_pending = false;
10623 transition_branch.transition_to(RuntimeBranchStatus::Completed)?;
10624 match result {
10625 Ok(ParallelTransitionSelection::Candidate(candidate)) => {
10626 transition_candidate = Some(candidate)
10627 }
10628 Ok(ParallelTransitionSelection::NoMatch) => {
10629 self.finalize_optional_branch(
10630 &transition_id,
10631 RuntimeOptimizationKind::ParallelStateTransition,
10632 RuntimeCommitBehavior::TransitionDecision,
10633 "discarded",
10634 false,
10635 );
10636 routing_resolved.store(true, Ordering::SeqCst);
10637 transition_finalized = true;
10638 }
10639 Ok(ParallelTransitionSelection::ReservationExhausted) => {
10640 self.finalize_optional_branch(
10641 &transition_id,
10642 RuntimeOptimizationKind::ParallelStateTransition,
10643 RuntimeCommitBehavior::TransitionDecision,
10644 "cancelled",
10645 false,
10646 );
10647 routing_resolved.store(true, Ordering::SeqCst);
10648 self.finalize_branch_loss(
10649 &main_id,
10650 RuntimeOptimizationKind::BufferedStreamingRouting,
10651 RuntimeCommitBehavior::FinalResponse,
10652 main_pending,
10653 main_result.as_ref().map(|result| result.is_err()),
10654 );
10655 return Ok(None);
10656 }
10657 Err(_) => {
10658 self.finalize_optional_branch(
10659 &transition_id,
10660 RuntimeOptimizationKind::ParallelStateTransition,
10661 RuntimeCommitBehavior::TransitionDecision,
10662 "failed",
10663 false,
10664 );
10665 routing_resolved.store(true, Ordering::SeqCst);
10666 transition_finalized = true;
10667 }
10668 }
10669 }
10670 }
10671 }
10672 }
10673
10674 fn run_loop_internal_stream<'a>(
10678 &'a self,
10679 input: &'a str,
10680 ) -> Pin<Box<dyn Stream<Item = StreamChunk> + Send + 'a>> {
10681 let include_tool_events = self.streaming.include_tool_events;
10682 let include_state_events = self.streaming.include_state_events;
10683
10684 Box::pin(async_stream::stream! {
10685 self.begin_root_turn();
10686 self.pre_turn_session_lifecycle().await;
10688
10689 let input_data = match self.process_input(input).await {
10690 Ok(data) => data,
10691 Err(e) => {
10692 yield StreamChunk::error(e.to_string());
10693 return;
10694 }
10695 };
10696 self.update_active_turn_context(&input_data.content, input_data.context.clone());
10697
10698 for (key, value) in &input_data.context {
10700 let _ = self.context_manager.set(key, value.clone());
10701 }
10702
10703 if input_data.metadata.rejected {
10704 let reason = input_data
10705 .metadata
10706 .rejection_reason
10707 .unwrap_or_else(|| "Input rejected".to_string());
10708 warn!(reason = %reason, "Input rejected (stream)");
10709 yield StreamChunk::error(reason);
10710 return;
10711 }
10712
10713 let processed_input = &input_data.content;
10714
10715 if self.runtime_config.optimization.enabled
10716 && matches!(
10717 self.runtime_config.optimization.streaming_policy,
10718 crate::optimization::StreamingOptimizationPolicy::BufferUntilRoutingDone
10719 )
10720 {
10721 match Box::pin(self.try_buffered_streaming_branches(processed_input, &input_data.context)).await {
10726 Ok(Some((_response, chunks))) => {
10727 for chunk in chunks {
10728 yield chunk;
10729 }
10730 yield StreamChunk::Done {};
10731 return;
10732 }
10733 Ok(None) => {}
10734 Err(e) => {
10735 yield StreamChunk::error(e.to_string());
10736 return;
10737 }
10738 }
10739 }
10740
10741 if self.runtime_config.optimization.enabled
10742 && matches!(
10743 self.runtime_config.optimization.streaming_policy,
10744 crate::optimization::StreamingOptimizationPolicy::PreflightOnly
10745 )
10746 {
10747 match self.try_pre_response_transition(processed_input).await {
10748 Ok(Some(response)) => {
10749 yield StreamChunk::content(&response.content);
10750 yield StreamChunk::Done {};
10751 return;
10752 }
10753 Ok(None) => {}
10754 Err(e) => {
10755 yield StreamChunk::error(e.to_string());
10756 return;
10757 }
10758 }
10759 }
10760
10761 if let Some(ref sm) = self.state_machine
10763 && let Some(def) = sm.current_definition()
10764 {
10765 let orchestration_result = if let Some(ref delegate_id) = def.delegate {
10766 Some(self.handle_delegated_state(processed_input, delegate_id, &def).await)
10767 } else if let Some(ref concurrent_config) = def.concurrent {
10768 Some(self.handle_concurrent_state(processed_input, concurrent_config).await)
10769 } else if let Some(ref group_chat_config) = def.group_chat {
10770 Some(self.handle_group_chat_state(processed_input, group_chat_config).await)
10771 } else if let Some(ref pipeline_config) = def.pipeline {
10772 Some(self.handle_pipeline_state(processed_input, pipeline_config).await)
10773 } else if let Some(ref handoff_config) = def.handoff {
10774 Some(self.handle_handoff_state(processed_input, handoff_config).await)
10775 } else {
10776 None
10777 };
10778
10779 if let Some(result) = orchestration_result {
10780 match result {
10781 Ok(response) => {
10782 yield StreamChunk::content(&response.content);
10783 yield StreamChunk::Done {};
10784 }
10785 Err(e) => {
10786 yield StreamChunk::error(e.to_string());
10787 }
10788 }
10789 return;
10790 }
10791 }
10792
10793 match self.try_skill_route(processed_input).await {
10795 Ok(SkillRouteResult::Response { skill_id, content }) => {
10796 if let Err(e) = self.commit_root_user_message(processed_input).await {
10797 yield StreamChunk::error(e.to_string());
10798 return;
10799 }
10800 match self.handle_skill_response(processed_input, &skill_id, content, &input_data.context).await {
10801 Ok(resp) => {
10802 yield StreamChunk::content(&resp.content);
10803 yield StreamChunk::Done {};
10804 return;
10805 }
10806 Err(e) => {
10807 yield StreamChunk::error(e.to_string());
10808 return;
10809 }
10810 }
10811 }
10812 Ok(SkillRouteResult::NeedsClarification(response)) => {
10813 if let Err(e) = self.commit_root_user_message(processed_input).await {
10814 yield StreamChunk::error(e.to_string());
10815 return;
10816 }
10817 let _ = self.memory.add_message(ChatMessage::assistant(&response.content)).await;
10818 if let Err(e) = self.finish_turn_if_root(&response).await {
10819 yield StreamChunk::error(e.to_string());
10820 return;
10821 }
10822 yield StreamChunk::content(&response.content);
10823 yield StreamChunk::Done {};
10824 return;
10825 }
10826 Ok(SkillRouteResult::NoMatch) => {} Err(e) => {
10828 yield StreamChunk::error(e.to_string());
10829 return;
10830 }
10831 }
10832
10833 let effective_reasoning = self.get_effective_reasoning_config();
10835 let reasoning_mode = match self.determine_reasoning_mode(processed_input).await {
10836 Ok(mode) => mode,
10837 Err(e) => {
10838 yield StreamChunk::error(e.to_string());
10839 return;
10840 }
10841 };
10842 let auto_detected = matches!(effective_reasoning.mode, ReasoningMode::Auto);
10843
10844 info!(
10845 reasoning_mode = ?reasoning_mode,
10846 auto_detected = auto_detected,
10847 "Reasoning mode determined (stream)"
10848 );
10849
10850 if matches!(reasoning_mode, ReasoningMode::PlanAndExecute) {
10852 if let Err(e) = self.commit_root_user_message(processed_input).await {
10853 yield StreamChunk::error(e.to_string());
10854 return;
10855 }
10856 match self.handle_plan_and_execute(processed_input, &input_data.context, auto_detected).await {
10857 Ok(resp) => {
10858 yield StreamChunk::content(&resp.content);
10859 yield StreamChunk::Done {};
10860 return;
10861 }
10862 Err(e) => {
10863 yield StreamChunk::error(e.to_string());
10864 return;
10865 }
10866 }
10867 }
10868
10869 if let Err(e) = self.commit_root_user_message(processed_input).await {
10870 yield StreamChunk::error(e.to_string());
10871 return;
10872 }
10873
10874 let llm = match self.get_state_llm() {
10875 Ok(llm) => llm,
10876 Err(e) => {
10877 yield StreamChunk::error(e.to_string());
10878 return;
10879 }
10880 };
10881
10882 let mut iterations = 0u32;
10883 let mut all_tool_calls: Vec<ToolCall> = Vec::new();
10884 let mut thinking_content: Option<String> = None;
10885
10886 loop {
10887 let effective_max = if reasoning_mode != ReasoningMode::None {
10889 let rc = self.get_effective_reasoning_config();
10890 self.max_iterations.min(rc.max_iterations)
10891 } else {
10892 self.max_iterations
10893 };
10894
10895 if iterations >= effective_max {
10896 let err_msg = format!("Max iterations ({}) exceeded", effective_max);
10897 let err = AgentError::Other(err_msg.clone());
10898 self.hooks.on_error(&err).await;
10899 error!(iterations = iterations, "Max iterations exceeded (stream)");
10900 yield StreamChunk::error(err_msg);
10901 return;
10902 }
10903 iterations += 1;
10904 *self.iteration_count.write() = iterations;
10905
10906 debug!(iteration = iterations, max = effective_max, "LLM call (stream)");
10907
10908 let protocol = match self.main_tool_protocol(llm.as_ref(), false).await {
10909 Ok(protocol) => protocol,
10910 Err(e) => {
10911 yield StreamChunk::error(e.to_string());
10912 return;
10913 }
10914 };
10915 let mut messages = match self
10916 .build_messages_internal(true, None, protocol.choice.is_none())
10917 .await
10918 {
10919 Ok(m) => m,
10920 Err(e) => {
10921 yield StreamChunk::error(e.to_string());
10922 return;
10923 }
10924 };
10925 self.inject_reasoning_prompt(&mut messages, &reasoning_mode, iterations == 1);
10926
10927 self.hooks.on_llm_start(&messages).await;
10928 let llm_start = Instant::now();
10929
10930 let reflection_active = self
10933 .should_reflect(processed_input, "")
10934 .await
10935 .unwrap_or_default();
10936
10937 let buffered_decision = reflection_active || protocol.choice.is_some();
10938 let content = if buffered_decision {
10939 let response = match self
10943 .complete_main_llm_with_recovery(
10944 Arc::clone(&llm),
10945 &messages,
10946 &protocol,
10947 )
10948 .await
10949 {
10950 Ok(r) => r,
10951 Err(e) => {
10952 yield StreamChunk::error(e.to_string());
10953 return;
10954 }
10955 };
10956 let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
10957 self.hooks.on_llm_complete(&response, llm_duration_ms).await;
10958 response.content.trim().to_string()
10959 } else {
10960 let llm_stream = match self
10962 .observe_purpose(
10963 ObservationPurpose::MainResponse,
10964 llm.complete_stream(&messages, None),
10965 )
10966 .await
10967 {
10968 Ok(s) => s,
10969 Err(e) => {
10970 yield StreamChunk::error(e.to_string());
10971 return;
10972 }
10973 };
10974 let mut accumulated = String::new();
10975 let mut stream_inner = llm_stream;
10976 while let Some(chunk_result) = stream_inner.next().await {
10977 match chunk_result {
10978 Ok(chunk) => {
10979 accumulated.push_str(&chunk.delta);
10980 yield StreamChunk::content(chunk.delta);
10981 }
10982 Err(e) => {
10983 yield StreamChunk::error(e.to_string());
10984 return;
10985 }
10986 }
10987 }
10988 let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
10989 let llm_response = ai_agents_core::LLMResponse::new(
10991 accumulated.trim(),
10992 ai_agents_core::FinishReason::Stop,
10993 );
10994 self.hooks.on_llm_complete(&llm_response, llm_duration_ms).await;
10995 accumulated.trim().to_string()
10996 };
10997
10998 if let Some(tool_calls) = self.parse_main_tool_calls(&content, &protocol) {
11000 let native_tool_call = Self::is_native_tool_call_content(&content);
11001 let transition_fired = match self.evaluate_transitions(processed_input, &content).await {
11004 Ok(v) => v,
11005 Err(e) => {
11006 yield StreamChunk::error(e.to_string());
11007 return;
11008 }
11009 };
11010 if transition_fired {
11011 let _ = self.memory.add_message(ChatMessage::assistant(
11012 "(Transitioned to new state — tool call handled by workflow)",
11013 )).await;
11014
11015 if include_state_events
11016 && let Some(state) = self.current_state()
11017 {
11018 yield StreamChunk::state_transition(None, state);
11019 }
11020 continue;
11021 }
11022
11023 let _ = self.memory.add_message(ChatMessage::assistant(&content)).await;
11025
11026 let results = self.execute_tools_parallel(&tool_calls).await;
11028
11029 for ((_id, result), tool_call) in results.into_iter().zip(tool_calls.iter()) {
11030 if include_tool_events {
11031 yield StreamChunk::tool_start(&tool_call.id, &tool_call.name);
11032 }
11033
11034 match result {
11035 Ok(output) => {
11036 if include_tool_events {
11037 yield StreamChunk::tool_result(
11038 &tool_call.id,
11039 &tool_call.name,
11040 &output,
11041 true,
11042 );
11043 }
11044 let _ = self.memory
11045 .add_message(Self::tool_result_message(
11046 tool_call,
11047 &output,
11048 native_tool_call,
11049 ))
11050 .await;
11051 }
11052 Err(e) => {
11053 if matches!(e, AgentError::HITLRejected(_)) {
11054 let _ = self.memory.add_message(ChatMessage::assistant(
11055 format!("The operation was rejected by the approver: {}", e),
11056 )).await;
11057 let response = AgentResponse {
11058 content: format!("Operation cancelled: {}", e),
11059 metadata: None,
11060 tool_calls: Some(all_tool_calls.clone()),
11061 };
11062 if let Err(finalize_error) = self.finish_turn_if_root(&response).await {
11063 yield StreamChunk::error(finalize_error.to_string());
11064 return;
11065 }
11066 yield StreamChunk::error(response.content);
11067 yield StreamChunk::Done {};
11068 return;
11069 }
11070 if include_tool_events {
11071 yield StreamChunk::tool_result(
11072 &tool_call.id,
11073 &tool_call.name,
11074 e.to_string(),
11075 false,
11076 );
11077 }
11078 let _ = self.memory
11079 .add_message(Self::tool_result_message(
11080 tool_call,
11081 &format!("Error: {}", e),
11082 native_tool_call,
11083 ))
11084 .await;
11085 }
11086 }
11087 all_tool_calls.push(tool_call.clone());
11088
11089 if include_tool_events {
11090 yield StreamChunk::tool_end(&tool_call.id);
11091 }
11092 }
11093 continue;
11094 }
11095
11096 let (extracted_thinking, answer) = self.extract_thinking(&content);
11098 if extracted_thinking.is_some() {
11099 thinking_content = extracted_thinking;
11100 }
11101
11102 let output_data = match self.process_output(&answer, &input_data.context).await {
11103 Ok(d) => d,
11104 Err(e) => {
11105 yield StreamChunk::error(e.to_string());
11106 return;
11107 }
11108 };
11109
11110 let final_content = if output_data.metadata.rejected {
11111 output_data
11112 .metadata
11113 .rejection_reason
11114 .unwrap_or_else(|| answer.to_string())
11115 } else {
11116 output_data.content
11117 };
11118
11119 let (final_content, _reflection_metadata) = match self
11121 .run_reflection(&*llm, processed_input, final_content)
11122 .await
11123 {
11124 Ok(r) => r,
11125 Err(e) => {
11126 yield StreamChunk::error(e.to_string());
11127 return;
11128 }
11129 };
11130
11131 let final_content = self.format_response_with_thinking(
11132 thinking_content.as_deref(),
11133 &final_content,
11134 );
11135
11136 if buffered_decision {
11138 yield StreamChunk::content(&final_content);
11139 }
11140
11141 let post_result = match self
11145 .post_loop_processing(processed_input, final_content)
11146 .await
11147 {
11148 Ok(r) => r,
11149 Err(e) => {
11150 yield StreamChunk::error(e.to_string());
11151 return;
11152 }
11153 };
11154
11155 let (final_content, transitioned) = match post_result {
11156 PostLoopResult::NoTransition(content) => (content, false),
11157 PostLoopResult::Transitioned(content) => (content, true),
11158 PostLoopResult::NeedsRedispatch => {
11159 const MAX_REDISPATCH_DEPTH: u32 = 3;
11160 let current_depth = *self.redispatch_depth.read();
11161 let content = if current_depth >= MAX_REDISPATCH_DEPTH {
11162 warn!(
11163 depth = current_depth,
11164 "Post-transition re-dispatch depth limit reached (stream)"
11165 );
11166 let c = String::new();
11167 let _ = self.memory.add_message(ChatMessage::assistant(&c)).await;
11168 c
11169 } else {
11170 *self.redispatch_depth.write() += 1;
11171 if let Some(context) = self.active_turn_context.write().as_mut() {
11172 context.enter_redispatch();
11173 }
11174 info!(
11175 depth = current_depth + 1,
11176 "Re-dispatching for new state after transition (stream)"
11177 );
11178 let result = self.run_loop_internal(processed_input).await;
11179 *self.redispatch_depth.write() -= 1;
11180 if let Some(context) = self.active_turn_context.write().as_mut() {
11181 context.exit_redispatch();
11182 }
11183 match result {
11184 Ok(resp) => resp.content,
11185 Err(e) => {
11186 yield StreamChunk::error(e.to_string());
11187 return;
11188 }
11189 }
11190 };
11191 (content, true)
11192 }
11193 };
11194
11195 if transitioned {
11196 if include_state_events
11197 && let Some(state) = self.current_state()
11198 {
11199 yield StreamChunk::state_transition(None, state);
11200 }
11201 yield StreamChunk::content(&final_content);
11203 }
11204
11205 let final_response = AgentResponse::new(&final_content);
11207 if let Err(e) = self.finish_turn_if_root(&final_response).await {
11208 yield StreamChunk::error(e.to_string());
11209 return;
11210 }
11211
11212 yield StreamChunk::Done {};
11213 return;
11214 }
11215 })
11216 }
11217
11218 fn run_loop_stream<'a>(
11221 &'a self,
11222 input: &'a str,
11223 ) -> Pin<Box<dyn Stream<Item = StreamChunk> + Send + 'a>> {
11224 Box::pin(async_stream::stream! {
11225 self.begin_root_turn();
11226 let _root_cleanup = RootTurnCleanup::new(self);
11227 self.hooks.on_message_received(input).await;
11228
11229 if !self.context_initialized.swap(true, Ordering::SeqCst) {
11231 if let Err(e) = self.context_manager.initialize().await {
11232 yield StreamChunk::error(e.to_string());
11233 return;
11234 }
11235 debug!("Context manager initialized (defaults, env, builtins)");
11236 }
11237
11238 if let Err(e) = self.check_turn_timeout().await {
11239 yield StreamChunk::error(e.to_string());
11240 return;
11241 }
11242 if let Err(e) = self.context_manager.refresh_per_turn().await {
11243 yield StreamChunk::error(e.to_string());
11244 return;
11245 }
11246
11247 self.clear_disambiguation_context();
11249
11250 if let Some(ref disambiguator) = self.disambiguation_manager {
11252 let disambiguation_context = match self.build_disambiguation_context().await {
11253 Ok(ctx) => ctx,
11254 Err(e) => {
11255 yield StreamChunk::error(e.to_string());
11256 return;
11257 }
11258 };
11259
11260 let state_override = self
11261 .state_machine
11262 .as_ref()
11263 .and_then(|sm| sm.current_definition())
11264 .and_then(|def| def.disambiguation.clone());
11265
11266 let result = match self
11267 .observe_purpose(
11268 ObservationPurpose::DisambiguationDetection,
11269 disambiguator.process_input_with_override(
11270 input,
11271 &disambiguation_context,
11272 state_override.as_ref(),
11273 None,
11274 ),
11275 )
11276 .await
11277 {
11278 Ok(r) => r,
11279 Err(e) => {
11280 yield StreamChunk::error(e.to_string());
11281 return;
11282 }
11283 };
11284
11285 match result {
11286 DisambiguationResult::Clear => {
11287 debug!("Input is clear, proceeding normally (stream)");
11288 }
11289 DisambiguationResult::NeedsClarification {
11290 question,
11291 detection,
11292 } => {
11293 info!(
11294 ambiguity_type = ?detection.ambiguity_type,
11295 confidence = detection.confidence,
11296 "Input requires clarification (stream)"
11297 );
11298 if let Err(e) = self.commit_root_user_message(input).await {
11299 yield StreamChunk::error(e.to_string());
11300 return;
11301 }
11302 let _ = self
11303 .memory
11304 .add_message(ChatMessage::assistant(&question.question))
11305 .await;
11306 let response = AgentResponse::new(&question.question);
11307 if let Err(e) = self.finish_turn_if_root(&response).await {
11308 yield StreamChunk::error(e.to_string());
11309 return;
11310 }
11311 yield StreamChunk::content(&question.question);
11312 yield StreamChunk::Done {};
11313 return;
11314 }
11315 DisambiguationResult::Clarified {
11316 enriched_input,
11317 resolved,
11318 ..
11319 } => {
11320 info!(
11321 resolved_count = resolved.len(),
11322 enriched = %enriched_input,
11323 "Input clarified (stream)"
11324 );
11325 for (key, value) in &resolved {
11326 let context_key = format!("disambiguation.{}", key);
11327 let _ = self.context_manager.set(&context_key, value.clone());
11328 }
11329 if let Some(intent) = resolved.get("intent") {
11330 let _ = self.context_manager.set("resolved_intent", intent.clone());
11331 }
11332 let _ = self
11333 .context_manager
11334 .set("disambiguation.resolved", serde_json::Value::Bool(true));
11335
11336 let skill_id = self.pending_skill_id.read().clone();
11340 if let Some(skill_id) = skill_id {
11341 info!(skill_id = %skill_id, "Re-checking skill disambiguation on clarified input (stream)");
11342 match self.recheck_skill_disambiguation(&skill_id, &enriched_input).await {
11343 Ok(resp) => {
11344 yield StreamChunk::content(&resp.content);
11345 yield StreamChunk::Done {};
11346 return;
11347 }
11348 Err(e) => {
11349 yield StreamChunk::error(e.to_string());
11350 return;
11351 }
11352 }
11353 }
11354
11355 let mut inner = self.run_loop_internal_stream(&enriched_input);
11357 while let Some(chunk) = inner.next().await {
11358 yield chunk;
11359 }
11360 return;
11361 }
11362 DisambiguationResult::ProceedWithBestGuess { enriched_input } => {
11363 info!("Proceeding with best guess (stream)");
11364
11365 let skill_id = self.pending_skill_id.read().clone();
11367 if let Some(skill_id) = skill_id {
11368 info!(skill_id = %skill_id, "Re-checking skill disambiguation on best-guess input (stream)");
11369 match self.recheck_skill_disambiguation(&skill_id, &enriched_input).await {
11370 Ok(resp) => {
11371 yield StreamChunk::content(&resp.content);
11372 yield StreamChunk::Done {};
11373 return;
11374 }
11375 Err(e) => {
11376 yield StreamChunk::error(e.to_string());
11377 return;
11378 }
11379 }
11380 }
11381
11382 let mut inner = self.run_loop_internal_stream(&enriched_input);
11383 while let Some(chunk) = inner.next().await {
11384 yield chunk;
11385 }
11386 return;
11387 }
11388 DisambiguationResult::GiveUp { reason } => {
11389 *self.pending_skill_id.write() = None;
11390 warn!(reason = %reason, "Disambiguation gave up (stream)");
11391 let apology = self
11392 .generate_localized_apology(
11393 "Generate a brief, polite apology saying you couldn't understand the request. Be concise.",
11394 &reason,
11395 )
11396 .await
11397 .unwrap_or_else(|_| {
11398 format!("I'm sorry, I couldn't understand your request: {}", reason)
11399 });
11400 let response = AgentResponse::new(&apology);
11401 if let Err(e) = self.finish_turn_if_root(&response).await {
11402 yield StreamChunk::error(e.to_string());
11403 return;
11404 }
11405 yield StreamChunk::content(&apology);
11406 yield StreamChunk::Done {};
11407 return;
11408 }
11409 DisambiguationResult::Escalate { reason } => {
11410 *self.pending_skill_id.write() = None;
11411 info!(reason = %reason, "Escalating to human (stream)");
11412 if let Some(ref hitl) = self.hitl_engine {
11413 let trigger =
11414 ApprovalTrigger::condition("disambiguation_escalation", reason.clone());
11415 let mut context_map = HashMap::new();
11416 context_map.insert("original_input".to_string(), serde_json::json!(input));
11417 context_map.insert("reason".to_string(), serde_json::json!(&reason));
11418 let check_result = HITLCheckResult::required(
11419 trigger,
11420 context_map,
11421 format!("User request needs human assistance: {}", reason),
11422 Some(hitl.config().default_timeout_seconds),
11423 );
11424 match self.request_hitl_approval(check_result).await {
11425 Ok(ApprovalResult::Approved | ApprovalResult::Modified { .. }) => {
11426 let mut inner = self.run_loop_internal_stream(input);
11427 while let Some(chunk) = inner.next().await {
11428 yield chunk;
11429 }
11430 return;
11431 }
11432 Ok(_) => {}
11433 Err(e) => {
11434 yield StreamChunk::error(e.to_string());
11435 return;
11436 }
11437 }
11438 }
11439 let apology = self
11440 .generate_localized_apology(
11441 "Explain briefly that you're transferring the user to a human agent for help.",
11442 &reason,
11443 )
11444 .await
11445 .unwrap_or_else(|_| {
11446 format!("I need human assistance to help with your request: {}", reason)
11447 });
11448 let response = AgentResponse::new(&apology);
11449 if let Err(e) = self.finish_turn_if_root(&response).await {
11450 yield StreamChunk::error(e.to_string());
11451 return;
11452 }
11453 yield StreamChunk::content(&apology);
11454 yield StreamChunk::Done {};
11455 return;
11456 }
11457 DisambiguationResult::Abandoned { new_input } => {
11458 *self.pending_skill_id.write() = None;
11459
11460 info!(
11461 has_new_input = new_input.is_some(),
11462 "Clarification abandoned by user (stream)"
11463 );
11464
11465 if let Err(e) = self.commit_root_user_message(input).await {
11466 yield StreamChunk::error(e.to_string());
11467 return;
11468 }
11469
11470 match new_input {
11471 Some(fresh_input) => {
11472 let mut inner = self.run_loop_internal_stream(&fresh_input);
11474 while let Some(chunk) = inner.next().await {
11475 yield chunk;
11476 }
11477 return;
11478 }
11479 None => {
11480 let ack = self
11482 .generate_localized_apology(
11483 "The user changed their mind about their previous request. \
11484 Generate a brief, friendly acknowledgment (e.g. 'OK, no problem. What else can I help with?'). \
11485 Do NOT apologize excessively. Be concise.",
11486 "User abandoned clarification",
11487 )
11488 .await
11489 .unwrap_or_else(|_| {
11490 "OK, no problem. What else can I help with?".to_string()
11491 });
11492
11493 let _ = self
11494 .memory
11495 .add_message(ChatMessage::assistant(&ack))
11496 .await;
11497
11498 let response = AgentResponse::new(&ack);
11499 if let Err(e) = self.finish_turn_if_root(&response).await {
11500 yield StreamChunk::error(e.to_string());
11501 return;
11502 }
11503 yield StreamChunk::content(&ack);
11504 yield StreamChunk::Done {};
11505 return;
11506 }
11507 }
11508 }
11509 }
11510 }
11511
11512 let mut inner = self.run_loop_internal_stream(input);
11514 while let Some(chunk) = inner.next().await {
11515 yield chunk;
11516 }
11517 })
11518 }
11519
11520 pub fn info(&self) -> AgentInfo {
11521 self.info.clone()
11522 }
11523
11524 pub fn skills(&self) -> &[SkillDefinition] {
11525 &self.skills
11526 }
11527
11528 pub async fn reset(&self) -> Result<()> {
11529 self.memory.clear().await?;
11530 *self.iteration_count.write() = 0;
11531 self.tool_call_history.write().clear();
11532 *self.pending_skill_id.write() = None;
11533 if let Some(ref sm) = self.state_machine {
11534 sm.reset();
11535 }
11536 Ok(())
11537 }
11538
11539 pub fn max_context_tokens(&self) -> u32 {
11540 self.max_context_tokens
11541 }
11542
11543 pub fn llm_registry(&self) -> &Arc<LLMRegistry> {
11544 &self.llm_registry
11545 }
11546
11547 pub fn state_machine(&self) -> Option<&Arc<StateMachine>> {
11548 self.state_machine.as_ref()
11549 }
11550
11551 pub fn context_manager(&self) -> &Arc<ContextManager> {
11552 &self.context_manager
11553 }
11554
11555 pub fn tool_call_history(&self) -> Vec<ToolCallRecord> {
11556 self.tool_call_history.read().clone()
11557 }
11558
11559 pub fn memory_token_budget(&self) -> Option<&MemoryTokenBudget> {
11560 self.memory_token_budget.as_ref()
11561 }
11562
11563 pub fn parallel_tools_config(&self) -> &ParallelToolsConfig {
11564 &self.parallel_tools
11565 }
11566
11567 pub fn streaming_config(&self) -> &StreamingConfig {
11568 &self.streaming
11569 }
11570
11571 pub fn hooks(&self) -> &Arc<dyn AgentHooks> {
11572 &self.hooks
11573 }
11574
11575 pub fn hitl_engine(&self) -> Option<&HITLEngine> {
11576 self.hitl_engine.as_ref()
11577 }
11578
11579 pub fn approval_handler(&self) -> &Arc<dyn ApprovalHandler> {
11580 &self.approval_handler
11581 }
11582
11583 fn build_hitl_language_context(&self) -> HashMap<String, Value> {
11585 let mut ctx = HashMap::new();
11586 for key in &["user.language", "input.detected.language", "language"] {
11587 if let Some(val) = self.context_manager.get(key) {
11588 ctx.insert(key.to_string(), val);
11589 }
11590 }
11591 ctx
11592 }
11593
11594 async fn request_hitl_approval(&self, check_result: HITLCheckResult) -> Result<ApprovalResult> {
11596 let Some(request) = check_result.into_request() else {
11597 return Ok(ApprovalResult::Approved);
11598 };
11599
11600 self.hooks.on_approval_requested(&request).await;
11601
11602 let timeout = request.timeout;
11603
11604 let raw_result = if let Some(duration) = timeout {
11605 match tokio::time::timeout(
11606 duration,
11607 self.approval_handler.request_approval(request.clone()),
11608 )
11609 .await
11610 {
11611 Ok(result) => result,
11612 Err(_) => ApprovalResult::timeout(),
11613 }
11614 } else {
11615 self.approval_handler
11616 .request_approval(request.clone())
11617 .await
11618 };
11619
11620 self.hooks
11621 .on_approval_result(&request.id, &raw_result)
11622 .await;
11623
11624 let (outcome, effective_result): (ApprovalResolvedOutcome, Result<ApprovalResult>) =
11625 match &raw_result {
11626 ApprovalResult::Approved => (
11627 ApprovalResolvedOutcome::Approved,
11628 Ok(ApprovalResult::Approved),
11629 ),
11630 ApprovalResult::Rejected { reason } => (
11631 ApprovalResolvedOutcome::Rejected {
11632 reason: reason.clone(),
11633 },
11634 Ok(ApprovalResult::Rejected {
11635 reason: reason.clone(),
11636 }),
11637 ),
11638 ApprovalResult::Modified { changes } => (
11639 ApprovalResolvedOutcome::Modified {
11640 changes: changes.clone(),
11641 },
11642 Ok(ApprovalResult::Modified {
11643 changes: changes.clone(),
11644 }),
11645 ),
11646 ApprovalResult::Timeout => {
11647 if let Some(ref engine) = self.hitl_engine {
11648 match engine.config().on_timeout {
11649 TimeoutAction::Approve => (
11650 ApprovalResolvedOutcome::Approved,
11651 Ok(ApprovalResult::Approved),
11652 ),
11653 TimeoutAction::Reject => {
11654 let reason = Some("Timeout".to_string());
11655 (
11656 ApprovalResolvedOutcome::Rejected {
11657 reason: reason.clone(),
11658 },
11659 Ok(ApprovalResult::Rejected { reason }),
11660 )
11661 }
11662 TimeoutAction::Error => {
11663 let message = "HITL approval timeout".to_string();
11664 (
11665 ApprovalResolvedOutcome::Error {
11666 message: message.clone(),
11667 },
11668 Err(AgentError::Other(message)),
11669 )
11670 }
11671 }
11672 } else {
11673 let reason = Some("Timeout (no engine)".to_string());
11674 (
11675 ApprovalResolvedOutcome::Rejected {
11676 reason: reason.clone(),
11677 },
11678 Ok(ApprovalResult::Rejected { reason }),
11679 )
11680 }
11681 }
11682 };
11683
11684 self.hooks
11685 .on_approval_resolved(&request, &raw_result, &outcome)
11686 .await;
11687
11688 effective_result
11689 }
11690
11691 pub async fn check_state_hitl(&self, from: Option<&str>, to: &str) -> Result<bool> {
11692 if let Some(ref hitl_engine) = self.hitl_engine {
11693 let hitl_lang_ctx = self.build_hitl_language_context();
11694 let check_result = self
11695 .observe_purpose(
11696 ObservationPurpose::HitlLocalization,
11697 hitl_engine.check_state_transition_with_localization(
11698 from,
11699 to,
11700 &hitl_lang_ctx,
11701 self.approval_handler.as_ref(),
11702 Some(&self.llm_registry),
11703 ),
11704 )
11705 .await?;
11706 if check_result.is_required() {
11707 let result = self.request_hitl_approval(check_result).await?;
11708 return Ok(matches!(
11709 result,
11710 ApprovalResult::Approved | ApprovalResult::Modified { .. }
11711 ));
11712 }
11713 }
11714 Ok(true)
11715 }
11716
11717 async fn execute_tools_parallel(
11719 &self,
11720 tool_calls: &[ToolCall],
11721 ) -> Vec<(String, Result<String>)> {
11722 let can_run_parallel = tool_calls.iter().all(|tc| {
11723 self.tools
11724 .resolve(&tc.name)
11725 .map(|resolved| resolved.tool.classify_call(&tc.arguments).concurrency_safe)
11726 .unwrap_or(false)
11727 });
11728
11729 if !self.parallel_tools.enabled || tool_calls.len() <= 1 || !can_run_parallel {
11730 let mut results = Vec::new();
11731 for tc in tool_calls {
11732 let result = self
11733 .observe_purpose(
11734 current_observation_context()
11735 .map(|context| context.purpose)
11736 .unwrap_or_default(),
11737 self.execute_tool_smart(tc),
11738 )
11739 .await;
11740 results.push((tc.id.clone(), result));
11741 }
11742 return results;
11743 }
11744
11745 let chunks: Vec<_> = tool_calls
11746 .chunks(self.parallel_tools.max_parallel)
11747 .collect();
11748
11749 let mut all_results = Vec::new();
11750
11751 for chunk in chunks {
11752 let futures: Vec<_> = chunk
11753 .iter()
11754 .map(|tc| {
11755 let tc = tc.clone();
11756 async move {
11757 let result = self.execute_tool_smart(&tc).await;
11758 (tc.id.clone(), result)
11759 }
11760 })
11761 .collect();
11762
11763 let results = futures::future::join_all(futures).await;
11764 all_results.extend(results);
11765 }
11766
11767 all_results
11768 }
11769
11770 pub async fn chat_stream<'a>(
11772 &'a self,
11773 input: &'a str,
11774 ) -> Result<Pin<Box<dyn Stream<Item = StreamChunk> + Send + 'a>>> {
11775 self.init_storage().await?;
11779 info!(input_len = input.len(), "Starting streaming chat");
11780 let inner = self.run_loop_stream(input);
11781 if let Some(context) = self.build_observation_context(None) {
11782 let stream: Pin<Box<dyn Stream<Item = StreamChunk> + Send + 'a>> =
11783 Box::pin(async_stream::stream! {
11784 let mut inner = inner;
11785 loop {
11786 let next = with_observation_context(context.clone(), inner.next()).await;
11787 match next {
11788 Some(chunk) => yield chunk,
11789 None => break,
11790 }
11791 }
11792 self.export_observability_if_configured().await;
11793 });
11794 Ok(stream)
11795 } else {
11796 Ok(inner)
11797 }
11798 }
11799}
11800
11801#[async_trait]
11802impl ToolInvoker for RuntimeAgent {
11803 async fn invoke_tool(&self, request: ToolExecutionRequest) -> Result<ToolExecutionRecord> {
11804 self.execute_tool_record(request).await
11805 }
11806}
11807
11808#[async_trait]
11809impl Agent for RuntimeAgent {
11810 async fn chat(&self, input: &str) -> Result<AgentResponse> {
11811 let result = if let Some(context) = self.build_observation_context(None) {
11812 with_observation_context(context, self.run_loop(input)).await
11813 } else {
11814 self.run_loop(input).await
11815 };
11816 self.export_observability_if_configured().await;
11817 result
11818 }
11819
11820 fn info(&self) -> AgentInfo {
11821 self.info.clone()
11822 }
11823
11824 async fn reset(&self) -> Result<()> {
11825 self.memory.clear().await?;
11826 *self.iteration_count.write() = 0;
11827 self.tool_call_history.write().clear();
11828 if let Some(ref sm) = self.state_machine {
11829 sm.reset();
11830 }
11831 Ok(())
11832 }
11833}
11834
11835fn background_maintenance_tags(
11845 label: &str,
11846 stage: &str,
11847 reason: Option<&str>,
11848 policy: Option<&crate::optimization::config::MaintenanceTaskPolicy>,
11849) -> HashMap<String, String> {
11850 let mut tags = HashMap::new();
11851 tags.insert("runtime.background".to_string(), "true".to_string());
11852 tags.insert("runtime.maintenance".to_string(), label.to_string());
11853 tags.insert("runtime.maintenance_stage".to_string(), stage.to_string());
11854 if let Some(policy) = policy {
11855 tags.insert(
11856 "runtime.await_before_next_turn".to_string(),
11857 await_before_next_turn_label(policy.await_before_next_turn).to_string(),
11858 );
11859 tags.insert(
11860 "runtime.maintenance_mode".to_string(),
11861 maintenance_mode_label(policy.mode).to_string(),
11862 );
11863 }
11864 if let Some(reason) = reason {
11865 tags.insert("runtime.reason".to_string(), reason.to_string());
11866 }
11867 tags
11868}
11869
11870fn await_before_next_turn_label(policy: AwaitBeforeNextTurn) -> &'static str {
11871 match policy {
11872 AwaitBeforeNextTurn::Never => "never",
11873 AwaitBeforeNextTurn::SameActor => "same_actor",
11874 AwaitBeforeNextTurn::Always => "always",
11875 }
11876}
11877
11878fn maintenance_mode_label(mode: MaintenanceMode) -> &'static str {
11879 match mode {
11880 MaintenanceMode::InlineSerial => "inline_serial",
11881 MaintenanceMode::InlineParallel => "inline_parallel",
11882 MaintenanceMode::Background => "background",
11883 }
11884}
11885
11886fn record_background_maintenance_event(
11888 manager: Option<&Arc<ObservabilityManager>>,
11889 label: &str,
11890 status: EventStatus,
11891 duration_ms: u64,
11892 stage: &str,
11893 reason: Option<String>,
11894 policy: Option<&crate::optimization::config::MaintenanceTaskPolicy>,
11895) {
11896 if let Some(manager) = manager {
11897 manager.record_lifecycle_event(
11898 EventType::MemoryOperation {
11899 operation: format!("{}_background_{}", label, stage),
11900 },
11901 ObservationPurpose::Other(format!("{}_maintenance", label)),
11902 status,
11903 duration_ms,
11904 background_maintenance_tags(label, stage, reason.as_deref(), policy),
11905 None,
11906 );
11907 }
11908}
11909
11910fn effective_maintenance_mode(mode: MaintenanceMode, force_parallel: bool) -> MaintenanceMode {
11911 if force_parallel && matches!(mode, MaintenanceMode::InlineSerial) {
11912 MaintenanceMode::InlineParallel
11913 } else {
11914 mode
11915 }
11916}
11917
11918fn observation_purpose_for_process(hint: ProcessPurposeHint) -> ObservationPurpose {
11919 match hint {
11920 ProcessPurposeHint::Detect => ObservationPurpose::ProcessDetect,
11921 ProcessPurposeHint::Extract => ObservationPurpose::ProcessExtract,
11922 ProcessPurposeHint::Validate => ObservationPurpose::ProcessValidate,
11923 ProcessPurposeHint::Transform | ProcessPurposeHint::Other => {
11924 ObservationPurpose::ProcessTransform
11925 }
11926 }
11927}
11928
11929fn new_tool_resource_locks() -> ToolResourceLocks {
11930 Arc::new(RwLock::new(HashMap::new()))
11931}
11932
11933fn tool_resource_lock_keys(
11938 _canonical_id: &str,
11939 args: &Value,
11940 bindings: &ai_agents_core::ToolPolicyBindings,
11941 classification: &ai_agents_core::ToolCallClassification,
11942) -> Vec<String> {
11943 if classification.concurrency_safe {
11944 return Vec::new();
11945 }
11946
11947 let mut keys = Vec::new();
11948 let mut has_path_resource = false;
11949 for binding in &bindings.path_fields {
11950 let value = value_at_argument_path(args, &binding.field)
11951 .cloned()
11952 .or_else(|| {
11953 binding
11954 .default_path
11955 .as_ref()
11956 .map(|path| Value::String(path.clone()))
11957 });
11958 if let Some(value) = value {
11959 collect_resource_strings(&value, |_| {
11960 has_path_resource = true;
11961 });
11962 }
11963 }
11964 for binding in &bindings.domain_fields {
11965 if let Some(value) = value_at_argument_path(args, &binding.field) {
11966 collect_resource_strings(value, |domain| {
11967 let normalized = if binding.is_url {
11968 normalized_url_resource_key(domain)
11969 } else {
11970 domain.trim().trim_end_matches('.').to_ascii_lowercase()
11971 };
11972 keys.push(format!("domain:{}", normalized));
11973 });
11974 }
11975 }
11976 for binding in &bindings.command_fields {
11977 if !matches!(binding.kind, ai_agents_core::CommandBindingKind::Cwd) {
11978 continue;
11979 }
11980 if let Some(value) = value_at_argument_path(args, &binding.field) {
11981 collect_resource_strings(value, |_| {
11982 has_path_resource = true;
11983 });
11984 }
11985 }
11986 if has_path_resource {
11987 keys.push("path-mutation:global".to_string());
11988 }
11989 if keys.is_empty() {
11990 keys.push("side-effect:unbound".to_string());
11991 }
11992 keys.sort();
11993 keys.dedup();
11994 keys
11995}
11996
11997fn value_at_argument_path<'a>(value: &'a Value, field: &str) -> Option<&'a Value> {
11998 let mut current = value;
11999 for segment in field.split('.') {
12000 if segment.is_empty() {
12001 return None;
12002 }
12003 current = current.get(segment)?;
12004 }
12005 Some(current)
12006}
12007
12008fn collect_resource_strings(value: &Value, mut collect: impl FnMut(&str)) {
12009 match value {
12010 Value::String(value) => collect(value),
12011 Value::Array(values) => {
12012 for value in values {
12013 if let Some(value) = value.as_str() {
12014 collect(value);
12015 }
12016 }
12017 }
12018 _ => {}
12019 }
12020}
12021
12022fn normalized_url_resource_key(value: &str) -> String {
12023 let value = value.trim();
12024 let Some((scheme, remainder)) = value.split_once("://") else {
12025 return value.to_ascii_lowercase();
12026 };
12027 let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
12028 let (authority, suffix) = remainder.split_at(authority_end);
12029 format!(
12030 "{}://{}{}",
12031 scheme.to_ascii_lowercase(),
12032 authority.to_ascii_lowercase(),
12033 suffix
12034 )
12035}
12036
12037fn render_concurrent_template(
12038 template: &str,
12039 user_input: &str,
12040 context_values: &std::collections::HashMap<String, serde_json::Value>,
12041) -> Result<String> {
12042 let mut env = minijinja::Environment::new();
12043 env.add_template("concurrent", template)
12044 .map_err(|e| AgentError::Other(format!("Concurrent template parse error: {}", e)))?;
12045
12046 let mut ctx = std::collections::BTreeMap::new();
12047 ctx.insert("user_input".to_string(), minijinja::Value::from(user_input));
12048
12049 let context_obj = minijinja::Value::from_serialize(context_values);
12051 ctx.insert("context".to_string(), context_obj);
12052
12053 let tmpl = env
12054 .get_template("concurrent")
12055 .map_err(|e| AgentError::Other(format!("Concurrent template error: {}", e)))?;
12056
12057 tmpl.render(minijinja::Value::from_serialize(&ctx))
12058 .map_err(|e| AgentError::Other(format!("Concurrent template render error: {}", e)))
12059}
12060
12061#[cfg(test)]
12062mod tests {
12063 use super::*;
12064 use crate::AgentBuilder;
12065 use ai_agents_core::{LLMChunk, LLMConfig, LLMError, LLMFeature, Tool};
12066 use ai_agents_llm::mock::MockLLMProvider;
12067 use ai_agents_tools::{
12068 CalculatorTool, CopyPathTool, DeletePathTool, FileWriteTool, MovePathTool,
12069 };
12070
12071 fn mock_with_response(response: &str) -> MockLLMProvider {
12072 let mut mock = MockLLMProvider::new("test");
12073 mock.set_response(response);
12074 mock
12075 }
12076
12077 fn mock_with_responses(responses: Vec<&str>) -> MockLLMProvider {
12078 let mut mock = MockLLMProvider::new("test");
12079 mock.set_responses(responses.into_iter().map(String::from).collect(), true);
12080 mock
12081 }
12082
12083 #[tokio::test]
12084 async fn native_required_choice_executes_through_the_shared_tool_path() {
12085 let mut mock = MockLLMProvider::new("native-required");
12086 mock.set_tool_choice(Some(ToolChoice::Required));
12087 mock.add_response(
12088 LLMResponse::new("", FinishReason::ToolCall)
12089 .with_tool_calls(vec![ToolCall {
12090 id: "provider-call-1".to_string(),
12091 name: "calculator".to_string(),
12092 arguments: serde_json::json!({"expression": "2 + 2"}),
12093 }])
12094 .unwrap(),
12095 );
12096 mock.add_response(LLMResponse::new("The answer is 4.", FinishReason::Stop));
12097 let observed = mock.clone();
12098 let agent = AgentBuilder::new()
12099 .system_prompt("Use the calculator when needed.")
12100 .llm(Arc::new(mock))
12101 .tool(Arc::new(CalculatorTool::new()))
12102 .build()
12103 .unwrap();
12104
12105 let response = agent.chat("What is 2 + 2?").await.unwrap();
12106
12107 assert_eq!(response.content, "The answer is 4.");
12108 assert_eq!(
12109 response.tool_calls.as_ref().unwrap()[0].id,
12110 "provider-call-1"
12111 );
12112 let calls = observed.call_history();
12113 assert_eq!(calls.len(), 2);
12114 assert!(matches!(
12115 calls[0].request.as_ref().map(|request| &request.choice),
12116 Some(ToolChoice::Required)
12117 ));
12118 assert!(matches!(
12119 calls[1].request.as_ref().map(|request| &request.choice),
12120 Some(ToolChoice::Auto)
12121 ));
12122 }
12123
12124 #[tokio::test]
12125 async fn prompt_fallback_uses_one_corrective_retry() {
12126 let mut mock = MockLLMProvider::new("prompt-required");
12127 mock.set_tool_choice(Some(ToolChoice::Required));
12128 mock.set_native_tool_support(false);
12129 mock.set_responses(
12130 vec![
12131 "I can calculate that.".to_string(),
12132 r#"{"tool":"calculator","arguments":{"expression":"2 + 2"}}"#.to_string(),
12133 "The answer is 4.".to_string(),
12134 ],
12135 false,
12136 );
12137 let observed = mock.clone();
12138 let agent = AgentBuilder::new()
12139 .system_prompt("Use tools.")
12140 .llm(Arc::new(mock))
12141 .tool(Arc::new(CalculatorTool::new()))
12142 .build()
12143 .unwrap();
12144
12145 let response = agent.chat("What is 2 + 2?").await.unwrap();
12146
12147 assert_eq!(response.content, "The answer is 4.");
12148 assert_eq!(observed.call_count(), 3);
12149 let corrective = &observed.call_history()[1].messages;
12150 assert!(
12151 corrective
12152 .last()
12153 .unwrap()
12154 .content
12155 .contains("previous response")
12156 );
12157 }
12158
12159 #[tokio::test]
12160 async fn prompt_fallback_fails_after_one_noncompliant_retry() {
12161 let mut mock = MockLLMProvider::new("prompt-required-failure");
12162 mock.set_tool_choice(Some(ToolChoice::Required));
12163 mock.set_native_tool_support(false);
12164 mock.set_responses(
12165 vec!["No tool.".to_string(), "Still no tool.".to_string()],
12166 false,
12167 );
12168 let observed = mock.clone();
12169 let agent = AgentBuilder::new()
12170 .system_prompt("Use tools.")
12171 .llm(Arc::new(mock))
12172 .tool(Arc::new(CalculatorTool::new()))
12173 .build()
12174 .unwrap();
12175
12176 let error = agent.chat("What is 2 + 2?").await.unwrap_err();
12177
12178 assert!(error.to_string().contains("one corrective retry"));
12179 assert_eq!(observed.call_count(), 2);
12180 }
12181
12182 #[tokio::test]
12183 async fn specific_choice_cannot_widen_the_effective_grant() {
12184 let mut mock = MockLLMProvider::new("specific-outside-grant");
12185 mock.set_tool_choice(Some(ToolChoice::Specific("random".to_string())));
12186 let observed = mock.clone();
12187 let agent = AgentBuilder::new()
12188 .system_prompt("Use tools.")
12189 .llm(Arc::new(mock))
12190 .tool(Arc::new(CalculatorTool::new()))
12191 .build()
12192 .unwrap();
12193
12194 let error = agent.chat("Generate a value.").await.unwrap_err();
12195
12196 assert!(error.to_string().contains("is not registered"));
12197 assert_eq!(observed.call_count(), 0);
12198 }
12199
12200 #[tokio::test]
12201 async fn none_choice_exposes_no_tool_protocol() {
12202 let mut mock = MockLLMProvider::new("no-tools");
12203 mock.set_tool_choice(Some(ToolChoice::None));
12204 mock.set_response(r#"{"tool":"calculator","arguments":{"expression":"2 + 2"}}"#);
12205 let observed = mock.clone();
12206 let agent = AgentBuilder::new()
12207 .system_prompt("Answer directly.")
12208 .llm(Arc::new(mock))
12209 .tool(Arc::new(CalculatorTool::new()))
12210 .build()
12211 .unwrap();
12212
12213 let response = agent.chat("Hello").await.unwrap();
12214
12215 assert!(response.tool_calls.is_none());
12216 assert_eq!(observed.call_count(), 1);
12217 let call = observed.last_call().unwrap();
12218 assert!(call.request.is_none());
12219 assert!(
12220 call.messages
12221 .iter()
12222 .all(|message| !message.content.contains("Available tools:"))
12223 );
12224 }
12225
12226 struct RuntimeStorage {
12227 capabilities: Box<[StorageCapability]>,
12228 snapshots: RwLock<HashMap<String, AgentSnapshot>>,
12229 metadata: RwLock<HashMap<String, ai_agents_core::SessionMetadata>>,
12230 metadata_save_calls: AtomicU64,
12231 metadata_load_calls: AtomicU64,
12232 fail_metadata_save: AtomicBool,
12233 fail_metadata_load: AtomicBool,
12234 }
12235
12236 impl RuntimeStorage {
12237 fn new(capabilities: impl IntoIterator<Item = StorageCapability>) -> Self {
12238 Self {
12239 capabilities: capabilities.into_iter().collect(),
12240 snapshots: RwLock::new(HashMap::new()),
12241 metadata: RwLock::new(HashMap::new()),
12242 metadata_save_calls: AtomicU64::new(0),
12243 metadata_load_calls: AtomicU64::new(0),
12244 fail_metadata_save: AtomicBool::new(false),
12245 fail_metadata_load: AtomicBool::new(false),
12246 }
12247 }
12248 }
12249
12250 #[async_trait]
12251 impl AgentStorage for RuntimeStorage {
12252 fn supports(&self, capability: StorageCapability) -> bool {
12253 self.capabilities.contains(&capability)
12254 }
12255
12256 async fn save(&self, session_id: &str, snapshot: &AgentSnapshot) -> Result<()> {
12257 self.snapshots
12258 .write()
12259 .insert(session_id.to_string(), snapshot.clone());
12260 Ok(())
12261 }
12262
12263 async fn load(&self, session_id: &str) -> Result<Option<AgentSnapshot>> {
12264 Ok(self.snapshots.read().get(session_id).cloned())
12265 }
12266
12267 async fn delete(&self, session_id: &str) -> Result<()> {
12268 self.snapshots.write().remove(session_id);
12269 Ok(())
12270 }
12271
12272 async fn list_sessions(&self) -> Result<Vec<String>> {
12273 Ok(self.snapshots.read().keys().cloned().collect())
12274 }
12275
12276 async fn save_snapshot_with_metadata(
12277 &self,
12278 session_id: &str,
12279 snapshot: &AgentSnapshot,
12280 metadata: &ai_agents_core::SessionMetadata,
12281 ) -> Result<()> {
12282 self.metadata_save_calls.fetch_add(1, Ordering::SeqCst);
12283 if self.fail_metadata_save.load(Ordering::SeqCst) {
12284 return Err(AgentError::Persistence("metadata save failed".into()));
12285 }
12286 self.snapshots
12287 .write()
12288 .insert(session_id.to_string(), snapshot.clone());
12289 self.metadata
12290 .write()
12291 .insert(session_id.to_string(), metadata.clone());
12292 Ok(())
12293 }
12294
12295 async fn save_metadata(
12296 &self,
12297 session_id: &str,
12298 metadata: &ai_agents_core::SessionMetadata,
12299 ) -> Result<()> {
12300 self.metadata_save_calls.fetch_add(1, Ordering::SeqCst);
12301 if self.fail_metadata_save.load(Ordering::SeqCst) {
12302 return Err(AgentError::Persistence("metadata save failed".into()));
12303 }
12304 self.metadata
12305 .write()
12306 .insert(session_id.to_string(), metadata.clone());
12307 Ok(())
12308 }
12309
12310 async fn load_metadata(
12311 &self,
12312 session_id: &str,
12313 ) -> Result<Option<ai_agents_core::SessionMetadata>> {
12314 self.metadata_load_calls.fetch_add(1, Ordering::SeqCst);
12315 if self.fail_metadata_load.load(Ordering::SeqCst) {
12316 return Err(AgentError::Persistence("metadata load failed".into()));
12317 }
12318 Ok(self.metadata.read().get(session_id).cloned())
12319 }
12320 }
12321
12322 fn runtime_storage_agent() -> RuntimeAgent {
12323 AgentBuilder::new()
12324 .system_prompt("Test runtime storage integration.")
12325 .llm(Arc::new(mock_with_response("done")))
12326 .build()
12327 .unwrap()
12328 }
12329
12330 fn restore_spec(id: &str) -> crate::spec::AgentSpec {
12331 crate::spec::AgentSpec {
12332 name: id.to_string(),
12333 system_prompt: format!("Restore child {id}."),
12334 ..crate::spec::AgentSpec::default()
12335 }
12336 }
12337
12338 fn restore_entry(id: &str) -> ai_agents_core::SpawnedAgentEntry {
12339 ai_agents_core::SpawnedAgentEntry {
12340 id: id.to_string(),
12341 name: id.to_string(),
12342 spec_yaml: serde_yaml::to_string(&restore_spec(id)).unwrap(),
12343 }
12344 }
12345
12346 fn restore_spawner(
12347 storage: Arc<RuntimeStorage>,
12348 max_agents: usize,
12349 ) -> (
12350 Arc<crate::spawner::AgentSpawner>,
12351 Arc<crate::spawner::AgentRegistry>,
12352 ) {
12353 let mut llms = LLMRegistry::new();
12354 llms.register("default", Arc::new(mock_with_response("done")));
12355 (
12356 Arc::new(
12357 crate::spawner::AgentSpawner::new()
12358 .with_shared_llms(llms)
12359 .with_shared_storage(storage)
12360 .with_max_agents(max_agents),
12361 ),
12362 Arc::new(crate::spawner::AgentRegistry::new()),
12363 )
12364 }
12365
12366 async fn save_restore_target(
12367 parent: &RuntimeAgent,
12368 storage: &RuntimeStorage,
12369 session_id: &str,
12370 entries: Vec<ai_agents_core::SpawnedAgentEntry>,
12371 ) {
12372 let mut snapshot = parent.save_state().await.unwrap();
12373 snapshot.spawned_agents = Some(entries);
12374 storage.save(session_id, &snapshot).await.unwrap();
12375 storage
12376 .save_metadata(session_id, &ai_agents_core::SessionMetadata::default())
12377 .await
12378 .unwrap();
12379 }
12380
12381 #[tokio::test]
12382 async fn storage_init_requires_storage_for_actor_facts() {
12383 let facts = ai_agents_facts::FactsConfig {
12384 enabled: true,
12385 ..Default::default()
12386 };
12387 let agent = runtime_storage_agent().with_facts_config(None, Some(facts));
12388
12389 let error = agent.init_storage().await.unwrap_err();
12390 assert!(matches!(
12391 error,
12392 AgentError::Config(message)
12393 if message.contains("actor facts or actor memory")
12394 && message.contains("none is configured or injected")
12395 ));
12396 }
12397
12398 #[tokio::test]
12399 async fn storage_init_validates_actor_facts_capability() {
12400 let storage = Arc::new(RuntimeStorage::new([StorageCapability::Snapshot]));
12401 let actor_memory = ai_agents_facts::ActorMemoryConfig {
12402 enabled: true,
12403 ..Default::default()
12404 };
12405 let agent = runtime_storage_agent()
12406 .with_storage(storage)
12407 .with_facts_config(Some(actor_memory), None);
12408
12409 assert!(matches!(
12410 agent.init_storage().await,
12411 Err(AgentError::UnsupportedStorageCapability(
12412 StorageCapability::ActorFacts
12413 ))
12414 ));
12415 }
12416
12417 #[tokio::test]
12418 async fn blocking_chat_rejects_unsupported_required_storage() {
12419 let storage = Arc::new(RuntimeStorage::new([StorageCapability::Snapshot]));
12420 let facts = ai_agents_facts::FactsConfig {
12421 enabled: true,
12422 ..Default::default()
12423 };
12424 let agent = runtime_storage_agent()
12425 .with_storage(storage)
12426 .with_facts_config(None, Some(facts));
12427
12428 assert!(matches!(
12429 agent.chat("hello").await,
12430 Err(AgentError::UnsupportedStorageCapability(
12431 StorageCapability::ActorFacts
12432 ))
12433 ));
12434 }
12435
12436 #[tokio::test]
12437 async fn streaming_chat_rejects_unsupported_required_storage_before_stream_creation() {
12438 let storage = Arc::new(RuntimeStorage::new([StorageCapability::Snapshot]));
12439 let config = ai_agents_relationships::RelationshipConfig {
12440 enabled: true,
12441 ..Default::default()
12442 };
12443 let manager = Arc::new(RelationshipManager::from_config(config).unwrap());
12444 let agent = runtime_storage_agent()
12445 .with_storage(storage)
12446 .with_relationships(manager);
12447
12448 assert!(matches!(
12449 agent.chat_stream("hello").await,
12450 Err(AgentError::UnsupportedStorageCapability(
12451 StorageCapability::ActorRelationships
12452 ))
12453 ));
12454 }
12455
12456 #[tokio::test]
12457 async fn storage_init_completes_facts_for_injected_storage() {
12458 let storage = Arc::new(RuntimeStorage::new([
12459 StorageCapability::Snapshot,
12460 StorageCapability::ActorFacts,
12461 ]));
12462 let facts = ai_agents_facts::FactsConfig {
12463 enabled: true,
12464 ..Default::default()
12465 };
12466 let agent = runtime_storage_agent()
12467 .with_storage(storage)
12468 .with_facts_config(None, Some(facts));
12469
12470 agent.init_storage().await.unwrap();
12471 assert!(agent.fact_store().is_some());
12472 }
12473
12474 #[tokio::test]
12475 async fn storage_init_requires_storage_for_persistent_relationships() {
12476 let config = ai_agents_relationships::RelationshipConfig {
12477 enabled: true,
12478 ..Default::default()
12479 };
12480 let manager = Arc::new(RelationshipManager::from_config(config).unwrap());
12481 let agent = runtime_storage_agent().with_relationships(manager);
12482
12483 let error = agent.init_storage().await.unwrap_err();
12484 assert!(matches!(
12485 error,
12486 AgentError::Config(message)
12487 if message.contains("persistent relationships")
12488 && message.contains("none is configured or injected")
12489 ));
12490 }
12491
12492 #[tokio::test]
12493 async fn storage_init_validates_persistent_relationships_capability() {
12494 let storage = Arc::new(RuntimeStorage::new([StorageCapability::Snapshot]));
12495 let config = ai_agents_relationships::RelationshipConfig {
12496 enabled: true,
12497 ..Default::default()
12498 };
12499 let manager = Arc::new(RelationshipManager::from_config(config).unwrap());
12500 let agent = runtime_storage_agent()
12501 .with_storage(storage)
12502 .with_relationships(manager);
12503
12504 assert!(matches!(
12505 agent.init_storage().await,
12506 Err(AgentError::UnsupportedStorageCapability(
12507 StorageCapability::ActorRelationships
12508 ))
12509 ));
12510 }
12511
12512 #[tokio::test]
12513 async fn session_restore_updates_identity_and_clears_stale_actor_binding() {
12514 let storage = Arc::new(RuntimeStorage::new([
12515 StorageCapability::Snapshot,
12516 StorageCapability::SessionMetadata,
12517 ]));
12518 let agent = runtime_storage_agent().with_storage(storage.clone());
12519 agent.set_actor_id("old-actor").unwrap();
12520 agent.save_session("old").await.unwrap();
12521 storage
12522 .save("target", &agent.save_state().await.unwrap())
12523 .await
12524 .unwrap();
12525 storage
12526 .save_metadata("target", &ai_agents_core::SessionMetadata::default())
12527 .await
12528 .unwrap();
12529
12530 assert!(agent.load_session("target").await.unwrap());
12531
12532 assert_eq!(agent.current_session_id.read().as_deref(), Some("target"));
12533 assert_eq!(agent.actor_id(), None);
12534 }
12535
12536 #[tokio::test]
12537 async fn complete_restore_reconciles_growth_shrink_and_empty_topologies() {
12538 let storage = Arc::new(RuntimeStorage::new([
12539 StorageCapability::Snapshot,
12540 StorageCapability::SessionMetadata,
12541 ]));
12542 let (spawner, registry) = restore_spawner(storage.clone(), 3);
12543 let parent = runtime_storage_agent()
12544 .with_storage(storage.clone())
12545 .with_spawner_handles(Arc::clone(&spawner), Arc::clone(®istry));
12546
12547 for id in ["a", "b"] {
12548 let spawned = spawner
12549 .spawn_with_id(id.to_string(), restore_spec(id))
12550 .await
12551 .unwrap();
12552 spawned.agent.save_session("grow").await.unwrap();
12553 registry.register(spawned).await.unwrap();
12554 }
12555 let staged_c = crate::spawner::storage::NamespacedStorage::new(storage.clone(), "c");
12556 staged_c
12557 .save("grow", &AgentSnapshot::new("c".into()))
12558 .await
12559 .unwrap();
12560 staged_c
12561 .save_metadata("grow", &ai_agents_core::SessionMetadata::default())
12562 .await
12563 .unwrap();
12564 save_restore_target(
12565 &parent,
12566 storage.as_ref(),
12567 "grow",
12568 vec![restore_entry("a"), restore_entry("b"), restore_entry("c")],
12569 )
12570 .await;
12571
12572 assert_eq!(parent.restore_session_full("grow").await.unwrap(), 3);
12573 assert_eq!(registry.count(), 3);
12574 assert!(registry.contains("c"));
12575 assert_eq!(spawner.spawned_count(), 3);
12576
12577 for id in ["a", "b"] {
12578 registry
12579 .get(id)
12580 .unwrap()
12581 .save_session("shrink")
12582 .await
12583 .unwrap();
12584 }
12585 save_restore_target(
12586 &parent,
12587 storage.as_ref(),
12588 "shrink",
12589 vec![restore_entry("a"), restore_entry("b")],
12590 )
12591 .await;
12592
12593 assert_eq!(parent.restore_session_full("shrink").await.unwrap(), 2);
12594 assert_eq!(registry.count(), 2);
12595 assert!(!registry.contains("c"));
12596 assert_eq!(spawner.spawned_count(), 2);
12597
12598 save_restore_target(&parent, storage.as_ref(), "empty", Vec::new()).await;
12599
12600 assert_eq!(parent.restore_session_full("empty").await.unwrap(), 0);
12601 assert_eq!(registry.count(), 0);
12602 assert_eq!(spawner.spawned_count(), 0);
12603 assert_eq!(parent.current_session_id.read().as_deref(), Some("empty"));
12604 }
12605
12606 #[tokio::test]
12607 async fn storage_session_metadata_is_called_only_when_advertised() {
12608 let storage = Arc::new(RuntimeStorage::new([StorageCapability::Snapshot]));
12609 storage.fail_metadata_save.store(true, Ordering::SeqCst);
12610 storage.fail_metadata_load.store(true, Ordering::SeqCst);
12611 let agent = runtime_storage_agent().with_storage(storage.clone());
12612
12613 agent.save_session("session").await.unwrap();
12614 assert!(agent.load_session("session").await.unwrap());
12615 assert_eq!(storage.metadata_save_calls.load(Ordering::SeqCst), 0);
12616 assert_eq!(storage.metadata_load_calls.load(Ordering::SeqCst), 0);
12617 }
12618
12619 #[cfg(feature = "sqlite")]
12620 #[tokio::test]
12621 async fn sqlite_runtime_save_filter_reopen_and_reload_stay_consistent() {
12622 let directory =
12623 std::env::temp_dir().join(format!("ai-agents-runtime-sqlite-{}", uuid::Uuid::new_v4()));
12624 let path = directory.join("sessions.sqlite");
12625 let path_string = path.to_string_lossy().into_owned();
12626 let storage = Arc::new(
12627 ai_agents_storage::SqliteStorage::new(&path_string)
12628 .await
12629 .unwrap(),
12630 );
12631 let agent = runtime_storage_agent().with_storage(storage.clone());
12632 agent.set_session_metadata(ai_agents_core::SessionMetadata {
12633 tags: vec!["initial".into()],
12634 ..Default::default()
12635 });
12636 agent.chat("persist this turn").await.unwrap();
12637 agent.save_session("session").await.unwrap();
12638
12639 agent.set_session_metadata(ai_agents_core::SessionMetadata {
12640 tags: vec!["updated".into()],
12641 ..Default::default()
12642 });
12643 agent.save_session("session").await.unwrap();
12644 assert!(
12645 agent
12646 .list_sessions_filtered(&ai_agents_core::SessionFilter {
12647 tags: Some(vec!["initial".into()]),
12648 ..Default::default()
12649 })
12650 .await
12651 .unwrap()
12652 .is_empty()
12653 );
12654 assert_eq!(
12655 agent
12656 .list_sessions_filtered(&ai_agents_core::SessionFilter {
12657 tags: Some(vec!["updated".into()]),
12658 ..Default::default()
12659 })
12660 .await
12661 .unwrap()
12662 .len(),
12663 1
12664 );
12665 drop(agent);
12666 storage.close().await;
12667 drop(storage);
12668
12669 let reopened_storage = Arc::new(
12670 ai_agents_storage::SqliteStorage::new(&path_string)
12671 .await
12672 .unwrap(),
12673 );
12674 let restored = runtime_storage_agent().with_storage(reopened_storage.clone());
12675 assert!(restored.load_session("session").await.unwrap());
12676 assert_eq!(restored.session_metadata().tags, vec!["updated"]);
12677 assert_eq!(
12678 restored.current_session_id.read().as_deref(),
12679 Some("session")
12680 );
12681 assert!(restored.save_state().await.unwrap().memory.messages.len() >= 2);
12682 assert_eq!(
12683 restored
12684 .list_sessions_filtered(&ai_agents_core::SessionFilter {
12685 tags: Some(vec!["updated".into()]),
12686 ..Default::default()
12687 })
12688 .await
12689 .unwrap()
12690 .len(),
12691 1
12692 );
12693
12694 drop(restored);
12695 reopened_storage.close().await;
12696 drop(reopened_storage);
12697 crate::remove_sqlite_test_directory(&directory)
12698 .await
12699 .unwrap();
12700 }
12701
12702 #[tokio::test]
12703 async fn storage_session_metadata_backend_failures_propagate() {
12704 let storage = Arc::new(RuntimeStorage::new([
12705 StorageCapability::Snapshot,
12706 StorageCapability::SessionMetadata,
12707 ]));
12708 let agent = runtime_storage_agent().with_storage(storage.clone());
12709
12710 agent.save_session("session").await.unwrap();
12711 storage
12712 .save("target", &agent.save_state().await.unwrap())
12713 .await
12714 .unwrap();
12715 storage.fail_metadata_load.store(true, Ordering::SeqCst);
12716 assert!(matches!(
12717 agent.load_session("target").await,
12718 Err(AgentError::Persistence(message)) if message == "metadata load failed"
12719 ));
12720 assert_eq!(agent.current_session_id.read().as_deref(), Some("session"));
12721
12722 storage.fail_metadata_save.store(true, Ordering::SeqCst);
12723 assert!(matches!(
12724 agent.save_session("session").await,
12725 Err(AgentError::Persistence(message)) if message == "metadata save failed"
12726 ));
12727 }
12728
12729 struct ProviderFutureDropSignal {
12730 dropped: Arc<AtomicBool>,
12731 }
12732
12733 impl Drop for ProviderFutureDropSignal {
12734 fn drop(&mut self) {
12735 self.dropped.store(true, Ordering::SeqCst);
12736 }
12737 }
12738
12739 struct BufferedLockingProvider {
12740 lock: Arc<tokio::sync::Mutex<()>>,
12741 stream_started: Arc<tokio::sync::Notify>,
12742 stream_dropped: Arc<AtomicBool>,
12743 committed_after_drop: Arc<AtomicBool>,
12744 }
12745
12746 #[async_trait]
12747 impl LLMProvider for BufferedLockingProvider {
12748 async fn complete(
12749 &self,
12750 _messages: &[ChatMessage],
12751 _config: Option<&LLMConfig>,
12752 ) -> std::result::Result<LLMResponse, LLMError> {
12753 let _guard = self.lock.lock().await;
12754 self.committed_after_drop
12755 .store(self.stream_dropped.load(Ordering::SeqCst), Ordering::SeqCst);
12756 Ok(LLMResponse::new(
12757 "Committed technical response.",
12758 FinishReason::Stop,
12759 ))
12760 }
12761
12762 async fn complete_stream(
12763 &self,
12764 _messages: &[ChatMessage],
12765 _config: Option<&LLMConfig>,
12766 ) -> std::result::Result<
12767 Box<dyn Stream<Item = std::result::Result<LLMChunk, LLMError>> + Unpin + Send>,
12768 LLMError,
12769 > {
12770 let _guard = self.lock.lock().await;
12771 let _drop_signal = ProviderFutureDropSignal {
12772 dropped: Arc::clone(&self.stream_dropped),
12773 };
12774 self.stream_started.notify_one();
12775 std::future::pending().await
12776 }
12777
12778 fn provider_name(&self) -> &str {
12779 "buffered-locking"
12780 }
12781
12782 fn supports(&self, _feature: LLMFeature) -> bool {
12783 false
12784 }
12785 }
12786
12787 struct PendingDropStream {
12788 dropped: Arc<AtomicBool>,
12789 dropped_notify: Arc<tokio::sync::Notify>,
12790 }
12791
12792 impl Stream for PendingDropStream {
12793 type Item = std::result::Result<LLMChunk, LLMError>;
12794
12795 fn poll_next(
12796 self: Pin<&mut Self>,
12797 _cx: &mut std::task::Context<'_>,
12798 ) -> std::task::Poll<Option<Self::Item>> {
12799 std::task::Poll::Pending
12800 }
12801 }
12802
12803 impl Drop for PendingDropStream {
12804 fn drop(&mut self) {
12805 self.dropped.store(true, Ordering::SeqCst);
12806 self.dropped_notify.notify_one();
12807 }
12808 }
12809
12810 struct EstablishedStreamProvider {
12811 stream_started: Arc<tokio::sync::Notify>,
12812 stream_dropped: Arc<AtomicBool>,
12813 stream_dropped_notify: Arc<tokio::sync::Notify>,
12814 committed_after_drop: Arc<AtomicBool>,
12815 }
12816
12817 #[async_trait]
12818 impl LLMProvider for EstablishedStreamProvider {
12819 async fn complete(
12820 &self,
12821 _messages: &[ChatMessage],
12822 _config: Option<&LLMConfig>,
12823 ) -> std::result::Result<LLMResponse, LLMError> {
12824 if !self.stream_dropped.load(Ordering::SeqCst) {
12825 self.stream_dropped_notify.notified().await;
12826 }
12827 self.committed_after_drop
12828 .store(self.stream_dropped.load(Ordering::SeqCst), Ordering::SeqCst);
12829 Ok(LLMResponse::new(
12830 "Committed technical response.",
12831 FinishReason::Stop,
12832 ))
12833 }
12834
12835 async fn complete_stream(
12836 &self,
12837 _messages: &[ChatMessage],
12838 _config: Option<&LLMConfig>,
12839 ) -> std::result::Result<
12840 Box<dyn Stream<Item = std::result::Result<LLMChunk, LLMError>> + Unpin + Send>,
12841 LLMError,
12842 > {
12843 self.stream_started.notify_one();
12844 Ok(Box::new(PendingDropStream {
12845 dropped: Arc::clone(&self.stream_dropped),
12846 dropped_notify: Arc::clone(&self.stream_dropped_notify),
12847 }))
12848 }
12849
12850 fn provider_name(&self) -> &str {
12851 "established-stream"
12852 }
12853
12854 fn supports(&self, _feature: LLMFeature) -> bool {
12855 false
12856 }
12857 }
12858
12859 struct FirstCallLockingProvider {
12860 lock: Arc<tokio::sync::Mutex<()>>,
12861 first_started: Arc<tokio::sync::Notify>,
12862 first_dropped: Arc<AtomicBool>,
12863 committed_after_drop: Arc<AtomicBool>,
12864 calls: AtomicU64,
12865 }
12866
12867 #[async_trait]
12868 impl LLMProvider for FirstCallLockingProvider {
12869 async fn complete(
12870 &self,
12871 _messages: &[ChatMessage],
12872 _config: Option<&LLMConfig>,
12873 ) -> std::result::Result<LLMResponse, LLMError> {
12874 let _guard = self.lock.lock().await;
12875 let call = self.calls.fetch_add(1, Ordering::SeqCst);
12876 if call == 0 {
12877 let _drop_signal = ProviderFutureDropSignal {
12878 dropped: Arc::clone(&self.first_dropped),
12879 };
12880 self.first_started.notify_one();
12881 return std::future::pending().await;
12882 }
12883 self.committed_after_drop
12884 .store(self.first_dropped.load(Ordering::SeqCst), Ordering::SeqCst);
12885 Ok(LLMResponse::new(
12886 "Committed technical response.",
12887 FinishReason::Stop,
12888 ))
12889 }
12890
12891 async fn complete_stream(
12892 &self,
12893 _messages: &[ChatMessage],
12894 _config: Option<&LLMConfig>,
12895 ) -> std::result::Result<
12896 Box<dyn Stream<Item = std::result::Result<LLMChunk, LLMError>> + Unpin + Send>,
12897 LLMError,
12898 > {
12899 Err(LLMError::Other(
12900 "streaming is not used in this test".to_string(),
12901 ))
12902 }
12903
12904 fn provider_name(&self) -> &str {
12905 "first-call-locking"
12906 }
12907
12908 fn supports(&self, _feature: LLMFeature) -> bool {
12909 false
12910 }
12911 }
12912
12913 struct RoutingAfterProviderStart {
12914 provider_started: Arc<tokio::sync::Notify>,
12915 }
12916
12917 #[async_trait]
12918 impl LLMProvider for RoutingAfterProviderStart {
12919 async fn complete(
12920 &self,
12921 _messages: &[ChatMessage],
12922 _config: Option<&LLMConfig>,
12923 ) -> std::result::Result<LLMResponse, LLMError> {
12924 self.provider_started.notified().await;
12925 Ok(LLMResponse::new("1", FinishReason::Stop))
12926 }
12927
12928 async fn complete_stream(
12929 &self,
12930 _messages: &[ChatMessage],
12931 _config: Option<&LLMConfig>,
12932 ) -> std::result::Result<
12933 Box<dyn Stream<Item = std::result::Result<LLMChunk, LLMError>> + Unpin + Send>,
12934 LLMError,
12935 > {
12936 Err(LLMError::Other(
12937 "streaming is not used in this test".to_string(),
12938 ))
12939 }
12940
12941 fn provider_name(&self) -> &str {
12942 "routing-after-start"
12943 }
12944
12945 fn supports(&self, _feature: LLMFeature) -> bool {
12946 false
12947 }
12948 }
12949
12950 struct ResponseCountingHooks {
12952 responses: Arc<std::sync::atomic::AtomicUsize>,
12953 }
12954
12955 struct ContextEchoTool;
12957
12958 #[async_trait]
12959 impl ai_agents_core::Tool for ContextEchoTool {
12960 fn id(&self) -> &str {
12961 "context_echo"
12962 }
12963
12964 fn name(&self) -> &str {
12965 "Context Echo"
12966 }
12967
12968 fn description(&self) -> &str {
12969 "Returns selected execution context fields."
12970 }
12971
12972 fn input_schema(&self) -> Value {
12973 serde_json::json!({"type": "object"})
12974 }
12975
12976 fn policy_bindings(&self) -> ai_agents_core::ToolPolicyBindings {
12977 ai_agents_core::ToolPolicyBindings {
12978 path_fields: vec![ai_agents_core::PathPolicyBinding::read("path")],
12979 result_limit_fields: vec![ai_agents_core::ResultLimitBinding::new(
12980 "max_results",
12981 ai_agents_core::ResultLimitKind::MaxResults,
12982 )],
12983 ..Default::default()
12984 }
12985 }
12986
12987 async fn execute(
12988 &self,
12989 _args: Value,
12990 ctx: ai_agents_core::ToolExecutionContext,
12991 ) -> ToolResult {
12992 ToolResult::ok(
12993 serde_json::json!({
12994 "requested_name": ctx.requested_name,
12995 "canonical_id": ctx.canonical_id,
12996 "display_name": ctx.display_name,
12997 "max_results": ctx.limits.max_results,
12998 "custom_config": ctx.custom_config,
12999 })
13000 .to_string(),
13001 )
13002 }
13003 }
13004
13005 struct SlowTool;
13007
13008 struct FlakyWriteTool {
13010 calls: Arc<std::sync::atomic::AtomicUsize>,
13011 }
13012
13013 struct LockedWriteTool {
13015 active: Arc<std::sync::atomic::AtomicUsize>,
13016 max_active: Arc<std::sync::atomic::AtomicUsize>,
13017 }
13018
13019 struct MultiResourceWriteTool {
13020 active: Arc<std::sync::atomic::AtomicUsize>,
13021 max_active: Arc<std::sync::atomic::AtomicUsize>,
13022 }
13023
13024 #[derive(Clone)]
13025 struct PathMutationGate {
13026 entered: Arc<AtomicBool>,
13027 entered_notify: Arc<tokio::sync::Notify>,
13028 release: Arc<tokio::sync::Notify>,
13029 }
13030
13031 impl PathMutationGate {
13032 fn new() -> Self {
13033 Self {
13034 entered: Arc::new(AtomicBool::new(false)),
13035 entered_notify: Arc::new(tokio::sync::Notify::new()),
13036 release: Arc::new(tokio::sync::Notify::new()),
13037 }
13038 }
13039
13040 async fn wait_until_entered(&self) {
13041 if !self.entered.load(Ordering::SeqCst) {
13042 self.entered_notify.notified().await;
13043 }
13044 }
13045
13046 fn release(&self) {
13047 self.release.notify_one();
13048 }
13049 }
13050
13051 struct BlockingPathMutationTool {
13052 id: &'static str,
13053 path_fields: Vec<ai_agents_core::PathPolicyBinding>,
13054 gate: PathMutationGate,
13055 }
13056
13057 struct NoBindingWriteTool {
13058 active: Arc<std::sync::atomic::AtomicUsize>,
13059 max_active: Arc<std::sync::atomic::AtomicUsize>,
13060 }
13061
13062 struct RecoveryTestTool {
13063 id: String,
13064 succeeds: bool,
13065 calls: Arc<std::sync::atomic::AtomicUsize>,
13066 }
13067
13068 struct BlockingApprovalHandler {
13069 entered: Arc<tokio::sync::Barrier>,
13070 release: Arc<tokio::sync::Notify>,
13071 result: ApprovalResult,
13072 }
13073
13074 struct ReentrantToolHooks {
13075 agent: parking_lot::Mutex<Option<Weak<RuntimeAgent>>>,
13076 invoked: AtomicBool,
13077 nested_success: AtomicBool,
13078 }
13079
13080 #[async_trait]
13081 impl ai_agents_core::Tool for SlowTool {
13082 fn id(&self) -> &str {
13083 "slow"
13084 }
13085
13086 fn name(&self) -> &str {
13087 "Slow"
13088 }
13089
13090 fn description(&self) -> &str {
13091 "Waits until cancelled or timed out."
13092 }
13093
13094 fn input_schema(&self) -> Value {
13095 serde_json::json!({"type": "object"})
13096 }
13097
13098 async fn execute(
13099 &self,
13100 _args: Value,
13101 _ctx: ai_agents_core::ToolExecutionContext,
13102 ) -> ToolResult {
13103 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
13104 ToolResult::ok("done")
13105 }
13106 }
13107
13108 #[async_trait]
13109 impl ai_agents_core::Tool for FlakyWriteTool {
13110 fn id(&self) -> &str {
13111 "flaky_write"
13112 }
13113
13114 fn name(&self) -> &str {
13115 "Flaky Write"
13116 }
13117
13118 fn description(&self) -> &str {
13119 "Fails on the first write attempt."
13120 }
13121
13122 fn input_schema(&self) -> Value {
13123 serde_json::json!({"type": "object", "properties": {"path": {"type": "string"}}})
13124 }
13125
13126 fn policy_bindings(&self) -> ai_agents_core::ToolPolicyBindings {
13127 ai_agents_core::ToolPolicyBindings {
13128 path_fields: vec![ai_agents_core::PathPolicyBinding::write("path")],
13129 ..Default::default()
13130 }
13131 }
13132
13133 fn safety_metadata(&self) -> ai_agents_core::ToolSafetyMetadata {
13134 ai_agents_core::ToolSafetyMetadata {
13135 read_only: false,
13136 concurrency_safe: false,
13137 operation: ai_agents_core::ToolOperationKind::Write,
13138 side_effect_level: ai_agents_core::ToolSideEffectLevel::LocalWrite,
13139 requires_network: false,
13140 destructive: false,
13141 open_world: false,
13142 host_dependent: false,
13143 requires_user_interaction: false,
13144 supports_cancellation: true,
13145 default_requires_approval: false,
13146 should_defer_schema: false,
13147 max_output_chars: Some(1024),
13148 max_result_size_chars: Some(1024),
13149 }
13150 }
13151
13152 fn classify_call(&self, _args: &Value) -> ai_agents_core::ToolCallClassification {
13153 let mut classification =
13154 ai_agents_core::ToolCallClassification::from_metadata(&self.safety_metadata());
13155 classification.safely_retryable = false;
13156 classification
13157 }
13158
13159 async fn execute(
13160 &self,
13161 _args: Value,
13162 _ctx: ai_agents_core::ToolExecutionContext,
13163 ) -> ToolResult {
13164 let call = self.calls.fetch_add(1, Ordering::SeqCst);
13165 if call == 0 {
13166 ToolResult::error("first failure")
13167 } else {
13168 ToolResult::ok("second success")
13169 }
13170 }
13171 }
13172
13173 #[async_trait]
13174 impl ai_agents_core::Tool for LockedWriteTool {
13175 fn id(&self) -> &str {
13176 "locked_write"
13177 }
13178
13179 fn name(&self) -> &str {
13180 "Locked Write"
13181 }
13182
13183 fn description(&self) -> &str {
13184 "Tracks concurrent execution on one resource."
13185 }
13186
13187 fn input_schema(&self) -> Value {
13188 serde_json::json!({"type": "object", "properties": {"path": {"type": "string"}}})
13189 }
13190
13191 fn policy_bindings(&self) -> ai_agents_core::ToolPolicyBindings {
13192 ai_agents_core::ToolPolicyBindings {
13193 path_fields: vec![ai_agents_core::PathPolicyBinding::write("path")],
13194 ..Default::default()
13195 }
13196 }
13197
13198 fn safety_metadata(&self) -> ai_agents_core::ToolSafetyMetadata {
13199 ai_agents_core::ToolSafetyMetadata {
13200 read_only: false,
13201 concurrency_safe: false,
13202 operation: ai_agents_core::ToolOperationKind::Write,
13203 side_effect_level: ai_agents_core::ToolSideEffectLevel::LocalWrite,
13204 requires_network: false,
13205 destructive: false,
13206 open_world: false,
13207 host_dependent: false,
13208 requires_user_interaction: false,
13209 supports_cancellation: true,
13210 default_requires_approval: false,
13211 should_defer_schema: false,
13212 max_output_chars: Some(1024),
13213 max_result_size_chars: Some(1024),
13214 }
13215 }
13216
13217 async fn execute(
13218 &self,
13219 _args: Value,
13220 _ctx: ai_agents_core::ToolExecutionContext,
13221 ) -> ToolResult {
13222 let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
13223 loop {
13224 let current_max = self.max_active.load(Ordering::SeqCst);
13225 if active <= current_max {
13226 break;
13227 }
13228 if self
13229 .max_active
13230 .compare_exchange(current_max, active, Ordering::SeqCst, Ordering::SeqCst)
13231 .is_ok()
13232 {
13233 break;
13234 }
13235 }
13236 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
13237 self.active.fetch_sub(1, Ordering::SeqCst);
13238 ToolResult::ok("done")
13239 }
13240 }
13241
13242 #[async_trait]
13243 impl ai_agents_core::Tool for MultiResourceWriteTool {
13244 fn id(&self) -> &str {
13245 "multi_resource_write"
13246 }
13247
13248 fn name(&self) -> &str {
13249 "Multi Resource Write"
13250 }
13251
13252 fn description(&self) -> &str {
13253 "Tracks concurrent execution across source and destination resources."
13254 }
13255
13256 fn input_schema(&self) -> Value {
13257 serde_json::json!({"type": "object"})
13258 }
13259
13260 fn policy_bindings(&self) -> ai_agents_core::ToolPolicyBindings {
13261 ai_agents_core::ToolPolicyBindings {
13262 path_fields: vec![
13263 ai_agents_core::PathPolicyBinding::read_write("source_path"),
13264 ai_agents_core::PathPolicyBinding::write("destination_path"),
13265 ],
13266 ..Default::default()
13267 }
13268 }
13269
13270 fn safety_metadata(&self) -> ai_agents_core::ToolSafetyMetadata {
13271 LockedWriteTool {
13272 active: Arc::clone(&self.active),
13273 max_active: Arc::clone(&self.max_active),
13274 }
13275 .safety_metadata()
13276 }
13277
13278 async fn execute(
13279 &self,
13280 _args: Value,
13281 _ctx: ai_agents_core::ToolExecutionContext,
13282 ) -> ToolResult {
13283 let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
13284 self.max_active.fetch_max(active, Ordering::SeqCst);
13285 tokio::time::sleep(std::time::Duration::from_millis(75)).await;
13286 self.active.fetch_sub(1, Ordering::SeqCst);
13287 ToolResult::ok("done")
13288 }
13289 }
13290
13291 #[async_trait]
13292 impl ai_agents_core::Tool for BlockingPathMutationTool {
13293 fn id(&self) -> &str {
13294 self.id
13295 }
13296
13297 fn name(&self) -> &str {
13298 self.id
13299 }
13300
13301 fn description(&self) -> &str {
13302 "Blocks a path mutation until the test releases it."
13303 }
13304
13305 fn input_schema(&self) -> Value {
13306 serde_json::json!({"type": "object"})
13307 }
13308
13309 fn policy_bindings(&self) -> ai_agents_core::ToolPolicyBindings {
13310 ai_agents_core::ToolPolicyBindings {
13311 path_fields: self.path_fields.clone(),
13312 ..Default::default()
13313 }
13314 }
13315
13316 fn safety_metadata(&self) -> ai_agents_core::ToolSafetyMetadata {
13317 ai_agents_core::ToolSafetyMetadata {
13318 read_only: false,
13319 concurrency_safe: false,
13320 operation: ai_agents_core::ToolOperationKind::Write,
13321 side_effect_level: ai_agents_core::ToolSideEffectLevel::LocalWrite,
13322 requires_network: false,
13323 destructive: false,
13324 open_world: false,
13325 host_dependent: false,
13326 requires_user_interaction: false,
13327 supports_cancellation: true,
13328 default_requires_approval: false,
13329 should_defer_schema: false,
13330 max_output_chars: Some(1024),
13331 max_result_size_chars: Some(1024),
13332 }
13333 }
13334
13335 async fn execute(
13336 &self,
13337 _args: Value,
13338 _ctx: ai_agents_core::ToolExecutionContext,
13339 ) -> ToolResult {
13340 self.gate.entered.store(true, Ordering::SeqCst);
13341 self.gate.entered_notify.notify_one();
13342 self.gate.release.notified().await;
13343 ToolResult::ok("done")
13344 }
13345 }
13346
13347 #[async_trait]
13348 impl ai_agents_core::Tool for NoBindingWriteTool {
13349 fn id(&self) -> &str {
13350 "no_binding_write"
13351 }
13352
13353 fn name(&self) -> &str {
13354 "No Binding Write"
13355 }
13356
13357 fn description(&self) -> &str {
13358 "Tracks concurrent execution without resource bindings."
13359 }
13360
13361 fn input_schema(&self) -> Value {
13362 serde_json::json!({"type": "object"})
13363 }
13364
13365 fn safety_metadata(&self) -> ai_agents_core::ToolSafetyMetadata {
13366 LockedWriteTool {
13367 active: Arc::clone(&self.active),
13368 max_active: Arc::clone(&self.max_active),
13369 }
13370 .safety_metadata()
13371 }
13372
13373 async fn execute(
13374 &self,
13375 _args: Value,
13376 _ctx: ai_agents_core::ToolExecutionContext,
13377 ) -> ToolResult {
13378 let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
13379 self.max_active.fetch_max(active, Ordering::SeqCst);
13380 tokio::time::sleep(std::time::Duration::from_millis(75)).await;
13381 self.active.fetch_sub(1, Ordering::SeqCst);
13382 ToolResult::ok("done")
13383 }
13384 }
13385
13386 #[async_trait]
13387 impl ai_agents_core::Tool for RecoveryTestTool {
13388 fn id(&self) -> &str {
13389 &self.id
13390 }
13391
13392 fn name(&self) -> &str {
13393 &self.id
13394 }
13395
13396 fn description(&self) -> &str {
13397 "Records recovery execution and returns a configured result."
13398 }
13399
13400 fn input_schema(&self) -> Value {
13401 serde_json::json!({"type": "object"})
13402 }
13403
13404 fn policy_bindings(&self) -> ai_agents_core::ToolPolicyBindings {
13405 ai_agents_core::ToolPolicyBindings {
13406 path_fields: vec![ai_agents_core::PathPolicyBinding::write("path")],
13407 ..Default::default()
13408 }
13409 }
13410
13411 fn safety_metadata(&self) -> ai_agents_core::ToolSafetyMetadata {
13412 ai_agents_core::ToolSafetyMetadata {
13413 read_only: false,
13414 concurrency_safe: false,
13415 operation: ai_agents_core::ToolOperationKind::Write,
13416 side_effect_level: ai_agents_core::ToolSideEffectLevel::LocalWrite,
13417 requires_network: false,
13418 destructive: false,
13419 open_world: false,
13420 host_dependent: false,
13421 requires_user_interaction: false,
13422 supports_cancellation: true,
13423 default_requires_approval: false,
13424 should_defer_schema: false,
13425 max_output_chars: Some(1024),
13426 max_result_size_chars: Some(1024),
13427 }
13428 }
13429
13430 async fn execute(
13431 &self,
13432 _args: Value,
13433 _ctx: ai_agents_core::ToolExecutionContext,
13434 ) -> ToolResult {
13435 self.calls.fetch_add(1, Ordering::SeqCst);
13436 if self.succeeds {
13437 ToolResult::ok(format!("{} succeeded", self.id))
13438 } else {
13439 ToolResult::error(format!("{} failed", self.id))
13440 }
13441 }
13442 }
13443
13444 #[async_trait]
13445 impl ApprovalHandler for BlockingApprovalHandler {
13446 async fn request_approval(
13447 &self,
13448 _request: ai_agents_hitl::ApprovalRequest,
13449 ) -> ApprovalResult {
13450 self.entered.wait().await;
13451 self.release.notified().await;
13452 self.result.clone()
13453 }
13454 }
13455
13456 #[async_trait]
13457 impl AgentHooks for ReentrantToolHooks {
13458 async fn on_tool_complete(&self, tool: &str, _result: &ToolResult, _duration_ms: u64) {
13459 if tool != "reentrant_write" || self.invoked.swap(true, Ordering::SeqCst) {
13460 return;
13461 }
13462 let agent = self.agent.lock().as_ref().and_then(Weak::upgrade);
13463 if let Some(agent) = agent {
13464 let result = agent
13465 .invoke_tool(ToolExecutionRequest::new(
13466 "nested-hook-call",
13467 "reentrant_write",
13468 serde_json::json!({"path": "./hook.txt"}),
13469 ToolCallSource::Manual,
13470 ))
13471 .await;
13472 self.nested_success
13473 .store(result.is_ok_and(|record| record.success), Ordering::SeqCst);
13474 }
13475 }
13476 }
13477
13478 #[async_trait]
13479 impl AgentHooks for ResponseCountingHooks {
13480 async fn on_response(&self, _response: &AgentResponse) {
13481 self.responses.fetch_add(1, Ordering::SeqCst);
13482 }
13483 }
13484
13485 struct ApprovalRecordingHooks {
13486 events: parking_lot::Mutex<Vec<String>>,
13487 }
13488
13489 impl ApprovalRecordingHooks {
13490 fn new() -> Self {
13491 Self {
13492 events: parking_lot::Mutex::new(Vec::new()),
13493 }
13494 }
13495
13496 fn events(&self) -> Vec<String> {
13497 self.events.lock().clone()
13498 }
13499 }
13500
13501 #[async_trait]
13502 impl AgentHooks for ApprovalRecordingHooks {
13503 async fn on_approval_result(&self, request_id: &str, result: &ApprovalResult) {
13504 self.events.lock().push(format!(
13505 "raw:{}:{}",
13506 request_id,
13507 approval_result_name(result)
13508 ));
13509 }
13510
13511 async fn on_approval_resolved(
13512 &self,
13513 request: &ai_agents_hitl::ApprovalRequest,
13514 raw_result: &ApprovalResult,
13515 outcome: &ApprovalResolvedOutcome,
13516 ) {
13517 self.events.lock().push(format!(
13518 "resolved:{}:{}:{}",
13519 request.id,
13520 approval_result_name(raw_result),
13521 approval_outcome_name(outcome)
13522 ));
13523 }
13524 }
13525
13526 fn approval_result_name(result: &ApprovalResult) -> &'static str {
13527 match result {
13528 ApprovalResult::Approved => "approved",
13529 ApprovalResult::Rejected { .. } => "rejected",
13530 ApprovalResult::Modified { .. } => "modified",
13531 ApprovalResult::Timeout => "timeout",
13532 }
13533 }
13534
13535 fn approval_outcome_name(outcome: &ApprovalResolvedOutcome) -> &'static str {
13536 match outcome {
13537 ApprovalResolvedOutcome::Approved => "approved",
13538 ApprovalResolvedOutcome::Rejected { .. } => "rejected",
13539 ApprovalResolvedOutcome::Modified { .. } => "modified",
13540 ApprovalResolvedOutcome::Error { .. } => "error",
13541 }
13542 }
13543
13544 fn assert_correlated_approval_events(
13545 events: &[String],
13546 raw_status: &str,
13547 outcome_status: &str,
13548 ) {
13549 assert_eq!(events.len(), 2);
13550 let raw: Vec<_> = events[0].split(':').collect();
13551 let resolved: Vec<_> = events[1].split(':').collect();
13552 assert_eq!(raw[0], "raw");
13553 assert_eq!(resolved[0], "resolved");
13554 assert_eq!(raw[1], resolved[1]);
13555 assert_eq!(raw[2], raw_status);
13556 assert_eq!(resolved[2], raw_status);
13557 assert_eq!(resolved[3], outcome_status);
13558 }
13559
13560 fn approval_security_config(policy_enabled: bool) -> ToolSecurityConfig {
13561 let mut security = ToolSecurityConfig {
13562 enabled: true,
13563 fail_closed: true,
13564 ..Default::default()
13565 };
13566 let policy = ai_agents_tools::ToolPolicyConfig {
13567 enabled: policy_enabled,
13568 write_paths: vec![".".to_string()],
13569 require_confirmation: true,
13570 ..Default::default()
13571 };
13572 security.tools.insert("locked_write".to_string(), policy);
13573 security
13574 }
13575
13576 struct MutationTestWorkspace {
13577 root: std::path::PathBuf,
13578 }
13579
13580 impl MutationTestWorkspace {
13581 fn new() -> Self {
13582 let root = std::env::temp_dir().join(format!(
13583 "ai-agents-runtime-mutation-{}",
13584 uuid::Uuid::new_v4()
13585 ));
13586 std::fs::create_dir_all(&root).unwrap();
13587 Self { root }
13588 }
13589 }
13590
13591 impl Drop for MutationTestWorkspace {
13592 fn drop(&mut self) {
13593 let _ = std::fs::remove_dir_all(&self.root);
13594 }
13595 }
13596
13597 async fn wait_for_resource_lock_strong_count(locks: &ToolResourceLocks, minimum: usize) {
13598 tokio::time::timeout(std::time::Duration::from_secs(2), async {
13599 loop {
13600 let strong_count = locks
13601 .read()
13602 .get("path-mutation:global")
13603 .map_or(0, |lock| lock.strong_count());
13604 if strong_count >= minimum {
13605 break;
13606 }
13607 tokio::task::yield_now().await;
13608 }
13609 })
13610 .await
13611 .expect("path mutation call did not reach the shared lock");
13612 }
13613
13614 async fn assert_path_mutation_pair_serialized(
13615 first_id: &'static str,
13616 first_fields: Vec<ai_agents_core::PathPolicyBinding>,
13617 first_args: Value,
13618 second_id: &'static str,
13619 second_fields: Vec<ai_agents_core::PathPolicyBinding>,
13620 second_args: Value,
13621 ) {
13622 let locks = new_tool_resource_locks();
13623 let first_gate = PathMutationGate::new();
13624 let second_gate = PathMutationGate::new();
13625 second_gate.release();
13626 let agent = Arc::new(
13627 AgentBuilder::new()
13628 .system_prompt("Test global path mutation locking.")
13629 .llm(Arc::new(mock_with_response("done")))
13630 .tool(Arc::new(BlockingPathMutationTool {
13631 id: first_id,
13632 path_fields: first_fields,
13633 gate: first_gate.clone(),
13634 }))
13635 .tool(Arc::new(BlockingPathMutationTool {
13636 id: second_id,
13637 path_fields: second_fields,
13638 gate: second_gate.clone(),
13639 }))
13640 .build()
13641 .unwrap()
13642 .with_shared_resource_locks(Arc::clone(&locks)),
13643 );
13644
13645 let first = {
13646 let agent = Arc::clone(&agent);
13647 tokio::spawn(async move {
13648 agent
13649 .invoke_tool(ToolExecutionRequest::new(
13650 format!("{}-first", first_id),
13651 first_id,
13652 first_args,
13653 ToolCallSource::Manual,
13654 ))
13655 .await
13656 .unwrap()
13657 })
13658 };
13659 first_gate.wait_until_entered().await;
13660
13661 let second = {
13662 let agent = Arc::clone(&agent);
13663 tokio::spawn(async move {
13664 agent
13665 .invoke_tool(ToolExecutionRequest::new(
13666 format!("{}-second", second_id),
13667 second_id,
13668 second_args,
13669 ToolCallSource::Manual,
13670 ))
13671 .await
13672 .unwrap()
13673 })
13674 };
13675 wait_for_resource_lock_strong_count(&locks, 2).await;
13676 assert!(!second_gate.entered.load(Ordering::SeqCst));
13677 assert!(!second.is_finished());
13678
13679 first_gate.release();
13680 let (first, second) = tokio::time::timeout(std::time::Duration::from_secs(2), async {
13681 tokio::join!(first, second)
13682 })
13683 .await
13684 .expect("serialized path mutation calls did not finish");
13685 assert!(first.unwrap().success);
13686 assert!(second.unwrap().success);
13687 assert!(second_gate.entered.load(Ordering::SeqCst));
13688 assert!(locks.read().is_empty());
13689 }
13690
13691 #[derive(Clone, Copy)]
13692 enum MutationDenial {
13693 Policy,
13694 Approval,
13695 }
13696
13697 fn mutation_denial_security_config(
13698 tool_id: &str,
13699 workspace: &std::path::Path,
13700 denial: MutationDenial,
13701 ) -> ToolSecurityConfig {
13702 let workspace = workspace.to_string_lossy().into_owned();
13703 let mut policy = ai_agents_tools::ToolPolicyConfig {
13704 read_paths: vec![workspace.clone()],
13705 write_paths: vec![workspace.clone()],
13706 ..Default::default()
13707 };
13708 match denial {
13709 MutationDenial::Policy => policy.blocked_paths = vec![workspace],
13710 MutationDenial::Approval => policy.require_confirmation = true,
13711 }
13712
13713 let mut security = ToolSecurityConfig {
13714 enabled: true,
13715 fail_closed: true,
13716 ..Default::default()
13717 };
13718 security.tools.insert(tool_id.to_string(), policy);
13719 security
13720 }
13721
13722 async fn assert_path_mutation_denied(tool: Arc<dyn Tool>, denial: MutationDenial) {
13723 let workspace = MutationTestWorkspace::new();
13724 let tool_id = tool.id().to_string();
13725 let preserved = workspace.root.join(format!("{}-preserved.txt", tool_id));
13726 let destination = workspace.root.join(format!("{}-destination.txt", tool_id));
13727 std::fs::write(&preserved, "preserved").unwrap();
13728 let arguments = match tool_id.as_str() {
13729 "copy_path" | "move_path" => serde_json::json!({
13730 "source_path": preserved.to_string_lossy(),
13731 "destination_path": destination.to_string_lossy(),
13732 "dry_run": false
13733 }),
13734 "delete_path" => serde_json::json!({
13735 "path": preserved.to_string_lossy(),
13736 "recursive": false,
13737 "dry_run": false
13738 }),
13739 _ => panic!("unsupported mutation tool: {}", tool_id),
13740 };
13741 let security = mutation_denial_security_config(&tool_id, &workspace.root, denial);
13742 let builder = AgentBuilder::new()
13743 .system_prompt("Test mutation denial.")
13744 .llm(Arc::new(mock_with_response("done")))
13745 .tool(tool)
13746 .tool_security(ToolSecurityEngine::new(security));
13747 let builder = match denial {
13748 MutationDenial::Policy => builder,
13749 MutationDenial::Approval => builder
13750 .hitl_engine(HITLEngine::new(ai_agents_hitl::HITLConfig::default()))
13751 .approval_handler(Arc::new(RejectAllHandler::new())),
13752 };
13753 let agent = builder.build().unwrap();
13754
13755 let record = agent
13756 .invoke_tool(ToolExecutionRequest::new(
13757 format!("{}-denied", tool_id),
13758 tool_id.clone(),
13759 arguments,
13760 ToolCallSource::Manual,
13761 ))
13762 .await
13763 .unwrap();
13764
13765 assert!(!record.executed, "{} must not be invoked", tool_id);
13766 assert!(!record.success);
13767 match denial {
13768 MutationDenial::Policy => {
13769 assert_eq!(record.policy.outcome, PermissionOutcome::Deny);
13770 assert!(record.approval.as_ref().is_some_and(|approval| matches!(
13771 &approval.status,
13772 ToolApprovalStatus::NotRequired
13773 )));
13774 }
13775 MutationDenial::Approval => {
13776 assert_eq!(record.policy.outcome, PermissionOutcome::RequiresApproval);
13777 assert!(record.approval.as_ref().is_some_and(|approval| matches!(
13778 &approval.status,
13779 ToolApprovalStatus::Rejected
13780 )));
13781 }
13782 }
13783 assert_eq!(std::fs::read_to_string(&preserved).unwrap(), "preserved");
13784 assert!(!destination.exists());
13785 }
13786
13787 fn recovery_manager_with_fallbacks(
13788 fallbacks: impl IntoIterator<Item = (String, String)>,
13789 ) -> RecoveryManager {
13790 use ai_agents_recovery::{ErrorRecoveryConfig, ToolRecoveryConfig, ToolRetryConfig};
13791
13792 let per_tool = fallbacks
13793 .into_iter()
13794 .map(|(tool, fallback_tool)| {
13795 (
13796 tool,
13797 ToolRetryConfig {
13798 max_retries: 0,
13799 timeout_ms: Some(1_000),
13800 on_failure: ToolFailureAction::Fallback { fallback_tool },
13801 },
13802 )
13803 })
13804 .collect();
13805 RecoveryManager::new(ErrorRecoveryConfig {
13806 tools: ToolRecoveryConfig {
13807 per_tool,
13808 ..Default::default()
13809 },
13810 ..Default::default()
13811 })
13812 }
13813
13814 fn approval_check() -> HITLCheckResult {
13815 HITLCheckResult::required(
13816 ApprovalTrigger::tool("test", serde_json::json!({})),
13817 HashMap::new(),
13818 "Approve?",
13819 None,
13820 )
13821 }
13822
13823 fn agent_with_approval_result(
13824 raw_result: ApprovalResult,
13825 timeout_action: TimeoutAction,
13826 hooks: Arc<ApprovalRecordingHooks>,
13827 ) -> RuntimeAgent {
13828 use ai_agents_hitl::{CallbackHandler, HITLConfig};
13829
13830 let config = HITLConfig {
13831 on_timeout: timeout_action,
13832 ..Default::default()
13833 };
13834 let handler = CallbackHandler::new(move |_| raw_result.clone());
13835 AgentBuilder::new()
13836 .system_prompt("Test HITL hooks.")
13837 .llm(Arc::new(mock_with_response("done")))
13838 .build()
13839 .unwrap()
13840 .with_hooks(hooks)
13841 .with_hitl(HITLEngine::new(config), Arc::new(handler))
13842 }
13843
13844 #[tokio::test]
13845 async fn approval_hooks_expose_direct_effective_decisions_after_raw_results() {
13846 let cases = vec![
13847 (ApprovalResult::Approved, "approved"),
13848 (
13849 ApprovalResult::Rejected {
13850 reason: Some("denied".to_string()),
13851 },
13852 "rejected",
13853 ),
13854 (
13855 ApprovalResult::Modified {
13856 changes: HashMap::from([("value".to_string(), serde_json::json!(2))]),
13857 },
13858 "modified",
13859 ),
13860 ];
13861
13862 for (raw_result, expected) in cases {
13863 let hooks = Arc::new(ApprovalRecordingHooks::new());
13864 let agent =
13865 agent_with_approval_result(raw_result, TimeoutAction::Reject, hooks.clone());
13866
13867 let result = agent.request_hitl_approval(approval_check()).await.unwrap();
13868
13869 assert_eq!(approval_result_name(&result), expected);
13870 assert_correlated_approval_events(&hooks.events(), expected, expected);
13871 }
13872 }
13873
13874 #[tokio::test]
13875 async fn approval_hooks_expose_timeout_policy_decisions() {
13876 for (timeout_action, expected) in [
13877 (TimeoutAction::Approve, "approved"),
13878 (TimeoutAction::Reject, "rejected"),
13879 ] {
13880 let hooks = Arc::new(ApprovalRecordingHooks::new());
13881 let agent =
13882 agent_with_approval_result(ApprovalResult::Timeout, timeout_action, hooks.clone());
13883
13884 let result = agent.request_hitl_approval(approval_check()).await.unwrap();
13885
13886 assert_eq!(approval_result_name(&result), expected);
13887 assert_correlated_approval_events(&hooks.events(), "timeout", expected);
13888 }
13889 }
13890
13891 #[tokio::test]
13892 async fn timeout_error_fires_correlated_resolved_error_before_returning() {
13893 let hooks = Arc::new(ApprovalRecordingHooks::new());
13894 let agent = agent_with_approval_result(
13895 ApprovalResult::Timeout,
13896 TimeoutAction::Error,
13897 hooks.clone(),
13898 );
13899
13900 let error = agent
13901 .request_hitl_approval(approval_check())
13902 .await
13903 .unwrap_err();
13904
13905 assert!(error.to_string().contains("HITL approval timeout"));
13906 assert_correlated_approval_events(&hooks.events(), "timeout", "error");
13907 }
13908
13909 #[tokio::test]
13911 async fn test_integration_yaml_to_chat_basic() {
13912 let mock = mock_with_response("Hello! How can I help you?");
13913 let agent = AgentBuilder::new()
13914 .system_prompt("You are a test assistant.")
13915 .llm(Arc::new(mock))
13916 .build()
13917 .unwrap();
13918
13919 let response = agent.chat("Hi").await.unwrap();
13920 assert!(!response.content.is_empty());
13921 assert_eq!(response.content, "Hello! How can I help you?");
13922 }
13923
13924 #[tokio::test]
13926 async fn test_integration_multi_turn_conversation() {
13927 let mock = mock_with_responses(vec![
13928 "Hello! I'm your assistant.",
13929 "The weather is sunny today.",
13930 "Goodbye!",
13931 ]);
13932 let agent = AgentBuilder::new()
13933 .system_prompt("You are helpful.")
13934 .llm(Arc::new(mock))
13935 .build()
13936 .unwrap();
13937
13938 let r1 = agent.chat("Hi").await.unwrap();
13939 assert_eq!(r1.content, "Hello! I'm your assistant.");
13940
13941 let r2 = agent.chat("What's the weather?").await.unwrap();
13942 assert_eq!(r2.content, "The weather is sunny today.");
13943
13944 let r3 = agent.chat("Bye").await.unwrap();
13945 assert_eq!(r3.content, "Goodbye!");
13946
13947 let messages = agent.memory.get_messages(None).await.unwrap();
13949 assert_eq!(messages.len(), 6);
13951 }
13952
13953 #[test]
13954 fn later_approval_preserves_modified_evidence() {
13955 let arguments = serde_json::json!({"dry_run": true});
13956 let mut record = Some(ToolApprovalRecord {
13957 status: ToolApprovalStatus::Modified,
13958 reason: None,
13959 modified_arguments: Some(arguments.clone()),
13960 });
13961
13962 merge_approved_record(&mut record);
13963
13964 let record = record.unwrap();
13965 assert!(matches!(record.status, ToolApprovalStatus::Modified));
13966 assert_eq!(record.modified_arguments, Some(arguments));
13967 }
13968
13969 #[test]
13970 fn approval_binding_rejects_replaced_tool_implementation() {
13971 let reviewed_tool: Arc<dyn ai_agents_core::Tool> = Arc::new(ContextEchoTool);
13972 let same_tool = Arc::clone(&reviewed_tool);
13973 let replacement_tool: Arc<dyn ai_agents_core::Tool> = Arc::new(ContextEchoTool);
13974 let arguments = serde_json::json!({"path": "."});
13975 let versions = ToolDecisionVersions {
13976 policy: 2,
13977 registry: 3,
13978 runtime_control: 4,
13979 state: Some(5),
13980 };
13981 let binding = ToolApprovalBinding {
13982 canonical_id: "context_echo".to_string(),
13983 arguments: arguments.clone(),
13984 confirmation_required: true,
13985 policy_version: versions.policy,
13986 runtime_control_version: versions.runtime_control,
13987 state_generation: versions.state,
13988 reviewed_tool,
13989 };
13990
13991 assert!(!binding.is_stale("context_echo", &arguments, true, versions, &same_tool,));
13992 assert!(binding.is_stale(
13993 "context_echo",
13994 &arguments,
13995 true,
13996 versions,
13997 &replacement_tool,
13998 ));
13999 }
14000
14001 #[tokio::test]
14002 async fn approved_mutation_to_dry_run_remains_executable() {
14003 use ai_agents_hitl::CallbackHandler;
14004
14005 let handler = CallbackHandler::new(|_| ApprovalResult::Modified {
14006 changes: HashMap::from([("dry_run".to_string(), serde_json::json!(true))]),
14007 });
14008 let agent = AgentBuilder::new()
14009 .system_prompt("Test safer approval modifications.")
14010 .llm(Arc::new(mock_with_response("done")))
14011 .tool(Arc::new(ai_agents_tools::FileWriteTool::new()))
14012 .hitl_engine(HITLEngine::new(ai_agents_hitl::HITLConfig::default()))
14013 .approval_handler(Arc::new(handler))
14014 .build()
14015 .unwrap();
14016
14017 let record = agent
14018 .invoke_tool(ToolExecutionRequest::new(
14019 "approved-dry-run",
14020 "file_write",
14021 serde_json::json!({
14022 "path": "./approval-dry-run.txt",
14023 "content": "not written"
14024 }),
14025 ToolCallSource::Manual,
14026 ))
14027 .await
14028 .unwrap();
14029
14030 assert!(record.executed);
14031 assert!(record.success);
14032 assert_eq!(record.executed_arguments["dry_run"], true);
14033 assert!(matches!(
14034 record.approval.as_ref().map(|approval| &approval.status),
14035 Some(ToolApprovalStatus::Modified)
14036 ));
14037 let output: Value = serde_json::from_str(&record.output).unwrap();
14038 assert_eq!(output["mutation_performed"], false);
14039 }
14040
14041 #[tokio::test]
14042 async fn context_preserves_requested_and_canonical_identity() {
14043 let mock = mock_with_response("hello");
14044 let mut tools = ai_agents_tools::ToolRegistry::new();
14045 tools.register(Arc::new(ContextEchoTool)).unwrap();
14046
14047 let mut security = ToolSecurityConfig {
14048 enabled: true,
14049 fail_closed: true,
14050 ..Default::default()
14051 };
14052 let mut policy = ai_agents_tools::ToolPolicyConfig {
14053 read_paths: vec![".".to_string()],
14054 max_results: Some(7),
14055 ..Default::default()
14056 };
14057 policy
14058 .config
14059 .insert("backend".to_string(), serde_json::json!("memory"));
14060 security.tools.insert("context_echo".to_string(), policy);
14061
14062 let agent = AgentBuilder::new()
14063 .system_prompt("You are helpful.")
14064 .llm(Arc::new(mock))
14065 .tools(tools)
14066 .tool_security(ToolSecurityEngine::new(security))
14067 .build()
14068 .unwrap();
14069
14070 let record = agent
14071 .invoke_tool(ToolExecutionRequest::new(
14072 "ctx-call",
14073 "Context Echo",
14074 serde_json::json!({"path": ".", "max_results": 99}),
14075 ToolCallSource::Manual,
14076 ))
14077 .await
14078 .unwrap();
14079
14080 assert!(record.success);
14081 assert!(matches!(&record.source, ToolCallSource::Manual));
14082 assert_eq!(record.requested_name, "Context Echo");
14083 assert_eq!(record.canonical_id, "context_echo");
14084 assert_eq!(record.policy.outcome, PermissionOutcome::Allow);
14085 assert_eq!(record.executed_arguments["max_results"], 7);
14086 let output: Value = serde_json::from_str(&record.output).unwrap();
14087 assert_eq!(output["requested_name"], "Context Echo");
14088 assert_eq!(output["canonical_id"], "context_echo");
14089 assert_eq!(output["max_results"], 7);
14090 assert_eq!(output["custom_config"]["backend"], "memory");
14091 assert!(record.metadata.contains_key("effective_limits"));
14092 assert!(record.metadata.contains_key("policy_snapshot"));
14093 }
14094
14095 #[tokio::test]
14096 async fn test_runtime_control_cancels_active_tool_call() {
14097 let mock = mock_with_response("hello");
14098 let agent = Arc::new(
14099 AgentBuilder::new()
14100 .system_prompt("You are helpful.")
14101 .llm(Arc::new(mock))
14102 .tool(Arc::new(SlowTool))
14103 .build()
14104 .unwrap(),
14105 );
14106 let control = agent.runtime_control();
14107 let running_agent = Arc::clone(&agent);
14108 let handle = tokio::spawn(async move {
14109 running_agent
14110 .invoke_tool(ToolExecutionRequest::new(
14111 "slow-call",
14112 "slow",
14113 serde_json::json!({}),
14114 ToolCallSource::Manual,
14115 ))
14116 .await
14117 .unwrap()
14118 });
14119
14120 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
14121 control.cancel_all();
14122 let record = handle.await.unwrap();
14123
14124 assert!(record.executed);
14125 assert!(record.cancelled);
14126 assert!(!record.success);
14127 assert!(record.cancellation_reason.is_some());
14128 }
14129
14130 #[tokio::test]
14131 async fn non_idempotent_tool_calls_are_not_retried() {
14132 use ai_agents_recovery::{ErrorRecoveryConfig, ToolRecoveryConfig, ToolRetryConfig};
14133
14134 let mock = mock_with_response("hello");
14135 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14136 let agent = AgentBuilder::new()
14137 .system_prompt("You are helpful.")
14138 .llm(Arc::new(mock))
14139 .tool(Arc::new(FlakyWriteTool {
14140 calls: Arc::clone(&calls),
14141 }))
14142 .recovery_manager(RecoveryManager::new(ErrorRecoveryConfig {
14143 tools: ToolRecoveryConfig {
14144 default: ToolRetryConfig {
14145 max_retries: 2,
14146 ..Default::default()
14147 },
14148 ..Default::default()
14149 },
14150 ..Default::default()
14151 }))
14152 .build()
14153 .unwrap();
14154
14155 let record = agent
14156 .invoke_tool(ToolExecutionRequest::new(
14157 "flaky-call",
14158 "flaky_write",
14159 serde_json::json!({"path": "./tmp.txt"}),
14160 ToolCallSource::Manual,
14161 ))
14162 .await
14163 .unwrap();
14164
14165 assert!(!record.success);
14166 assert_eq!(calls.load(Ordering::SeqCst), 1);
14167 }
14168
14169 #[tokio::test]
14170 async fn side_effecting_tools_are_serialized_per_resource() {
14171 let mock = mock_with_response("hello");
14172 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14173 let max_active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14174 let agent = Arc::new(
14175 AgentBuilder::new()
14176 .system_prompt("You are helpful.")
14177 .llm(Arc::new(mock))
14178 .tool(Arc::new(LockedWriteTool {
14179 active: Arc::clone(&active),
14180 max_active: Arc::clone(&max_active),
14181 }))
14182 .build()
14183 .unwrap(),
14184 );
14185
14186 let left = {
14187 let agent = Arc::clone(&agent);
14188 tokio::spawn(async move {
14189 agent
14190 .invoke_tool(ToolExecutionRequest::new(
14191 "lock-1",
14192 "locked_write",
14193 serde_json::json!({"path": "./same.txt"}),
14194 ToolCallSource::Manual,
14195 ))
14196 .await
14197 .unwrap()
14198 })
14199 };
14200 let right = {
14201 let agent = Arc::clone(&agent);
14202 tokio::spawn(async move {
14203 agent
14204 .invoke_tool(ToolExecutionRequest::new(
14205 "lock-2",
14206 "locked_write",
14207 serde_json::json!({"path": "./same.txt"}),
14208 ToolCallSource::Manual,
14209 ))
14210 .await
14211 .unwrap()
14212 })
14213 };
14214
14215 let left = left.await.unwrap();
14216 let right = right.await.unwrap();
14217 assert!(left.success);
14218 assert!(right.success);
14219 assert_eq!(max_active.load(Ordering::SeqCst), 1);
14220 }
14221
14222 #[tokio::test]
14223 async fn path_resources_use_shared_global_lock_and_cleanup() {
14224 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14225 let max_active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14226 let bindings = ai_agents_core::ToolPolicyBindings {
14227 path_fields: vec![
14228 ai_agents_core::PathPolicyBinding::read_write("source_path"),
14229 ai_agents_core::PathPolicyBinding::write("destination_path"),
14230 ],
14231 ..Default::default()
14232 };
14233 let classification = ai_agents_core::ToolCallClassification::from_metadata(
14234 &MultiResourceWriteTool {
14235 active: Arc::clone(&active),
14236 max_active: Arc::clone(&max_active),
14237 }
14238 .safety_metadata(),
14239 );
14240 let left_args = serde_json::json!({
14241 "source_path": "./a/../first.txt",
14242 "destination_path": "./second.txt"
14243 });
14244 let right_args = serde_json::json!({
14245 "source_path": "./second.txt",
14246 "destination_path": "./first.txt"
14247 });
14248 let left_keys = tool_resource_lock_keys(
14249 "multi_resource_write",
14250 &left_args,
14251 &bindings,
14252 &classification,
14253 );
14254 let right_keys = tool_resource_lock_keys(
14255 "multi_resource_write",
14256 &right_args,
14257 &bindings,
14258 &classification,
14259 );
14260 assert_eq!(left_keys, right_keys);
14261 assert_eq!(left_keys, vec!["path-mutation:global".to_string()]);
14262
14263 let locks = new_tool_resource_locks();
14264 let build_agent = || {
14265 AgentBuilder::new()
14266 .system_prompt("Test shared resource locks.")
14267 .llm(Arc::new(mock_with_response("done")))
14268 .tool(Arc::new(MultiResourceWriteTool {
14269 active: Arc::clone(&active),
14270 max_active: Arc::clone(&max_active),
14271 }))
14272 .build()
14273 .unwrap()
14274 .with_shared_resource_locks(Arc::clone(&locks))
14275 };
14276 let left_agent = Arc::new(build_agent());
14277 let right_agent = Arc::new(build_agent());
14278 let left = tokio::spawn(async move {
14279 left_agent
14280 .invoke_tool(ToolExecutionRequest::new(
14281 "multi-left",
14282 "multi_resource_write",
14283 left_args,
14284 ToolCallSource::Manual,
14285 ))
14286 .await
14287 .unwrap()
14288 });
14289 let right = tokio::spawn(async move {
14290 right_agent
14291 .invoke_tool(ToolExecutionRequest::new(
14292 "multi-right",
14293 "multi_resource_write",
14294 right_args,
14295 ToolCallSource::Manual,
14296 ))
14297 .await
14298 .unwrap()
14299 });
14300 let (left, right) = tokio::time::timeout(std::time::Duration::from_secs(2), async {
14301 tokio::join!(left, right)
14302 })
14303 .await
14304 .expect("reversed resource acquisition must not deadlock");
14305
14306 assert!(left.unwrap().success);
14307 assert!(right.unwrap().success);
14308 assert_eq!(max_active.load(Ordering::SeqCst), 1);
14309 assert!(locks.read().is_empty());
14310 }
14311
14312 #[tokio::test]
14313 async fn global_path_lock_serializes_copy_destination_with_file_write() {
14314 assert_path_mutation_pair_serialized(
14315 "copy_path",
14316 CopyPathTool::new().policy_bindings().path_fields,
14317 serde_json::json!({
14318 "source_path": "./source.txt",
14319 "destination_path": "./shared.txt"
14320 }),
14321 "file_write",
14322 FileWriteTool::new().policy_bindings().path_fields,
14323 serde_json::json!({"path": "./shared.txt"}),
14324 )
14325 .await;
14326 }
14327
14328 #[tokio::test]
14329 async fn parent_and_spawned_runtime_share_global_path_lock() {
14330 let workspace = MutationTestWorkspace::new();
14331 let destination = workspace.root.join("spawned.txt");
14332 let parent_gate = PathMutationGate::new();
14333 let parent = Arc::new(
14334 AgentBuilder::from_yaml(
14335 r#"
14336name: LockParent
14337system_prompt: parent
14338llm:
14339 default: default
14340tools:
14341 - parent_path_write
14342spawner:
14343 shared_llms: true
14344"#,
14345 )
14346 .unwrap()
14347 .llm(Arc::new(mock_with_response("done")))
14348 .auto_configure_spawner()
14349 .await
14350 .unwrap()
14351 .tool(Arc::new(BlockingPathMutationTool {
14352 id: "parent_path_write",
14353 path_fields: vec![ai_agents_core::PathPolicyBinding::write("path")],
14354 gate: parent_gate.clone(),
14355 }))
14356 .build()
14357 .unwrap(),
14358 );
14359
14360 let mut child_spec = crate::spec::AgentSpec {
14361 name: "LockChild".to_string(),
14362 system_prompt: "child".to_string(),
14363 tools: Some(vec![crate::spec::ToolEntry::Simple(
14364 "file_write".to_string(),
14365 )]),
14366 ..Default::default()
14367 };
14368 child_spec.tool_security.enabled = true;
14369 child_spec.tool_security.fail_closed = true;
14370 let file_write_policy = ai_agents_tools::ToolPolicyConfig {
14371 write_paths: vec![workspace.root.to_string_lossy().into_owned()],
14372 allow_without_confirmation: true,
14373 ..Default::default()
14374 };
14375 child_spec
14376 .tool_security
14377 .tools
14378 .insert("file_write".to_string(), file_write_policy);
14379 let spawned = parent
14380 .spawner()
14381 .unwrap()
14382 .spawn_from_spec(child_spec)
14383 .await
14384 .unwrap();
14385 assert!(Arc::ptr_eq(
14386 &parent.resource_locks,
14387 &spawned.agent.resource_locks
14388 ));
14389 assert!(!Arc::ptr_eq(
14390 &parent.runtime_control,
14391 &spawned.agent.runtime_control
14392 ));
14393
14394 let parent_call = {
14395 let parent = Arc::clone(&parent);
14396 let destination = destination.clone();
14397 tokio::spawn(async move {
14398 parent
14399 .invoke_tool(ToolExecutionRequest::new(
14400 "parent-lock-holder",
14401 "parent_path_write",
14402 serde_json::json!({"path": destination}),
14403 ToolCallSource::Manual,
14404 ))
14405 .await
14406 .unwrap()
14407 })
14408 };
14409 parent_gate.wait_until_entered().await;
14410
14411 let child_call = {
14412 let child = Arc::clone(&spawned.agent);
14413 let destination = destination.clone();
14414 tokio::spawn(async move {
14415 child
14416 .invoke_tool(ToolExecutionRequest::new(
14417 "spawned-file-write",
14418 "file_write",
14419 serde_json::json!({
14420 "path": destination,
14421 "content": "spawned",
14422 "dry_run": false
14423 }),
14424 ToolCallSource::Manual,
14425 ))
14426 .await
14427 .unwrap()
14428 })
14429 };
14430 wait_for_resource_lock_strong_count(&parent.resource_locks, 2).await;
14431 assert!(!child_call.is_finished());
14432
14433 parent_gate.release();
14434 let (parent_record, child_record) =
14435 tokio::time::timeout(std::time::Duration::from_secs(2), async {
14436 tokio::join!(parent_call, child_call)
14437 })
14438 .await
14439 .expect("parent and spawned path mutations did not finish");
14440 assert!(parent_record.unwrap().success);
14441 assert!(child_record.unwrap().success);
14442 assert_eq!(std::fs::read_to_string(destination).unwrap(), "spawned");
14443 assert!(parent.resource_locks.read().is_empty());
14444 }
14445
14446 #[tokio::test]
14447 async fn cancelled_global_path_lock_waiter_does_not_retain_weak_entry() {
14448 let locks = new_tool_resource_locks();
14449 let holder_gate = PathMutationGate::new();
14450 let waiter_gate = PathMutationGate::new();
14451 waiter_gate.release();
14452 let holder = Arc::new(
14453 AgentBuilder::new()
14454 .system_prompt("Hold the global path lock.")
14455 .llm(Arc::new(mock_with_response("done")))
14456 .tool(Arc::new(BlockingPathMutationTool {
14457 id: "holder_write",
14458 path_fields: vec![ai_agents_core::PathPolicyBinding::write("path")],
14459 gate: holder_gate.clone(),
14460 }))
14461 .build()
14462 .unwrap()
14463 .with_shared_resource_locks(Arc::clone(&locks)),
14464 );
14465 let waiter = Arc::new(
14466 AgentBuilder::new()
14467 .system_prompt("Wait for the global path lock.")
14468 .llm(Arc::new(mock_with_response("done")))
14469 .tool(Arc::new(BlockingPathMutationTool {
14470 id: "waiter_write",
14471 path_fields: vec![ai_agents_core::PathPolicyBinding::write("path")],
14472 gate: waiter_gate.clone(),
14473 }))
14474 .build()
14475 .unwrap()
14476 .with_shared_resource_locks(Arc::clone(&locks)),
14477 );
14478
14479 let holder_call = {
14480 let holder = Arc::clone(&holder);
14481 tokio::spawn(async move {
14482 holder
14483 .invoke_tool(ToolExecutionRequest::new(
14484 "holder-call",
14485 "holder_write",
14486 serde_json::json!({"path": "./shared.txt"}),
14487 ToolCallSource::Manual,
14488 ))
14489 .await
14490 .unwrap()
14491 })
14492 };
14493 holder_gate.wait_until_entered().await;
14494
14495 let waiter_call = {
14496 let waiter = Arc::clone(&waiter);
14497 tokio::spawn(async move {
14498 waiter
14499 .invoke_tool(ToolExecutionRequest::new(
14500 "waiter-call",
14501 "waiter_write",
14502 serde_json::json!({"path": "./shared.txt"}),
14503 ToolCallSource::Manual,
14504 ))
14505 .await
14506 .unwrap()
14507 })
14508 };
14509 wait_for_resource_lock_strong_count(&locks, 2).await;
14510 waiter.runtime_control().cancel_all();
14511
14512 let waiter_record = tokio::time::timeout(std::time::Duration::from_secs(2), waiter_call)
14513 .await
14514 .expect("cancelled lock waiter did not finish")
14515 .unwrap();
14516 assert!(!waiter_record.executed);
14517 assert!(!waiter_gate.entered.load(Ordering::SeqCst));
14518 assert_eq!(
14519 locks
14520 .read()
14521 .get("path-mutation:global")
14522 .map_or(0, |lock| lock.strong_count()),
14523 1
14524 );
14525
14526 holder_gate.release();
14527 let holder_record = tokio::time::timeout(std::time::Duration::from_secs(2), holder_call)
14528 .await
14529 .expect("lock holder did not finish")
14530 .unwrap();
14531 assert!(holder_record.success);
14532 assert!(locks.read().is_empty());
14533 }
14534
14535 #[tokio::test]
14536 async fn path_mutation_policy_and_approval_denials_do_not_invoke_tools() {
14537 for denial in [MutationDenial::Policy, MutationDenial::Approval] {
14538 let tools: [Arc<dyn Tool>; 3] = [
14539 Arc::new(CopyPathTool::new()),
14540 Arc::new(MovePathTool::new()),
14541 Arc::new(DeletePathTool::new()),
14542 ];
14543 for tool in tools {
14544 assert_path_mutation_denied(tool, denial).await;
14545 }
14546 }
14547 }
14548
14549 #[tokio::test]
14550 async fn approval_argument_changes_are_rechecked_against_final_scope() {
14551 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14552 let max_active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14553 let entered = Arc::new(tokio::sync::Barrier::new(2));
14554 let release = Arc::new(tokio::sync::Notify::new());
14555 let handler = Arc::new(BlockingApprovalHandler {
14556 entered: Arc::clone(&entered),
14557 release: Arc::clone(&release),
14558 result: ApprovalResult::Modified {
14559 changes: HashMap::from([(
14560 "path".to_string(),
14561 Value::String("./after-approval.txt".to_string()),
14562 )]),
14563 },
14564 });
14565 let agent = Arc::new(
14566 AgentBuilder::new()
14567 .system_prompt("Test final scope validation.")
14568 .llm(Arc::new(mock_with_response("done")))
14569 .tool(Arc::new(LockedWriteTool {
14570 active: Arc::clone(&active),
14571 max_active: Arc::clone(&max_active),
14572 }))
14573 .tool_security(ToolSecurityEngine::new(approval_security_config(true)))
14574 .hitl_engine(HITLEngine::new(ai_agents_hitl::HITLConfig::default()))
14575 .approval_handler(handler)
14576 .build()
14577 .unwrap(),
14578 );
14579 let control = agent.runtime_control();
14580 let running = Arc::clone(&agent);
14581 let call = tokio::spawn(async move {
14582 running
14583 .invoke_tool(ToolExecutionRequest::new(
14584 "approval-scope",
14585 "locked_write",
14586 serde_json::json!({"path": "./before-approval.txt"}),
14587 ToolCallSource::Manual,
14588 ))
14589 .await
14590 .unwrap()
14591 });
14592 entered.wait().await;
14593 let expected_version = control.set_tool_scope(Vec::new());
14594 release.notify_one();
14595 let record = call.await.unwrap();
14596
14597 assert!(!record.executed);
14598 assert!(!record.success);
14599 assert_eq!(record.runtime_config_version, expected_version);
14600 assert_eq!(record.executed_arguments["path"], "./after-approval.txt");
14601 assert_eq!(max_active.load(Ordering::SeqCst), 0);
14602 assert_eq!(
14603 record.metadata["runtime_scope_snapshot"],
14604 serde_json::json!([])
14605 );
14606 }
14607
14608 #[tokio::test]
14609 async fn approval_is_rechecked_against_final_policy_snapshot() {
14610 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14611 let max_active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14612 let entered = Arc::new(tokio::sync::Barrier::new(2));
14613 let release = Arc::new(tokio::sync::Notify::new());
14614 let handler = Arc::new(BlockingApprovalHandler {
14615 entered: Arc::clone(&entered),
14616 release: Arc::clone(&release),
14617 result: ApprovalResult::Approved,
14618 });
14619 let agent = Arc::new(
14620 AgentBuilder::new()
14621 .system_prompt("Test final policy validation.")
14622 .llm(Arc::new(mock_with_response("done")))
14623 .tool(Arc::new(LockedWriteTool {
14624 active: Arc::clone(&active),
14625 max_active: Arc::clone(&max_active),
14626 }))
14627 .tool_security(ToolSecurityEngine::new(approval_security_config(true)))
14628 .hitl_engine(HITLEngine::new(ai_agents_hitl::HITLConfig::default()))
14629 .approval_handler(handler)
14630 .build()
14631 .unwrap(),
14632 );
14633 let control = agent.runtime_control();
14634 let running = Arc::clone(&agent);
14635 let call = tokio::spawn(async move {
14636 running
14637 .invoke_tool(ToolExecutionRequest::new(
14638 "approval-policy",
14639 "locked_write",
14640 serde_json::json!({"path": "./policy.txt"}),
14641 ToolCallSource::Manual,
14642 ))
14643 .await
14644 .unwrap()
14645 });
14646 entered.wait().await;
14647 let expected_version = control.set_tool_security(approval_security_config(false));
14648 release.notify_one();
14649 let record = call.await.unwrap();
14650
14651 assert!(!record.executed);
14652 assert!(!record.success);
14653 assert_eq!(record.runtime_config_version, expected_version);
14654 assert_eq!(record.policy.outcome, PermissionOutcome::Unavailable);
14655 assert_eq!(max_active.load(Ordering::SeqCst), 0);
14656 assert!(record.metadata.contains_key("policy_snapshot"));
14657 }
14658
14659 #[test]
14660 fn invalid_live_policy_does_not_replace_snapshot_or_generation() {
14661 let agent = AgentBuilder::new()
14662 .system_prompt("Test runtime policy validation.")
14663 .llm(Arc::new(mock_with_response("done")))
14664 .build()
14665 .unwrap();
14666 let control = agent.runtime_control();
14667 let mut valid = ToolSecurityConfig::default();
14668 valid.tools.insert(
14669 "web_search".to_string(),
14670 ai_agents_tools::ToolPolicyConfig {
14671 max_results: Some(5),
14672 ..Default::default()
14673 },
14674 );
14675 let generation = control.try_set_tool_security(valid).unwrap();
14676
14677 let mut invalid = ToolSecurityConfig::default();
14678 invalid.tools.insert(
14679 "web_search".to_string(),
14680 ai_agents_tools::ToolPolicyConfig {
14681 max_results: Some(0),
14682 ..Default::default()
14683 },
14684 );
14685 let error = control.try_set_tool_security(invalid).unwrap_err();
14686
14687 assert!(
14688 error
14689 .to_string()
14690 .contains("max_results must be greater than 0")
14691 );
14692 assert_eq!(control.version(), generation);
14693 assert_eq!(
14694 control
14695 .state
14696 .tool_security_override
14697 .read()
14698 .as_ref()
14699 .unwrap()
14700 .config()
14701 .tools["web_search"]
14702 .max_results,
14703 Some(5)
14704 );
14705 }
14706
14707 #[tokio::test]
14708 async fn persistent_override_preserves_rate_history_within_generation() {
14709 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14710 let agent = AgentBuilder::new()
14711 .system_prompt("Test persistent policy overrides.")
14712 .llm(Arc::new(mock_with_response("done")))
14713 .tool(Arc::new(RecoveryTestTool {
14714 id: "limited_override".to_string(),
14715 succeeds: true,
14716 calls: Arc::clone(&calls),
14717 }))
14718 .build()
14719 .unwrap();
14720 let mut security = ToolSecurityConfig {
14721 enabled: true,
14722 fail_closed: true,
14723 ..Default::default()
14724 };
14725 let policy = ai_agents_tools::ToolPolicyConfig {
14726 write_paths: vec![".".to_string()],
14727 rate_limit: Some(1),
14728 ..Default::default()
14729 };
14730 security
14731 .tools
14732 .insert("limited_override".to_string(), policy);
14733 let generation = agent.runtime_control().set_tool_security(security);
14734
14735 let first = agent
14736 .invoke_tool(ToolExecutionRequest::new(
14737 "limited-first",
14738 "limited_override",
14739 serde_json::json!({"path": "./limited.txt"}),
14740 ToolCallSource::Manual,
14741 ))
14742 .await
14743 .unwrap();
14744 let second = agent
14745 .invoke_tool(ToolExecutionRequest::new(
14746 "limited-second",
14747 "limited_override",
14748 serde_json::json!({"path": "./limited.txt"}),
14749 ToolCallSource::Manual,
14750 ))
14751 .await
14752 .unwrap();
14753
14754 assert!(first.success);
14755 assert_eq!(first.policy_version, generation);
14756 assert!(!second.executed);
14757 assert!(second.output.contains("Rate limit exceeded"));
14758 assert_eq!(second.policy_version, generation);
14759 assert_eq!(calls.load(Ordering::SeqCst), 1);
14760 }
14761
14762 #[tokio::test]
14763 async fn concurrent_rate_admission_consumes_capacity_atomically() {
14764 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14765 let tool = Arc::new(RecoveryTestTool {
14766 id: "atomic_rate".to_string(),
14767 succeeds: true,
14768 calls: Arc::clone(&calls),
14769 });
14770 let arguments = serde_json::json!({"path": "./atomic-rate.txt"});
14771 let bindings = tool.policy_bindings();
14772 let classification = tool.classify_call(&arguments);
14773 let resource_keys =
14774 tool_resource_lock_keys(tool.id(), &arguments, &bindings, &classification);
14775 let mut security = ToolSecurityConfig {
14776 enabled: true,
14777 fail_closed: true,
14778 ..Default::default()
14779 };
14780 let policy = ai_agents_tools::ToolPolicyConfig {
14781 write_paths: vec![".".to_string()],
14782 rate_limit: Some(1),
14783 ..Default::default()
14784 };
14785 security.tools.insert(tool.id().to_string(), policy);
14786 let agent = Arc::new(
14787 AgentBuilder::new()
14788 .system_prompt("Test atomic rate admission.")
14789 .llm(Arc::new(mock_with_response("done")))
14790 .tool(tool)
14791 .tool_security(ToolSecurityEngine::new(security))
14792 .build()
14793 .unwrap(),
14794 );
14795 let held = agent
14796 .acquire_tool_resource_locks(&resource_keys)
14797 .await
14798 .unwrap();
14799 let left = {
14800 let agent = Arc::clone(&agent);
14801 let arguments = arguments.clone();
14802 tokio::spawn(async move {
14803 agent
14804 .invoke_tool(ToolExecutionRequest::new(
14805 "atomic-rate-left",
14806 "atomic_rate",
14807 arguments,
14808 ToolCallSource::Manual,
14809 ))
14810 .await
14811 .unwrap()
14812 })
14813 };
14814 let right = {
14815 let agent = Arc::clone(&agent);
14816 tokio::spawn(async move {
14817 agent
14818 .invoke_tool(ToolExecutionRequest::new(
14819 "atomic-rate-right",
14820 "atomic_rate",
14821 arguments,
14822 ToolCallSource::Manual,
14823 ))
14824 .await
14825 .unwrap()
14826 })
14827 };
14828 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
14829 drop(held);
14830 let (left, right) = tokio::join!(left, right);
14831 let records = [left.unwrap(), right.unwrap()];
14832
14833 assert_eq!(records.iter().filter(|record| record.success).count(), 1);
14834 assert_eq!(records.iter().filter(|record| record.executed).count(), 1);
14835 assert!(
14836 records.iter().any(|record| {
14837 !record.executed && record.output.contains("Rate limit exceeded")
14838 })
14839 );
14840 assert_eq!(calls.load(Ordering::SeqCst), 1);
14841 }
14842
14843 #[tokio::test]
14844 async fn changed_policy_generation_invalidates_pending_approval() {
14845 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14846 let max_active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14847 let entered = Arc::new(tokio::sync::Barrier::new(2));
14848 let release = Arc::new(tokio::sync::Notify::new());
14849 let handler = Arc::new(BlockingApprovalHandler {
14850 entered: Arc::clone(&entered),
14851 release: Arc::clone(&release),
14852 result: ApprovalResult::Approved,
14853 });
14854 let agent = Arc::new(
14855 AgentBuilder::new()
14856 .system_prompt("Test stale approval denial.")
14857 .llm(Arc::new(mock_with_response("done")))
14858 .tool(Arc::new(LockedWriteTool {
14859 active: Arc::clone(&active),
14860 max_active: Arc::clone(&max_active),
14861 }))
14862 .tool_security(ToolSecurityEngine::new(approval_security_config(true)))
14863 .hitl_engine(HITLEngine::new(ai_agents_hitl::HITLConfig::default()))
14864 .approval_handler(handler)
14865 .build()
14866 .unwrap(),
14867 );
14868 let running = Arc::clone(&agent);
14869 let call = tokio::spawn(async move {
14870 running
14871 .invoke_tool(ToolExecutionRequest::new(
14872 "stale-approval",
14873 "locked_write",
14874 serde_json::json!({"path": "./stale.txt"}),
14875 ToolCallSource::Manual,
14876 ))
14877 .await
14878 .unwrap()
14879 });
14880 entered.wait().await;
14881 let generation = agent
14882 .runtime_control()
14883 .set_tool_security(approval_security_config(true));
14884 release.notify_one();
14885 let record = call.await.unwrap();
14886
14887 assert!(!record.executed);
14888 assert!(record.output.contains("Approval became stale"));
14889 assert_eq!(record.policy_version, generation);
14890 assert_eq!(max_active.load(Ordering::SeqCst), 0);
14891 }
14892
14893 #[tokio::test]
14894 async fn final_policy_reapplies_argument_caps_after_approval_changes() {
14895 use ai_agents_hitl::CallbackHandler;
14896
14897 let mut security = ToolSecurityConfig {
14898 enabled: true,
14899 fail_closed: true,
14900 ..Default::default()
14901 };
14902 let policy = ai_agents_tools::ToolPolicyConfig {
14903 read_paths: vec![".".to_string()],
14904 max_results: Some(5),
14905 require_confirmation: true,
14906 ..Default::default()
14907 };
14908 security.tools.insert("context_echo".to_string(), policy);
14909 let handler = CallbackHandler::new(|_| ApprovalResult::Modified {
14910 changes: HashMap::from([("max_results".to_string(), serde_json::json!(99))]),
14911 });
14912 let agent = AgentBuilder::new()
14913 .system_prompt("Test final argument caps.")
14914 .llm(Arc::new(mock_with_response("done")))
14915 .tool(Arc::new(ContextEchoTool))
14916 .tool_security(ToolSecurityEngine::new(security))
14917 .hitl_engine(HITLEngine::new(ai_agents_hitl::HITLConfig::default()))
14918 .approval_handler(Arc::new(handler))
14919 .build()
14920 .unwrap();
14921
14922 let record = agent
14923 .invoke_tool(ToolExecutionRequest::new(
14924 "final-cap",
14925 "context_echo",
14926 serde_json::json!({"path": ".", "max_results": 1}),
14927 ToolCallSource::Manual,
14928 ))
14929 .await
14930 .unwrap();
14931
14932 assert!(record.success);
14933 assert_eq!(record.executed_arguments["max_results"], 5);
14934 assert_eq!(
14935 record.approval.unwrap().modified_arguments.unwrap()["max_results"],
14936 5
14937 );
14938 }
14939
14940 #[tokio::test]
14941 async fn no_binding_writes_use_canonical_fallback_lock() {
14942 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14943 let max_active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14944 let agent = Arc::new(
14945 AgentBuilder::new()
14946 .system_prompt("Test fallback resource locks.")
14947 .llm(Arc::new(mock_with_response("done")))
14948 .tool(Arc::new(NoBindingWriteTool {
14949 active: Arc::clone(&active),
14950 max_active: Arc::clone(&max_active),
14951 }))
14952 .build()
14953 .unwrap(),
14954 );
14955 let left = {
14956 let agent = Arc::clone(&agent);
14957 tokio::spawn(async move {
14958 agent
14959 .invoke_tool(ToolExecutionRequest::new(
14960 "no-binding-left",
14961 "no_binding_write",
14962 serde_json::json!({}),
14963 ToolCallSource::Manual,
14964 ))
14965 .await
14966 .unwrap()
14967 })
14968 };
14969 let right = {
14970 let agent = Arc::clone(&agent);
14971 tokio::spawn(async move {
14972 agent
14973 .invoke_tool(ToolExecutionRequest::new(
14974 "no-binding-right",
14975 "no_binding_write",
14976 serde_json::json!({}),
14977 ToolCallSource::Manual,
14978 ))
14979 .await
14980 .unwrap()
14981 })
14982 };
14983 let (left, right) = tokio::join!(left, right);
14984
14985 assert!(left.unwrap().success);
14986 assert!(right.unwrap().success);
14987 assert_eq!(max_active.load(Ordering::SeqCst), 1);
14988 }
14989
14990 #[tokio::test]
14991 async fn parent_and_child_paths_share_a_resource_lock() {
14992 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14993 let max_active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14994 let agent = Arc::new(
14995 AgentBuilder::new()
14996 .system_prompt("Test parent child resource locks.")
14997 .llm(Arc::new(mock_with_response("done")))
14998 .tool(Arc::new(LockedWriteTool {
14999 active: Arc::clone(&active),
15000 max_active: Arc::clone(&max_active),
15001 }))
15002 .build()
15003 .unwrap(),
15004 );
15005 let parent = format!("./lock-parent-{}", uuid::Uuid::new_v4());
15006 let child = format!("{}/child.txt", parent);
15007 let left = {
15008 let agent = Arc::clone(&agent);
15009 tokio::spawn(async move {
15010 agent
15011 .invoke_tool(ToolExecutionRequest::new(
15012 "parent-lock",
15013 "locked_write",
15014 serde_json::json!({"path": parent}),
15015 ToolCallSource::Manual,
15016 ))
15017 .await
15018 .unwrap()
15019 })
15020 };
15021 let right = {
15022 let agent = Arc::clone(&agent);
15023 tokio::spawn(async move {
15024 agent
15025 .invoke_tool(ToolExecutionRequest::new(
15026 "child-lock",
15027 "locked_write",
15028 serde_json::json!({"path": child}),
15029 ToolCallSource::Manual,
15030 ))
15031 .await
15032 .unwrap()
15033 })
15034 };
15035 let (left, right) = tokio::join!(left, right);
15036
15037 assert!(left.unwrap().success);
15038 assert!(right.unwrap().success);
15039 assert_eq!(max_active.load(Ordering::SeqCst), 1);
15040 }
15041
15042 #[tokio::test]
15043 async fn tool_hooks_can_reenter_after_resource_guards_are_dropped() {
15044 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15045 let hooks = Arc::new(ReentrantToolHooks {
15046 agent: parking_lot::Mutex::new(None),
15047 invoked: AtomicBool::new(false),
15048 nested_success: AtomicBool::new(false),
15049 });
15050 let agent = Arc::new(
15051 AgentBuilder::new()
15052 .system_prompt("Test hook reentrancy.")
15053 .llm(Arc::new(mock_with_response("done")))
15054 .tool(Arc::new(RecoveryTestTool {
15055 id: "reentrant_write".to_string(),
15056 succeeds: true,
15057 calls: Arc::clone(&calls),
15058 }))
15059 .hooks(hooks.clone())
15060 .build()
15061 .unwrap(),
15062 );
15063 *hooks.agent.lock() = Some(Arc::downgrade(&agent));
15064 let record = tokio::time::timeout(
15065 std::time::Duration::from_secs(2),
15066 agent.invoke_tool(ToolExecutionRequest::new(
15067 "outer-hook-call",
15068 "reentrant_write",
15069 serde_json::json!({"path": "./hook.txt"}),
15070 ToolCallSource::Manual,
15071 )),
15072 )
15073 .await
15074 .expect("tool completion hook must not retain resource guards")
15075 .unwrap();
15076
15077 assert!(record.success);
15078 assert!(hooks.nested_success.load(Ordering::SeqCst));
15079 assert_eq!(calls.load(Ordering::SeqCst), 2);
15080 }
15081
15082 #[tokio::test]
15083 async fn fallback_releases_primary_resource_locks() {
15084 let primary_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15085 let fallback_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15086 let agent = AgentBuilder::new()
15087 .system_prompt("Test fallback execution.")
15088 .llm(Arc::new(mock_with_response("done")))
15089 .tool(Arc::new(RecoveryTestTool {
15090 id: "primary".to_string(),
15091 succeeds: false,
15092 calls: Arc::clone(&primary_calls),
15093 }))
15094 .tool(Arc::new(RecoveryTestTool {
15095 id: "fallback".to_string(),
15096 succeeds: true,
15097 calls: Arc::clone(&fallback_calls),
15098 }))
15099 .recovery_manager(recovery_manager_with_fallbacks([(
15100 "primary".to_string(),
15101 "fallback".to_string(),
15102 )]))
15103 .build()
15104 .unwrap();
15105 let record = tokio::time::timeout(
15106 std::time::Duration::from_secs(2),
15107 agent.invoke_tool(ToolExecutionRequest::new(
15108 "fallback-call",
15109 "primary",
15110 serde_json::json!({"path": "./shared.txt"}),
15111 ToolCallSource::Manual,
15112 )),
15113 )
15114 .await
15115 .expect("fallback must not retain the primary resource guard")
15116 .unwrap();
15117
15118 assert!(record.success);
15119 assert_eq!(record.canonical_id, "fallback");
15120 assert_eq!(record.call_id, "fallback-call");
15121 assert!(matches!(record.source, ToolCallSource::Fallback { .. }));
15122 assert_eq!(primary_calls.load(Ordering::SeqCst), 1);
15123 assert_eq!(fallback_calls.load(Ordering::SeqCst), 1);
15124 }
15125
15126 #[tokio::test]
15127 async fn diagnostics_without_provider_records_unavailable_without_execution() {
15128 let mock = mock_with_response("hello");
15129 let yaml = r#"
15130name: DiagnosticsNoProviderAgent
15131system_prompt: "Review diagnostics."
15132tools: [diagnostics]
15133"#;
15134 let agent = AgentBuilder::from_yaml(yaml)
15135 .unwrap()
15136 .llm(Arc::new(mock))
15137 .auto_configure_features()
15138 .unwrap()
15139 .build()
15140 .unwrap();
15141
15142 let record = agent
15143 .invoke_tool(ToolExecutionRequest::new(
15144 "diagnostics-call",
15145 "diagnostics",
15146 serde_json::json!({}),
15147 ToolCallSource::Manual,
15148 ))
15149 .await
15150 .unwrap();
15151
15152 assert!(!record.executed);
15153 assert!(!record.success);
15154 assert_eq!(record.policy.outcome, PermissionOutcome::Unavailable);
15155 }
15156
15157 #[tokio::test]
15158 async fn web_search_without_provider_records_unavailable_without_execution() {
15159 let mock = mock_with_response("hello");
15160 let yaml = r#"
15161name: WebSearchNoProviderAgent
15162system_prompt: "You search the web."
15163tools: [web_search]
15164"#;
15165 let agent = AgentBuilder::from_yaml(yaml)
15166 .unwrap()
15167 .llm(Arc::new(mock))
15168 .auto_configure_features()
15169 .unwrap()
15170 .build()
15171 .unwrap();
15172
15173 let record = agent
15174 .invoke_tool(ToolExecutionRequest::new(
15175 "web-search-call",
15176 "web_search",
15177 serde_json::json!({"query": "rust async"}),
15178 ToolCallSource::Manual,
15179 ))
15180 .await
15181 .unwrap();
15182
15183 assert!(!record.executed);
15184 assert!(!record.success);
15185 assert_eq!(record.policy.outcome, PermissionOutcome::Unavailable);
15186 }
15187
15188 #[tokio::test]
15189 async fn test_spawner_section_does_not_grant_core_tools_when_top_level_tools_omitted() {
15190 let mock = mock_with_response("hello");
15191 let yaml = r#"
15192name: SpawnerNoGrantAgent
15193system_prompt: "You manage agents."
15194spawner:
15195 max_agents: 2
15196"#;
15197 let agent = AgentBuilder::from_yaml(yaml)
15198 .unwrap()
15199 .llm(Arc::new(mock))
15200 .auto_configure_features()
15201 .unwrap()
15202 .auto_configure_spawner()
15203 .await
15204 .unwrap()
15205 .build()
15206 .unwrap();
15207
15208 let available = agent.get_available_tool_ids().await.unwrap();
15209 assert!(available.is_empty());
15210 }
15211
15212 #[tokio::test]
15213 async fn test_spawner_section_does_not_grant_core_tools_when_top_level_tools_empty() {
15214 let mock = mock_with_response("hello");
15215 let yaml = r#"
15216name: EmptySpawnerNoGrantAgent
15217system_prompt: "You manage agents."
15218tools: []
15219spawner:
15220 max_agents: 2
15221"#;
15222 let agent = AgentBuilder::from_yaml(yaml)
15223 .unwrap()
15224 .llm(Arc::new(mock))
15225 .auto_configure_features()
15226 .unwrap()
15227 .auto_configure_spawner()
15228 .await
15229 .unwrap()
15230 .build()
15231 .unwrap();
15232
15233 let available = agent.get_available_tool_ids().await.unwrap();
15234 assert!(available.is_empty());
15235 }
15236
15237 #[tokio::test]
15238 async fn test_management_tools_flag_grants_core_tools_when_top_level_tools_empty() {
15239 let mock = mock_with_response("hello");
15240 let yaml = r#"
15241name: ManagementGrantAgent
15242system_prompt: "You manage agents."
15243tools: []
15244spawner:
15245 management_tools: true
15246"#;
15247 let agent = AgentBuilder::from_yaml(yaml)
15248 .unwrap()
15249 .llm(Arc::new(mock))
15250 .auto_configure_features()
15251 .unwrap()
15252 .auto_configure_spawner()
15253 .await
15254 .unwrap()
15255 .build()
15256 .unwrap();
15257
15258 let available = agent.get_available_tool_ids().await.unwrap();
15259 assert_eq!(available.len(), 4);
15260 assert!(available.contains(&"spawn_agent".to_string()));
15261 assert!(available.contains(&"send_agent_message".to_string()));
15262 assert!(available.contains(&"list_agents".to_string()));
15263 assert!(available.contains(&"remove_agent".to_string()));
15264 }
15265
15266 #[tokio::test]
15267 async fn test_management_tools_flag_grants_core_tools_when_top_level_tools_omitted() {
15268 let mock = mock_with_response("hello");
15269 let yaml = r#"
15270name: ManagementOmittedToolsGrantAgent
15271system_prompt: "You manage agents."
15272spawner:
15273 management_tools: true
15274"#;
15275 let agent = AgentBuilder::from_yaml(yaml)
15276 .unwrap()
15277 .llm(Arc::new(mock))
15278 .auto_configure_features()
15279 .unwrap()
15280 .auto_configure_spawner()
15281 .await
15282 .unwrap()
15283 .build()
15284 .unwrap();
15285
15286 let available = agent.get_available_tool_ids().await.unwrap();
15287 assert_eq!(available.len(), 4);
15288 assert!(available.contains(&"spawn_agent".to_string()));
15289 assert!(available.contains(&"send_agent_message".to_string()));
15290 assert!(available.contains(&"list_agents".to_string()));
15291 assert!(available.contains(&"remove_agent".to_string()));
15292 }
15293
15294 #[tokio::test]
15295 async fn test_management_tools_selected_grants_only_selected_tools() {
15296 let mock = mock_with_response("hello");
15297 let yaml = r#"
15298name: ManagementSelectedGrantAgent
15299system_prompt: "You manage agents."
15300tools: []
15301spawner:
15302 management_tools:
15303 - spawn_agent
15304 - send_agent_message
15305 - list_agents
15306"#;
15307 let agent = AgentBuilder::from_yaml(yaml)
15308 .unwrap()
15309 .llm(Arc::new(mock))
15310 .auto_configure_features()
15311 .unwrap()
15312 .auto_configure_spawner()
15313 .await
15314 .unwrap()
15315 .build()
15316 .unwrap();
15317
15318 let available = agent.get_available_tool_ids().await.unwrap();
15319 assert_eq!(available.len(), 3);
15320 assert!(available.contains(&"spawn_agent".to_string()));
15321 assert!(available.contains(&"send_agent_message".to_string()));
15322 assert!(available.contains(&"list_agents".to_string()));
15323 assert!(!available.contains(&"remove_agent".to_string()));
15324 }
15325
15326 #[tokio::test]
15327 async fn test_orchestration_tools_flag_grants_tools_when_top_level_tools_empty() {
15328 let mock = mock_with_response("hello");
15329 let yaml = r#"
15330name: OrchestrationGrantAgent
15331system_prompt: "You coordinate agents."
15332llms:
15333 default:
15334 provider: openai
15335 model: gpt-4
15336 router:
15337 provider: openai
15338 model: gpt-4
15339llm:
15340 default: default
15341 router: router
15342tools: []
15343spawner:
15344 orchestration_tools: true
15345"#;
15346 let agent = AgentBuilder::from_yaml(yaml)
15347 .unwrap()
15348 .llm(Arc::new(mock))
15349 .auto_configure_features()
15350 .unwrap()
15351 .auto_configure_spawner()
15352 .await
15353 .unwrap()
15354 .build()
15355 .unwrap();
15356
15357 let available = agent.get_available_tool_ids().await.unwrap();
15358 assert_eq!(available.len(), 5);
15359 assert!(available.contains(&"route_to_agent".to_string()));
15360 assert!(available.contains(&"pipeline_process".to_string()));
15361 assert!(available.contains(&"concurrent_ask".to_string()));
15362 assert!(available.contains(&"group_discussion".to_string()));
15363 assert!(available.contains(&"handoff_conversation".to_string()));
15364 }
15365
15366 #[tokio::test]
15367 async fn test_persona_evolve_flag_grants_tool_when_top_level_tools_empty() {
15368 let mock = mock_with_response("hello");
15369 let yaml = r#"
15370name: PersonaGrantAgent
15371system_prompt: "You can evolve persona."
15372llm:
15373 provider: openai
15374 model: gpt-4
15375tools: []
15376persona:
15377 identity:
15378 name: "Guide"
15379 role: "Helper"
15380 evolution:
15381 enabled: true
15382 allow_llm_evolve: true
15383 mutable_fields:
15384 - traits.personality
15385"#;
15386 let agent = AgentBuilder::from_yaml(yaml)
15387 .unwrap()
15388 .llm(Arc::new(mock))
15389 .build()
15390 .unwrap();
15391
15392 let available = agent.get_available_tool_ids().await.unwrap();
15393 assert_eq!(available, vec!["persona_evolve".to_string()]);
15394 }
15395
15396 #[tokio::test]
15397 async fn test_persona_evolve_flag_grants_tool_when_top_level_tools_omitted() {
15398 let mock = mock_with_response("hello");
15399 let yaml = r#"
15400name: PersonaOmittedToolsGrantAgent
15401system_prompt: "You can evolve persona."
15402llm:
15403 provider: openai
15404 model: gpt-4
15405persona:
15406 identity:
15407 name: "Guide"
15408 role: "Helper"
15409 evolution:
15410 enabled: true
15411 allow_llm_evolve: true
15412 mutable_fields:
15413 - traits.personality
15414"#;
15415 let agent = AgentBuilder::from_yaml(yaml)
15416 .unwrap()
15417 .llm(Arc::new(mock))
15418 .build()
15419 .unwrap();
15420
15421 let available = agent.get_available_tool_ids().await.unwrap();
15422 assert_eq!(available, vec!["persona_evolve".to_string()]);
15423 }
15424
15425 #[tokio::test]
15426 async fn test_omitted_yaml_tools_exposes_no_tools() {
15427 let mock = mock_with_response("hello");
15428 let yaml = r#"
15429name: NoToolsAgent
15430system_prompt: "You are helpful."
15431"#;
15432 let agent = AgentBuilder::from_yaml(yaml)
15433 .unwrap()
15434 .llm(Arc::new(mock))
15435 .auto_configure_features()
15436 .unwrap()
15437 .build()
15438 .unwrap();
15439
15440 let available = agent.get_available_tool_ids().await.unwrap();
15441 assert!(available.is_empty());
15442 }
15443
15444 #[tokio::test]
15445 async fn runtime_scope_cannot_widen_omitted_or_empty_yaml_grants() {
15446 for tools in ["", "tools: []"] {
15447 let yaml = format!(
15448 r#"
15449name: RuntimeScopeNoGrantAgent
15450system_prompt: "No ordinary tools are granted."
15451{tools}
15452"#
15453 );
15454 let agent = AgentBuilder::from_yaml(&yaml)
15455 .unwrap()
15456 .llm(Arc::new(mock_with_response("done")))
15457 .auto_configure_features()
15458 .unwrap()
15459 .build()
15460 .unwrap();
15461
15462 agent
15463 .runtime_control()
15464 .set_tool_scope(vec!["calculator".to_string()]);
15465
15466 assert!(agent.get_available_tool_ids().await.unwrap().is_empty());
15467 }
15468 }
15469
15470 #[tokio::test]
15471 async fn runtime_scope_widening_attempt_keeps_only_declared_tools() {
15472 let yaml = r#"
15473name: RuntimeScopeWideningAgent
15474system_prompt: "Runtime scope cannot add authority."
15475tools: [calculator]
15476"#;
15477 let agent = AgentBuilder::from_yaml(yaml)
15478 .unwrap()
15479 .llm(Arc::new(mock_with_response("done")))
15480 .auto_configure_features()
15481 .unwrap()
15482 .build()
15483 .unwrap();
15484
15485 agent
15486 .runtime_control()
15487 .set_tool_scope(vec!["calculator".to_string(), "datetime".to_string()]);
15488
15489 assert_eq!(
15490 agent.get_available_tool_ids().await.unwrap(),
15491 vec!["calculator".to_string()]
15492 );
15493 }
15494
15495 #[tokio::test]
15496 async fn runtime_scope_is_canonical_unique_ordered_and_clear_restores_declared_grant() {
15497 let yaml = r#"
15498name: RuntimeScopeIntersectionAgent
15499system_prompt: "Use only declared tools."
15500tools: [calculator, datetime]
15501"#;
15502 let agent = AgentBuilder::from_yaml(yaml)
15503 .unwrap()
15504 .llm(Arc::new(mock_with_response("done")))
15505 .auto_configure_features()
15506 .unwrap()
15507 .build()
15508 .unwrap();
15509 let mut aliases = ai_agents_tools::ToolAliases::default();
15510 aliases
15511 .names
15512 .insert("en".to_string(), "calculate_alias".to_string());
15513 agent.tools.set_tool_aliases("calculator", aliases);
15514 let control = agent.runtime_control();
15515
15516 control.set_tool_scope(vec![
15517 "datetime".to_string(),
15518 "calculate_alias".to_string(),
15519 "calculator".to_string(),
15520 "unknown".to_string(),
15521 "datetime".to_string(),
15522 ]);
15523 assert_eq!(
15524 agent.get_available_tool_ids().await.unwrap(),
15525 vec!["calculator".to_string(), "datetime".to_string()]
15526 );
15527
15528 control.set_tool_scope(vec!["datetime".to_string()]);
15529 assert_eq!(
15530 agent.get_available_tool_ids().await.unwrap(),
15531 vec!["datetime".to_string()]
15532 );
15533
15534 control.clear_tool_scope_override();
15535 assert_eq!(
15536 agent.get_available_tool_ids().await.unwrap(),
15537 vec!["calculator".to_string(), "datetime".to_string()]
15538 );
15539 }
15540
15541 #[tokio::test]
15542 async fn runtime_scope_preserves_programmatic_registration_as_declared_grant() {
15543 let agent = AgentBuilder::new()
15544 .system_prompt("Use registered tools.")
15545 .llm(Arc::new(mock_with_response("done")))
15546 .tool(Arc::new(ContextEchoTool))
15547 .tool(Arc::new(SlowTool))
15548 .build()
15549 .unwrap();
15550
15551 agent.runtime_control().set_tool_scope(vec![
15552 "Context Echo".to_string(),
15553 "context_echo".to_string(),
15554 "unknown".to_string(),
15555 ]);
15556
15557 assert_eq!(
15558 agent.get_available_tool_ids().await.unwrap(),
15559 vec!["context_echo".to_string()]
15560 );
15561 }
15562
15563 #[tokio::test]
15564 async fn nested_state_scopes_intersect_every_ancestor_with_aliases() {
15565 let yaml = r#"
15566name: NestedStateScopeAgent
15567system_prompt: "Honor every state scope."
15568tools: [calculator, datetime, echo]
15569states:
15570 initial: root
15571 states:
15572 root:
15573 tools: [calculate_alias, datetime]
15574 initial: middle
15575 states:
15576 middle:
15577 initial: leaf
15578 states:
15579 leaf:
15580 tools: [datetime_alias, echo]
15581"#;
15582 let agent = AgentBuilder::from_yaml(yaml)
15583 .unwrap()
15584 .llm(Arc::new(mock_with_response("done")))
15585 .auto_configure_features()
15586 .unwrap()
15587 .build()
15588 .unwrap();
15589 let mut calculator_aliases = ai_agents_tools::ToolAliases::default();
15590 calculator_aliases
15591 .names
15592 .insert("en".to_string(), "calculate_alias".to_string());
15593 agent
15594 .tools
15595 .set_tool_aliases("calculator", calculator_aliases);
15596 let mut datetime_aliases = ai_agents_tools::ToolAliases::default();
15597 datetime_aliases
15598 .names
15599 .insert("en".to_string(), "datetime_alias".to_string());
15600 agent.tools.set_tool_aliases("datetime", datetime_aliases);
15601 agent.runtime_control().set_tool_scope(vec![
15602 "unknown".to_string(),
15603 "datetime_alias".to_string(),
15604 "calculate_alias".to_string(),
15605 "datetime".to_string(),
15606 ]);
15607
15608 assert_eq!(agent.current_state().as_deref(), Some("root.middle.leaf"));
15609 assert_eq!(
15610 agent.get_available_tool_ids().await.unwrap(),
15611 vec!["datetime".to_string()]
15612 );
15613 }
15614
15615 #[tokio::test]
15616 async fn ancestor_empty_state_scope_denies_omitted_descendants() {
15617 let yaml = r#"
15618name: NestedEmptyStateScopeAgent
15619system_prompt: "An empty ancestor scope denies all tools."
15620tools: [calculator]
15621states:
15622 initial: root
15623 states:
15624 root:
15625 tools: []
15626 initial: middle
15627 states:
15628 middle:
15629 initial: leaf
15630 states:
15631 leaf: {}
15632"#;
15633 let agent = AgentBuilder::from_yaml(yaml)
15634 .unwrap()
15635 .llm(Arc::new(mock_with_response("done")))
15636 .auto_configure_features()
15637 .unwrap()
15638 .build()
15639 .unwrap();
15640
15641 assert!(agent.get_available_tool_ids().await.unwrap().is_empty());
15642 }
15643
15644 #[tokio::test]
15645 async fn state_change_during_approval_invalidates_the_reviewed_authority() {
15646 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15647 let max_active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15648 let entered = Arc::new(tokio::sync::Barrier::new(2));
15649 let release = Arc::new(tokio::sync::Notify::new());
15650 let handler = Arc::new(BlockingApprovalHandler {
15651 entered: Arc::clone(&entered),
15652 release: Arc::clone(&release),
15653 result: ApprovalResult::Approved,
15654 });
15655 let yaml = r#"
15656name: ApprovalStateGenerationAgent
15657system_prompt: "State authority may change during approval."
15658tools: [locked_write]
15659states:
15660 initial: first
15661 states:
15662 first:
15663 tools: [locked_write]
15664 second:
15665 tools: [locked_write]
15666"#;
15667 let agent = Arc::new(
15668 AgentBuilder::from_yaml(yaml)
15669 .unwrap()
15670 .llm(Arc::new(mock_with_response("done")))
15671 .tool(Arc::new(LockedWriteTool {
15672 active: Arc::clone(&active),
15673 max_active: Arc::clone(&max_active),
15674 }))
15675 .tool_security(ToolSecurityEngine::new(approval_security_config(true)))
15676 .hitl_engine(HITLEngine::new(ai_agents_hitl::HITLConfig::default()))
15677 .approval_handler(handler)
15678 .build()
15679 .unwrap(),
15680 );
15681 let running = Arc::clone(&agent);
15682 let call = tokio::spawn(async move {
15683 running
15684 .invoke_tool(ToolExecutionRequest::new(
15685 "approval-state-generation",
15686 "locked_write",
15687 serde_json::json!({"path": "./state-generation.txt"}),
15688 ToolCallSource::Manual,
15689 ))
15690 .await
15691 .unwrap()
15692 });
15693
15694 entered.wait().await;
15695 agent.transition_to("second").await.unwrap();
15696 release.notify_one();
15697 let record = call.await.unwrap();
15698
15699 assert!(!record.executed);
15700 assert!(record.output.contains("Approval became stale"));
15701 assert_eq!(max_active.load(Ordering::SeqCst), 0);
15702 }
15703
15704 #[tokio::test]
15705 async fn state_change_while_waiting_for_resource_lock_fails_final_admission() {
15706 let holder_gate = PathMutationGate::new();
15707 let waiter_gate = PathMutationGate::new();
15708 let yaml = r#"
15709name: LockedStateGenerationAgent
15710system_prompt: "State authority must remain stable through admission."
15711tools: [state_lock_holder, state_lock_waiter]
15712states:
15713 initial: first
15714 states:
15715 first:
15716 tools: [state_lock_holder, state_lock_waiter]
15717 second:
15718 tools: [state_lock_holder, state_lock_waiter]
15719"#;
15720 let agent = Arc::new(
15721 AgentBuilder::from_yaml(yaml)
15722 .unwrap()
15723 .llm(Arc::new(mock_with_response("done")))
15724 .tool(Arc::new(BlockingPathMutationTool {
15725 id: "state_lock_holder",
15726 path_fields: vec![ai_agents_core::PathPolicyBinding::write("path")],
15727 gate: holder_gate.clone(),
15728 }))
15729 .tool(Arc::new(BlockingPathMutationTool {
15730 id: "state_lock_waiter",
15731 path_fields: vec![ai_agents_core::PathPolicyBinding::write("path")],
15732 gate: waiter_gate.clone(),
15733 }))
15734 .build()
15735 .unwrap(),
15736 );
15737 let holder_call = {
15738 let agent = Arc::clone(&agent);
15739 tokio::spawn(async move {
15740 agent
15741 .invoke_tool(ToolExecutionRequest::new(
15742 "state-lock-holder",
15743 "state_lock_holder",
15744 serde_json::json!({"path": "./shared-state-path.txt"}),
15745 ToolCallSource::Manual,
15746 ))
15747 .await
15748 .unwrap()
15749 })
15750 };
15751 holder_gate.wait_until_entered().await;
15752 let waiter_call = {
15753 let agent = Arc::clone(&agent);
15754 tokio::spawn(async move {
15755 agent
15756 .invoke_tool(ToolExecutionRequest::new(
15757 "state-lock-waiter",
15758 "state_lock_waiter",
15759 serde_json::json!({"path": "./shared-state-path.txt"}),
15760 ToolCallSource::Manual,
15761 ))
15762 .await
15763 .unwrap()
15764 })
15765 };
15766
15767 wait_for_resource_lock_strong_count(&agent.resource_locks, 2).await;
15768 agent.transition_to("second").await.unwrap();
15769 holder_gate.release();
15770 let holder_record = holder_call.await.unwrap();
15771 let waiter_record = waiter_call.await.unwrap();
15772
15773 assert!(holder_record.success);
15774 assert!(!waiter_record.executed);
15775 assert!(
15776 waiter_record
15777 .output
15778 .contains("state scope changed before admission")
15779 );
15780 assert!(!waiter_gate.entered.load(Ordering::SeqCst));
15781 }
15782
15783 #[tokio::test]
15784 async fn test_state_tools_cannot_widen_top_level_grant() {
15785 let mock = mock_with_response("hello");
15786 let yaml = r#"
15787name: NarrowToolsAgent
15788system_prompt: "You are helpful."
15789tools:
15790 - calculator
15791states:
15792 initial: current
15793 states:
15794 current:
15795 tools: [datetime]
15796"#;
15797 let agent = AgentBuilder::from_yaml(yaml)
15798 .unwrap()
15799 .llm(Arc::new(mock))
15800 .auto_configure_features()
15801 .unwrap()
15802 .build()
15803 .unwrap();
15804
15805 let available = agent.get_available_tool_ids().await.unwrap();
15806 assert!(available.is_empty());
15807 }
15808
15809 #[tokio::test]
15811 async fn test_integration_tool_execution() {
15812 let mock = mock_with_responses(vec![
15814 r#"I'll calculate that for you.
15816[TOOL_CALL: {"name": "calculator", "arguments": {"expression": "2+2"}}]"#,
15817 "The answer is 4.",
15819 ]);
15820 let mut tools = ai_agents_tools::ToolRegistry::new();
15821 tools
15822 .register(Arc::new(ai_agents_tools::CalculatorTool))
15823 .unwrap();
15824
15825 let agent = AgentBuilder::new()
15826 .system_prompt("You are a calculator assistant.")
15827 .llm(Arc::new(mock))
15828 .tools(tools)
15829 .build()
15830 .unwrap();
15831
15832 let response = agent.chat("What is 2+2?").await.unwrap();
15833 assert!(!response.content.is_empty());
15835 }
15836
15837 #[tokio::test]
15838 async fn test_tool_hitl_rejection_finalizes_blocking_turn() {
15839 let responses = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15840 let hooks = Arc::new(ResponseCountingHooks {
15841 responses: Arc::clone(&responses),
15842 });
15843 let mock = mock_with_response(r#"{"tool":"echo","arguments":{"message":"hello"}}"#);
15844 let yaml = r#"
15845name: ToolRejectAgent
15846system_prompt: "You use tools when requested."
15847tools:
15848 - echo
15849hitl:
15850 tools:
15851 echo:
15852 require_approval: true
15853 approval_message: "Approve echo?"
15854"#;
15855 let agent = AgentBuilder::from_yaml(yaml)
15856 .unwrap()
15857 .llm(Arc::new(mock))
15858 .auto_configure_features()
15859 .unwrap()
15860 .hooks(hooks)
15861 .build()
15862 .unwrap();
15863
15864 let response = agent.chat("echo hello").await.unwrap();
15865
15866 assert!(
15867 response.content.contains("Operation cancelled"),
15868 "unexpected response: {}",
15869 response.content
15870 );
15871 assert_eq!(responses.load(Ordering::SeqCst), 1);
15872 let messages = agent.memory.get_messages(None).await.unwrap();
15873 assert_eq!(messages.len(), 3);
15874 assert_eq!(messages[0].content, "echo hello");
15875 assert!(messages[1].content.contains("\"tool\":\"echo\""));
15876 assert!(messages[2].content.contains("rejected by the approver"));
15877 }
15878
15879 #[tokio::test]
15880 async fn test_tool_hitl_rejection_finalizes_streaming_turn() {
15881 use futures::StreamExt;
15882
15883 let responses = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15884 let hooks = Arc::new(ResponseCountingHooks {
15885 responses: Arc::clone(&responses),
15886 });
15887 let mock = mock_with_response(r#"{"tool":"echo","arguments":{"message":"hello"}}"#);
15888 let yaml = r#"
15889name: ToolRejectStreamingAgent
15890system_prompt: "You use tools when requested."
15891tools:
15892 - echo
15893streaming:
15894 enabled: true
15895hitl:
15896 tools:
15897 echo:
15898 require_approval: true
15899 approval_message: "Approve echo?"
15900"#;
15901 let agent = AgentBuilder::from_yaml(yaml)
15902 .unwrap()
15903 .llm(Arc::new(mock))
15904 .auto_configure_features()
15905 .unwrap()
15906 .hooks(hooks)
15907 .build()
15908 .unwrap();
15909
15910 let mut stream = agent.chat_stream("echo hello").await.unwrap();
15911 let mut terminal_error = String::new();
15912 let mut done = false;
15913 while let Some(chunk) = stream.next().await {
15914 match chunk {
15915 StreamChunk::Error { message } => terminal_error = message,
15916 StreamChunk::Done {} => {
15917 done = true;
15918 break;
15919 }
15920 _ => {}
15921 }
15922 }
15923
15924 assert!(done);
15925 assert!(
15926 terminal_error.contains("Operation cancelled"),
15927 "unexpected terminal error: {}",
15928 terminal_error
15929 );
15930 assert_eq!(responses.load(Ordering::SeqCst), 1);
15931 let messages = agent.memory.get_messages(None).await.unwrap();
15932 assert_eq!(messages.len(), 3);
15933 assert_eq!(messages[0].content, "echo hello");
15934 assert!(messages[1].content.contains("\"tool\":\"echo\""));
15935 assert!(messages[2].content.contains("rejected by the approver"));
15936 }
15937
15938 #[tokio::test]
15939 async fn test_pre_response_guard_transition_skips_old_state_llm() {
15940 let mock = mock_with_response("Billing state response");
15941 let call_counter = mock.clone();
15942 let yaml = r#"
15943name: OptimizedStateAgent
15944system_prompt: "You route before answering."
15945runtime:
15946 optimization:
15947 enabled: true
15948 pre_response_deterministic_transitions: true
15949states:
15950 initial: greeting
15951 states:
15952 greeting:
15953 prompt: "Old state prompt that should be skipped."
15954 transitions:
15955 - to: billing
15956 guard:
15957 context:
15958 topic:
15959 eq: billing
15960 timing: pre_response
15961 billing:
15962 prompt: "Answer from the billing state."
15963"#;
15964 let agent = AgentBuilder::from_yaml(yaml)
15965 .unwrap()
15966 .llm(Arc::new(mock))
15967 .build()
15968 .unwrap();
15969 agent
15970 .set_context("topic", serde_json::json!("billing"))
15971 .unwrap();
15972
15973 let response = agent.chat("I need billing help").await.unwrap();
15974
15975 assert_eq!(agent.current_state().as_deref(), Some("billing"));
15976 assert_eq!(response.content, "Billing state response");
15977 assert_eq!(call_counter.call_count(), 1);
15978 assert_eq!(agent.actor_facts().len(), 0);
15979 }
15980
15981 #[tokio::test]
15982 async fn test_set_context_supports_dotted_paths_for_pre_response_guards() {
15983 let mock = mock_with_response("Billing state response");
15984 let call_counter = mock.clone();
15985 let yaml = r#"
15986name: OptimizedStateAgent
15987system_prompt: "You route before answering."
15988runtime:
15989 optimization:
15990 enabled: true
15991 pre_response_deterministic_transitions: true
15992context:
15993 request:
15994 type: runtime
15995 default:
15996 topic: general
15997states:
15998 initial: greeting
15999 states:
16000 greeting:
16001 prompt: "Old state prompt that should be skipped."
16002 transitions:
16003 - to: billing
16004 guard:
16005 context:
16006 request.topic:
16007 eq: billing
16008 timing: pre_response
16009 billing:
16010 prompt: "Answer from the billing state."
16011"#;
16012 let agent = AgentBuilder::from_yaml(yaml)
16013 .unwrap()
16014 .llm(Arc::new(mock))
16015 .build()
16016 .unwrap();
16017 agent
16018 .set_context("request.topic", serde_json::json!("billing"))
16019 .unwrap();
16020
16021 let response = agent.chat("I need billing help").await.unwrap();
16022
16023 assert_eq!(agent.current_state().as_deref(), Some("billing"));
16024 assert_eq!(response.content, "Billing state response");
16025 assert_eq!(call_counter.call_count(), 1);
16026 assert_eq!(
16027 agent.get_context().get("request"),
16028 Some(&serde_json::json!({"topic": "billing"}))
16029 );
16030 }
16031
16032 #[tokio::test]
16033 async fn test_pre_response_rejection_does_not_commit_staged_context_or_user() {
16034 let mock = mock_with_response("billing");
16035 let yaml = r#"
16036name: OptimizedStateAgent
16037system_prompt: "You route before answering."
16038runtime:
16039 optimization:
16040 enabled: true
16041 pre_response_deterministic_transitions: true
16042hitl:
16043 states:
16044 billing:
16045 on_enter: require_approval
16046 approval_message: "Approve billing route?"
16047states:
16048 initial: greeting
16049 states:
16050 greeting:
16051 prompt: "Old state prompt."
16052 extract:
16053 - key: topic
16054 description: "Support topic"
16055 transitions:
16056 - to: billing
16057 guard:
16058 context:
16059 topic:
16060 eq: billing
16061 timing: pre_response
16062 run_extractors: true
16063 billing:
16064 prompt: "Billing state."
16065"#;
16066 let agent = AgentBuilder::from_yaml(yaml)
16067 .unwrap()
16068 .llm(Arc::new(mock))
16069 .build()
16070 .unwrap();
16071
16072 let response = agent
16073 .try_pre_response_transition("billing please")
16074 .await
16075 .unwrap();
16076
16077 assert!(response.is_none());
16078 assert_eq!(agent.current_state().as_deref(), Some("greeting"));
16079 assert!(!agent.get_context().contains_key("topic"));
16080 assert_eq!(agent.memory.get_messages(None).await.unwrap().len(), 0);
16081 }
16082
16083 #[tokio::test]
16084 async fn test_pre_response_extractor_commits_context_on_winning_path() {
16085 let mock = mock_with_responses(vec!["billing", "Billing response"]);
16086 let yaml = r#"
16087name: OptimizedStateAgent
16088system_prompt: "You route before answering."
16089runtime:
16090 optimization:
16091 enabled: true
16092 pre_response_deterministic_transitions: true
16093states:
16094 initial: greeting
16095 states:
16096 greeting:
16097 prompt: "Old state prompt."
16098 extract:
16099 - key: topic
16100 description: "Support topic"
16101 transitions:
16102 - to: billing
16103 guard:
16104 context:
16105 topic:
16106 eq: billing
16107 timing: pre_response
16108 run_extractors: true
16109 billing:
16110 prompt: "Billing state."
16111"#;
16112 let agent = AgentBuilder::from_yaml(yaml)
16113 .unwrap()
16114 .llm(Arc::new(mock))
16115 .build()
16116 .unwrap();
16117
16118 let response = agent.chat("billing please").await.unwrap();
16119
16120 assert_eq!(agent.current_state().as_deref(), Some("billing"));
16121 assert_eq!(response.content, "Billing response");
16122 assert_eq!(
16123 agent.get_context().get("topic"),
16124 Some(&serde_json::json!("billing"))
16125 );
16126 }
16127
16128 #[tokio::test]
16129 async fn test_pre_response_extractor_miss_does_not_mutate_context() {
16130 let mock = mock_with_response("__NONE__");
16131 let yaml = r#"
16132name: OptimizedStateAgent
16133system_prompt: "You route before answering."
16134runtime:
16135 optimization:
16136 enabled: true
16137 pre_response_deterministic_transitions: true
16138states:
16139 initial: greeting
16140 states:
16141 greeting:
16142 prompt: "Old state prompt."
16143 extract:
16144 - key: topic
16145 description: "Support topic"
16146 transitions:
16147 - to: billing
16148 guard:
16149 context:
16150 topic:
16151 eq: billing
16152 timing: pre_response
16153 run_extractors: true
16154 billing:
16155 prompt: "Billing state."
16156"#;
16157 let agent = AgentBuilder::from_yaml(yaml)
16158 .unwrap()
16159 .llm(Arc::new(mock))
16160 .build()
16161 .unwrap();
16162
16163 let response = agent.try_pre_response_transition("hello").await.unwrap();
16164
16165 assert!(response.is_none());
16166 assert_eq!(agent.current_state().as_deref(), Some("greeting"));
16167 assert!(!agent.get_context().contains_key("topic"));
16168 }
16169
16170 #[tokio::test]
16171 async fn test_default_guard_transition_stays_post_response() {
16172 let mock = mock_with_responses(vec!["Greeting response", "Billing response"]);
16173 let call_counter = mock.clone();
16174 let yaml = r#"
16175name: TimingAgent
16176system_prompt: "You route carefully."
16177runtime:
16178 optimization:
16179 enabled: true
16180 pre_response_deterministic_transitions: true
16181states:
16182 initial: greeting
16183 states:
16184 greeting:
16185 prompt: "Old state prompt."
16186 transitions:
16187 - to: billing
16188 guard:
16189 context:
16190 topic:
16191 eq: billing
16192 billing:
16193 prompt: "Billing state."
16194"#;
16195 let agent = AgentBuilder::from_yaml(yaml)
16196 .unwrap()
16197 .llm(Arc::new(mock))
16198 .build()
16199 .unwrap();
16200 agent
16201 .set_context("topic", serde_json::json!("billing"))
16202 .unwrap();
16203
16204 let response = agent.chat("billing please").await.unwrap();
16205
16206 assert_eq!(agent.current_state().as_deref(), Some("billing"));
16207 assert_eq!(response.content, "Billing response");
16208 assert_eq!(call_counter.call_count(), 2);
16209 }
16210
16211 #[tokio::test]
16212 async fn test_explicit_post_response_guard_transition_stays_post_response() {
16213 let mock = mock_with_responses(vec!["Greeting response", "Billing response"]);
16214 let call_counter = mock.clone();
16215 let yaml = r#"
16216name: TimingAgent
16217system_prompt: "You route carefully."
16218runtime:
16219 optimization:
16220 enabled: true
16221 pre_response_deterministic_transitions: true
16222states:
16223 initial: greeting
16224 states:
16225 greeting:
16226 prompt: "Old state prompt."
16227 transitions:
16228 - to: billing
16229 guard:
16230 context:
16231 topic:
16232 eq: billing
16233 timing: post_response
16234 billing:
16235 prompt: "Billing state."
16236"#;
16237 let agent = AgentBuilder::from_yaml(yaml)
16238 .unwrap()
16239 .llm(Arc::new(mock))
16240 .build()
16241 .unwrap();
16242 agent
16243 .set_context("topic", serde_json::json!("billing"))
16244 .unwrap();
16245
16246 let response = agent.chat("billing please").await.unwrap();
16247
16248 assert_eq!(agent.current_state().as_deref(), Some("billing"));
16249 assert_eq!(response.content, "Billing response");
16250 assert_eq!(call_counter.call_count(), 2);
16251 }
16252
16253 #[tokio::test]
16254 async fn test_pre_response_extractors_are_transition_scoped() {
16255 let mock = mock_with_responses(vec!["billing", "Billing response"]);
16256 let yaml = r#"
16257name: ScopedExtractorAgent
16258system_prompt: "You route carefully."
16259runtime:
16260 optimization:
16261 enabled: true
16262 pre_response_deterministic_transitions: true
16263states:
16264 initial: greeting
16265 states:
16266 greeting:
16267 prompt: "Old state prompt."
16268 extract:
16269 - key: topic
16270 description: "Support topic"
16271 transitions:
16272 - to: wrong
16273 guard:
16274 context:
16275 topic:
16276 eq: billing
16277 timing: pre_response
16278 - to: billing
16279 guard:
16280 context:
16281 topic:
16282 eq: billing
16283 timing: pre_response
16284 run_extractors: true
16285 wrong:
16286 prompt: "Wrong state."
16287 billing:
16288 prompt: "Billing state."
16289"#;
16290 let agent = AgentBuilder::from_yaml(yaml)
16291 .unwrap()
16292 .llm(Arc::new(mock))
16293 .build()
16294 .unwrap();
16295
16296 let response = agent.chat("billing please").await.unwrap();
16297
16298 assert_eq!(agent.current_state().as_deref(), Some("billing"));
16299 assert_eq!(response.content, "Billing response");
16300 }
16301
16302 #[tokio::test]
16303 async fn test_pre_response_resolved_intent_routes_early() {
16304 let mock = mock_with_response("Billing response");
16305 let yaml = r#"
16306name: IntentAgent
16307system_prompt: "You route carefully."
16308runtime:
16309 optimization:
16310 enabled: true
16311 pre_response_deterministic_transitions: true
16312states:
16313 initial: greeting
16314 states:
16315 greeting:
16316 prompt: "Old state prompt."
16317 transitions:
16318 - to: billing
16319 intent: billing
16320 timing: pre_response
16321 billing:
16322 prompt: "Billing state."
16323"#;
16324 let agent = AgentBuilder::from_yaml(yaml)
16325 .unwrap()
16326 .llm(Arc::new(mock))
16327 .build()
16328 .unwrap();
16329 agent
16330 .set_context("resolved_intent", serde_json::json!("billing"))
16331 .unwrap();
16332
16333 let response = agent
16334 .try_pre_response_transition("I need billing help")
16335 .await
16336 .unwrap()
16337 .unwrap();
16338
16339 assert_eq!(agent.current_state().as_deref(), Some("billing"));
16340 assert_eq!(response.content, "Billing response");
16341 }
16342
16343 #[tokio::test]
16344 async fn test_background_overflow_error_surfaces() {
16345 let mut config = RuntimeConfig::default();
16346 config.optimization.enabled = true;
16347 config.optimization.post_turn.max_background_tasks = 1;
16348 config.optimization.post_turn.on_background_overflow = BackgroundOverflowPolicy::Error;
16349 let policy = crate::optimization::MaintenanceTaskPolicy {
16350 mode: MaintenanceMode::Background,
16351 await_before_next_turn: AwaitBeforeNextTurn::Always,
16352 };
16353 let agent = AgentBuilder::new()
16354 .system_prompt("You are helpful.")
16355 .llm(Arc::new(mock_with_response("ok")))
16356 .build()
16357 .unwrap()
16358 .with_runtime_config(config);
16359 agent
16360 .background_maintenance
16361 .spawn(None, async { std::future::pending::<Result<()>>().await })
16362 .unwrap();
16363
16364 let result = agent
16365 .spawn_or_handle_background(None, async { Ok(()) }, "facts", &policy)
16366 .await;
16367
16368 assert!(result.is_err());
16369 }
16370
16371 #[tokio::test]
16372 async fn test_speculative_reasoning_low_cap_uses_serial_reasoning() {
16373 let default_mock = mock_with_response("Plain draft response");
16374 let router_mock = mock_with_response("cot");
16375 let router_counter = router_mock.clone();
16376 let yaml = r#"
16377name: ReasoningReservationAgent
16378system_prompt: "You answer plainly unless reasoning wins."
16379llm:
16380 default: default
16381 router: router
16382observability:
16383 enabled: true
16384 export:
16385 write_raw_events: true
16386reasoning:
16387 mode: auto
16388 judge_llm: router
16389runtime:
16390 optimization:
16391 enabled: true
16392 max_speculative_llm_calls_per_turn: 1
16393 speculative_reasoning_auto: true
16394 max_parallel_runtime_tasks: 2
16395"#;
16396 let agent = AgentBuilder::from_yaml(yaml)
16397 .unwrap()
16398 .llm_alias("default", Arc::new(default_mock))
16399 .llm_alias("router", Arc::new(router_mock))
16400 .build()
16401 .unwrap();
16402
16403 let response = agent.chat("hello").await.unwrap();
16404
16405 assert_eq!(response.content, "Plain draft response");
16406 assert_eq!(router_counter.call_count(), 1);
16407 let events = agent.observability().unwrap().raw_events();
16408 assert!(!events.iter().any(|event| {
16409 event.dimensions.get("commit_behavior") == Some(&"reasoning_decision".to_string())
16410 }));
16411 }
16412
16413 #[tokio::test]
16414 async fn test_forced_reasoning_skips_plain_speculative_draft() {
16415 let mock = mock_with_response("Reasoned response");
16416 let yaml = r#"
16417name: ForcedReasoningAgent
16418system_prompt: "You reason before answering."
16419observability:
16420 enabled: true
16421 export:
16422 write_raw_events: true
16423reasoning:
16424 mode: cot
16425runtime:
16426 optimization:
16427 enabled: true
16428 max_speculative_llm_calls_per_turn: 2
16429 speculative_state_transitions: true
16430 max_parallel_runtime_tasks: 2
16431states:
16432 initial: triage
16433 states:
16434 triage:
16435 prompt: "Answer from triage."
16436 transitions:
16437 - to: billing
16438 guard:
16439 context:
16440 route:
16441 eq: billing
16442 timing: parallel
16443 billing:
16444 prompt: "Billing state."
16445"#;
16446 let agent = AgentBuilder::from_yaml(yaml)
16447 .unwrap()
16448 .llm(Arc::new(mock))
16449 .build()
16450 .unwrap();
16451
16452 let response = agent.chat("hello").await.unwrap();
16453
16454 assert_eq!(response.content, "Reasoned response");
16455 let events = agent.observability().unwrap().raw_events();
16456 assert!(
16457 !events
16458 .iter()
16459 .any(|event| event.dimensions.contains_key("branch_status"))
16460 );
16461 }
16462
16463 #[tokio::test]
16464 async fn test_speculative_skill_low_cap_uses_serial_skill_route() {
16465 let default_mock = mock_with_response("Skill committed response");
16466 let router_mock = mock_with_response("helper");
16467 let router_counter = router_mock.clone();
16468 let yaml = r#"
16469name: SkillReservationAgent
16470system_prompt: "Use skills when they match."
16471llm:
16472 default: default
16473 router: router
16474observability:
16475 enabled: true
16476 export:
16477 write_raw_events: true
16478runtime:
16479 optimization:
16480 enabled: true
16481 max_speculative_llm_calls_per_turn: 1
16482 speculative_skill_routing: true
16483 max_parallel_runtime_tasks: 2
16484skills:
16485 - id: helper
16486 description: "Answer helper requests"
16487 trigger: "User asks for helper"
16488 steps:
16489 - prompt: "Answer the helper request: {{ user_input }}"
16490"#;
16491 let agent = AgentBuilder::from_yaml(yaml)
16492 .unwrap()
16493 .llm_alias("default", Arc::new(default_mock))
16494 .llm_alias("router", Arc::new(router_mock))
16495 .build()
16496 .unwrap();
16497
16498 let response = agent.chat("please use helper").await.unwrap();
16499
16500 assert_eq!(response.content, "Skill committed response");
16501 assert_eq!(router_counter.call_count(), 1);
16502 let events = agent.observability().unwrap().raw_events();
16503 assert!(
16504 !events
16505 .iter()
16506 .any(|event| event.dimensions.contains_key("branch_status"))
16507 );
16508 }
16509
16510 #[tokio::test]
16511 async fn test_parallel_transition_low_cap_allows_deterministic_route() {
16512 let mock = mock_with_response("unused");
16513 let call_counter = mock.clone();
16514 let yaml = r#"
16515name: ParallelTransitionLowCapAgent
16516system_prompt: "Route before stale responses when safe."
16517runtime:
16518 optimization:
16519 enabled: true
16520 max_speculative_llm_calls_per_turn: 1
16521 speculative_state_transitions: true
16522 max_parallel_runtime_tasks: 2
16523states:
16524 initial: triage
16525 states:
16526 triage:
16527 prompt: "Triage state."
16528 transitions:
16529 - to: billing
16530 guard:
16531 context:
16532 route:
16533 eq: billing
16534 timing: parallel
16535 billing:
16536 prompt: "Billing state."
16537"#;
16538 let agent = AgentBuilder::from_yaml(yaml)
16539 .unwrap()
16540 .llm(Arc::new(mock))
16541 .build()
16542 .unwrap();
16543 agent
16544 .set_context("route", serde_json::json!("billing"))
16545 .unwrap();
16546 agent.update_active_turn_context("billing help", HashMap::new());
16547 assert!(
16548 agent.reserve_active_speculative_llm_call(
16549 RuntimeOptimizationKind::ParallelStateTransition
16550 )
16551 );
16552
16553 let selection = agent
16554 .select_parallel_transition_candidate("billing help")
16555 .await
16556 .unwrap();
16557 agent.end_root_turn();
16558
16559 match selection {
16560 ParallelTransitionSelection::Candidate(candidate) => {
16561 assert_eq!(candidate.target(), "billing");
16562 }
16563 ParallelTransitionSelection::NoMatch => panic!("deterministic route did not match"),
16564 ParallelTransitionSelection::ReservationExhausted => {
16565 panic!("deterministic route consumed LLM budget")
16566 }
16567 }
16568 assert_eq!(call_counter.call_count(), 0);
16569 }
16570
16571 #[tokio::test]
16572 async fn speculative_transition_drops_loser_before_state_actions() {
16573 let lock = Arc::new(tokio::sync::Mutex::new(()));
16574 let first_started = Arc::new(tokio::sync::Notify::new());
16575 let first_dropped = Arc::new(AtomicBool::new(false));
16576 let committed_after_drop = Arc::new(AtomicBool::new(false));
16577 let default = Arc::new(FirstCallLockingProvider {
16578 lock,
16579 first_started: Arc::clone(&first_started),
16580 first_dropped: Arc::clone(&first_dropped),
16581 committed_after_drop: Arc::clone(&committed_after_drop),
16582 calls: AtomicU64::new(0),
16583 });
16584 let router = Arc::new(RoutingAfterProviderStart {
16585 provider_started: first_started,
16586 });
16587 let yaml = r#"
16588name: SpeculativeCancellationAgent
16589system_prompt: "Route before committed work."
16590llm:
16591 default: default
16592 router: router
16593runtime:
16594 optimization:
16595 enabled: true
16596 max_speculative_llm_calls_per_turn: 2
16597 speculative_state_transitions: true
16598 max_parallel_runtime_tasks: 2
16599states:
16600 initial: triage
16601 states:
16602 triage:
16603 prompt: "Triage state."
16604 transitions:
16605 - to: technical
16606 when: "The request needs technical support"
16607 timing: parallel
16608 technical:
16609 prompt: "Technical state."
16610 on_enter:
16611 - prompt: "Prepare technical context."
16612 llm: default
16613 store_as: preparation
16614"#;
16615 let agent = AgentBuilder::from_yaml(yaml)
16616 .unwrap()
16617 .llm_alias("default", default)
16618 .llm_alias("router", router)
16619 .build()
16620 .unwrap();
16621
16622 let response = tokio::time::timeout(
16623 std::time::Duration::from_secs(2),
16624 agent.chat("I cannot log in because of AUTH-17."),
16625 )
16626 .await
16627 .expect("committed work must not wait on the losing provider future")
16628 .unwrap();
16629
16630 assert_eq!(response.content, "Committed technical response.");
16631 assert_eq!(agent.current_state().as_deref(), Some("technical"));
16632 assert!(first_dropped.load(Ordering::SeqCst));
16633 assert!(committed_after_drop.load(Ordering::SeqCst));
16634 }
16635
16636 #[tokio::test]
16637 async fn buffered_transition_drops_stale_stream_before_redispatch() {
16638 use futures::StreamExt;
16639
16640 let lock = Arc::new(tokio::sync::Mutex::new(()));
16641 let stream_started = Arc::new(tokio::sync::Notify::new());
16642 let stream_dropped = Arc::new(AtomicBool::new(false));
16643 let committed_after_drop = Arc::new(AtomicBool::new(false));
16644 let default = Arc::new(BufferedLockingProvider {
16645 lock,
16646 stream_started: Arc::clone(&stream_started),
16647 stream_dropped: Arc::clone(&stream_dropped),
16648 committed_after_drop: Arc::clone(&committed_after_drop),
16649 });
16650 let router = Arc::new(RoutingAfterProviderStart {
16651 provider_started: stream_started,
16652 });
16653 let yaml = r#"
16654name: BufferedCancellationAgent
16655system_prompt: "Hide stale streamed output."
16656llm:
16657 default: default
16658 router: router
16659streaming:
16660 enabled: true
16661 buffer_size: 8
16662runtime:
16663 optimization:
16664 enabled: true
16665 max_speculative_llm_calls_per_turn: 2
16666 speculative_state_transitions: true
16667 streaming_policy: buffer_until_routing_done
16668 max_parallel_runtime_tasks: 2
16669states:
16670 initial: triage
16671 states:
16672 triage:
16673 prompt: "Triage state."
16674 transitions:
16675 - to: technical
16676 when: "The request needs technical support"
16677 timing: parallel
16678 technical:
16679 prompt: "Technical state."
16680"#;
16681 let agent = AgentBuilder::from_yaml(yaml)
16682 .unwrap()
16683 .llm_alias("default", default)
16684 .llm_alias("router", router)
16685 .build()
16686 .unwrap();
16687
16688 let content = tokio::time::timeout(std::time::Duration::from_secs(2), async {
16689 let mut stream = agent
16690 .chat_stream("AUTH-17 needs technical help.")
16691 .await
16692 .unwrap();
16693 let mut content = String::new();
16694 while let Some(chunk) = stream.next().await {
16695 match chunk {
16696 StreamChunk::Content { text } => content.push_str(&text),
16697 StreamChunk::Done {} => break,
16698 StreamChunk::Error { message } => panic!("unexpected stream error: {message}"),
16699 _ => {}
16700 }
16701 }
16702 content
16703 })
16704 .await
16705 .expect("redispatch must not wait on the stale streaming future");
16706
16707 assert_eq!(content, "Committed technical response.");
16708 assert_eq!(agent.current_state().as_deref(), Some("technical"));
16709 assert!(stream_dropped.load(Ordering::SeqCst));
16710 assert!(committed_after_drop.load(Ordering::SeqCst));
16711 }
16712
16713 #[tokio::test]
16714 async fn buffered_transition_drops_established_stream_before_redispatch() {
16715 use futures::StreamExt;
16716
16717 let stream_started = Arc::new(tokio::sync::Notify::new());
16718 let stream_dropped = Arc::new(AtomicBool::new(false));
16719 let stream_dropped_notify = Arc::new(tokio::sync::Notify::new());
16720 let committed_after_drop = Arc::new(AtomicBool::new(false));
16721 let default = Arc::new(EstablishedStreamProvider {
16722 stream_started: Arc::clone(&stream_started),
16723 stream_dropped: Arc::clone(&stream_dropped),
16724 stream_dropped_notify,
16725 committed_after_drop: Arc::clone(&committed_after_drop),
16726 });
16727 let router = Arc::new(RoutingAfterProviderStart {
16728 provider_started: stream_started,
16729 });
16730 let yaml = r#"
16731name: EstablishedStreamCancellationAgent
16732system_prompt: "Hide stale streamed output."
16733llm:
16734 default: default
16735 router: router
16736streaming:
16737 enabled: true
16738 buffer_size: 8
16739runtime:
16740 optimization:
16741 enabled: true
16742 max_speculative_llm_calls_per_turn: 2
16743 speculative_state_transitions: true
16744 streaming_policy: buffer_until_routing_done
16745 max_parallel_runtime_tasks: 2
16746states:
16747 initial: triage
16748 states:
16749 triage:
16750 prompt: "Triage state."
16751 transitions:
16752 - to: technical
16753 when: "The request needs technical support"
16754 timing: parallel
16755 technical:
16756 prompt: "Technical state."
16757"#;
16758 let agent = AgentBuilder::from_yaml(yaml)
16759 .unwrap()
16760 .llm_alias("default", default)
16761 .llm_alias("router", router)
16762 .build()
16763 .unwrap();
16764
16765 let content = tokio::time::timeout(std::time::Duration::from_secs(2), async {
16766 let mut stream = agent
16767 .chat_stream("AUTH-17 needs technical help.")
16768 .await
16769 .unwrap();
16770 let mut content = String::new();
16771 while let Some(chunk) = stream.next().await {
16772 match chunk {
16773 StreamChunk::Content { text } => content.push_str(&text),
16774 StreamChunk::Done {} => break,
16775 StreamChunk::Error { message } => panic!("unexpected stream error: {message}"),
16776 _ => {}
16777 }
16778 }
16779 content
16780 })
16781 .await
16782 .expect("redispatch must wait for the established stale stream to be dropped");
16783
16784 assert_eq!(content, "Committed technical response.");
16785 assert_eq!(agent.current_state().as_deref(), Some("technical"));
16786 assert!(stream_dropped.load(Ordering::SeqCst));
16787 assert!(committed_after_drop.load(Ordering::SeqCst));
16788 }
16789
16790 #[tokio::test]
16791 async fn test_buffered_streaming_transition_reservation_falls_back() {
16792 use futures::StreamExt;
16793
16794 let mock = mock_with_responses(vec![
16795 "Serial streaming response",
16796 "Serial streaming response",
16797 ]);
16798 let router_mock = mock_with_response("1");
16799 let router_counter = router_mock.clone();
16800 let yaml = r#"
16801name: BufferedReservationFallbackAgent
16802system_prompt: "Stream normally if speculative routing cannot be evaluated."
16803llm:
16804 default: default
16805 router: router
16806observability:
16807 enabled: true
16808 export:
16809 write_raw_events: true
16810streaming:
16811 enabled: true
16812 buffer_size: 8
16813runtime:
16814 optimization:
16815 enabled: true
16816 max_speculative_llm_calls_per_turn: 1
16817 speculative_state_transitions: true
16818 streaming_policy: buffer_until_routing_done
16819 max_parallel_runtime_tasks: 2
16820states:
16821 initial: triage
16822 states:
16823 triage:
16824 prompt: "Triage state."
16825 transitions:
16826 - to: billing
16827 guard:
16828 context:
16829 route:
16830 eq: billing
16831 when: "User asks about billing"
16832 timing: parallel
16833 billing:
16834 prompt: "Billing state."
16835"#;
16836 let agent = AgentBuilder::from_yaml(yaml)
16837 .unwrap()
16838 .llm_alias("default", Arc::new(mock))
16839 .llm_alias("router", Arc::new(router_mock))
16840 .build()
16841 .unwrap();
16842
16843 let mut stream = agent.chat_stream("hello").await.unwrap();
16844 let mut content = String::new();
16845 let mut error = None;
16846 while let Some(chunk) = stream.next().await {
16847 match chunk {
16848 StreamChunk::Content { text } => content.push_str(&text),
16849 StreamChunk::Error { message } => error = Some(message),
16850 StreamChunk::Done {} => break,
16851 _ => {}
16852 }
16853 }
16854
16855 assert_eq!(error, None);
16856 assert_eq!(content, "Serial streaming response");
16857 assert_eq!(router_counter.call_count(), 0);
16858 let events = agent.observability().unwrap().raw_events();
16859 assert!(events.iter().any(|event| {
16860 event.dimensions.get("branch_status") == Some(&"cancelled".to_string())
16861 && event.dimensions.get("commit_behavior")
16862 == Some(&"transition_decision".to_string())
16863 }));
16864 }
16865
16866 #[tokio::test]
16867 async fn test_blocking_error_cleanup_resets_root_turn_for_next_chat() {
16868 let mut mock = mock_with_response("Recovered response");
16869 mock.set_error("boom");
16870 let mut handle = mock.clone();
16871 let agent = AgentBuilder::new()
16872 .system_prompt("You are helpful.")
16873 .llm(Arc::new(mock))
16874 .build()
16875 .unwrap();
16876
16877 assert!(agent.chat("first").await.is_err());
16878 handle.clear_error();
16879 let response = agent.chat("second").await.unwrap();
16880
16881 assert_eq!(response.content, "Recovered response");
16882 let messages = agent.memory.get_messages(None).await.unwrap();
16883 let user_count = messages
16884 .iter()
16885 .filter(|message| message.role == ai_agents_core::Role::User)
16886 .count();
16887 assert_eq!(user_count, 2);
16888 }
16889
16890 #[tokio::test]
16891 async fn test_streaming_error_cleanup_resets_root_turn_for_next_chat() {
16892 use futures::StreamExt;
16893
16894 let mut mock = mock_with_response("Recovered response");
16895 mock.set_error("stream boom");
16896 let mut handle = mock.clone();
16897 let agent = AgentBuilder::new()
16898 .system_prompt("You are helpful.")
16899 .llm(Arc::new(mock))
16900 .build()
16901 .unwrap();
16902
16903 let mut stream = agent.chat_stream("first").await.unwrap();
16904 let mut saw_error = false;
16905 while let Some(chunk) = stream.next().await {
16906 if matches!(chunk, StreamChunk::Error { .. }) {
16907 saw_error = true;
16908 }
16909 }
16910 assert!(saw_error);
16911
16912 handle.clear_error();
16913 let response = agent.chat("second").await.unwrap();
16914
16915 assert_eq!(response.content, "Recovered response");
16916 let messages = agent.memory.get_messages(None).await.unwrap();
16917 let user_count = messages
16918 .iter()
16919 .filter(|message| message.role == ai_agents_core::Role::User)
16920 .count();
16921 assert_eq!(user_count, 2);
16922 }
16923
16924 #[tokio::test]
16925 async fn test_buffered_streaming_route_miss_releases_buffer_limit() {
16926 use futures::StreamExt;
16927
16928 let mut mock = mock_with_response("one two three");
16929 mock.set_latency(10);
16930 let yaml = r#"
16931name: BufferedMissAgent
16932system_prompt: "You stream safely."
16933llm:
16934 default: default
16935streaming:
16936 enabled: true
16937 buffer_size: 1
16938runtime:
16939 optimization:
16940 enabled: true
16941 max_speculative_llm_calls_per_turn: 2
16942 speculative_state_transitions: true
16943 streaming_policy: buffer_until_routing_done
16944 max_parallel_runtime_tasks: 2
16945states:
16946 initial: triage
16947 states:
16948 triage:
16949 prompt: "Answer from triage."
16950 transitions:
16951 - to: billing
16952 guard:
16953 context:
16954 route:
16955 eq: billing
16956 timing: parallel
16957 billing:
16958 prompt: "Billing state."
16959"#;
16960 let agent = AgentBuilder::from_yaml(yaml)
16961 .unwrap()
16962 .llm_alias("default", Arc::new(mock))
16963 .build()
16964 .unwrap();
16965
16966 let mut stream = agent.chat_stream("hello").await.unwrap();
16967 let mut content = String::new();
16968 let mut error = None;
16969 while let Some(chunk) = stream.next().await {
16970 match chunk {
16971 StreamChunk::Content { text } => content.push_str(&text),
16972 StreamChunk::Error { message } => error = Some(message),
16973 StreamChunk::Done {} => break,
16974 _ => {}
16975 }
16976 }
16977
16978 assert_eq!(error, None);
16979 assert_eq!(content, "one two three");
16980 }
16981
16982 #[tokio::test]
16983 async fn test_buffered_streaming_main_failure_finalizes_branch() {
16984 use futures::StreamExt;
16985
16986 let mock = mock_with_response("one two");
16987 let mut router_mock = mock_with_response("0");
16988 router_mock.set_latency(50);
16989 let yaml = r#"
16990name: BufferedFailureAgent
16991system_prompt: "You stream safely."
16992llm:
16993 default: default
16994 router: router
16995observability:
16996 enabled: true
16997 export:
16998 write_raw_events: true
16999streaming:
17000 enabled: true
17001 buffer_size: 1
17002runtime:
17003 optimization:
17004 enabled: true
17005 max_speculative_llm_calls_per_turn: 2
17006 speculative_state_transitions: true
17007 streaming_policy: buffer_until_routing_done
17008 max_parallel_runtime_tasks: 2
17009states:
17010 initial: triage
17011 states:
17012 triage:
17013 prompt: "Ask for the category."
17014 transitions:
17015 - to: billing
17016 when: "User asks about billing"
17017 timing: parallel
17018 billing:
17019 prompt: "Billing state."
17020"#;
17021 let agent = AgentBuilder::from_yaml(yaml)
17022 .unwrap()
17023 .llm_alias("default", Arc::new(mock))
17024 .llm_alias("router", Arc::new(router_mock))
17025 .build()
17026 .unwrap();
17027
17028 let mut stream = agent.chat_stream("hello").await.unwrap();
17029 let mut error = String::new();
17030 while let Some(chunk) = stream.next().await {
17031 if let StreamChunk::Error { message } = chunk {
17032 error = message;
17033 }
17034 }
17035
17036 assert!(
17037 error.contains("stream buffer filled"),
17038 "unexpected stream error: {}",
17039 error
17040 );
17041 let events = agent.observability().unwrap().raw_events();
17042 assert!(events.iter().any(|event| {
17043 event.dimensions.get("branch_status") == Some(&"failed".to_string())
17044 && event.dimensions.get("commit_behavior") == Some(&"final_response".to_string())
17045 && event.dimensions.get("optimization")
17046 == Some(&"buffered_streaming_routing".to_string())
17047 }));
17048 }
17049
17050 #[tokio::test]
17051 async fn test_streaming_preflight_does_not_emit_old_state_content() {
17052 use futures::StreamExt;
17053
17054 let mock = mock_with_response("Billing streamed response");
17055 let yaml = r#"
17056name: StreamingOptimizedAgent
17057system_prompt: "You route before streaming."
17058runtime:
17059 optimization:
17060 enabled: true
17061 pre_response_deterministic_transitions: true
17062streaming:
17063 enabled: true
17064states:
17065 initial: greeting
17066 states:
17067 greeting:
17068 prompt: "OLD_STATE_SENTINEL"
17069 transitions:
17070 - to: billing
17071 guard:
17072 context:
17073 topic:
17074 eq: billing
17075 timing: pre_response
17076 billing:
17077 prompt: "Billing state."
17078"#;
17079 let agent = AgentBuilder::from_yaml(yaml)
17080 .unwrap()
17081 .llm(Arc::new(mock))
17082 .build()
17083 .unwrap();
17084 agent
17085 .set_context("topic", serde_json::json!("billing"))
17086 .unwrap();
17087
17088 let mut stream = agent.chat_stream("billing please").await.unwrap();
17089 let mut content = String::new();
17090 while let Some(chunk) = stream.next().await {
17091 match chunk {
17092 StreamChunk::Content { text } => content.push_str(&text),
17093 StreamChunk::Error { message } => panic!("stream error: {}", message),
17094 StreamChunk::Done {} => break,
17095 _ => {}
17096 }
17097 }
17098
17099 assert_eq!(agent.current_state().as_deref(), Some("billing"));
17100 assert!(content.contains("Billing streamed response"));
17101 assert!(!content.contains("OLD_STATE_SENTINEL"));
17102 }
17103
17104 #[tokio::test]
17106 async fn test_integration_state_machine_basic() {
17107 let yaml = r#"
17108name: StateAgent
17109system_prompt: "You are a support agent."
17110states:
17111 initial: greeting
17112 states:
17113 greeting:
17114 prompt: "Welcome the user warmly."
17115 transitions:
17116 - to: support
17117 when: "User needs help"
17118 auto: true
17119 support:
17120 prompt: "Help solve the user's problem."
17121"#;
17122 let mock = mock_with_responses(vec![
17123 "Welcome! How can I help?", "1", "I'll help you with that.", ]);
17127 let builder = AgentBuilder::from_yaml(yaml).unwrap();
17128 let agent = builder.llm(Arc::new(mock)).build().unwrap();
17129
17130 assert_eq!(agent.current_state(), Some("greeting".to_string()));
17131 let _ = agent.chat("I need help").await.unwrap();
17132 }
17135
17136 #[tokio::test]
17138 async fn test_integration_state_on_enter_set_context() {
17139 let yaml = r#"
17140name: ActionAgent
17141system_prompt: "You are helpful."
17142states:
17143 initial: step1
17144 states:
17145 step1:
17146 prompt: "Step 1"
17147 on_exit:
17148 - set_context:
17149 step1_exited: true
17150 transitions:
17151 - to: step2
17152 when: "always"
17153 auto: true
17154 step2:
17155 prompt: "Step 2"
17156 on_enter:
17157 - set_context:
17158 step2_entered: true
17159"#;
17160 let mock = mock_with_responses(vec![
17162 "Processing step 1.",
17163 "0", ]);
17165 let builder = AgentBuilder::from_yaml(yaml).unwrap();
17166 let agent = builder.llm(Arc::new(mock)).build().unwrap();
17167
17168 assert_eq!(agent.current_state(), Some("step1".to_string()));
17169
17170 agent.transition_to("step2").await.unwrap();
17172
17173 assert_eq!(agent.current_state(), Some("step2".to_string()));
17174
17175 let ctx = agent.get_context();
17177 assert_eq!(ctx.get("step1_exited"), Some(&serde_json::json!(true)));
17178 assert_eq!(ctx.get("step2_entered"), Some(&serde_json::json!(true)));
17179 }
17180
17181 #[tokio::test]
17182 async fn state_action_tool_preserves_source_in_stored_record() {
17183 let yaml = r#"
17184name: StateActionToolAgent
17185system_prompt: "You are helpful."
17186tools:
17187 - context_echo
17188states:
17189 initial: idle
17190 states:
17191 idle:
17192 prompt: "Idle"
17193 active:
17194 prompt: "Active"
17195 on_enter:
17196 - set_context:
17197 action_started: true
17198 - tool: context_echo
17199 args: {}
17200"#;
17201 let agent = AgentBuilder::from_yaml(yaml)
17202 .unwrap()
17203 .llm(Arc::new(mock_with_response("unused")))
17204 .tool(Arc::new(ContextEchoTool))
17205 .build()
17206 .unwrap();
17207
17208 agent.transition_to("active").await.unwrap();
17209
17210 let record: ToolExecutionRecord = serde_json::from_value(
17211 agent
17212 .get_context()
17213 .get("last_tool_record")
17214 .cloned()
17215 .expect("successful state action must store its execution record"),
17216 )
17217 .unwrap();
17218 assert!(record.executed);
17219 assert!(record.success);
17220 assert_eq!(record.canonical_id, "context_echo");
17221 assert!(matches!(
17222 &record.source,
17223 ToolCallSource::StateAction {
17224 state: Some(state),
17225 action_index: 1,
17226 } if state == "active"
17227 ));
17228 }
17229
17230 #[tokio::test]
17231 async fn test_ordinary_transition_uses_on_enter_then_on_reenter() {
17232 let yaml = r#"
17233name: OrdinaryLifecycleAgent
17234system_prompt: "You are helpful."
17235states:
17236 initial: intake
17237 regenerate_on_transition: false
17238 states:
17239 intake:
17240 prompt: "Intake"
17241 transitions:
17242 - to: drafting
17243 guard:
17244 context:
17245 route:
17246 eq: drafting
17247 drafting:
17248 prompt: "Drafting"
17249 on_enter:
17250 - set_context:
17251 draft_version: 1
17252 on_reenter:
17253 - set_context:
17254 draft_version: 2
17255 transitions:
17256 - to: review
17257 guard:
17258 context:
17259 route:
17260 eq: review
17261 review:
17262 prompt: "Review"
17263 on_enter:
17264 - set_context:
17265 review_entry: first
17266 transitions:
17267 - to: drafting
17268 guard:
17269 context:
17270 route:
17271 eq: drafting
17272"#;
17273 let agent = AgentBuilder::from_yaml(yaml)
17274 .unwrap()
17275 .llm(Arc::new(mock_with_responses(vec![
17276 "Intake response",
17277 "Draft response",
17278 "Review response",
17279 ])))
17280 .build()
17281 .unwrap();
17282
17283 agent
17284 .set_context("route", serde_json::json!("drafting"))
17285 .unwrap();
17286 agent.chat("Start a draft").await.unwrap();
17287 assert_eq!(agent.current_state().as_deref(), Some("drafting"));
17288 assert_eq!(
17289 agent.get_context().get("draft_version"),
17290 Some(&serde_json::json!(1))
17291 );
17292
17293 agent
17294 .set_context("route", serde_json::json!("review"))
17295 .unwrap();
17296 agent.chat("Review this").await.unwrap();
17297 assert_eq!(agent.current_state().as_deref(), Some("review"));
17298 assert_eq!(
17299 agent.get_context().get("review_entry"),
17300 Some(&serde_json::json!("first"))
17301 );
17302
17303 agent
17304 .set_context("route", serde_json::json!("drafting"))
17305 .unwrap();
17306 agent.chat("Revise this").await.unwrap();
17307 assert_eq!(agent.current_state().as_deref(), Some("drafting"));
17308 assert_eq!(
17309 agent.get_context().get("draft_version"),
17310 Some(&serde_json::json!(2))
17311 );
17312 }
17313
17314 #[tokio::test]
17315 async fn test_manual_transition_uses_on_enter_then_on_reenter() {
17316 let yaml = r#"
17317name: ManualLifecycleAgent
17318system_prompt: "You are helpful."
17319states:
17320 initial: intake
17321 states:
17322 intake:
17323 prompt: "Intake"
17324 drafting:
17325 prompt: "Drafting"
17326 on_enter:
17327 - set_context:
17328 draft_version: 1
17329 on_reenter:
17330 - set_context:
17331 draft_version: 2
17332 review:
17333 prompt: "Review"
17334"#;
17335 let agent = AgentBuilder::from_yaml(yaml)
17336 .unwrap()
17337 .llm(Arc::new(mock_with_response("unused")))
17338 .build()
17339 .unwrap();
17340
17341 assert!(!agent.get_context().contains_key("draft_version"));
17342 agent.transition_to("drafting").await.unwrap();
17343 assert_eq!(agent.current_state().as_deref(), Some("drafting"));
17344 assert_eq!(
17345 agent.get_context().get("draft_version"),
17346 Some(&serde_json::json!(1))
17347 );
17348
17349 agent.transition_to("review").await.unwrap();
17350 agent.transition_to("drafting").await.unwrap();
17351 assert_eq!(agent.current_state().as_deref(), Some("drafting"));
17352 assert_eq!(
17353 agent.get_context().get("draft_version"),
17354 Some(&serde_json::json!(2))
17355 );
17356 }
17357
17358 #[tokio::test]
17359 async fn test_timeout_transition_uses_on_enter_then_on_reenter() {
17360 let yaml = r#"
17361name: TimeoutLifecycleAgent
17362system_prompt: "You are helpful."
17363states:
17364 initial: intake
17365 regenerate_on_transition: false
17366 states:
17367 intake:
17368 prompt: "Intake"
17369 max_turns: 1
17370 timeout_to: drafting
17371 drafting:
17372 prompt: "Drafting"
17373 max_turns: 1
17374 timeout_to: review
17375 on_enter:
17376 - set_context:
17377 draft_version: 1
17378 on_reenter:
17379 - set_context:
17380 draft_version: 2
17381 review:
17382 prompt: "Review"
17383 max_turns: 1
17384 timeout_to: drafting
17385 on_enter:
17386 - set_context:
17387 review_entry: first
17388"#;
17389 let agent = AgentBuilder::from_yaml(yaml)
17390 .unwrap()
17391 .llm(Arc::new(mock_with_responses(vec![
17392 "Intake",
17393 "First draft",
17394 "Review",
17395 "Revised draft",
17396 ])))
17397 .build()
17398 .unwrap();
17399
17400 agent.chat("First turn").await.unwrap();
17401 assert_eq!(agent.current_state().as_deref(), Some("intake"));
17402 assert!(!agent.get_context().contains_key("draft_version"));
17403
17404 agent.chat("Second turn").await.unwrap();
17405 assert_eq!(agent.current_state().as_deref(), Some("drafting"));
17406 assert_eq!(
17407 agent.get_context().get("draft_version"),
17408 Some(&serde_json::json!(1))
17409 );
17410
17411 agent.chat("Third turn").await.unwrap();
17412 assert_eq!(agent.current_state().as_deref(), Some("review"));
17413 assert_eq!(
17414 agent.get_context().get("review_entry"),
17415 Some(&serde_json::json!("first"))
17416 );
17417
17418 agent.chat("Fourth turn").await.unwrap();
17419 assert_eq!(agent.current_state().as_deref(), Some("drafting"));
17420 assert_eq!(
17421 agent.get_context().get("draft_version"),
17422 Some(&serde_json::json!(2))
17423 );
17424 }
17425
17426 #[tokio::test]
17428 async fn test_integration_process_normalize() {
17429 let yaml = r#"
17430name: ProcessAgent
17431system_prompt: "You are helpful."
17432process:
17433 input:
17434 - type: normalize
17435 config:
17436 trim: true
17437 collapse_whitespace: true
17438"#;
17439 let mock = mock_with_response("Got your message.");
17440 let builder = AgentBuilder::from_yaml(yaml).unwrap();
17441 let agent = builder.llm(Arc::new(mock.clone())).build().unwrap();
17442
17443 let _ = agent.chat(" hello world ").await.unwrap();
17444
17445 let history = mock.call_history();
17447 assert!(!history.is_empty());
17448 let last_call = history.last().unwrap();
17450 let user_msg = last_call
17451 .messages
17452 .iter()
17453 .find(|m| m.role == ai_agents_core::Role::User)
17454 .unwrap();
17455 assert_eq!(user_msg.content, "hello world");
17456 }
17457
17458 #[tokio::test]
17462 async fn test_integration_memory_compression() {
17463 let yaml = r#"
17464name: MemoryAgent
17465system_prompt: "You are helpful."
17466memory:
17467 type: compacting
17468 max_messages: 100
17469 compress_threshold: 5
17470 max_recent_messages: 3
17471 summarize_batch_size: 2
17472"#;
17473 let responses: Vec<&str> = (0..8).map(|_| "Response from assistant.").collect();
17475 let mock = mock_with_responses(responses);
17476 let builder = AgentBuilder::from_yaml(yaml).unwrap();
17477 let agent = builder.llm(Arc::new(mock)).build().unwrap();
17478
17479 for i in 0..6 {
17481 let _ = agent.chat(&format!("Message {}", i)).await.unwrap();
17482 }
17483
17484 let messages = agent.memory.get_messages(None).await.unwrap();
17487 assert!(messages.len() <= 12); }
17491
17492 #[tokio::test]
17494 async fn test_integration_multi_llm_registry() {
17495 let mut mock_default = MockLLMProvider::new("default");
17496 mock_default.set_response("Default LLM response.");
17497 let mut mock_router = MockLLMProvider::new("router");
17498 mock_router.set_response("Router response.");
17499
17500 let agent = AgentBuilder::new()
17501 .system_prompt("You are helpful.")
17502 .llm_alias("default", Arc::new(mock_default))
17503 .llm_alias("router", Arc::new(mock_router))
17504 .build()
17505 .unwrap();
17506
17507 let response = agent.chat("Hello").await.unwrap();
17508 assert_eq!(response.content, "Default LLM response.");
17509 }
17510
17511 #[tokio::test]
17513 async fn test_integration_agent_reset() {
17514 let mock = mock_with_responses(vec!["Hello!", "Hello again!"]);
17515 let agent = AgentBuilder::new()
17516 .system_prompt("You are helpful.")
17517 .llm(Arc::new(mock))
17518 .build()
17519 .unwrap();
17520
17521 let _ = agent.chat("Hi").await.unwrap();
17522 let messages = agent.memory.get_messages(None).await.unwrap();
17523 assert_eq!(messages.len(), 2); agent.reset().await.unwrap();
17526 let messages = agent.memory.get_messages(None).await.unwrap();
17527 assert_eq!(messages.len(), 0);
17528 }
17529
17530 #[tokio::test]
17532 async fn test_integration_process_validate_reject() {
17533 use ai_agents_process::{ProcessConfig, ProcessProcessor};
17534
17535 let validate_config = ai_agents_process::ValidateStage {
17536 id: Some("length_check".to_string()),
17537 condition: None,
17538 config: ai_agents_process::ValidateConfig {
17539 rules: vec![ai_agents_process::ValidationRule::MinLength {
17540 min_length: 10,
17541 on_fail: ai_agents_process::ValidationAction {
17542 action: ai_agents_process::ValidationActionType::Reject,
17543 message: None,
17544 },
17545 }],
17546 ..Default::default()
17547 },
17548 };
17549 let process_config = ProcessConfig {
17550 input: vec![ai_agents_process::ProcessStage::Validate(validate_config)],
17551 ..Default::default()
17552 };
17553 let processor = ProcessProcessor::new(process_config);
17554
17555 let mock = mock_with_response("Should not reach here.");
17556 let agent = AgentBuilder::new()
17557 .system_prompt("You are helpful.")
17558 .llm(Arc::new(mock))
17559 .process_processor(processor)
17560 .build()
17561 .unwrap();
17562
17563 let response = agent.chat("Hi").await.unwrap();
17564 assert!(
17566 response.content.contains("rejected")
17567 || response.content.contains("Input rejected")
17568 || response.content.contains("too short")
17569 || response.content.contains("Too short")
17570 || response.content.len() < 50, "Expected rejection response, got: {}",
17572 response.content
17573 );
17574 }
17575
17576 #[tokio::test]
17578 async fn test_llm_fallback_on_failure() {
17579 use ai_agents_recovery::{ErrorRecoveryConfig, LLMFailureAction, LLMRecoveryConfig};
17580
17581 let mut primary = MockLLMProvider::new("primary");
17582 primary.set_error("Primary LLM is unavailable");
17583
17584 let mut fallback = MockLLMProvider::new("fallback");
17585 fallback.set_response("Fallback response works!");
17586
17587 let agent = AgentBuilder::new()
17588 .system_prompt("You are helpful.")
17589 .llm_alias("default", Arc::new(primary))
17590 .llm_alias("backup", Arc::new(fallback))
17591 .recovery_manager(RecoveryManager::new(ErrorRecoveryConfig {
17592 llm: LLMRecoveryConfig {
17593 on_failure: LLMFailureAction::FallbackLlm {
17594 fallback_llm: "backup".to_string(),
17595 },
17596 ..Default::default()
17597 },
17598 ..Default::default()
17599 }))
17600 .build()
17601 .unwrap();
17602
17603 let response = agent.chat("Hello").await.unwrap();
17604 assert!(
17605 response.content.contains("Fallback response"),
17606 "Expected fallback response, got: {}",
17607 response.content
17608 );
17609 }
17610
17611 #[tokio::test]
17613 async fn test_llm_fallback_response_static_message() {
17614 use ai_agents_recovery::{ErrorRecoveryConfig, LLMFailureAction, LLMRecoveryConfig};
17615
17616 let mut primary = MockLLMProvider::new("primary");
17617 primary.set_error("Primary LLM is unavailable");
17618
17619 let agent = AgentBuilder::new()
17620 .system_prompt("You are helpful.")
17621 .llm(Arc::new(primary))
17622 .recovery_manager(RecoveryManager::new(ErrorRecoveryConfig {
17623 llm: LLMRecoveryConfig {
17624 on_failure: LLMFailureAction::FallbackResponse {
17625 message: "I am temporarily unavailable. Please try again later."
17626 .to_string(),
17627 },
17628 ..Default::default()
17629 },
17630 ..Default::default()
17631 }))
17632 .build()
17633 .unwrap();
17634
17635 let response = agent.chat("Hello").await.unwrap();
17636 assert!(
17637 response.content.contains("temporarily unavailable"),
17638 "Expected static fallback message, got: {}",
17639 response.content
17640 );
17641 }
17642
17643 #[tokio::test]
17645 async fn test_tool_failure_skip() {
17646 use ai_agents_recovery::{
17647 ErrorRecoveryConfig, ToolFailureAction, ToolRecoveryConfig, ToolRetryConfig,
17648 };
17649
17650 let mock = mock_with_responses(vec![
17652 r#"I'll use the nonexistent tool.
17653[TOOL_CALL: {"name": "nonexistent_tool", "arguments": {}}]"#,
17654 "The tool was unavailable, but I can still help you.",
17655 ]);
17656
17657 let agent = AgentBuilder::new()
17658 .system_prompt("You are helpful.")
17659 .llm(Arc::new(mock))
17660 .recovery_manager(RecoveryManager::new(ErrorRecoveryConfig {
17661 tools: ToolRecoveryConfig {
17662 default: ToolRetryConfig {
17663 max_retries: 0,
17664 timeout_ms: None,
17665 on_failure: ToolFailureAction::Skip,
17666 },
17667 ..Default::default()
17668 },
17669 ..Default::default()
17670 }))
17671 .build()
17672 .unwrap();
17673
17674 let response = agent.chat("Use the nonexistent tool").await;
17676 assert!(
17677 response.is_ok(),
17678 "Expected Ok with skip policy, got: {:?}",
17679 response
17680 );
17681 }
17682}