Skip to main content

ai_agents_runtime/
runtime.rs

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
13/// Shared lock table used to serialize side-effecting tool calls by canonical resource.
14pub(crate) type ToolResourceLocks = Arc<RwLock<HashMap<String, Weak<tokio::sync::Mutex<()>>>>>;
15
16//
17// Owns every lock acquired for one tool call and removes dead weak entries after release.
18//
19struct 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//
45// Captures runtime controls under one read guard so policy and scope belong to the same generation.
46//
47#[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//
56// Records the generations used by the final authorization decision.
57//
58#[derive(Clone, Copy)]
59struct ToolDecisionVersions {
60    policy: u64,
61    registry: u64,
62    runtime_control: u64,
63    state: Option<u64>,
64}
65
66//
67// Carries deterministic effective tools with the state generation that authorized their scopes.
68//
69struct AvailableToolIdsSnapshot {
70    tool_ids: Vec<String>,
71    state_generation: Option<u64>,
72}
73
74//
75// Binds human approval to the reviewed action and exact tool implementation.
76//
77#[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
88//
89// A later plain approval must not erase arguments modified by an earlier approval stage.
90//
91fn 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    /// Returns true when final authorization no longer matches the reviewed action.
107    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
192/// Outcome of processing tool calls within the agent loop.
193enum ToolCallOutcome {
194    /// Tools executed successfully, continue the LLM loop for the next iteration.
195    Continue,
196    /// A state transition fired during tool call handling, continue the loop.
197    TransitionFired,
198    /// HITL rejected a tool call, return this response immediately.
199    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
214//
215// Carries one committed model answer through shared output processing and root-turn finalization.
216//
217struct 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
228//
229// Carries the finalized response fields into metadata assembly without changing their ownership.
230//
231struct 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
241/// Outcome of skill routing — used by `try_skill_route`.
242enum SkillRouteResult {
243    /// No skill matched, continue to normal LLM chat.
244    NoMatch,
245    /// Skill executed successfully.
246    Response { skill_id: String, content: String },
247    /// Skill matched but needs disambiguation first.
248    NeedsClarification(AgentResponse),
249}
250
251/// Result of response-independent parallel transition selection.
252enum ParallelTransitionSelection {
253    /// A transition matched and can be committed before the old-state response.
254    Candidate(TransitionCandidate),
255    /// All eligible transition checks ran and none matched.
256    NoMatch,
257    /// LLM-based route evaluation needed speculative capacity that was unavailable.
258    ReservationExhausted,
259}
260
261/// Outcome of post_loop_processing - drives the caller's next step.
262enum PostLoopResult {
263    /// No transition fired. Content is the LLM response for this turn.
264    NoTransition(String),
265    /// Transition fired. Content is from plain post-transition re-generation.
266    Transitioned(String),
267    /// Transition fired into a state that requires full dispatch.
268    /// Caller re-enters run_loop_internal to apply the correct handler.
269    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/// Host-owned runtime control state shared with active agents.
289#[derive(Debug)]
290struct RuntimeControlState {
291    /// Serializes control mutations with exact runtime safety snapshots.
292    snapshot_guard: RwLock<()>,
293    /// Monotonic version for runtime-control snapshots.
294    version: AtomicU64,
295    /// Emergency switch that denies future calls and is shared with active tool contexts.
296    emergency_deny: Arc<AtomicBool>,
297    /// Optional live replacement for tool security policy.
298    tool_security_override: RwLock<Option<ToolSecurityEngine>>,
299    /// Optional live narrowing scope applied after the runtime's declared grant.
300    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/// Host-only handle for live runtime safety controls.
316#[derive(Clone)]
317pub struct RuntimeControlHandle {
318    state: Arc<RuntimeControlState>,
319}
320
321impl RuntimeControlHandle {
322    /// Returns the current runtime-control version.
323    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    /// Overrides tool security for later tool calls and panics if host policy is invalid.
332    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    /// Validates replacement policy before changing the runtime-control generation or active snapshot.
338    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    /// Clears the live tool security override.
349    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    /// Narrows the runtime's declared tool grant for later calls without adding authority.
356    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    /// Clears live narrowing so later calls return to the runtime's declared grant.
363    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    /// Enables or disables emergency denial for future tool calls.
370    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    /// Denies future calls and asks active cancellable work to stop.
377    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    /// Structured persona manager for identity, evolution, and secrets.
416    persona_manager: Option<Arc<ai_agents_persona::PersonaManager>>,
417    /// Skill ID that triggered the current pending disambiguation.
418    /// Set by try_skill_route() when skill-level disambiguation triggers clarification.
419    /// Read by run_loop() when clarification resolves to route directly to the skill.
420    pending_skill_id: RwLock<Option<String>>,
421    current_plan: RwLock<Option<Plan>>,
422    /// Tool IDs declared in the top-level `tools:` spec.
423    declared_tool_ids: Option<Vec<String>>,
424    /// Whether the context manager has been initialized (defaults loaded, env resolved, etc.)
425    context_initialized: AtomicBool,
426    /// Spawner for dynamic agent creation (set when YAML has a spawner: section).
427    spawner: Option<Arc<crate::spawner::AgentSpawner>>,
428    /// Registry tracking spawned agents (set when YAML has a spawner: section).
429    spawner_registry: Option<Arc<crate::spawner::AgentRegistry>>,
430    /// Re-dispatch depth for post-transition full dispatch.
431    /// 0 = not re-dispatching. > 0 = user message already in memory, skip re-adding.
432    redispatch_depth: RwLock<u32>,
433    /// Active optimized turn context used to keep root lifecycle state in one place.
434    active_turn_context: RwLock<Option<TurnOptimizationContext>>,
435    /// Tracks whether the root turn already wrote the processed user message.
436    root_user_message_committed: AtomicBool,
437    /// Current actor ID for cross-session memory.
438    actor_id: RwLock<Option<String>>,
439    /// Fact store for managing per-actor extracted facts.
440    fact_store: RwLock<Option<Arc<ai_agents_facts::FactStore>>>,
441    /// Fact extractor for LLM-based fact extraction.
442    /// None when actor_memory is enabled without facts.enabled.
443    fact_extractor: RwLock<Option<Arc<dyn ai_agents_facts::FactExtractor>>>,
444    /// Cached actor facts keyed by actor ID so concurrent or alternating turns do not overwrite one another.
445    actor_facts_cache: Arc<RwLock<HashMap<String, Vec<ai_agents_core::KeyFact>>>>,
446    /// Number of messages since last fact extraction.
447    messages_since_extraction: Arc<RwLock<usize>>,
448    /// Actor memory configuration.
449    actor_memory_config: Option<ai_agents_facts::ActorMemoryConfig>,
450    /// Facts configuration.
451    facts_config: Option<ai_agents_facts::FactsConfig>,
452    /// Session-scoped metadata (tags, ttl, actor roster).
453    session_metadata: RwLock<ai_agents_core::SessionMetadata>,
454    /// Session id currently bound to this runtime instance.
455    current_session_id: RwLock<Option<String>>,
456    /// Relationship manager for actor-scoped social memory.
457    relationship_manager: Option<Arc<RelationshipManager>>,
458    /// Observability manager for traces, metrics, reports, and exports.
459    observability_manager: Option<Arc<ObservabilityManager>>,
460    /// Runtime optimization and maintenance policy.
461    runtime_config: RuntimeConfig,
462    /// Queue for background maintenance tasks.
463    background_maintenance: Arc<BackgroundMaintenanceQueue>,
464    /// Cross-tool locks for side-effecting calls that target the same resource.
465    resource_locks: ToolResourceLocks,
466    /// Host-only runtime control state.
467    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    /// Scopes clarification question generation as disambiguation_clarification.
497    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    /// Scopes clarification response parsing as disambiguation_clarification.
507    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    /// Scopes one process stage using the purpose implied by its stage type.
521    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    /// Attach a relationship manager configured by the builder or host application.
657    pub fn with_relationships(mut self, manager: Arc<RelationshipManager>) -> Self {
658        self.relationship_manager = Some(manager);
659        self
660    }
661
662    /// Attach a shared observability manager for traces, metrics, reports, and exports.
663    pub fn with_observability(mut self, manager: Arc<ObservabilityManager>) -> Self {
664        self.observability_manager = Some(manager);
665        self
666    }
667
668    /// Attach runtime optimization policy and resize the background queue.
669    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    /// Returns the runtime optimization policy.
677    pub fn runtime_config(&self) -> &RuntimeConfig {
678        &self.runtime_config
679    }
680
681    /// Wait for all background maintenance tasks to finish.
682    pub async fn flush_background_tasks(&self) -> Result<()> {
683        self.background_maintenance.flush_all().await
684    }
685
686    /// Wait for background maintenance associated with one actor to finish.
687    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    /// Wait for background maintenance associated with one task kind to finish.
692    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    /// Wait for background maintenance associated with one actor and task kind to finish.
700    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    /// Flush background maintenance before a host shuts down the runtime.
711    pub async fn shutdown_background_tasks(&self) -> Result<()> {
712        self.flush_background_tasks().await
713    }
714
715    /// Returns the configured observability manager for report and export access.
716    pub fn observability(&self) -> Option<Arc<ObservabilityManager>> {
717        self.observability_manager.clone()
718    }
719
720    /// Exports observability files after a turn when export settings request it.
721    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    /// Returns the configured relationship manager, if relationship memory is enabled.
735    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    /// Returns the current session ID or creates one for unsaved observed turns.
775    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    /// Builds the root or child observation context for a chat entry point.
784    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    /// Refreshes task-local context with current runtime labels and a purpose.
801    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    /// Runs a future under a purpose while preserving current trace context.
820    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    /// Runs chat with actor and observation context while avoiding recursive async types.
832    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    /// Run one turn with turn-scoped actor context without mutating the runtime's global actor ID.
857    ///
858    /// The supplied context is available to actor-scoped facts, relationship memory, orchestration, and prompt templates only for the lifetime of this call.
859    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    /// Convenience wrapper around [`Self::chat_with_actor_context`] for a turn whose original actor is known up front.
869    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    /// Ensure the effective actor's relationship is loaded from storage into the relationship manager.
875    pub async fn load_actor_relationship(&self) -> Result<()> {
876        self.maybe_load_actor_relationship().await;
877        Ok(())
878    }
879
880    /// Manually apply a delta to the effective actor's `agent_to_actor` relationship perspective and persist the updated relationship when storage is configured.
881    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    /// Manually apply a delta to a specific relationship perspective for the effective actor.
897    ///
898    /// Use this for two-sided configurations when you need to update `agent_to_actor`, `perceived_actor_to_agent`, or `mutual` explicitly from application logic.
899    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    /// Set only the actor memory and facts configs without creating the store.
945    /// The store and extractor are created lazily in init_storage().
946    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    /// Configure fact store and optional extractor for actor memory.
957    /// Pass `None` for `extractor` to load existing facts without running extraction.
958    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    /// Get the fact store for direct fact manipulation.
973    pub fn fact_store(&self) -> Option<Arc<ai_agents_facts::FactStore>> {
974        self.fact_store.read().clone()
975    }
976
977    /// Get the current actor ID.
978    pub fn actor_id(&self) -> Option<String> {
979        self.actor_id.read().clone()
980    }
981
982    /// Set the current actor ID (player, user, another agent, etc.).
983    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    /// Clear the current actor binding while retaining the session actor roster.
996    pub fn clear_actor_id(&self) {
997        *self.actor_id.write() = None;
998        self.session_metadata.write().actor_id = None;
999    }
1000
1001    /// Set the current actor ID. Convenience wrapper around set_actor_id.
1002    pub fn set_user_id(&self, user_id: &str) -> ai_agents_core::Result<()> {
1003        self.set_actor_id(user_id)
1004    }
1005
1006    /// Load facts for the current actor from storage and cache them for prompt injection.
1007    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    /// Load actor memory only when the effective actor has no cached facts yet.
1028    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    /// Pre-turn lifecycle shared by streaming and non-streaming paths.
1039    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    /// Post-turn lifecycle shared by streaming and non-streaming paths.
1052    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    /// Starts root-turn bookkeeping for user-message commit tracking.
1061    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    /// Writes the processed user message once for the root turn.
1110    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    /// Clears root-turn bookkeeping after final response handling.
1129    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    /// Applies freshness policy before rendering the next prompt.
1161    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    /// Runs post-turn facts and relationship maintenance according to runtime policy.
1211    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    /// Queues background maintenance or applies the configured overflow behavior.
1443    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    /// Records a skipped background maintenance event when work cannot run.
1578    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    /// Get cached actor facts for the effective actor.
1602    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    /// Returns the formatted relationship prompt text for the effective actor, if relationship injection produced any text for this turn.
1614    pub fn relationship_memory_text(&self) -> Option<String> {
1615        self.format_relationship_for_context().map(|(_, text)| text)
1616    }
1617
1618    /// Manually extract facts from the last N messages.
1619    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        // Save to storage and update the actor-scoped cache when an actor is known.
1663        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                // add_facts now returns the authoritative post-write set.
1669                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    /// Resolve actor_id from context if method is from_context.
1703    /// Supports dotted paths (e.g. "player.id", "user.profile.id").
1704    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            // get_path resolves dotted paths; get only handles top-level keys.
1718            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    /// Format actor facts for template injection.
1739    fn format_actor_facts_for_context(&self) -> String {
1740        // Respect inject_in_context: false.
1741        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        // Effective token cap: prefer memory.token_budget.allocation.facts when present,
1766        // otherwise fall back to actor_memory.injection.max_tokens.
1767        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        // Filter by category when injection.mode = category.
1776        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    /// Run auto-extraction after a chat turn.
2046    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        //
2111        // One readiness guard prevents concurrent entry points from constructing storage or fact state more than once.
2112        //
2113        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        //
2158        // Durable features must fail before execution instead of degrading to volatile state.
2159        //
2160        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    /// Initialize fact store and extractor from stored config and current storage.
2174    /// Called from init_storage() so facts are ready before the first turn.
2175    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    /// Returns the spawner if configured via a spawner: YAML section.
2244    pub fn spawner(&self) -> Option<&Arc<crate::spawner::AgentSpawner>> {
2245        self.spawner.as_ref()
2246    }
2247
2248    /// Returns the agent registry if configured via a spawner: YAML section.
2249    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    /// Returns the host-only runtime control handle.
2309    pub fn runtime_control(&self) -> RuntimeControlHandle {
2310        RuntimeControlHandle {
2311            state: Arc::clone(&self.runtime_control),
2312        }
2313    }
2314
2315    /// Installs or clears the host question handler used by `ask_user`.
2316    pub fn set_question_handler(&self, handler: Option<Arc<dyn QuestionHandler>>) {
2317        self.tools.set_question_handler(handler);
2318    }
2319
2320    /// Installs the host diagnostics provider used by `diagnostics`.
2321    pub fn set_diagnostics_provider(&self, provider: Arc<dyn DiagnosticsProvider>) {
2322        self.tools.set_diagnostics_provider(provider);
2323    }
2324
2325    /// Installs the host command runner used by `command`.
2326    pub fn set_command_runner(&self, runner: Arc<dyn CommandRunner>) {
2327        self.tools.set_command_runner(runner);
2328    }
2329
2330    /// Installs the host web-search provider used by `web_search`.
2331    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    /// Returns the current session-local todo list.
2336    pub fn todos(&self) -> Vec<TodoItem> {
2337        self.tools.todos()
2338    }
2339
2340    /// Returns the effective tool security snapshot for the next decision.
2341    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    /// Reads policy, scope, emergency state, and generation as one safety snapshot.
2350    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    /// Performs the single rate admission after locks are held and rejects changed runtime, policy, or state authority.
2366    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    /// Get a copy of current session metadata.
2481    pub fn session_metadata(&self) -> ai_agents_core::SessionMetadata {
2482        self.session_metadata.read().clone()
2483    }
2484
2485    /// Delete all facts and sessions for an actor, gated by privacy.allow_deletion.
2486    /// Returns Err when actor_memory.privacy.allow_deletion is false.
2487    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            //
2501            // Composite support is checked before mutation so privacy deletion cannot become partial.
2502            //
2503            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            //
2511            // Clone the fallback store before awaiting so backend I/O never holds the runtime read guard.
2512            //
2513            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    /// Overwrite session metadata (tags, ttl, custom fields).
2526    pub fn set_session_metadata(&self, meta: ai_agents_core::SessionMetadata) {
2527        *self.session_metadata.write() = meta;
2528    }
2529
2530    /// Delete sessions whose TTL has expired. Returns number of sessions removed.
2531    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    /// List sessions matching a filter. Supports actor, tag, and date filters.
2548    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    /// Save state including spawned agents manifest for session persistence.
2591    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        //
2642        // Metadata is restored only when the backend explicitly owns that durable contract.
2643        //
2644        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                // Fire on_session_created when this session id is first seen on this runtime.
2757                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                // Update metadata before persisting.
2767                {
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                //
2785                // Metadata-capable backends own atomic snapshot and metadata persistence.
2786                //
2787                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    /// Restore this runtime and its complete saved child topology from one named session.
2812    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        //
2939        // Every record is loaded before runtime state changes. Registry replacement is the final topology commit point.
2940        //
2941        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        // Inject actor_facts for {{ actor_facts }} template variable.
3136        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    /// Canonicalizes IDs once while preserving their first declaration order and ignoring unknown entries.
3153    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    /// Applies runtime narrowing to the canonical declared grant without permitting scope-based expansion.
3162    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    /// Reads the live runtime narrowing and returns the current deterministic effective tool IDs.
3176    async fn get_available_tool_ids(&self) -> Result<Vec<String>> {
3177        Ok(self.get_available_tool_ids_snapshot().await?.tool_ids)
3178    }
3179
3180    /// Captures the runtime scope used for ordinary availability checks before applying state narrowing.
3181    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    /// Applies every explicit ancestor and current-state scope to one runtime snapshot and retains its state generation.
3188    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            // Persona always prepended regardless of prompt_mode.
3351            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        // No state machine - prepend persona to base.
3375        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        // Include the current state's prompt text so the detector understands
3458        // what kind of input is expected (e.g., "Ask for the order number").
3459        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        // Extract canonical intent labels from current state's transitions
3475        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        //
3665        // Provider-visible definitions are derived only from the current effective grant. Tool choice never expands registration, scope, or authorization.
3666        //
3667        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        // Try direct JSON parse first
4039        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) {
4040            // Handle JSON array of tool calls (parallel tool calling)
4041            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            // Handle single JSON object
4051            if let Some(tool_call) = self.extract_tool_call_from_value(&parsed) {
4052                return Some(vec![tool_call]);
4053            }
4054        }
4055
4056        // Try to extract JSON from content (handles extra text/braces from LLM)
4057        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            // Handle JSON array of tool calls (parallel tool calling)
4061            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            // Handle single JSON object
4071            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    // Lite models could generate unmatched braces: this function handles such cases
4100    fn extract_json_from_content(&self, content: &str) -> Option<String> {
4101        // Try array first (for parallel tool calls), then single object
4102        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    /// Extract a JSON array `[...]` containing tool calls from mixed content.
4109    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            // Verify it looks like an array of tool calls
4132            if json_str.contains("\"tool\"") {
4133                return Some(json_str.to_string());
4134            }
4135        }
4136
4137        None
4138    }
4139
4140    /// Extract a JSON object `{...}` containing a tool call from mixed content.
4141    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        // Count braces to find the matching closing brace
4146        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            // Verify it looks like a tool call
4165            if json_str.contains("\"tool\"") {
4166                return Some(json_str.to_string());
4167            }
4168        }
4169
4170        None
4171    }
4172
4173    /// Builds a structured record from executor state.
4174    ///
4175    /// The explicit fields preserve one audit boundary for execution, policy, approval, timing, and generation evidence.
4176    #[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    /// Builds evidence with the exact generations used by the corresponding decision.
4221    #[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    /// Sends a finalized tool record to hooks, history, and error handling.
4265    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    /// Releases resource locks before hooks run because hooks may invoke another tool.
4288    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    /// Invokes a resolved tool once with timeout, cancellation, and actor context.
4298    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        //
4314        // Mark invocation inside the future so cancellation before the first poll remains executed false.
4315        //
4316        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    /// Truncates model-facing tool output on a character boundary.
4358    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    /// Acquires all declared resource locks in stable key order and supports emergency cancellation while waiting.
4372    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    /// Executes a resolved tool with retry policy from recovery settings.
4416    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    /// Executes a tool request through scope, policy, HITL, timeout, recovery, and evidence recording.
4446    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        //
5200        // Freeze the action reviewed by HITL after every approved argument change and policy cap.
5201        // A later plain approval preserves any earlier Modified evidence.
5202        //
5203        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        //
5242        // Re-resolve once after HITL because registry, scope, and policy may change while approval waits.
5243        // The final Arc must still match the implementation that the approver reviewed.
5244        //
5245        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        //
5358        // Final scope evaluation uses the same post-HITL runtime snapshot and captures state authority before locks.
5359        // Admission must reject any later state transition, reset, or restore before consuming rate capacity or invoking the tool.
5360        //
5361        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        //
5396        // Validation may run more than once, but it must not consume rate capacity.
5397        // Rate capacity is consumed once after resource locks are held.
5398        //
5399        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        //
5495        // Hold conflict locks across final generation admission and invocation.
5496        // Completion hooks and fallback execution run only after these guards are released.
5497        //
5498        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        //
5513        // The lock-held admission is the last authority boundary. Runtime, policy, and state generations must still match before the one atomic rate admission.
5514        // Updates after admission apply to later calls, while emergency cancellation remains live for this call.
5515        //
5516        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    //
5692    // This method is branch-safe because it only asks the router which skill matches.
5693    // Do not add disambiguation, pending-skill writes, or skill execution here.
5694    //
5695    /// Selects a skill without executing it.
5696    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    //
5723    // This is the commit half of skill routing.
5724    // It may mutate pending skill state, run clarification, and execute skill steps.
5725    //
5726    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    /// Result of skill routing.
5844    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    /// Execute a skill with reasoning and reflection, returning the response string.
5854    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    /// Execute a skill by ID, bypassing the skill router.
5893    /// Used after skill-triggered disambiguation resolves to route directly to the matched skill.
5894    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        // Gate pass against confidence threshold.
6080        // LLM may say PASS but with low confidence - the threshold catches this.
6081        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    /// Process input through the pipeline (state-level override or agent-level).
6104    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    /// Process output through the pipeline (state-level override or agent-level).
6121    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    /// Build a ProcessProcessor from the current state's process config, if any.
6142    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(&current, &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    /// Builds transition context with optional staged writes overlaid.
6203    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    /// Selects a post-response transition without committing side effects.
6217    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, &current_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    /// Selects a guard or resolved-intent transition without an LLM call.
6253    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    /// Commits a selected transition through the shared post-response path.
6295    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    /// Runs state transition approval before any transition side effects.
6301    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    /// Applies transition side effects after approval has succeeded.
6310    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    /// Approves and applies a transition without staged context writes.
6339    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    /// Applies an approved pre-response transition before redispatch.
6353    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    /// Commits a pre-response transition after approval and before redispatch.
6370    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    /// Handles post-response transition misses with fallback counters.
6387    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    /// Evaluates and commits post-response transitions for the committed response path.
6400    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(&current_state).await
6414    }
6415
6416    /// Attempts deterministic pre-response routing before old-state response generation.
6417    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                &current_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    //
6485    // Speculative branches overlap independent decisions but still commit exactly one path.
6486    // Losing branches must remain data only and must not write memory, run tools, or emit output.
6487    //
6488    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                    // Drop losing provider futures before transition side effects can reuse their shared resources.
6726                    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    //
7075    // Pending losers are reported as cancelled because their futures are dropped before completion.
7076    // Completed losers keep failed or discarded status based on their recorded result.
7077    //
7078    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    //
7097    // Finalization is separated from commit so losing branches remain observable.
7098    // This helper must not mutate runtime state other than observability.
7099    //
7100    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    //
7119    // This is only an eligibility check.
7120    // Actual transition selection happens in select_parallel_transition_candidate.
7121    //
7122    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    //
7133    // Parallel transition prompts must not depend on assistant response text.
7134    // Keep this branch response-independent or it can race against invalid context.
7135    //
7136    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            &current_state,
7155            &parallel,
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    /// Re-enters the runtime loop after an optimized transition commits.
7217    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    /// Runs final response hooks and maintenance only for the root dispatch.
7241    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    /// Execute on_exit actions for a state being left.
7254    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    /// Returns whether a transition target had already been entered before this transition.
7265    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    /// Execute on_enter (or on_reenter) actions for a state being entered.
7277    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    /// Execute a list of state actions (tool calls, skill invocations, context updates, LLM prompts).
7292    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                            // Render template variables and include conversation context
7367                            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    /// Run context extractors for the current state on the user's input.
7483    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        // Handle overflow AFTER compression, then check warning threshold
7512        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        // Overall budget warning
7529        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        // Per-component warning: summary
7542        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        // Per-component warning: recent_messages
7556        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        // Estimate ~50 tokens per message on average
7648        ((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        // Apply planning-level tool and skill filters.
7803        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        // Build tool descriptions with argument schemas so the planner
7813        // knows how to construct valid args for each step.
7814        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                    // When a tool step has dependency results, ask the LLM to
7978                    // produce the correct arguments given the context and tool schema.
7979                    // This avoids brittle {{stepN}} template substitution and lets
7980                    // the LLM handle type adaptation (e.g. picking the iso field
7981                    // from a datetime result for a downstream format call).
7982                    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                                // Parse the LLM's JSON response, fall back to planner args.
8038                                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        // Set plan status based on whether any steps actually failed.
8154        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        // Synthesize final output from all completed step results.
8170        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        // Synthesize a coherent summary from multiple step results via LLM.
8192        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        //
8246        // Blocking execution must fail before a turn starts when required persistence is unavailable.
8247        //
8248        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        // One-shot context initialization: load runtime defaults, resolve env vars,
8256        // populate builtin sources (session, agent), etc.  This must happen before
8257        // the first template render so that {{ context.* }} variables are available.
8258        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        // Clear stale disambiguation context from previous turns.
8267        // This prevents resolved_intent from leaking across turns and causing incorrect deterministic routing on subsequent inputs.
8268        self.clear_disambiguation_context();
8269
8270        // Disambiguation check (before input processing)
8271        if let Some(ref disambiguator) = self.disambiguation_manager {
8272            let disambiguation_context = self.build_disambiguation_context().await?;
8273
8274            // Get state-level disambiguation override
8275            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                    // Routing uses `resolved` (structured, deterministic)
8339                    // This is what makes post-disambiguation routing DETERMINISTIC
8340                    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                    // Check if this clarification was triggered by a skill-level override.
8354                    // If so, route directly to the matched skill instead of going through
8355                    // skill routing again (which might match a different skill).
8356                    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                    // Same skill-id re-check for best-guess path
8370                    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                            // Topic switch: process the user's new input from scratch.
8445                            // The LLM sees full conversation context including the abandoned exchange.
8446                            return self.run_loop_internal(&fresh_input).await;
8447                        }
8448                        None => {
8449                            // Pure abandonment: generate a brief acknowledgment.
8450                            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    /// Generate a localized response using the router LLM
8479    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    /// Clear disambiguation-related keys from the context manager.
8522    ///
8523    /// Render template variables in state action args using the context manager.
8524    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    /// Called at the start of each turn to prevent stale `resolved_intent` from leaking across turns.
8553    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    /// Re-run skill disambiguation on enriched input before executing the skill.
8567    /// After clarification resolves, the enriched input may still be missing required_clarity fields (e.g. "Transfer money to Jane." still lacks amount).
8568    /// This method re-runs the skill's disambiguation pass.
8569    /// If fields are still missing, it returns the new clarification question and keeps pending_skill_id set.
8570    /// If all fields are present (Clear), it executes the skill and returns the response.
8571    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 the skill has disambiguation enabled, re-run it on the enriched input.
8582        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                    // Keep pending_skill_id set (do NOT clear it).
8620                    // The next turn will resolve this new clarification and
8621                    // re-enter this method until all fields are present.
8622                    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                    // Fall through to execute with the further-enriched input.
8652                    *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                    // User abandoned during skill re-check.
8716                    // Clear skill routing state and fall through to normal execution.
8717                    *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        // Skill has no disambiguation or all fields present: execute.
8744        *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    /// Handle skill routing result: output processing, memory, transitions.
8754    /// Returns a fully formed AgentResponse for skill-routed requests.
8755    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    /// Run the Plan-and-Execute flow: generate plan, execute steps, finalize.
8782    /// Supports plan-level reflection with replan loop when configured.
8783    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    /// Inject CoT/ReAct reasoning prompt into the system message (first iteration only).
8876    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    //
8907    // Draft generation must not commit user memory or run tools.
8908    // The current user input is added only as an ephemeral message for this LLM call.
8909    //
8910    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    //
8940    // This is the only place where a winning draft is allowed to become runtime state.
8941    // Prompt and native tool calls remain inert until this method commits the draft into the shared executor path.
8942    //
8943    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    //
8993    // Tool drafts need a committed continuation after function results are written.
8994    // Redispatch depth suppresses duplicate root lifecycle work during that continuation.
8995    //
8996    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    //
9015    // Shared committed text finalization for normal responses and winning text drafts.
9016    // Keep output processing, reflection, transitions, hooks, and maintenance behind this commit boundary.
9017    //
9018    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    //
9068    // Auto reasoning uses this path after the judge wins with a deeper mode.
9069    // It intentionally uses committed message building instead of the draft overlay.
9070    //
9071    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    /// Handle tool calls: check transitions, execute tools in parallel, handle HITL rejection.
9143    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        // Check if a transition should fire before executing the LLM's tool call.
9151        // If a transition fires, on_enter actions handle the tool call correctly
9152        // (with proper URLs from YAML), so skip the LLM's tool call.
9153        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        // Store the assistant's tool-call message so the LLM sees its own decision in conversation history. Without this, the model only sees the tool result and may repeat the same call.
9164        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                    // Check if this is a HITL rejection - if so, break the loop
9184                    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 the rejection message to user, don't continue loop
9192                        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    /// Run the reflection loop on a response, returning (improved_content, reflection_metadata).
9213    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    /// Record the assistant turn, evaluate transitions, and decide what to do next.
9287    /// Returns PostLoopResult so callers can apply_post_loop_result for re-dispatch.
9288    async fn post_loop_processing(
9289        &self,
9290        processed_input: &str,
9291        content: String,
9292    ) -> Result<PostLoopResult> {
9293        // Do NOT add the assistant message to memory yet.
9294        // evaluate_transitions receives content as a direct parameter, so the message does not need to be in memory for transitions to evaluate correctly.
9295        // For NeedsRedispatch we skip adding the stale old-state response entirely, keeping memory clean for the re-dispatched handler.
9296
9297        self.increment_turn();
9298
9299        // Run context extractors so guards can check freshly-extracted values.
9300        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        // Check if we should skip re-generation after this transition.
9313        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        // Check if the new state needs full dispatch.
9322        // Orchestration states (concurrent, group_chat, pipeline, handoff, delegate) need their dedicated handlers.
9323        // Any non-None effective reasoning mode needs CoT/ReAct prompt injection, the plan-and-execute handler, or Auto re-determination - all of which live in run_loop_internal, not here.
9324        if self.needs_redispatch_for_new_state() {
9325            info!("Post-transition NeedsRedispatch: new state requires full dispatch");
9326            // Stale old-state content is NOT added to memory.
9327            // apply_post_loop_result will increment redispatch_depth and call run_loop_internal, which produces the correct response and adds it.
9328            return Ok(PostLoopResult::NeedsRedispatch);
9329        }
9330
9331        // Normal post-transition re-generation (plain LLM with optional tool calls).
9332        // Add the stale response to history so the new LLM sees the conversation.
9333        self.memory
9334            .add_message(ChatMessage::assistant(&content))
9335            .await?;
9336        self.check_memory_compression().await?;
9337
9338        // If a transition fired, on_enter actions already executed (e.g., HTTP calls).
9339        // The current content was generated in the OLD state context and is stale.
9340        // Re-generate in the new state context so the LLM can reference on_enter results.
9341        // If the LLM responds with a tool call, execute it in a mini-loop so the
9342        // result is not returned as raw JSON text.
9343        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            // Check if the post-transition response contains tool calls.
9368            // If so, execute them and loop so the LLM can summarize the result.
9369            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                // Loop to let the LLM see the tool result and produce a text response.
9405                continue;
9406            }
9407
9408            // No tool call - this is the final text response.
9409            self.memory
9410                .add_message(ChatMessage::assistant(&final_content))
9411                .await?;
9412            return Ok(PostLoopResult::Transitioned(final_content));
9413        }
9414
9415        // Exhausted post-transition iterations (unlikely). Return last content.
9416        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    /// Build the final AgentResponse with all metadata.
9425    /// Check whether to re-generate a response after a state transition.
9426    fn should_regenerate_after_transition(&self) -> bool {
9427        if let Some(ref sm) = self.state_machine {
9428            // Global setting
9429            if !sm.config().regenerate_on_transition {
9430                return false;
9431            }
9432            // Per-state override on the new (current) state
9433            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    /// Return true when the new state requires full dispatch via run_loop_internal.
9443    /// Covers orchestration states and any non-None effective reasoning mode.
9444    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            // Any non-None effective reasoning mode requires the main dispatch loop.
9457            let effective = self.get_effective_reasoning_config();
9458            if !matches!(effective.mode, ReasoningMode::None) {
9459                return true;
9460            }
9461        }
9462        false
9463    }
9464
9465    /// Consume a PostLoopResult. NeedsRedispatch re-enters run_loop_internal.
9466    /// The user message is already in memory - redispatch_depth suppresses re-adding it.
9467    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    /// Builds the final response metadata after committed output and transition handling complete.
9509    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    // Handle delegation: forward user input to a registry agent.
9549    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        // Prepare input based on delegate_context mode.
9582        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        // Backward-compatible context key.
9605        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        // Structured orchestration context.
9612        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        // post_loop_processing records the assistant turn and evaluates transitions.
9626        // apply_post_loop_result handles NeedsRedispatch by re-entering run_loop_internal.
9627        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    // Handle concurrent execution: run multiple registry agents in parallel and aggregate.
9658    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        // Render input template if provided, otherwise use the raw input.
9672        // Uses direct minijinja rendering (same approach as pipeline) so variables
9673        // are top-level: {{ user_input }}, not {{ context.user_input }}.
9674        // Enrich input with parent conversation history when context_mode is set.
9675        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        // Backward-compatible context key.
9743        let _ = self.context_manager.set(
9744            "concurrent.result",
9745            serde_json::Value::String(result.response.content.clone()),
9746        );
9747
9748        // Build per-agent result data for context and metadata.
9749        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        // Structured orchestration context with per-agent results.
9764        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    // Handle group chat: run a multi-turn multi-agent conversation.
9804    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        // Enrich input with parent conversation history when context_mode is set.
9822        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        // Render input template if provided, otherwise use the raw user message as topic.
9836        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        // Backward-compatible context key.
9862        let _ = self.context_manager.set(
9863            "group_chat.conclusion",
9864            serde_json::Value::String(result.response.content.clone()),
9865        );
9866
9867        // Build transcript data for context and metadata.
9868        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        // Structured orchestration context with full transcript.
9881        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    // Handle pipeline: run agents sequentially with per-stage input templates.
9923    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        // Enrich input with parent conversation history when context_mode is set.
9951        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        // Backward-compatible context key.
9985        let _ = self.context_manager.set(
9986            "pipeline.result",
9987            serde_json::Value::String(result.response.content.clone()),
9988        );
9989
9990        // Build per-stage data for context and metadata.
9991        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        // Structured orchestration context.
10005        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    // Handle handoff: LLM-directed agent-to-agent control transfer.
10043    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        // Enrich input with parent conversation history when context_mode is set.
10064        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        // Render input template if provided, otherwise forward the raw user message.
10078        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        // Backward-compatible context key.
10106        let _ = self.context_manager.set(
10107            "handoff.result",
10108            serde_json::Value::String(result.response.content.clone()),
10109        );
10110
10111        // Build handoff chain data for context and metadata.
10112        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        // Structured orchestration context.
10125        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    // run_loop_internal: blocking (non-streaming) agent pipeline.
10165    async fn run_loop_internal(&self, input: &str) -> Result<AgentResponse> {
10166        self.begin_root_turn();
10167        // Resolve actor_id from context, reload facts if actor changed, bump counter.
10168        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        // Inject process context (detect/extract results) into agent context
10174        // so system prompt templates can use {{ context.detected_language }} etc.
10175        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        // Handle orchestration states (delegate, concurrent, group_chat, pipeline, handoff).
10197        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        //
10228        // The speculative future is boxed to keep the runtime future size manageable.
10229        // Removing the box can overflow small test stacks because this function is recursive through redispatch.
10230        //
10231        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                    // Store the clarification question in memory so the next turn
10255                    // can be handled as a clarification response.
10256                    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 => {} // continue to normal LLM chat
10264        }
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            // When reasoning is active, cap iterations at the reasoning-specific limit.
10294            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            // Run reflection (blocking LLM calls for retries)
10359            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            // Post-loop: memory, transitions, post-transition re-generation.
10368            // apply_post_loop_result handles NeedsRedispatch by re-entering
10369            // run_loop_internal so the new state's full dispatch activates.
10370            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 the stale stream future before transition side effects or redispatch reuse the provider.
10527                    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    /// Streaming agent pipeline
10675    /// Uses all the same shared helpers as run_loop_internal.
10676    /// The ONLY difference: LLM calls use complete_stream() + yield deltas.
10677    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            // Parity with non-stream: resolve actor from context and load facts if changed.
10687            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            // Inject process context (detect/extract results) into agent context
10699            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                //
10722                // Buffered routing keeps stale stream output hidden until a branch winner is known.
10723                // The boxed future prevents this stream state machine from becoming too large.
10724                //
10725                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            // Handle orchestration states in streaming mode.
10762            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            // Skill routing
10794            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) => {} // no skill matched, continue
10827                Err(e) => {
10828                    yield StreamChunk::error(e.to_string());
10829                    return;
10830                }
10831            }
10832
10833            // Reasoning mode determination
10834            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            // Plan-and-Execute: yield final result as single chunk (not token-by-token)
10851            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                // When reasoning is active, cap iterations at the reasoning-specific limit.
10888                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                // Check if reflection is active — if so, suppress streaming for this LLM call
10931                // because we may need to retry and the user would see a stale first attempt.
10932                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                    //
10940                    // Explicit tool choice buffers the provider decision so no text or tool call is visible before the runtime validates and commits it.
10941                    //
10942                    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                    // Streaming LLM call
10961                    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                    // Construct LLMResponse for hooks
10990                    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                // Tool call handling
10999                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                    // Emit tool events for streaming
11002                    // First check transitions (same as blocking path)
11003                    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                    // Store the assistant's tool-call message (same as blocking path)
11024                    let _ = self.memory.add_message(ChatMessage::assistant(&content)).await;
11025
11026                    // Execute tools with streaming events
11027                    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                // Extract thinking, process output
11097                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                // Reflection (uses blocking LLM calls for retries)
11120                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                // Buffered decisions emit only the accepted final text.
11137                if buffered_decision {
11138                    yield StreamChunk::content(&final_content);
11139                }
11140
11141                // Post-loop: memory, transitions, post-transition re-generation.
11142                // For NeedsRedispatch, run_loop_internal handles the new state's full
11143                // dispatch and its result is yielded as a single non-streamed chunk.
11144                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 the post-transition re-generated or re-dispatched content.
11202                    yield StreamChunk::content(&final_content);
11203                }
11204
11205                // Parity with non-stream: finalize the committed response once.
11206                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    /// Streaming entry point with disambiguation
11219    /// Mirrors run_loop but yields StreamChunks instead of AgentResponse.
11220    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            // One-shot context initialization (mirrors run_loop)
11230            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            // Clear stale disambiguation context from previous turns.
11248            self.clear_disambiguation_context();
11249
11250            // Disambiguation check (before input processing)
11251            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                        // Check if this clarification was triggered by a skill-level override.
11337                        // Re-run skill disambiguation to verify all required_clarity fields
11338                        // are present before executing.
11339                        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                        // Forward to internal stream with enriched input
11356                        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                        // Same skill-id re-check for best-guess path
11366                        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                                // Topic switch: forward to internal stream with fresh input.
11473                                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                                // Pure abandonment: generate a brief acknowledgment.
11481                                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            // No disambiguation or Clear result — proceed with internal stream
11513            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    /// Build a context map with language hints from context_manager for HITL message localization.
11584    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    /// Send a HITL check result through the approval flow and return the full ApprovalResult.
11595    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    /// Execute multiple tools in parallel
11718    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    /// Stream a chat response with real-time updates
11771    pub async fn chat_stream<'a>(
11772        &'a self,
11773        input: &'a str,
11774    ) -> Result<Pin<Box<dyn Stream<Item = StreamChunk> + Send + 'a>>> {
11775        //
11776        // Streaming readiness is checked before returning a stream so capability failures cannot appear as partial output.
11777        //
11778        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
11835//
11836// Render a concurrent input template using direct minijinja.
11837// Same approach as pipeline's render_stage_template so variables are top-level.
11838//
11839// Available variables:
11840//   {{ user_input }}    - the user's actual message
11841//   {{ context.<key> }} - values from the context manager
11842//
11843/// Builds safe runtime tags for background maintenance lifecycle events.
11844fn 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
11886/// Records a background maintenance lifecycle event when observability is enabled.
11887fn 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
11933//
11934// Path-bound mutations share one conservative lock so aliases and parent-child paths cannot bypass serialization.
11935// Exact domain keys remain available for non-path side effects, and unbound effects share one fallback lock.
11936//
11937fn 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    // Expose context manager values under {{ context.<key> }}.
12050    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(&registry));
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    /// Test hook that counts completed responses.
12951    struct ResponseCountingHooks {
12952        responses: Arc<std::sync::atomic::AtomicUsize>,
12953    }
12954
12955    /// Test tool that returns the execution context it received.
12956    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    /// Test tool that stays active long enough for runtime cancellation.
13006    struct SlowTool;
13007
13008    /// Test tool that fails once and must not be retried for writes.
13009    struct FlakyWriteTool {
13010        calls: Arc<std::sync::atomic::AtomicUsize>,
13011    }
13012
13013    /// Test tool that tracks concurrent execution on one path.
13014    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    // Basic YAML → Build → Chat flow
13910    #[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    // Multi-turn conversation
13925    #[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        // Verify memory accumulated messages
13948        let messages = agent.memory.get_messages(None).await.unwrap();
13949        // 3 user + 3 assistant = 6 messages
13950        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    // Tool execution in chat flow
15810    #[tokio::test]
15811    async fn test_integration_tool_execution() {
15812        // Mock LLM that returns a tool call then a final answer
15813        let mock = mock_with_responses(vec![
15814            // First response: tool call
15815            r#"I'll calculate that for you.
15816[TOOL_CALL: {"name": "calculator", "arguments": {"expression": "2+2"}}]"#,
15817            // After tool result: final answer
15818            "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        // The agent should eventually produce a response
15834        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    // State machine transitions
17105    #[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?", // greeting response
17124            "1",                        // transition evaluator picks first (index 0)
17125            "I'll help you with that.", // support response
17126        ]);
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        // After transition evaluation, state may or may not have changed
17133        // depending on mock evaluator response - the key is that it doesn't crash
17134    }
17135
17136    // State on_enter/on_exit actions
17137    #[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        // The transition evaluator will pick the first transition (index 0)
17161        let mock = mock_with_responses(vec![
17162            "Processing step 1.",
17163            "0", // transition evaluator response: select first transition
17164        ]);
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        // Manually transition to test on_enter/on_exit
17171        agent.transition_to("step2").await.unwrap();
17172
17173        assert_eq!(agent.current_state(), Some("step2".to_string()));
17174
17175        // Verify context was set by on_exit and on_enter actions
17176        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    // Process pipeline transforms input
17427    #[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        // Verify the LLM received the normalized input (trimmed + collapsed whitespace)
17446        let history = mock.call_history();
17447        assert!(!history.is_empty());
17448        // The user message in LLM call should be normalized
17449        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    // ═══════════════════════════════════════════════════════════
17459    // Integration Test 2.1.7: Memory compression triggers
17460    // ═══════════════════════════════════════════════════════════
17461    #[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        // Provide enough responses for compression to trigger
17474        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        // Send enough messages to trigger compression
17480        for i in 0..6 {
17481            let _ = agent.chat(&format!("Message {}", i)).await.unwrap();
17482        }
17483
17484        // Memory should have compressed - verify it didn't crash
17485        // and that messages are bounded
17486        let messages = agent.memory.get_messages(None).await.unwrap();
17487        // With compress_threshold=5 and max_recent_messages=3,
17488        // after 6 turns (12 messages), compression should have run
17489        assert!(messages.len() <= 12); // At most all messages if no compression, fewer if compressed
17490    }
17491
17492    // YAML with multiple LLMs
17493    #[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    // Agent reset clears state
17512    #[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); // user + assistant
17524
17525        agent.reset().await.unwrap();
17526        let messages = agent.memory.get_messages(None).await.unwrap();
17527        assert_eq!(messages.len(), 0);
17528    }
17529
17530    // Process pipeline rejects input
17531    #[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        // Rejected input should produce a rejection response, not call LLM
17565        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, // rejection message is typically short
17571            "Expected rejection response, got: {}",
17572            response.content
17573        );
17574    }
17575
17576    // LLM fallback: primary fails, fallback LLM responds
17577    #[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    // LLM fallback: primary fails, static message returned
17612    #[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    // Tool skip: tool fails, on_failure: skip absorbs the error
17644    #[tokio::test]
17645    async fn test_tool_failure_skip() {
17646        use ai_agents_recovery::{
17647            ErrorRecoveryConfig, ToolFailureAction, ToolRecoveryConfig, ToolRetryConfig,
17648        };
17649
17650        // LLM requests a nonexistent tool, then responds after seeing the skip result
17651        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        // The tool will fail (not found), but on_failure: skip absorbs the error
17675        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}