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}
64
65//
66// Binds human approval to the reviewed action and exact tool implementation.
67//
68#[derive(Clone)]
69struct ToolApprovalBinding {
70    canonical_id: String,
71    arguments: Value,
72    confirmation_required: bool,
73    policy_version: u64,
74    runtime_control_version: u64,
75    reviewed_tool: Arc<dyn ai_agents_core::Tool>,
76}
77
78//
79// A later plain approval must not erase arguments modified by an earlier approval stage.
80//
81fn merge_approved_record(record: &mut Option<ToolApprovalRecord>) {
82    if record
83        .as_ref()
84        .is_some_and(|record| matches!(record.status, ToolApprovalStatus::Modified))
85    {
86        return;
87    }
88    *record = Some(ToolApprovalRecord {
89        status: ToolApprovalStatus::Approved,
90        reason: None,
91        modified_arguments: None,
92    });
93}
94
95impl ToolApprovalBinding {
96    /// Returns true when final authorization no longer matches the reviewed action.
97    fn is_stale(
98        &self,
99        canonical_id: &str,
100        arguments: &Value,
101        confirmation_required: bool,
102        versions: ToolDecisionVersions,
103        resolved_tool: &Arc<dyn ai_agents_core::Tool>,
104    ) -> bool {
105        self.canonical_id != canonical_id
106            || self.arguments != *arguments
107            || self.confirmation_required != confirmation_required
108            || self.policy_version != versions.policy
109            || self.runtime_control_version != versions.runtime_control
110            || !Arc::ptr_eq(&self.reviewed_tool, resolved_tool)
111    }
112}
113
114use crate::turn_context::{current_turn_actor_context, scope_actor_context};
115
116use ai_agents_context::{ContextManager, ContextProvider, TemplateRenderer};
117use ai_agents_core::traits::storage::StorageCapability;
118use ai_agents_core::{
119    AgentError, AgentSnapshot, AgentStorage, ChatMessage, FinishReason, LLMError, LLMProvider,
120    LLMResponse, LLMToolDefinition, LLMToolRequest, PermissionOutcome, Result, ToolActorContext,
121    ToolApprovalRecord, ToolApprovalStatus, ToolCallSource, ToolCancellationToken, ToolChoice,
122    ToolExecutionContext, ToolExecutionRecord, ToolExecutionRequest, ToolInvoker,
123    ToolPolicyDecisionRecord, ToolResult,
124};
125use ai_agents_disambiguation::{
126    ClarificationObserver, ClarificationParseFuture, ClarificationQuestionFuture,
127    DisambiguationConfig, DisambiguationContext, DisambiguationManager, DisambiguationResult,
128};
129use ai_agents_hitl::{
130    ApprovalHandler, ApprovalResolvedOutcome, ApprovalResult, ApprovalTrigger, HITLCheckResult,
131    HITLEngine, RejectAllHandler, TimeoutAction,
132};
133use ai_agents_hooks::{AgentHooks, NoopHooks};
134use ai_agents_llm::LLMRegistry;
135use ai_agents_memory::{
136    CompressResult, EvictionReason, Memory, MemoryBudgetEvent, MemoryCompressEvent,
137    MemoryEvictEvent, MemoryTokenBudget, OverflowStrategy,
138};
139use ai_agents_observability::{
140    EventStatus, EventType, ObservabilityManager, ObservationPurpose, SpanContext,
141    current_observation_context, new_session_id as new_observation_session_id,
142    resolve_language_from_context, with_observation_context, with_observation_purpose,
143};
144use ai_agents_process::{
145    ProcessData, ProcessProcessor, ProcessPurposeHint, ProcessStageFuture, ProcessStageObserver,
146};
147use ai_agents_reasoning::{
148    CriterionResult, EvaluationResult, Plan, PlanAction, PlanStatus, PlanStep, ReasoningConfig,
149    ReasoningMetadata, ReasoningMode, ReasoningOutput, ReflectionAttempt, ReflectionConfig,
150    ReflectionMetadata, StepFailureAction,
151};
152use ai_agents_recovery::{
153    ByRoleFilter, ContextOverflowAction, FilterConfig, IntoClassifiedError, KeepRecentFilter,
154    LLMFailureAction, MessageFilter, RecoveryManager, SkipPatternFilter, ToolFailureAction,
155};
156use ai_agents_relationships::RelationshipManager;
157use ai_agents_skills::{SkillDefinition, SkillExecutor, SkillRouter};
158use ai_agents_state::{
159    PromptMode, StateAction, StateMachine, StateMachineSnapshot, StateTransitionEvent, ToolRef,
160    Transition, TransitionContext, TransitionEvaluator, TransitionTiming, evaluate_guard,
161};
162use ai_agents_storage::{StorageConfig as StorageStorageConfig, create_storage};
163use ai_agents_tools::{
164    CommandRunner, ConditionEvaluator, DiagnosticsProvider, EvaluationContext, LLMGetter,
165    QuestionHandler, SecurityCheckResult, TodoItem, ToolCallRecord, ToolRegistry,
166    ToolSecurityConfig, ToolSecurityEngine,
167};
168
169use super::{
170    Agent, AgentInfo, AgentResponse, ParallelToolsConfig, StreamChunk, StreamingConfig, ToolCall,
171};
172use crate::optimization::{
173    AwaitBeforeNextTurn, BackgroundMaintenanceQueue, BackgroundOverflowPolicy, MainResponseDraft,
174    MaintenanceMode, MaintenanceSequenceKey, RuntimeBranch, RuntimeBranchResult,
175    RuntimeBranchStatus, RuntimeCommitBehavior, RuntimeConfig, RuntimeOptimizationKind,
176    RuntimeTaskPriority, RuntimeTaskPurpose, ScheduledBranchSet, SkillCandidate,
177    StreamingDraftResult, TransitionCandidate, TurnBranchScheduler, TurnOptimizationContext,
178};
179use crate::spec::StorageConfig;
180
181/// Outcome of processing tool calls within the agent loop.
182enum ToolCallOutcome {
183    /// Tools executed successfully, continue the LLM loop for the next iteration.
184    Continue,
185    /// A state transition fired during tool call handling, continue the loop.
186    TransitionFired,
187    /// HITL rejected a tool call, return this response immediately.
188    Rejected(AgentResponse),
189}
190
191#[derive(Clone)]
192struct MainToolProtocol {
193    choice: Option<ToolChoice>,
194    tool_ids: Vec<String>,
195    definitions: Vec<LLMToolDefinition>,
196}
197
198struct MainProviderResponse {
199    response: LLMResponse,
200    used_native_tools: bool,
201}
202
203//
204// Carries one committed model answer through shared output processing and root-turn finalization.
205//
206struct CommittedTextResponse<'a> {
207    processed_input: &'a str,
208    input_context: &'a HashMap<String, Value>,
209    answer: String,
210    reasoning_mode: ReasoningMode,
211    auto_detected: bool,
212    iterations: u32,
213    thinking_content: Option<String>,
214    all_tool_calls: Vec<ToolCall>,
215}
216
217//
218// Carries the finalized response fields into metadata assembly without changing their ownership.
219//
220struct AgentResponseParts {
221    content: String,
222    all_tool_calls: Vec<ToolCall>,
223    reasoning_mode: ReasoningMode,
224    auto_detected: bool,
225    iterations: u32,
226    thinking: Option<String>,
227    reflection_metadata: Option<ReflectionMetadata>,
228}
229
230/// Outcome of skill routing — used by `try_skill_route`.
231enum SkillRouteResult {
232    /// No skill matched, continue to normal LLM chat.
233    NoMatch,
234    /// Skill executed successfully.
235    Response { skill_id: String, content: String },
236    /// Skill matched but needs disambiguation first.
237    NeedsClarification(AgentResponse),
238}
239
240/// Result of response-independent parallel transition selection.
241enum ParallelTransitionSelection {
242    /// A transition matched and can be committed before the old-state response.
243    Candidate(TransitionCandidate),
244    /// All eligible transition checks ran and none matched.
245    NoMatch,
246    /// LLM-based route evaluation needed speculative capacity that was unavailable.
247    ReservationExhausted,
248}
249
250/// Outcome of post_loop_processing - drives the caller's next step.
251enum PostLoopResult {
252    /// No transition fired. Content is the LLM response for this turn.
253    NoTransition(String),
254    /// Transition fired. Content is from plain post-transition re-generation.
255    Transitioned(String),
256    /// Transition fired into a state that requires full dispatch.
257    /// Caller re-enters run_loop_internal to apply the correct handler.
258    NeedsRedispatch,
259}
260
261struct RootTurnCleanup<'a> {
262    agent: &'a RuntimeAgent,
263}
264
265impl<'a> RootTurnCleanup<'a> {
266    fn new(agent: &'a RuntimeAgent) -> Self {
267        Self { agent }
268    }
269}
270
271impl Drop for RootTurnCleanup<'_> {
272    fn drop(&mut self) {
273        self.agent.end_root_turn();
274    }
275}
276
277/// Host-owned runtime control state shared with active agents.
278#[derive(Debug)]
279struct RuntimeControlState {
280    /// Serializes control mutations with exact runtime safety snapshots.
281    snapshot_guard: RwLock<()>,
282    /// Monotonic version for runtime-control snapshots.
283    version: AtomicU64,
284    /// Emergency switch that denies future calls and is shared with active tool contexts.
285    emergency_deny: Arc<AtomicBool>,
286    /// Optional live replacement for tool security policy.
287    tool_security_override: RwLock<Option<ToolSecurityEngine>>,
288    /// Optional live replacement for the top-level tool scope.
289    tool_scope_override: RwLock<Option<Vec<String>>>,
290}
291
292impl Default for RuntimeControlState {
293    fn default() -> Self {
294        Self {
295            snapshot_guard: RwLock::new(()),
296            version: AtomicU64::new(1),
297            emergency_deny: Arc::new(AtomicBool::new(false)),
298            tool_security_override: RwLock::new(None),
299            tool_scope_override: RwLock::new(None),
300        }
301    }
302}
303
304/// Host-only handle for live runtime safety controls.
305#[derive(Clone)]
306pub struct RuntimeControlHandle {
307    state: Arc<RuntimeControlState>,
308}
309
310impl RuntimeControlHandle {
311    /// Returns the current runtime-control version.
312    pub fn version(&self) -> u64 {
313        self.state.version.load(Ordering::SeqCst)
314    }
315
316    fn bump(&self) -> u64 {
317        self.state.version.fetch_add(1, Ordering::SeqCst) + 1
318    }
319
320    /// Overrides tool security for later tool calls.
321    pub fn set_tool_security(&self, config: ToolSecurityConfig) -> u64 {
322        let _guard = self.state.snapshot_guard.write();
323        let generation = self.bump();
324        *self.state.tool_security_override.write() = Some(
325            ToolSecurityEngine::new_with_policy_version(config, generation),
326        );
327        generation
328    }
329
330    /// Clears the live tool security override.
331    pub fn clear_tool_security_override(&self) -> u64 {
332        let _guard = self.state.snapshot_guard.write();
333        *self.state.tool_security_override.write() = None;
334        self.bump()
335    }
336
337    /// Overrides the top-level tool scope for later calls.
338    pub fn set_tool_scope(&self, tool_ids: Vec<String>) -> u64 {
339        let _guard = self.state.snapshot_guard.write();
340        *self.state.tool_scope_override.write() = Some(tool_ids);
341        self.bump()
342    }
343
344    /// Clears the live tool scope override.
345    pub fn clear_tool_scope_override(&self) -> u64 {
346        let _guard = self.state.snapshot_guard.write();
347        *self.state.tool_scope_override.write() = None;
348        self.bump()
349    }
350
351    /// Enables or disables emergency denial for future tool calls.
352    pub fn set_emergency_deny(&self, enabled: bool) -> u64 {
353        let _guard = self.state.snapshot_guard.write();
354        self.state.emergency_deny.store(enabled, Ordering::SeqCst);
355        self.bump()
356    }
357
358    /// Denies future calls and asks active cancellable work to stop.
359    pub fn cancel_all(&self) -> u64 {
360        self.set_emergency_deny(true)
361    }
362}
363
364pub struct RuntimeAgent {
365    info: AgentInfo,
366    llm_registry: Arc<LLMRegistry>,
367    memory: Arc<dyn Memory>,
368    tools: Arc<ToolRegistry>,
369    skills: Vec<SkillDefinition>,
370    skill_router: Option<SkillRouter>,
371    skill_executor: Option<SkillExecutor>,
372    base_system_prompt: String,
373    max_iterations: u32,
374    iteration_count: RwLock<u32>,
375    max_context_tokens: u32,
376    memory_token_budget: Option<MemoryTokenBudget>,
377    recovery_manager: RecoveryManager,
378    tool_security: ToolSecurityEngine,
379    process_processor: Option<ProcessProcessor>,
380    message_filters: RwLock<HashMap<String, Arc<dyn MessageFilter>>>,
381    state_machine: Option<Arc<StateMachine>>,
382    transition_evaluator: Option<Arc<dyn TransitionEvaluator>>,
383    context_manager: Arc<ContextManager>,
384    template_renderer: TemplateRenderer,
385    tool_call_history: RwLock<Vec<ToolCallRecord>>,
386    parallel_tools: ParallelToolsConfig,
387    streaming: StreamingConfig,
388    hooks: Arc<dyn AgentHooks>,
389    hitl_engine: Option<HITLEngine>,
390    approval_handler: Arc<dyn ApprovalHandler>,
391    storage_config: StorageConfig,
392    storage: RwLock<Option<Arc<dyn AgentStorage>>>,
393    storage_init: tokio::sync::Mutex<()>,
394    reasoning_config: ReasoningConfig,
395    reflection_config: ReflectionConfig,
396    disambiguation_manager: Option<DisambiguationManager>,
397    /// Structured persona manager for identity, evolution, and secrets.
398    persona_manager: Option<Arc<ai_agents_persona::PersonaManager>>,
399    /// Skill ID that triggered the current pending disambiguation.
400    /// Set by try_skill_route() when skill-level disambiguation triggers clarification.
401    /// Read by run_loop() when clarification resolves to route directly to the skill.
402    pending_skill_id: RwLock<Option<String>>,
403    current_plan: RwLock<Option<Plan>>,
404    /// Tool IDs declared in the top-level `tools:` spec.
405    declared_tool_ids: Option<Vec<String>>,
406    /// Whether the context manager has been initialized (defaults loaded, env resolved, etc.)
407    context_initialized: AtomicBool,
408    /// Spawner for dynamic agent creation (set when YAML has a spawner: section).
409    spawner: Option<Arc<crate::spawner::AgentSpawner>>,
410    /// Registry tracking spawned agents (set when YAML has a spawner: section).
411    spawner_registry: Option<Arc<crate::spawner::AgentRegistry>>,
412    /// Re-dispatch depth for post-transition full dispatch.
413    /// 0 = not re-dispatching. > 0 = user message already in memory, skip re-adding.
414    redispatch_depth: RwLock<u32>,
415    /// Active optimized turn context used to keep root lifecycle state in one place.
416    active_turn_context: RwLock<Option<TurnOptimizationContext>>,
417    /// Tracks whether the root turn already wrote the processed user message.
418    root_user_message_committed: AtomicBool,
419    /// Current actor ID for cross-session memory.
420    actor_id: RwLock<Option<String>>,
421    /// Fact store for managing per-actor extracted facts.
422    fact_store: RwLock<Option<Arc<ai_agents_facts::FactStore>>>,
423    /// Fact extractor for LLM-based fact extraction.
424    /// None when actor_memory is enabled without facts.enabled.
425    fact_extractor: RwLock<Option<Arc<dyn ai_agents_facts::FactExtractor>>>,
426    /// Cached actor facts keyed by actor ID so concurrent or alternating turns do not overwrite one another.
427    actor_facts_cache: Arc<RwLock<HashMap<String, Vec<ai_agents_core::KeyFact>>>>,
428    /// Number of messages since last fact extraction.
429    messages_since_extraction: Arc<RwLock<usize>>,
430    /// Actor memory configuration.
431    actor_memory_config: Option<ai_agents_facts::ActorMemoryConfig>,
432    /// Facts configuration.
433    facts_config: Option<ai_agents_facts::FactsConfig>,
434    /// Session-scoped metadata (tags, ttl, actor roster).
435    session_metadata: RwLock<ai_agents_core::SessionMetadata>,
436    /// Session id currently bound to this runtime instance.
437    current_session_id: RwLock<Option<String>>,
438    /// Relationship manager for actor-scoped social memory.
439    relationship_manager: Option<Arc<RelationshipManager>>,
440    /// Observability manager for traces, metrics, reports, and exports.
441    observability_manager: Option<Arc<ObservabilityManager>>,
442    /// Runtime optimization and maintenance policy.
443    runtime_config: RuntimeConfig,
444    /// Queue for background maintenance tasks.
445    background_maintenance: Arc<BackgroundMaintenanceQueue>,
446    /// Cross-tool locks for side-effecting calls that target the same resource.
447    resource_locks: ToolResourceLocks,
448    /// Host-only runtime control state.
449    runtime_control: Arc<RuntimeControlState>,
450}
451
452impl std::fmt::Debug for RuntimeAgent {
453    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454        f.debug_struct("RuntimeAgent")
455            .field("info", &self.info)
456            .field("base_system_prompt", &self.base_system_prompt)
457            .field("max_iterations", &self.max_iterations)
458            .field("skills_count", &self.skills.len())
459            .field("max_context_tokens", &self.max_context_tokens)
460            .field("has_state_machine", &self.state_machine.is_some())
461            .field("parallel_tools", &self.parallel_tools)
462            .field("streaming", &self.streaming)
463            .field("has_hooks", &true)
464            .field("has_hitl", &self.hitl_engine.is_some())
465            .field("storage_type", &self.storage_config.storage_type())
466            .field("reasoning_mode", &self.reasoning_config.mode)
467            .field("reflection_enabled", &self.reflection_config.enabled)
468            .field("declared_tool_ids", &self.declared_tool_ids)
469            .field("has_persona", &self.persona_manager.is_some())
470            .field("has_observability", &self.observability_manager.is_some())
471            .finish_non_exhaustive()
472    }
473}
474
475struct ObservabilityClarificationObserver;
476
477impl ClarificationObserver for ObservabilityClarificationObserver {
478    /// Scopes clarification question generation as disambiguation_clarification.
479    fn observe_question<'a>(
480        &'a self,
481        future: ClarificationQuestionFuture<'a>,
482    ) -> ClarificationQuestionFuture<'a> {
483        Box::pin(async move {
484            with_observation_purpose(ObservationPurpose::DisambiguationClarification, future).await
485        })
486    }
487
488    /// Scopes clarification response parsing as disambiguation_clarification.
489    fn observe_parse<'a>(
490        &'a self,
491        future: ClarificationParseFuture<'a>,
492    ) -> ClarificationParseFuture<'a> {
493        Box::pin(async move {
494            with_observation_purpose(ObservationPurpose::DisambiguationClarification, future).await
495        })
496    }
497}
498
499struct ObservabilityProcessStageObserver;
500
501impl ProcessStageObserver for ObservabilityProcessStageObserver {
502    /// Scopes one process stage using the purpose implied by its stage type.
503    fn observe<'a>(
504        &'a self,
505        hint: ProcessPurposeHint,
506        future: ProcessStageFuture<'a>,
507    ) -> ProcessStageFuture<'a> {
508        Box::pin(async move {
509            with_observation_purpose(observation_purpose_for_process(hint), future).await
510        })
511    }
512}
513
514struct RegistryLLMGetter {
515    registry: Arc<LLMRegistry>,
516}
517
518impl LLMGetter for RegistryLLMGetter {
519    fn get_llm(&self, alias: &str) -> Option<Arc<dyn LLMProvider>> {
520        self.registry.get(alias).ok()
521    }
522}
523
524impl RuntimeAgent {
525    #[allow(clippy::too_many_arguments)]
526    pub fn new(
527        info: AgentInfo,
528        llm_registry: Arc<LLMRegistry>,
529        memory: Arc<dyn Memory>,
530        tools: Arc<ToolRegistry>,
531        skills: Vec<SkillDefinition>,
532        system_prompt: String,
533        max_iterations: u32,
534    ) -> Self {
535        let (skill_router, skill_executor) = if !skills.is_empty() {
536            let router_llm = llm_registry.router().ok();
537            let router = router_llm.map(|llm| SkillRouter::new(llm, skills.clone()));
538            let executor = SkillExecutor::new(llm_registry.clone(), tools.clone());
539            (router, Some(executor))
540        } else {
541            (None, None)
542        };
543
544        let context_manager =
545            ContextManager::new(HashMap::new(), info.name.clone(), info.version.clone());
546
547        Self {
548            info,
549            llm_registry,
550            memory,
551            tools,
552            skills,
553            skill_router,
554            skill_executor,
555            base_system_prompt: system_prompt,
556            max_iterations,
557            iteration_count: RwLock::new(0),
558            max_context_tokens: 128000,
559            memory_token_budget: None,
560            recovery_manager: RecoveryManager::default(),
561            tool_security: ToolSecurityEngine::default(),
562            process_processor: None,
563            message_filters: RwLock::new(HashMap::new()),
564            state_machine: None,
565            transition_evaluator: None,
566            context_manager: Arc::new(context_manager),
567            template_renderer: TemplateRenderer::new(),
568            tool_call_history: RwLock::new(Vec::new()),
569            parallel_tools: ParallelToolsConfig::default(),
570            streaming: StreamingConfig::default(),
571            hooks: Arc::new(NoopHooks),
572            hitl_engine: None,
573            approval_handler: Arc::new(RejectAllHandler::new()),
574            storage_config: StorageConfig::default(),
575            storage: RwLock::new(None),
576            storage_init: tokio::sync::Mutex::new(()),
577            reasoning_config: ReasoningConfig::default(),
578            reflection_config: ReflectionConfig::default(),
579            disambiguation_manager: None,
580            persona_manager: None,
581            pending_skill_id: RwLock::new(None),
582            current_plan: RwLock::new(None),
583            declared_tool_ids: None,
584            context_initialized: AtomicBool::new(false),
585            spawner: None,
586            spawner_registry: None,
587            redispatch_depth: RwLock::new(0),
588            active_turn_context: RwLock::new(None),
589            root_user_message_committed: AtomicBool::new(false),
590            actor_id: RwLock::new(None),
591            fact_store: RwLock::new(None),
592            fact_extractor: RwLock::new(None),
593            actor_facts_cache: Arc::new(RwLock::new(HashMap::new())),
594            messages_since_extraction: Arc::new(RwLock::new(0)),
595            actor_memory_config: None,
596            facts_config: None,
597            session_metadata: RwLock::new(ai_agents_core::SessionMetadata::default()),
598            current_session_id: RwLock::new(None),
599            relationship_manager: None,
600            observability_manager: None,
601            runtime_config: RuntimeConfig::default(),
602            background_maintenance: Arc::new(BackgroundMaintenanceQueue::default()),
603            resource_locks: new_tool_resource_locks(),
604            runtime_control: Arc::new(RuntimeControlState::default()),
605        }
606    }
607
608    pub fn with_declared_tool_ids(mut self, ids: Option<Vec<String>>) -> Self {
609        self.declared_tool_ids = ids;
610        self
611    }
612
613    pub fn with_storage_config(mut self, config: StorageConfig) -> Self {
614        self.storage_config = config;
615        self
616    }
617
618    pub fn with_storage(self, storage: Arc<dyn AgentStorage>) -> Self {
619        *self.storage.write() = Some(storage);
620        self
621    }
622
623    pub(crate) fn with_shared_resource_locks(mut self, locks: ToolResourceLocks) -> Self {
624        self.resource_locks = locks;
625        self
626    }
627
628    pub fn with_reasoning(mut self, config: ReasoningConfig) -> Self {
629        self.reasoning_config = config;
630        self
631    }
632
633    pub fn with_reflection(mut self, config: ReflectionConfig) -> Self {
634        self.reflection_config = config;
635        self
636    }
637
638    /// Attach a relationship manager configured by the builder or host application.
639    pub fn with_relationships(mut self, manager: Arc<RelationshipManager>) -> Self {
640        self.relationship_manager = Some(manager);
641        self
642    }
643
644    /// Attach a shared observability manager for traces, metrics, reports, and exports.
645    pub fn with_observability(mut self, manager: Arc<ObservabilityManager>) -> Self {
646        self.observability_manager = Some(manager);
647        self
648    }
649
650    /// Attach runtime optimization policy and resize the background queue.
651    pub fn with_runtime_config(mut self, config: RuntimeConfig) -> Self {
652        let max_tasks = config.optimization.post_turn.max_background_tasks;
653        self.background_maintenance = Arc::new(BackgroundMaintenanceQueue::new(max_tasks));
654        self.runtime_config = config;
655        self
656    }
657
658    /// Returns the runtime optimization policy.
659    pub fn runtime_config(&self) -> &RuntimeConfig {
660        &self.runtime_config
661    }
662
663    /// Wait for all background maintenance tasks to finish.
664    pub async fn flush_background_tasks(&self) -> Result<()> {
665        self.background_maintenance.flush_all().await
666    }
667
668    /// Wait for background maintenance associated with one actor to finish.
669    pub async fn flush_background_tasks_for_actor(&self, actor_id: &str) -> Result<()> {
670        self.background_maintenance.flush_scope(actor_id).await
671    }
672
673    /// Wait for background maintenance associated with one task kind to finish.
674    pub async fn flush_background_tasks_for_purpose(
675        &self,
676        purpose: RuntimeTaskPurpose,
677    ) -> Result<()> {
678        self.background_maintenance.flush_purpose(purpose).await
679    }
680
681    /// Wait for background maintenance associated with one actor and task kind to finish.
682    pub async fn flush_background_tasks_for_actor_purpose(
683        &self,
684        actor_id: &str,
685        purpose: RuntimeTaskPurpose,
686    ) -> Result<()> {
687        self.background_maintenance
688            .flush_scope_purpose(actor_id, purpose)
689            .await
690    }
691
692    /// Flush background maintenance before a host shuts down the runtime.
693    pub async fn shutdown_background_tasks(&self) -> Result<()> {
694        self.flush_background_tasks().await
695    }
696
697    /// Returns the configured observability manager for report and export access.
698    pub fn observability(&self) -> Option<Arc<ObservabilityManager>> {
699        self.observability_manager.clone()
700    }
701
702    /// Exports observability files after a turn when export settings request it.
703    async fn export_observability_if_configured(&self) {
704        let Some(manager) = self.observability_manager.as_ref() else {
705            return;
706        };
707        let export = &manager.config().export;
708        if !export.write_report && !export.write_raw_events {
709            return;
710        }
711        if let Err(error) = manager.export().await {
712            warn!(error = %error, "Observability export failed");
713        }
714    }
715
716    /// Returns the configured relationship manager, if relationship memory is enabled.
717    pub fn relationship_manager(&self) -> Option<Arc<RelationshipManager>> {
718        self.relationship_manager.clone()
719    }
720
721    fn current_turn_actor_context(&self) -> Option<crate::TurnActorContext> {
722        current_turn_actor_context()
723    }
724
725    fn effective_actor_id(&self) -> Option<String> {
726        self.current_turn_actor_context()
727            .and_then(|ctx| ctx.effective_actor_id().map(|id| id.to_string()))
728            .or_else(|| self.actor_id.read().clone())
729    }
730
731    fn effective_origin_actor_id(&self) -> Option<String> {
732        self.current_turn_actor_context()
733            .and_then(|ctx| ctx.origin_actor_id.clone())
734            .or_else(|| self.actor_id.read().clone())
735    }
736
737    fn record_session_actor_if_needed(&self) {
738        if let Some(actor_id) = self.effective_origin_actor_id() {
739            let mut meta = self.session_metadata.write();
740            meta.actor_id = Some(actor_id.clone());
741            if !meta.actors.iter().any(|a| a == &actor_id) {
742                meta.actors.push(actor_id);
743            }
744        }
745    }
746
747    fn outbound_actor_context(&self) -> crate::TurnActorContext {
748        let mut context = self.current_turn_actor_context().unwrap_or_default();
749        if context.origin_actor_id.is_none() {
750            context.origin_actor_id = self.effective_origin_actor_id();
751        }
752        context.sender_agent_id = Some(self.info.id.clone());
753        context
754    }
755
756    /// Returns the current session ID or creates one for unsaved observed turns.
757    fn observation_session_id(&self) -> Option<String> {
758        let mut current = self.current_session_id.write();
759        if current.is_none() {
760            *current = Some(new_observation_session_id());
761        }
762        current.clone()
763    }
764
765    /// Builds the root or child observation context for a chat entry point.
766    fn build_observation_context(&self, actor_id: Option<String>) -> Option<SpanContext> {
767        let manager = self.observability_manager.as_ref()?;
768        let context = self.build_context_with_overlays();
769        let language = resolve_language_from_context(manager.config(), &context);
770        let context = current_observation_context()
771            .map(|parent| parent.child_for_agent(self.info.id.clone()).with_new_turn())
772            .unwrap_or_else(|| SpanContext::new_root(self.info.id.clone()));
773        Some(
774            context
775                .with_actor(actor_id.or_else(|| self.effective_actor_id()))
776                .with_session(self.observation_session_id())
777                .with_state(self.current_state())
778                .with_language(Some(language)),
779        )
780    }
781
782    /// Refreshes task-local context with current runtime labels and a purpose.
783    fn current_runtime_observation_context(
784        &self,
785        purpose: ObservationPurpose,
786    ) -> Option<SpanContext> {
787        let manager = self.observability_manager.as_ref()?;
788        let context = self.build_context_with_overlays();
789        let language = resolve_language_from_context(manager.config(), &context);
790        let mut observation = current_observation_context()
791            .unwrap_or_else(|| SpanContext::new_root(self.info.id.clone()));
792        observation.agent_id = self.info.id.clone();
793        observation.actor_id = self.effective_actor_id();
794        observation.session_id = self.observation_session_id();
795        observation.state = self.current_state();
796        observation.language = Some(language);
797        observation.purpose = purpose;
798        Some(observation)
799    }
800
801    /// Runs a future under a purpose while preserving current trace context.
802    async fn observe_purpose<F, T>(&self, purpose: ObservationPurpose, future: F) -> T
803    where
804        F: Future<Output = T>,
805    {
806        if let Some(context) = self.current_runtime_observation_context(purpose) {
807            with_observation_context(context, future).await
808        } else {
809            future.await
810        }
811    }
812
813    /// Runs chat with actor and observation context while avoiding recursive async types.
814    fn chat_with_actor_context_boxed<'a>(
815        &'a self,
816        input: &'a str,
817        actor_context: crate::TurnActorContext,
818    ) -> Pin<Box<dyn Future<Output = Result<AgentResponse>> + Send + 'a>> {
819        Box::pin(async move {
820            let actor_id = actor_context.effective_actor_id().map(str::to_string);
821            let run = async move {
822                scope_actor_context(
823                    actor_context,
824                    Box::pin(async move { self.run_loop(input).await }),
825                )
826                .await
827            };
828            let result = if let Some(context) = self.build_observation_context(actor_id) {
829                with_observation_context(context, run).await
830            } else {
831                run.await
832            };
833            self.export_observability_if_configured().await;
834            result
835        })
836    }
837
838    /// Run one turn with turn-scoped actor context without mutating the runtime's global actor ID.
839    ///
840    /// The supplied context is available to actor-scoped facts, relationship memory, orchestration, and prompt templates only for the lifetime of this call.
841    pub async fn chat_with_actor_context(
842        &self,
843        input: &str,
844        actor_context: crate::TurnActorContext,
845    ) -> Result<AgentResponse> {
846        self.chat_with_actor_context_boxed(input, actor_context)
847            .await
848    }
849
850    /// Convenience wrapper around [`Self::chat_with_actor_context`] for a turn whose original actor is known up front.
851    pub async fn chat_as_actor(&self, actor_id: &str, input: &str) -> Result<AgentResponse> {
852        let actor_context = crate::TurnActorContext::new().with_origin_actor(actor_id);
853        self.chat_with_actor_context(input, actor_context).await
854    }
855
856    /// Ensure the effective actor's relationship is loaded from storage into the relationship manager.
857    pub async fn load_actor_relationship(&self) -> Result<()> {
858        self.maybe_load_actor_relationship().await;
859        Ok(())
860    }
861
862    /// Manually apply a delta to the effective actor's `agent_to_actor` relationship perspective and persist the updated relationship when storage is configured.
863    pub async fn update_relationship_dimension(
864        &self,
865        dimension: &str,
866        delta: f64,
867        reason: Option<&str>,
868    ) -> Result<ai_agents_relationships::DimensionChange> {
869        self.update_relationship_dimension_for_perspective(
870            ai_agents_relationships::RelationshipPerspective::AgentToActor,
871            dimension,
872            delta,
873            reason,
874        )
875        .await
876    }
877
878    /// Manually apply a delta to a specific relationship perspective for the effective actor.
879    ///
880    /// Use this for two-sided configurations when you need to update `agent_to_actor`, `perceived_actor_to_agent`, or `mutual` explicitly from application logic.
881    pub async fn update_relationship_dimension_for_perspective(
882        &self,
883        perspective: ai_agents_relationships::RelationshipPerspective,
884        dimension: &str,
885        delta: f64,
886        reason: Option<&str>,
887    ) -> Result<ai_agents_relationships::DimensionChange> {
888        let manager = self
889            .relationship_manager
890            .as_ref()
891            .ok_or_else(|| AgentError::Config("Relationship memory is not configured".into()))?;
892        let actor_id = self.effective_actor_id().ok_or_else(|| {
893            AgentError::Config("No actor ID set. Use set_actor_id() first".into())
894        })?;
895        let change = manager.update_dimension_for_perspective(
896            &actor_id,
897            perspective,
898            dimension,
899            delta,
900            1.0,
901            reason.unwrap_or("manual relationship update"),
902        )?;
903        self.persist_actor_relationship(&actor_id).await?;
904        info!(
905            actor_id = %actor_id,
906            perspective = %change.perspective,
907            dimension = %change.dimension,
908            delta = change.delta,
909            current = change.current,
910            "relationship updated manually"
911        );
912        self.hooks
913            .on_relationship_change(&actor_id, std::slice::from_ref(&change))
914            .await;
915        Ok(change)
916    }
917
918    pub fn reasoning_config(&self) -> &ReasoningConfig {
919        &self.reasoning_config
920    }
921
922    pub fn reflection_config(&self) -> &ReflectionConfig {
923        &self.reflection_config
924    }
925
926    /// Set only the actor memory and facts configs without creating the store.
927    /// The store and extractor are created lazily in init_storage().
928    pub fn with_facts_config(
929        mut self,
930        actor_memory_config: Option<ai_agents_facts::ActorMemoryConfig>,
931        facts_config: Option<ai_agents_facts::FactsConfig>,
932    ) -> Self {
933        self.actor_memory_config = actor_memory_config;
934        self.facts_config = facts_config;
935        self
936    }
937
938    /// Configure fact store and optional extractor for actor memory.
939    /// Pass `None` for `extractor` to load existing facts without running extraction.
940    pub fn with_facts(
941        mut self,
942        store: Arc<ai_agents_facts::FactStore>,
943        extractor: Option<Arc<dyn ai_agents_facts::FactExtractor>>,
944        actor_memory_config: Option<ai_agents_facts::ActorMemoryConfig>,
945        facts_config: Option<ai_agents_facts::FactsConfig>,
946    ) -> Self {
947        *self.fact_store.write() = Some(store);
948        *self.fact_extractor.write() = extractor;
949        self.actor_memory_config = actor_memory_config;
950        self.facts_config = facts_config;
951        self
952    }
953
954    /// Get the fact store for direct fact manipulation.
955    pub fn fact_store(&self) -> Option<Arc<ai_agents_facts::FactStore>> {
956        self.fact_store.read().clone()
957    }
958
959    /// Get the current actor ID.
960    pub fn actor_id(&self) -> Option<String> {
961        self.actor_id.read().clone()
962    }
963
964    /// Set the current actor ID (player, user, another agent, etc.).
965    pub fn set_actor_id(&self, actor_id: &str) -> ai_agents_core::Result<()> {
966        *self.actor_id.write() = Some(actor_id.to_string());
967        {
968            let mut meta = self.session_metadata.write();
969            meta.actor_id = Some(actor_id.to_string());
970            if !meta.actors.iter().any(|a| a == actor_id) {
971                meta.actors.push(actor_id.to_string());
972            }
973        }
974        Ok(())
975    }
976
977    /// Clear the current actor binding while retaining the session actor roster.
978    pub fn clear_actor_id(&self) {
979        *self.actor_id.write() = None;
980        self.session_metadata.write().actor_id = None;
981    }
982
983    /// Set the current actor ID. Convenience wrapper around set_actor_id.
984    pub fn set_user_id(&self, user_id: &str) -> ai_agents_core::Result<()> {
985        self.set_actor_id(user_id)
986    }
987
988    /// Load facts for the current actor from storage and cache them for prompt injection.
989    pub async fn load_actor_memory(&self) -> ai_agents_core::Result<()> {
990        let actor_id = match self.effective_actor_id() {
991            Some(id) => id,
992            None => return Ok(()),
993        };
994
995        let store_opt = self.fact_store.read().clone();
996        if let Some(store) = store_opt {
997            let facts = store.get_facts(&actor_id).await?;
998            let count = facts.len();
999            self.actor_facts_cache
1000                .write()
1001                .insert(actor_id.clone(), facts);
1002            self.hooks.on_actor_memory_loaded(&actor_id, count).await;
1003            tracing::debug!("loaded {} facts for actor {}", count, actor_id);
1004        }
1005
1006        Ok(())
1007    }
1008
1009    /// Load actor memory only when the effective actor has no cached facts yet.
1010    async fn maybe_load_actor_memory(&self) {
1011        let Some(actor_id) = self.effective_actor_id() else {
1012            return;
1013        };
1014        if self.actor_facts_cache.read().contains_key(&actor_id) {
1015            return;
1016        }
1017        let _ = self.load_actor_memory().await;
1018    }
1019
1020    /// Pre-turn lifecycle shared by streaming and non-streaming paths.
1021    async fn pre_turn_session_lifecycle(&self) {
1022        if *self.redispatch_depth.read() > 0 {
1023            return;
1024        }
1025        self.resolve_actor_id_from_context();
1026        self.await_background_before_next_turn().await;
1027        self.record_session_actor_if_needed();
1028        self.maybe_load_actor_memory().await;
1029        self.maybe_load_actor_relationship().await;
1030        *self.messages_since_extraction.write() += 1;
1031    }
1032
1033    /// Post-turn lifecycle shared by streaming and non-streaming paths.
1034    async fn post_turn_session_lifecycle(&self) -> Result<()> {
1035        if *self.redispatch_depth.read() > 0 {
1036            return Ok(());
1037        }
1038        *self.messages_since_extraction.write() += 1;
1039        self.run_post_turn_maintenance().await
1040    }
1041
1042    /// Starts root-turn bookkeeping for user-message commit tracking.
1043    fn begin_root_turn(&self) {
1044        if *self.redispatch_depth.read() == 0 {
1045            let mut guard = self.active_turn_context.write();
1046            if guard.is_none() {
1047                self.root_user_message_committed
1048                    .store(false, Ordering::SeqCst);
1049                let max_calls = self
1050                    .runtime_config
1051                    .optimization
1052                    .max_speculative_llm_calls_per_turn;
1053                *guard = Some(TurnOptimizationContext::new(
1054                    String::new(),
1055                    HashMap::new(),
1056                    max_calls,
1057                ));
1058            }
1059        }
1060    }
1061
1062    fn update_active_turn_context(
1063        &self,
1064        processed_input: &str,
1065        input_context: HashMap<String, Value>,
1066    ) {
1067        if *self.redispatch_depth.read() > 0 {
1068            return;
1069        }
1070        let max_calls = self
1071            .runtime_config
1072            .optimization
1073            .max_speculative_llm_calls_per_turn;
1074        let mut guard = self.active_turn_context.write();
1075        match guard.as_mut() {
1076            Some(context) => {
1077                context.processed_input = processed_input.to_string();
1078                context.input_context = input_context;
1079                context.max_speculative_llm_calls = max_calls;
1080            }
1081            None => {
1082                *guard = Some(TurnOptimizationContext::new(
1083                    processed_input,
1084                    input_context,
1085                    max_calls,
1086                ));
1087            }
1088        }
1089    }
1090
1091    /// Writes the processed user message once for the root turn.
1092    async fn commit_root_user_message(&self, processed_input: &str) -> Result<()> {
1093        if *self.redispatch_depth.read() > 0 {
1094            return Ok(());
1095        }
1096        if !self
1097            .root_user_message_committed
1098            .swap(true, Ordering::SeqCst)
1099        {
1100            self.memory
1101                .add_message(ChatMessage::user(processed_input))
1102                .await?;
1103            if let Some(context) = self.active_turn_context.write().as_mut() {
1104                context.mark_user_message_committed();
1105            }
1106        }
1107        Ok(())
1108    }
1109
1110    /// Clears root-turn bookkeeping after final response handling.
1111    fn end_root_turn(&self) {
1112        if *self.redispatch_depth.read() == 0 {
1113            self.root_user_message_committed
1114                .store(false, Ordering::SeqCst);
1115            *self.active_turn_context.write() = None;
1116        }
1117    }
1118
1119    fn reserve_active_speculative_llm_call(&self, kind: RuntimeOptimizationKind) -> bool {
1120        self.begin_root_turn();
1121        let mut guard = self.active_turn_context.write();
1122        let Some(context) = guard.as_mut() else {
1123            return false;
1124        };
1125        context.reserve_speculative_llm_call_for(kind)
1126    }
1127
1128    fn branch_context_preview(&self) -> String {
1129        let context = self.build_context_with_overlays();
1130        let mut value = serde_json::to_string_pretty(&context).unwrap_or_else(|_| "{}".to_string());
1131        const MAX_CONTEXT_PREVIEW_CHARS: usize = 2048;
1132        if value.chars().count() > MAX_CONTEXT_PREVIEW_CHARS {
1133            value = value
1134                .chars()
1135                .take(MAX_CONTEXT_PREVIEW_CHARS)
1136                .collect::<String>();
1137            value.push_str("...");
1138        }
1139        value
1140    }
1141
1142    /// Applies freshness policy before rendering the next prompt.
1143    async fn await_background_before_next_turn(&self) {
1144        let optimization = &self.runtime_config.optimization;
1145        if !optimization.enabled {
1146            return;
1147        }
1148        let actor_id = self.effective_actor_id();
1149        let post = &optimization.post_turn;
1150        self.await_background_task(
1151            post.facts.await_before_next_turn,
1152            RuntimeTaskPurpose::PostTurnFacts,
1153            actor_id.as_deref(),
1154            "facts",
1155        )
1156        .await;
1157        self.await_background_task(
1158            post.relationships.await_before_next_turn,
1159            RuntimeTaskPurpose::PostTurnRelationship,
1160            actor_id.as_deref(),
1161            "relationships",
1162        )
1163        .await;
1164    }
1165
1166    async fn await_background_task(
1167        &self,
1168        policy: AwaitBeforeNextTurn,
1169        purpose: RuntimeTaskPurpose,
1170        actor_id: Option<&str>,
1171        label: &str,
1172    ) {
1173        match policy {
1174            AwaitBeforeNextTurn::Never => {}
1175            AwaitBeforeNextTurn::Always => {
1176                if let Err(error) = self.flush_background_tasks_for_purpose(purpose).await {
1177                    warn!(label = label, error = %error, "background maintenance flush failed");
1178                }
1179            }
1180            AwaitBeforeNextTurn::SameActor => {
1181                if let Some(actor_id) = actor_id
1182                    && let Err(error) = self
1183                        .flush_background_tasks_for_actor_purpose(actor_id, purpose)
1184                        .await
1185                {
1186                    warn!(label = label, actor_id = %actor_id, error = %error, "actor background maintenance flush failed");
1187                }
1188            }
1189        }
1190    }
1191
1192    /// Runs post-turn facts and relationship maintenance according to runtime policy.
1193    async fn run_post_turn_maintenance(&self) -> Result<()> {
1194        let optimization = &self.runtime_config.optimization;
1195        if !optimization.enabled {
1196            self.auto_extract_facts().await;
1197            self.auto_update_relationship().await;
1198            return Ok(());
1199        }
1200
1201        let facts_mode = effective_maintenance_mode(
1202            optimization.post_turn.facts.mode,
1203            optimization.parallel_post_turn_memory,
1204        );
1205        let relationships_mode = effective_maintenance_mode(
1206            optimization.post_turn.relationships.mode,
1207            optimization.parallel_post_turn_memory,
1208        );
1209
1210        match (facts_mode, relationships_mode) {
1211            (MaintenanceMode::InlineSerial, MaintenanceMode::InlineSerial) => {
1212                self.auto_extract_facts().await;
1213                self.auto_update_relationship().await;
1214            }
1215            (MaintenanceMode::InlineParallel, MaintenanceMode::InlineParallel) => {
1216                let facts = self.auto_extract_facts();
1217                let relationships = self.auto_update_relationship();
1218                tokio::join!(facts, relationships);
1219            }
1220            (MaintenanceMode::Background, MaintenanceMode::Background) => {
1221                self.schedule_facts_background().await?;
1222                self.schedule_relationship_background().await?;
1223            }
1224            (MaintenanceMode::Background, MaintenanceMode::InlineParallel)
1225            | (MaintenanceMode::Background, MaintenanceMode::InlineSerial) => {
1226                self.schedule_facts_background().await?;
1227                self.auto_update_relationship().await;
1228            }
1229            (MaintenanceMode::InlineParallel, MaintenanceMode::Background)
1230            | (MaintenanceMode::InlineSerial, MaintenanceMode::Background) => {
1231                self.auto_extract_facts().await;
1232                self.schedule_relationship_background().await?;
1233            }
1234            _ => {
1235                self.auto_extract_facts().await;
1236                self.auto_update_relationship().await;
1237            }
1238        }
1239        Ok(())
1240    }
1241
1242    async fn schedule_facts_background(&self) -> Result<()> {
1243        let policy = self.runtime_config.optimization.post_turn.facts.clone();
1244        let should_extract = self
1245            .facts_config
1246            .as_ref()
1247            .map(|c| c.enabled && c.auto_extract)
1248            .unwrap_or(false);
1249        if !should_extract {
1250            return Ok(());
1251        }
1252        let msgs_since = *self.messages_since_extraction.read();
1253        if msgs_since < 2 {
1254            return Ok(());
1255        }
1256        let Some(actor_id) = self.effective_actor_id() else {
1257            self.record_skipped_maintenance(
1258                "facts",
1259                ObservationPurpose::FactsExtraction,
1260                "missing_actor",
1261                Some(&policy),
1262            );
1263            return Ok(());
1264        };
1265        let Some(extractor) = self.fact_extractor.read().clone() else {
1266            return Ok(());
1267        };
1268        let messages = match self.memory.get_messages(None).await {
1269            Ok(messages) => messages,
1270            Err(error) => {
1271                warn!(error = %error, "failed to snapshot messages for fact extraction");
1272                return Ok(());
1273            }
1274        };
1275        let recent: Vec<_> = messages
1276            .iter()
1277            .rev()
1278            .take(msgs_since)
1279            .rev()
1280            .cloned()
1281            .collect();
1282        if recent.is_empty() {
1283            return Ok(());
1284        }
1285        let existing = self
1286            .actor_facts_cache
1287            .read()
1288            .get(&actor_id)
1289            .cloned()
1290            .unwrap_or_default();
1291        let categories = self
1292            .facts_config
1293            .as_ref()
1294            .map(|c| c.custom_categories.clone())
1295            .unwrap_or_default();
1296        let store = self.fact_store.read().clone();
1297        let cache = Arc::clone(&self.actor_facts_cache);
1298        let counter = Arc::clone(&self.messages_since_extraction);
1299        let hooks = Arc::clone(&self.hooks);
1300        let agent_id = self.info.id.clone();
1301        let observation = current_observation_context();
1302        let key = MaintenanceSequenceKey::actor(
1303            agent_id,
1304            actor_id.clone(),
1305            RuntimeTaskPurpose::PostTurnFacts,
1306        );
1307        let actor_for_task = actor_id.clone();
1308        let task = async move {
1309            let run = async move {
1310                let facts = extractor
1311                    .extract(&recent, &existing, Some(&actor_for_task), &categories)
1312                    .await?;
1313                if !facts.is_empty() {
1314                    if let Some(store) = store {
1315                        let authoritative = store.add_facts(&actor_for_task, facts.clone()).await?;
1316                        cache.write().insert(actor_for_task.clone(), authoritative);
1317                    } else {
1318                        cache
1319                            .write()
1320                            .entry(actor_for_task.clone())
1321                            .or_default()
1322                            .extend(facts.clone());
1323                    }
1324                    {
1325                        let mut count = counter.write();
1326                        if *count <= msgs_since {
1327                            *count = 0;
1328                        } else {
1329                            *count -= msgs_since;
1330                        }
1331                    }
1332                    hooks.on_facts_extracted(&actor_for_task, &facts).await;
1333                }
1334                Ok(())
1335            };
1336            if let Some(context) = observation {
1337                with_observation_context(
1338                    context.with_purpose(ObservationPurpose::FactsExtraction),
1339                    run,
1340                )
1341                .await
1342            } else {
1343                run.await
1344            }
1345        };
1346        self.spawn_or_handle_background(Some(key), task, "facts", &policy)
1347            .await
1348    }
1349
1350    async fn schedule_relationship_background(&self) -> Result<()> {
1351        let policy = self
1352            .runtime_config
1353            .optimization
1354            .post_turn
1355            .relationships
1356            .clone();
1357        let Some(manager) = self.relationship_manager.as_ref().cloned() else {
1358            return Ok(());
1359        };
1360        let Some(actor_id) = self.effective_actor_id() else {
1361            self.record_skipped_maintenance(
1362                "relationships",
1363                ObservationPurpose::RelationshipUpdate,
1364                "missing_actor",
1365                Some(&policy),
1366            );
1367            return Ok(());
1368        };
1369        let recent_messages = manager.config().auto_update.recent_messages;
1370        let messages = match self.memory.get_messages(Some(recent_messages)).await {
1371            Ok(messages) => messages,
1372            Err(error) => {
1373                warn!(actor = %actor_id, error = %error, "failed to snapshot messages for relationship update");
1374                return Ok(());
1375            }
1376        };
1377        let storage = self.storage.read().clone();
1378        let hooks = Arc::clone(&self.hooks);
1379        let agent_id = self.info.id.clone();
1380        let observation = current_observation_context();
1381        let key = MaintenanceSequenceKey::actor(
1382            agent_id.clone(),
1383            actor_id.clone(),
1384            RuntimeTaskPurpose::PostTurnRelationship,
1385        );
1386        let actor_for_task = actor_id.clone();
1387        let task = async move {
1388            let run = async move {
1389                if manager.config().auto_update.enabled {
1390                    let update = manager.auto_update(&actor_for_task, &messages).await?;
1391                    if !update.changes.is_empty() {
1392                        hooks
1393                            .on_relationship_change(&actor_for_task, &update.changes)
1394                            .await;
1395                    }
1396                    if let Some(ref event) = update.event {
1397                        hooks.on_notable_event(&actor_for_task, event).await;
1398                    }
1399                }
1400                if manager.config().persistence.enabled
1401                    && let (Some(storage), Some(value)) =
1402                        (storage, manager.relationship_as_value(&actor_for_task)?)
1403                {
1404                    storage
1405                        .save_relationship(&agent_id, &actor_for_task, &value)
1406                        .await?;
1407                }
1408                Ok(())
1409            };
1410            if let Some(context) = observation {
1411                with_observation_context(
1412                    context.with_purpose(ObservationPurpose::RelationshipUpdate),
1413                    run,
1414                )
1415                .await
1416            } else {
1417                run.await
1418            }
1419        };
1420        self.spawn_or_handle_background(Some(key), task, "relationships", &policy)
1421            .await
1422    }
1423
1424    /// Queues background maintenance or applies the configured overflow behavior.
1425    async fn spawn_or_handle_background<F>(
1426        &self,
1427        key: Option<MaintenanceSequenceKey>,
1428        task: F,
1429        label: &'static str,
1430        policy: &crate::optimization::config::MaintenanceTaskPolicy,
1431    ) -> Result<()>
1432    where
1433        F: Future<Output = Result<()>> + Send + 'static,
1434    {
1435        if self.background_maintenance.is_full() {
1436            match self
1437                .runtime_config
1438                .optimization
1439                .post_turn
1440                .on_background_overflow
1441            {
1442                BackgroundOverflowPolicy::RunInline => {
1443                    record_background_maintenance_event(
1444                        self.observability_manager.as_ref(),
1445                        label,
1446                        EventStatus::Success,
1447                        0,
1448                        "inline_overflow",
1449                        None,
1450                        Some(policy),
1451                    );
1452                    let start = Instant::now();
1453                    match task.await {
1454                        Ok(()) => record_background_maintenance_event(
1455                            self.observability_manager.as_ref(),
1456                            label,
1457                            EventStatus::Success,
1458                            start.elapsed().as_millis() as u64,
1459                            "inline_completed",
1460                            None,
1461                            Some(policy),
1462                        ),
1463                        Err(error) => {
1464                            warn!(label = label, error = %error, "inline maintenance fallback failed");
1465                            record_background_maintenance_event(
1466                                self.observability_manager.as_ref(),
1467                                label,
1468                                EventStatus::Error,
1469                                start.elapsed().as_millis() as u64,
1470                                "inline_failed",
1471                                Some(error.to_string()),
1472                                Some(policy),
1473                            );
1474                            return Err(error);
1475                        }
1476                    }
1477                }
1478                BackgroundOverflowPolicy::Drop => {
1479                    self.record_skipped_maintenance(
1480                        label,
1481                        ObservationPurpose::Other(label.to_string()),
1482                        "queue_full",
1483                        Some(policy),
1484                    );
1485                }
1486                BackgroundOverflowPolicy::Error => {
1487                    record_background_maintenance_event(
1488                        self.observability_manager.as_ref(),
1489                        label,
1490                        EventStatus::Error,
1491                        0,
1492                        "queue_full",
1493                        None,
1494                        Some(policy),
1495                    );
1496                    warn!(label = label, "background maintenance queue full");
1497                    return Err(AgentError::Other(format!(
1498                        "background maintenance queue is full for {}",
1499                        label
1500                    )));
1501                }
1502            }
1503            return Ok(());
1504        }
1505
1506        record_background_maintenance_event(
1507            self.observability_manager.as_ref(),
1508            label,
1509            EventStatus::Success,
1510            0,
1511            "scheduled",
1512            None,
1513            Some(policy),
1514        );
1515        let manager = self.observability_manager.clone();
1516        let policy_for_task = policy.clone();
1517        let observed_task = async move {
1518            let start = Instant::now();
1519            let result = task.await;
1520            match &result {
1521                Ok(()) => record_background_maintenance_event(
1522                    manager.as_ref(),
1523                    label,
1524                    EventStatus::Success,
1525                    start.elapsed().as_millis() as u64,
1526                    "completed",
1527                    None,
1528                    Some(&policy_for_task),
1529                ),
1530                Err(error) => record_background_maintenance_event(
1531                    manager.as_ref(),
1532                    label,
1533                    EventStatus::Error,
1534                    start.elapsed().as_millis() as u64,
1535                    "failed",
1536                    Some(error.to_string()),
1537                    Some(&policy_for_task),
1538                ),
1539            }
1540            result
1541        };
1542
1543        if let Err(error) = self.background_maintenance.spawn(key, observed_task) {
1544            record_background_maintenance_event(
1545                self.observability_manager.as_ref(),
1546                label,
1547                EventStatus::Error,
1548                0,
1549                "spawn_failed",
1550                Some(error.to_string()),
1551                Some(policy),
1552            );
1553            warn!(label = label, error = %error, "background maintenance spawn failed");
1554            return Err(error);
1555        }
1556        Ok(())
1557    }
1558
1559    /// Records a skipped background maintenance event when work cannot run.
1560    fn record_skipped_maintenance(
1561        &self,
1562        label: &str,
1563        purpose: ObservationPurpose,
1564        reason: &str,
1565        policy: Option<&crate::optimization::config::MaintenanceTaskPolicy>,
1566    ) {
1567        if let Some(manager) = self.observability_manager.as_ref() {
1568            let mut tags = background_maintenance_tags(label, "skipped", Some(reason), policy);
1569            tags.insert("runtime.skip_reason".to_string(), reason.to_string());
1570            manager.record_lifecycle_event(
1571                EventType::MemoryOperation {
1572                    operation: format!("{}_maintenance", label),
1573                },
1574                purpose,
1575                EventStatus::Skipped,
1576                0,
1577                tags,
1578                None,
1579            );
1580        }
1581    }
1582
1583    /// Get cached actor facts for the effective actor.
1584    pub fn actor_facts(&self) -> Vec<ai_agents_core::KeyFact> {
1585        let Some(actor_id) = self.effective_actor_id() else {
1586            return Vec::new();
1587        };
1588        self.actor_facts_cache
1589            .read()
1590            .get(&actor_id)
1591            .cloned()
1592            .unwrap_or_default()
1593    }
1594
1595    /// Returns the formatted relationship prompt text for the effective actor, if relationship injection produced any text for this turn.
1596    pub fn relationship_memory_text(&self) -> Option<String> {
1597        self.format_relationship_for_context().map(|(_, text)| text)
1598    }
1599
1600    /// Manually extract facts from the last N messages.
1601    pub async fn extract_facts(
1602        &self,
1603        last_n: usize,
1604    ) -> ai_agents_core::Result<Vec<ai_agents_core::KeyFact>> {
1605        self.extract_facts_with_source(last_n, "manual").await
1606    }
1607
1608    async fn extract_facts_with_source(
1609        &self,
1610        last_n: usize,
1611        source: &'static str,
1612    ) -> ai_agents_core::Result<Vec<ai_agents_core::KeyFact>> {
1613        let extractor = match self.fact_extractor.read().clone() {
1614            Some(e) => e,
1615            None => return Ok(vec![]),
1616        };
1617
1618        let messages = self.memory.get_messages(None).await?;
1619        let recent: Vec<_> = messages.iter().rev().take(last_n).rev().cloned().collect();
1620
1621        if recent.is_empty() {
1622            return Ok(vec![]);
1623        }
1624
1625        let actor_id = self.effective_actor_id();
1626        let existing = actor_id
1627            .as_ref()
1628            .and_then(|aid| self.actor_facts_cache.read().get(aid).cloned())
1629            .unwrap_or_default();
1630
1631        let categories = self
1632            .facts_config
1633            .as_ref()
1634            .map(|c| c.custom_categories.clone())
1635            .unwrap_or_default();
1636
1637        let facts = self
1638            .observe_purpose(
1639                ObservationPurpose::FactsExtraction,
1640                extractor.extract(&recent, &existing, actor_id.as_deref(), &categories),
1641            )
1642            .await?;
1643
1644        // Save to storage and update the actor-scoped cache when an actor is known.
1645        if !facts.is_empty() {
1646            let fact_store_opt = self.fact_store.read().clone();
1647            let mut stored_total = 0usize;
1648            let mut cache_updated = false;
1649            if let (Some(store), Some(aid)) = (fact_store_opt, &actor_id) {
1650                // add_facts now returns the authoritative post-write set.
1651                let authoritative = store.add_facts(aid, facts.clone()).await?;
1652                stored_total = authoritative.len();
1653                self.actor_facts_cache
1654                    .write()
1655                    .insert(aid.clone(), authoritative);
1656                cache_updated = true;
1657            } else if let Some(aid) = &actor_id {
1658                let mut cache = self.actor_facts_cache.write();
1659                let entry = cache.entry(aid.clone()).or_default();
1660                entry.extend(facts.clone());
1661                stored_total = entry.len();
1662                cache_updated = true;
1663            }
1664
1665            info!(
1666                actor_id = %actor_id.as_deref().unwrap_or("<none>"),
1667                source = source,
1668                requested_messages = last_n,
1669                message_count = recent.len(),
1670                extracted_count = facts.len(),
1671                cache_updated = cache_updated,
1672                stored_total = stored_total,
1673                "facts extracted"
1674            );
1675
1676            if let Some(ref aid) = actor_id {
1677                self.hooks.on_facts_extracted(aid, &facts).await;
1678            }
1679        }
1680
1681        Ok(facts)
1682    }
1683
1684    /// Resolve actor_id from context if method is from_context.
1685    /// Supports dotted paths (e.g. "player.id", "user.profile.id").
1686    fn resolve_actor_id_from_context(&self) {
1687        if self
1688            .current_turn_actor_context()
1689            .and_then(|ctx| ctx.effective_actor_id().map(str::to_string))
1690            .is_some()
1691        {
1692            return;
1693        }
1694
1695        if let Some(ref am_config) = self.actor_memory_config
1696            && am_config.identification.method == ai_agents_facts::IdentificationMethod::FromContext
1697            && let Some(ref path) = am_config.identification.context_path
1698        {
1699            // get_path resolves dotted paths; get only handles top-level keys.
1700            let val = self
1701                .context_manager
1702                .get_path(path)
1703                .or_else(|| self.context_manager.get(path));
1704            if let Some(val) = val
1705                && let Some(id_str) = val.as_str()
1706            {
1707                let current = self.actor_id.read().clone();
1708                if current.as_deref() != Some(id_str) {
1709                    *self.actor_id.write() = Some(id_str.to_string());
1710                    let mut meta = self.session_metadata.write();
1711                    meta.actor_id = Some(id_str.to_string());
1712                    if !meta.actors.iter().any(|a| a == id_str) {
1713                        meta.actors.push(id_str.to_string());
1714                    }
1715                }
1716            }
1717        }
1718    }
1719
1720    /// Format actor facts for template injection.
1721    fn format_actor_facts_for_context(&self) -> String {
1722        // Respect inject_in_context: false.
1723        let should_inject = self
1724            .facts_config
1725            .as_ref()
1726            .map(|c| c.inject_in_context)
1727            .unwrap_or(true);
1728        if !should_inject {
1729            return String::new();
1730        }
1731
1732        let Some(actor_id) = self.effective_actor_id() else {
1733            return String::new();
1734        };
1735
1736        let facts = self
1737            .actor_facts_cache
1738            .read()
1739            .get(&actor_id)
1740            .cloned()
1741            .unwrap_or_default();
1742        if facts.is_empty() {
1743            return String::new();
1744        }
1745
1746        let am_config = self.actor_memory_config.as_ref();
1747        // Effective token cap: prefer memory.token_budget.allocation.facts when present,
1748        // otherwise fall back to actor_memory.injection.max_tokens.
1749        let facts_budget = self
1750            .memory_token_budget
1751            .as_ref()
1752            .map(|b| b.allocation.facts as usize)
1753            .filter(|n| *n > 0);
1754        let default_max = am_config.map(|c| c.injection.max_tokens).unwrap_or(800);
1755        let max_tokens = facts_budget.unwrap_or(default_max);
1756
1757        // Filter by category when injection.mode = category.
1758        let filtered: Vec<ai_agents_core::KeyFact> = if let Some(cfg) = am_config {
1759            if cfg.injection.mode == ai_agents_facts::InjectionMode::OnDemand {
1760                return String::new();
1761            }
1762            if cfg.injection.mode == ai_agents_facts::InjectionMode::Category
1763                && !cfg.injection.categories.is_empty()
1764            {
1765                facts
1766                    .iter()
1767                    .filter(|f| {
1768                        cfg.injection
1769                            .categories
1770                            .iter()
1771                            .any(|c| f.category.to_string() == *c)
1772                    })
1773                    .cloned()
1774                    .collect()
1775            } else {
1776                facts.clone()
1777            }
1778        } else {
1779            facts.clone()
1780        };
1781
1782        if filtered.is_empty() {
1783            return String::new();
1784        }
1785
1786        if let Some(store) = self.fact_store.read().clone() {
1787            store.format_for_context(&filtered, max_tokens)
1788        } else {
1789            String::new()
1790        }
1791    }
1792
1793    fn build_context_with_staged(&self, staged: &HashMap<String, Value>) -> HashMap<String, Value> {
1794        let context = self.build_context_with_overlays();
1795        let mut root = Value::Object(context.into_iter().collect());
1796        for (path, value) in staged {
1797            if let Ok(updated) = ai_agents_core::set_dot_path(root.clone(), path, value.clone()) {
1798                root = updated;
1799            }
1800        }
1801        match root {
1802            Value::Object(obj) => obj.into_iter().collect(),
1803            _ => HashMap::new(),
1804        }
1805    }
1806
1807    fn build_context_with_overlays(&self) -> HashMap<String, Value> {
1808        let mut context = self.context_manager.get_all();
1809        let mut root = Value::Object(context.clone().into_iter().collect());
1810
1811        if let Some(turn_ctx) = self.current_turn_actor_context() {
1812            if let Some(ref origin_actor_id) = turn_ctx.origin_actor_id
1813                && let Ok(updated) = ai_agents_core::set_dot_path(
1814                    root.clone(),
1815                    "interaction.origin_actor_id",
1816                    serde_json::json!(origin_actor_id),
1817                )
1818            {
1819                root = updated;
1820            }
1821            if let Some(ref sender_agent_id) = turn_ctx.sender_agent_id
1822                && let Ok(updated) = ai_agents_core::set_dot_path(
1823                    root.clone(),
1824                    "interaction.sender_agent_id",
1825                    serde_json::json!(sender_agent_id),
1826                )
1827            {
1828                root = updated;
1829            }
1830        }
1831
1832        if let Some(ref actor_id) = self.effective_actor_id()
1833            && let Ok(updated) = ai_agents_core::set_dot_path(
1834                root.clone(),
1835                "interaction.actor_id",
1836                serde_json::json!(actor_id),
1837            )
1838        {
1839            root = updated;
1840        }
1841
1842        if let Some(manager) = self.relationship_manager.as_ref()
1843            && let Some(actor_id) = self.effective_actor_id()
1844            && let Some(value) = manager.to_context_value(&actor_id)
1845            && let Ok(updated) = ai_agents_core::set_dot_path(
1846                root.clone(),
1847                &manager.config().injection.context_path,
1848                value,
1849            )
1850        {
1851            root = updated;
1852        }
1853
1854        if let Value::Object(obj) = root {
1855            context = obj.into_iter().collect();
1856        }
1857
1858        context
1859    }
1860
1861    fn resolve_actor_name_from_context(&self) -> Option<String> {
1862        for path in ["actor.name", "user.name", "player.name", "customer.name"] {
1863            if let Some(value) = self.context_manager.get_path(path)
1864                && let Some(name) = value.as_str()
1865            {
1866                return Some(name.to_string());
1867            }
1868        }
1869        None
1870    }
1871
1872    async fn maybe_load_actor_relationship(&self) {
1873        let Some(manager) = self.relationship_manager.as_ref() else {
1874            return;
1875        };
1876        let Some(actor_id) = self.effective_actor_id() else {
1877            return;
1878        };
1879
1880        let mut should_fire_loaded = false;
1881        if manager.get(&actor_id).is_none() {
1882            let mut loaded = false;
1883            if manager.config().persistence.enabled {
1884                let storage = self.storage.read().clone();
1885                if let Some(storage) = storage {
1886                    match storage.load_relationship(&self.info.id, &actor_id).await {
1887                        Ok(Some(value)) => match manager.insert_from_value(value) {
1888                            Ok(_) => loaded = true,
1889                            Err(e) => {
1890                                warn!(actor = %actor_id, error = %e, "failed to restore relationship")
1891                            }
1892                        },
1893                        Ok(None) => {}
1894                        Err(e) => {
1895                            warn!(actor = %actor_id, error = %e, "failed to load relationship")
1896                        }
1897                    }
1898                }
1899            }
1900
1901            if !loaded {
1902                manager.get_or_create(&actor_id, self.resolve_actor_name_from_context().as_deref());
1903            }
1904            should_fire_loaded = true;
1905        }
1906
1907        let actor_name = self.resolve_actor_name_from_context();
1908        let relationship = manager.touch_interaction(&actor_id, actor_name.as_deref());
1909        if should_fire_loaded {
1910            self.hooks
1911                .on_relationship_loaded(&actor_id, &relationship)
1912                .await;
1913        }
1914    }
1915
1916    fn format_relationship_for_context(&self) -> Option<(String, String)> {
1917        let manager = self.relationship_manager.as_ref()?;
1918        if !manager.config().injection.enabled {
1919            return None;
1920        }
1921        let actor_id = self.effective_actor_id()?;
1922        let relationship = manager.get(&actor_id)?;
1923        let local_cap = manager.config().injection.max_tokens;
1924        let global_cap = self
1925            .memory_token_budget
1926            .as_ref()
1927            .map(|b| b.allocation.relationships as usize)
1928            .filter(|n| *n > 0);
1929        let max_tokens = global_cap.map(|g| g.min(local_cap)).unwrap_or(local_cap);
1930        let text = ai_agents_relationships::format_relationship(
1931            &relationship,
1932            &manager.config().injection.format,
1933            max_tokens,
1934        );
1935        if text.is_empty() {
1936            None
1937        } else {
1938            Some((manager.config().injection.prompt_variable.clone(), text))
1939        }
1940    }
1941
1942    async fn persist_actor_relationship(&self, actor_id: &str) -> Result<()> {
1943        let Some(manager) = self.relationship_manager.as_ref() else {
1944            return Ok(());
1945        };
1946        if !manager.config().persistence.enabled {
1947            return Ok(());
1948        }
1949        let storage = self.storage.read().clone();
1950        let Some(storage) = storage else {
1951            return Ok(());
1952        };
1953        if let Some(value) = manager.relationship_as_value(actor_id)? {
1954            storage
1955                .save_relationship(&self.info.id, actor_id, &value)
1956                .await?;
1957        }
1958        Ok(())
1959    }
1960
1961    async fn auto_update_relationship(&self) {
1962        let Some(manager) = self.relationship_manager.as_ref() else {
1963            return;
1964        };
1965        let Some(actor_id) = self.effective_actor_id() else {
1966            return;
1967        };
1968        if !manager.config().auto_update.enabled {
1969            let _ = self.persist_actor_relationship(&actor_id).await;
1970            return;
1971        }
1972
1973        let recent_messages = manager.config().auto_update.recent_messages;
1974        let messages = match self.memory.get_messages(Some(recent_messages)).await {
1975            Ok(messages) => messages,
1976            Err(e) => {
1977                warn!(actor = %actor_id, error = %e, "failed to read messages for relationship update");
1978                return;
1979            }
1980        };
1981
1982        match self
1983            .observe_purpose(
1984                ObservationPurpose::RelationshipUpdate,
1985                manager.auto_update(&actor_id, &messages),
1986            )
1987            .await
1988        {
1989            Ok(update) => {
1990                if !update.changes.is_empty() {
1991                    self.hooks
1992                        .on_relationship_change(&actor_id, &update.changes)
1993                        .await;
1994                }
1995                if let Some(ref event) = update.event {
1996                    self.hooks.on_notable_event(&actor_id, event).await;
1997                }
1998                let persisted = match self.persist_actor_relationship(&actor_id).await {
1999                    Ok(()) => true,
2000                    Err(e) => {
2001                        warn!(actor = %actor_id, error = %e, "failed to persist relationship");
2002                        false
2003                    }
2004                };
2005                if !update.changes.is_empty() || update.event.is_some() {
2006                    let changed_dimensions: Vec<String> = update
2007                        .changes
2008                        .iter()
2009                        .map(|change| format!("{}:{}", change.perspective, change.dimension))
2010                        .collect();
2011                    info!(
2012                        actor_id = %actor_id,
2013                        change_count = update.changes.len(),
2014                        changed_dimensions = ?changed_dimensions,
2015                        event_present = update.event.is_some(),
2016                        persisted = persisted,
2017                        "relationship updated"
2018                    );
2019                } else {
2020                    debug!(actor_id = %actor_id, persisted = persisted, "relationship evaluation ran but found no changes");
2021                }
2022            }
2023            Err(e) => warn!(actor = %actor_id, error = %e, "relationship update failed"),
2024        }
2025    }
2026
2027    /// Run auto-extraction after a chat turn.
2028    async fn auto_extract_facts(&self) {
2029        let should_extract = self
2030            .facts_config
2031            .as_ref()
2032            .map(|c| c.enabled && c.auto_extract)
2033            .unwrap_or(false);
2034
2035        if !should_extract {
2036            debug!("fact extraction skipped because auto extraction is disabled");
2037            return;
2038        }
2039
2040        let msgs_since = *self.messages_since_extraction.read();
2041        if msgs_since < 2 {
2042            debug!(
2043                messages_since_extraction = msgs_since,
2044                "fact extraction skipped until threshold is reached"
2045            );
2046            return;
2047        }
2048
2049        match self.extract_facts_with_source(msgs_since, "auto").await {
2050            Ok(facts) => {
2051                if !facts.is_empty() {
2052                    *self.messages_since_extraction.write() = 0;
2053                } else {
2054                    debug!("fact extraction ran but found no new facts");
2055                }
2056            }
2057            Err(e) => {
2058                warn!("fact extraction failed: {}", e);
2059            }
2060        }
2061    }
2062
2063    pub fn with_persona(mut self, manager: Arc<ai_agents_persona::PersonaManager>) -> Self {
2064        self.persona_manager = Some(manager);
2065        self
2066    }
2067
2068    pub fn persona_manager(&self) -> Option<&Arc<ai_agents_persona::PersonaManager>> {
2069        self.persona_manager.as_ref()
2070    }
2071
2072    pub fn with_disambiguation(mut self, config: DisambiguationConfig) -> Self {
2073        if config.is_enabled() {
2074            let manager = DisambiguationManager::new(config, Arc::clone(&self.llm_registry))
2075                .with_clarification_observer(Arc::new(ObservabilityClarificationObserver));
2076            self.disambiguation_manager = Some(manager);
2077        }
2078        self
2079    }
2080
2081    pub fn disambiguation_manager(&self) -> Option<&DisambiguationManager> {
2082        self.disambiguation_manager.as_ref()
2083    }
2084
2085    pub fn has_disambiguation(&self) -> bool {
2086        self.disambiguation_manager
2087            .as_ref()
2088            .is_some_and(|m| m.is_enabled())
2089    }
2090
2091    pub async fn init_storage(&self) -> Result<()> {
2092        //
2093        // One readiness guard prevents concurrent entry points from constructing storage or fact state more than once.
2094        //
2095        let _guard = self.storage_init.lock().await;
2096        let mut storage = self.storage.read().clone();
2097        if storage.is_none() && !self.storage_config.is_none() {
2098            let storage_config = self.convert_storage_config();
2099            storage = create_storage(&storage_config).await?;
2100            *self.storage.write() = storage.clone();
2101        }
2102
2103        self.validate_storage_requirements(storage.as_deref())?;
2104        self.complete_facts_init().await;
2105        Ok(())
2106    }
2107
2108    fn validate_storage_requirements(&self, storage: Option<&dyn AgentStorage>) -> Result<()> {
2109        let facts_required = self
2110            .facts_config
2111            .as_ref()
2112            .is_some_and(|config| config.enabled)
2113            || self
2114                .actor_memory_config
2115                .as_ref()
2116                .is_some_and(|config| config.enabled);
2117        let relationships_required = self
2118            .relationship_manager
2119            .as_ref()
2120            .is_some_and(|manager| manager.config().persistence.enabled);
2121
2122        let Some(storage) = storage else {
2123            let mut requirements = Vec::new();
2124            if facts_required {
2125                requirements.push("actor facts or actor memory");
2126            }
2127            if relationships_required {
2128                requirements.push("persistent relationships");
2129            }
2130            if requirements.is_empty() {
2131                return Ok(());
2132            }
2133            return Err(AgentError::Config(format!(
2134                "Storage is required for enabled {} but none is configured or injected",
2135                requirements.join(" and ")
2136            )));
2137        };
2138
2139        //
2140        // Durable features must fail before execution instead of degrading to volatile state.
2141        //
2142        if facts_required && !storage.supports(StorageCapability::ActorFacts) {
2143            return Err(AgentError::UnsupportedStorageCapability(
2144                StorageCapability::ActorFacts,
2145            ));
2146        }
2147        if relationships_required && !storage.supports(StorageCapability::ActorRelationships) {
2148            return Err(AgentError::UnsupportedStorageCapability(
2149                StorageCapability::ActorRelationships,
2150            ));
2151        }
2152        Ok(())
2153    }
2154
2155    /// Initialize fact store and extractor from stored config and current storage.
2156    /// Called from init_storage() so facts are ready before the first turn.
2157    async fn complete_facts_init(&self) {
2158        if self.fact_store.read().is_some() {
2159            return;
2160        }
2161        let storage = match self.storage.read().clone() {
2162            Some(s) => s,
2163            None => return,
2164        };
2165
2166        let facts_enabled = self
2167            .facts_config
2168            .as_ref()
2169            .map(|f| f.enabled)
2170            .unwrap_or(false);
2171        let actor_memory_enabled = self
2172            .actor_memory_config
2173            .as_ref()
2174            .map(|a| a.enabled)
2175            .unwrap_or(false);
2176
2177        if !facts_enabled && !actor_memory_enabled {
2178            return;
2179        }
2180
2181        let fc = self.facts_config.clone().unwrap_or_default();
2182        let store = Arc::new(ai_agents_facts::FactStore::new(
2183            storage,
2184            self.info.id.clone(),
2185            fc.clone(),
2186        ));
2187
2188        let extractor: Option<Arc<dyn ai_agents_facts::FactExtractor>> = if facts_enabled {
2189            let extractor_llm = fc
2190                .extractor_llm
2191                .as_ref()
2192                .and_then(|alias| self.llm_registry.get(alias).ok())
2193                .or_else(|| self.llm_registry.router().ok())
2194                .or_else(|| self.llm_registry.default().ok());
2195            extractor_llm.map(|llm| {
2196                Arc::new(ai_agents_facts::LLMFactExtractor::new(llm, fc.clone()))
2197                    as Arc<dyn ai_agents_facts::FactExtractor>
2198            })
2199        } else {
2200            None
2201        };
2202
2203        *self.fact_store.write() = Some(store);
2204        *self.fact_extractor.write() = extractor;
2205        debug!(
2206            agent = %self.info.id,
2207            facts_enabled,
2208            actor_memory_enabled,
2209            "facts storage initialized"
2210        );
2211    }
2212
2213    fn convert_storage_config(&self) -> StorageStorageConfig {
2214        crate::spec::storage::to_storage_config(&self.storage_config)
2215    }
2216
2217    pub fn storage(&self) -> Option<Arc<dyn AgentStorage>> {
2218        self.storage.read().clone()
2219    }
2220
2221    pub fn storage_config(&self) -> &StorageConfig {
2222        &self.storage_config
2223    }
2224
2225    /// Returns the spawner if configured via a spawner: YAML section.
2226    pub fn spawner(&self) -> Option<&Arc<crate::spawner::AgentSpawner>> {
2227        self.spawner.as_ref()
2228    }
2229
2230    /// Returns the agent registry if configured via a spawner: YAML section.
2231    pub fn spawner_registry(&self) -> Option<&Arc<crate::spawner::AgentRegistry>> {
2232        self.spawner_registry.as_ref()
2233    }
2234
2235    pub fn has_spawner(&self) -> bool {
2236        self.spawner_registry.is_some()
2237    }
2238
2239    pub fn with_spawner_handles(
2240        mut self,
2241        spawner: Arc<crate::spawner::AgentSpawner>,
2242        registry: Arc<crate::spawner::AgentRegistry>,
2243    ) -> Self {
2244        self.spawner = Some(spawner);
2245        self.spawner_registry = Some(registry);
2246        self
2247    }
2248
2249    pub fn with_hooks(mut self, hooks: Arc<dyn AgentHooks>) -> Self {
2250        self.hooks = hooks;
2251        self
2252    }
2253
2254    pub fn with_parallel_tools(mut self, config: ParallelToolsConfig) -> Self {
2255        self.parallel_tools = config;
2256        self
2257    }
2258
2259    pub fn with_streaming(mut self, config: StreamingConfig) -> Self {
2260        self.streaming = config;
2261        self
2262    }
2263
2264    pub fn with_hitl(mut self, engine: HITLEngine, handler: Arc<dyn ApprovalHandler>) -> Self {
2265        self.hitl_engine = Some(engine);
2266        self.approval_handler = handler;
2267        self
2268    }
2269
2270    pub fn with_max_context_tokens(mut self, tokens: u32) -> Self {
2271        self.max_context_tokens = tokens;
2272        self
2273    }
2274
2275    pub fn with_memory_token_budget(mut self, budget: MemoryTokenBudget) -> Self {
2276        self.memory_token_budget = Some(budget);
2277        self
2278    }
2279
2280    pub fn with_recovery_manager(mut self, manager: RecoveryManager) -> Self {
2281        self.recovery_manager = manager;
2282        self
2283    }
2284
2285    pub fn with_tool_security(mut self, engine: ToolSecurityEngine) -> Self {
2286        self.tool_security = engine;
2287        self
2288    }
2289
2290    /// Returns the host-only runtime control handle.
2291    pub fn runtime_control(&self) -> RuntimeControlHandle {
2292        RuntimeControlHandle {
2293            state: Arc::clone(&self.runtime_control),
2294        }
2295    }
2296
2297    /// Installs or clears the host question handler used by `ask_user`.
2298    pub fn set_question_handler(&self, handler: Option<Arc<dyn QuestionHandler>>) {
2299        self.tools.set_question_handler(handler);
2300    }
2301
2302    /// Installs the host diagnostics provider used by `diagnostics`.
2303    pub fn set_diagnostics_provider(&self, provider: Arc<dyn DiagnosticsProvider>) {
2304        self.tools.set_diagnostics_provider(provider);
2305    }
2306
2307    /// Installs the host command runner used by `command`.
2308    pub fn set_command_runner(&self, runner: Arc<dyn CommandRunner>) {
2309        self.tools.set_command_runner(runner);
2310    }
2311
2312    /// Installs the host web-search provider used by `web_search`.
2313    pub fn set_web_search_provider(&self, provider: Arc<dyn ai_agents_tools::WebSearchProvider>) {
2314        self.tools.set_web_search_provider(provider);
2315    }
2316
2317    /// Returns the current session-local todo list.
2318    pub fn todos(&self) -> Vec<TodoItem> {
2319        self.tools.todos()
2320    }
2321
2322    /// Returns the effective tool security snapshot for the next decision.
2323    fn active_tool_security(&self) -> ToolSecurityEngine {
2324        self.runtime_control
2325            .tool_security_override
2326            .read()
2327            .clone()
2328            .unwrap_or_else(|| self.tool_security.clone())
2329    }
2330
2331    /// Reads policy, scope, emergency state, and generation as one safety snapshot.
2332    fn runtime_safety_snapshot(&self) -> RuntimeSafetySnapshot {
2333        let _guard = self.runtime_control.snapshot_guard.read();
2334        RuntimeSafetySnapshot {
2335            version: self.runtime_control.version.load(Ordering::SeqCst),
2336            emergency_deny: self.runtime_control.emergency_deny.load(Ordering::SeqCst),
2337            tool_security: self
2338                .runtime_control
2339                .tool_security_override
2340                .read()
2341                .clone()
2342                .unwrap_or_else(|| self.tool_security.clone()),
2343            tool_scope_override: self.runtime_control.tool_scope_override.read().clone(),
2344        }
2345    }
2346
2347    /// Performs the single rate admission after locks are held and rejects changed controls.
2348    fn admit_tool_execution(
2349        &self,
2350        expected_runtime_version: u64,
2351        expected_policy_version: u64,
2352        canonical_id: &str,
2353    ) -> SecurityCheckResult {
2354        let _guard = self.runtime_control.snapshot_guard.read();
2355        if self.runtime_control.emergency_deny.load(Ordering::SeqCst) {
2356            return SecurityCheckResult::Block {
2357                reason: "runtime emergency deny is enabled".to_string(),
2358            };
2359        }
2360        let runtime_version = self.runtime_control.version.load(Ordering::SeqCst);
2361        let security_engine = self
2362            .runtime_control
2363            .tool_security_override
2364            .read()
2365            .clone()
2366            .unwrap_or_else(|| self.tool_security.clone());
2367        if runtime_version != expected_runtime_version
2368            || security_engine.policy_version() != expected_policy_version
2369        {
2370            return SecurityCheckResult::Block {
2371                reason: "runtime safety controls changed before admission".to_string(),
2372            };
2373        }
2374        security_engine.admit_tool_execution(canonical_id)
2375    }
2376
2377    pub fn with_process_processor(mut self, processor: ProcessProcessor) -> Self {
2378        let processor = processor.with_stage_observer(Arc::new(ObservabilityProcessStageObserver));
2379        self.process_processor = Some(processor);
2380        self
2381    }
2382
2383    pub fn with_state_machine(
2384        mut self,
2385        state_machine: Arc<StateMachine>,
2386        evaluator: Arc<dyn TransitionEvaluator>,
2387    ) -> Self {
2388        self.state_machine = Some(state_machine);
2389        self.transition_evaluator = Some(evaluator);
2390        self
2391    }
2392
2393    pub fn with_context_manager(mut self, manager: Arc<ContextManager>) -> Self {
2394        self.context_manager = manager;
2395        self
2396    }
2397
2398    pub fn register_message_filter(&self, name: impl Into<String>, filter: Arc<dyn MessageFilter>) {
2399        self.message_filters.write().insert(name.into(), filter);
2400    }
2401
2402    pub fn set_context(&self, key: &str, value: Value) -> Result<()> {
2403        self.context_manager.update(key, value)
2404    }
2405
2406    pub fn update_context(&self, path: &str, value: Value) -> Result<()> {
2407        self.context_manager.update(path, value)
2408    }
2409
2410    pub fn get_context(&self) -> HashMap<String, Value> {
2411        self.build_context_with_overlays()
2412    }
2413
2414    pub fn remove_context(&self, key: &str) -> Option<Value> {
2415        self.context_manager.remove(key)
2416    }
2417
2418    pub async fn refresh_context(&self, key: &str) -> Result<()> {
2419        self.context_manager.refresh(key).await
2420    }
2421
2422    pub fn register_context_provider(&self, name: &str, provider: Arc<dyn ContextProvider>) {
2423        self.context_manager.register_provider(name, provider);
2424    }
2425
2426    pub fn current_state(&self) -> Option<String> {
2427        self.state_machine.as_ref().map(|sm| sm.current())
2428    }
2429
2430    pub async fn transition_to(&self, state: &str) -> Result<()> {
2431        if let Some(ref sm) = self.state_machine {
2432            let from_state = sm.current();
2433            let history_before = sm.history();
2434            self.execute_state_exit_actions(&from_state).await;
2435            sm.transition_to(state, "manual transition")?;
2436            let entered = sm.current();
2437            let is_reentry =
2438                Self::state_was_previously_entered(&entered, &from_state, &history_before);
2439            self.execute_state_enter_actions(&entered, is_reentry).await;
2440            info!(to = %entered, "Manual state transition");
2441        }
2442        Ok(())
2443    }
2444
2445    pub fn state_history(&self) -> Vec<StateTransitionEvent> {
2446        self.state_machine
2447            .as_ref()
2448            .map(|sm| sm.history())
2449            .unwrap_or_default()
2450    }
2451
2452    /// Get a copy of current session metadata.
2453    pub fn session_metadata(&self) -> ai_agents_core::SessionMetadata {
2454        self.session_metadata.read().clone()
2455    }
2456
2457    /// Delete all facts and sessions for an actor, gated by privacy.allow_deletion.
2458    /// Returns Err when actor_memory.privacy.allow_deletion is false.
2459    pub async fn delete_actor_data(&self, actor_id: &str) -> Result<()> {
2460        let allowed = self
2461            .actor_memory_config
2462            .as_ref()
2463            .map(|c| c.privacy.allow_deletion)
2464            .unwrap_or(true);
2465        if !allowed {
2466            return Err(AgentError::Config(
2467                "privacy.allow_deletion is false; actor data deletion is not permitted".into(),
2468            ));
2469        }
2470        let storage = self.storage.read().clone();
2471        if let Some(storage) = storage {
2472            //
2473            // Composite support is checked before mutation so privacy deletion cannot become partial.
2474            //
2475            if !storage.supports(StorageCapability::ActorDataDeletion) {
2476                return Err(AgentError::UnsupportedStorageCapability(
2477                    StorageCapability::ActorDataDeletion,
2478                ));
2479            }
2480            storage.delete_actor_data(&self.info.id, actor_id).await?;
2481        } else {
2482            //
2483            // Clone the fallback store before awaiting so backend I/O never holds the runtime read guard.
2484            //
2485            let store = { self.fact_store.read().clone() };
2486            if let Some(store) = store {
2487                store.delete_actor_data(actor_id).await?;
2488            }
2489        }
2490        if let Some(manager) = self.relationship_manager.as_ref() {
2491            manager.remove(actor_id);
2492        }
2493        self.actor_facts_cache.write().remove(actor_id);
2494        Ok(())
2495    }
2496
2497    /// Overwrite session metadata (tags, ttl, custom fields).
2498    pub fn set_session_metadata(&self, meta: ai_agents_core::SessionMetadata) {
2499        *self.session_metadata.write() = meta;
2500    }
2501
2502    /// Delete sessions whose TTL has expired. Returns number of sessions removed.
2503    pub async fn cleanup_expired_sessions(&self) -> Result<usize> {
2504        let storage = self.storage.read().clone();
2505        match storage {
2506            Some(s) => {
2507                let count = s.cleanup_expired().await?;
2508                if count > 0 {
2509                    self.hooks.on_sessions_expired(count).await;
2510                }
2511                Ok(count)
2512            }
2513            None => Err(AgentError::Config(
2514                "No storage configured. Use with_storage_config() or with_storage() first".into(),
2515            )),
2516        }
2517    }
2518
2519    /// List sessions matching a filter. Supports actor, tag, and date filters.
2520    pub async fn list_sessions_filtered(
2521        &self,
2522        filter: &ai_agents_core::SessionFilter,
2523    ) -> Result<Vec<ai_agents_core::SessionSummary>> {
2524        let storage = self.storage.read().clone();
2525        match storage {
2526            Some(s) => s.list_sessions_filtered(filter).await,
2527            None => Err(AgentError::Config(
2528                "No storage configured. Use with_storage_config() or with_storage() first".into(),
2529            )),
2530        }
2531    }
2532
2533    pub async fn save_state(&self) -> Result<AgentSnapshot> {
2534        let memory_snapshot = self.memory.snapshot().await?;
2535        let state_machine_snapshot = self.state_machine.as_ref().map(|sm| sm.snapshot());
2536        let context_snapshot = self.context_manager.snapshot();
2537
2538        let mut snapshot = AgentSnapshot::new(self.info.id.clone())
2539            .with_memory(memory_snapshot)
2540            .with_context(context_snapshot)
2541            .with_state_machine(
2542                state_machine_snapshot.unwrap_or_else(|| StateMachineSnapshot {
2543                    current_state: String::new(),
2544                    previous_state: None,
2545                    turn_count: 0,
2546                    no_transition_count: 0,
2547                    history: vec![],
2548                }),
2549            );
2550
2551        if let Some(ref persona) = self.persona_manager {
2552            snapshot.persona = Some(persona.snapshot_as_value()?);
2553        }
2554
2555        if let Some(ref relationships) = self.relationship_manager {
2556            snapshot.relationships = Some(relationships.snapshot_as_value()?);
2557        }
2558
2559        Ok(snapshot)
2560    }
2561
2562    /// Save state including spawned agents manifest for session persistence.
2563    pub async fn save_state_full(&self) -> Result<AgentSnapshot> {
2564        let mut snapshot = self.save_state().await?;
2565        if let Some(ref registry) = self.spawner_registry {
2566            let entries = registry.list_with_specs();
2567            if !entries.is_empty() {
2568                snapshot = snapshot.with_spawned_agents(entries);
2569            }
2570        }
2571        Ok(snapshot)
2572    }
2573
2574    pub async fn restore_state(&self, snapshot: AgentSnapshot) -> Result<()> {
2575        self.memory.restore(snapshot.memory).await?;
2576
2577        if let (Some(sm), Some(sm_snapshot)) = (&self.state_machine, snapshot.state_machine)
2578            && !sm_snapshot.current_state.is_empty()
2579        {
2580            sm.restore(sm_snapshot)?;
2581        }
2582
2583        self.context_manager.restore(snapshot.context);
2584
2585        if let (Some(persona_value), Some(persona_manager)) =
2586            (snapshot.persona, &self.persona_manager)
2587        {
2588            persona_manager.restore_from_value(persona_value)?;
2589        }
2590
2591        if let (Some(relationship_value), Some(relationship_manager)) =
2592            (snapshot.relationships, &self.relationship_manager)
2593        {
2594            relationship_manager.restore_from_value(relationship_value)?;
2595        }
2596
2597        info!(agent_id = %snapshot.agent_id, "State restored");
2598        Ok(())
2599    }
2600
2601    pub async fn save_to(&self, storage: &dyn AgentStorage, session_id: &str) -> Result<()> {
2602        let snapshot = self.save_state().await?;
2603        storage.save(session_id, &snapshot).await
2604    }
2605
2606    async fn load_session_restore(
2607        storage: &dyn AgentStorage,
2608        session_id: &str,
2609    ) -> Result<Option<StoredSessionRestore>> {
2610        let Some(snapshot) = storage.load(session_id).await? else {
2611            return Ok(None);
2612        };
2613        //
2614        // Metadata is restored only when the backend explicitly owns that durable contract.
2615        //
2616        let metadata = if storage.supports(StorageCapability::SessionMetadata) {
2617            storage.load_metadata(session_id).await?
2618        } else {
2619            None
2620        };
2621        Ok(Some(StoredSessionRestore { snapshot, metadata }))
2622    }
2623
2624    async fn capture_session_restore_point(&self) -> Result<RuntimeSessionRestorePoint> {
2625        Ok(RuntimeSessionRestorePoint {
2626            snapshot: self.save_state().await?,
2627            metadata: self.session_metadata(),
2628            actor_id: self.actor_id(),
2629            session_id: self.current_session_id.read().clone(),
2630        })
2631    }
2632
2633    async fn apply_session_restore_unchecked(
2634        &self,
2635        session_id: &str,
2636        stored: StoredSessionRestore,
2637    ) -> Result<()> {
2638        self.restore_state(stored.snapshot).await?;
2639        let metadata = stored.metadata.unwrap_or_default();
2640        if let Some(actor_id) = metadata.actor_id.as_deref() {
2641            self.set_actor_id(actor_id)?;
2642        } else {
2643            self.clear_actor_id();
2644        }
2645        self.set_session_metadata(metadata);
2646        *self.current_session_id.write() = Some(session_id.to_string());
2647        Ok(())
2648    }
2649
2650    async fn restore_session_restore_point(
2651        &self,
2652        restore_point: &RuntimeSessionRestorePoint,
2653    ) -> Result<()> {
2654        self.restore_state(restore_point.snapshot.clone()).await?;
2655        if let Some(actor_id) = restore_point.actor_id.as_deref() {
2656            self.set_actor_id(actor_id)?;
2657        } else {
2658            self.clear_actor_id();
2659        }
2660        self.set_session_metadata(restore_point.metadata.clone());
2661        *self.current_session_id.write() = restore_point.session_id.clone();
2662        Ok(())
2663    }
2664
2665    async fn apply_session_restore(
2666        &self,
2667        session_id: &str,
2668        stored: StoredSessionRestore,
2669    ) -> Result<()> {
2670        let before = self.capture_session_restore_point().await?;
2671        if let Err(error) = self
2672            .apply_session_restore_unchecked(session_id, stored)
2673            .await
2674        {
2675            return match self.restore_session_restore_point(&before).await {
2676                Ok(()) => Err(error),
2677                Err(rollback_error) => Err(AgentError::Other(format!(
2678                    "Session restore failed: {error}; rollback failed: {rollback_error}"
2679                ))),
2680            };
2681        }
2682        Ok(())
2683    }
2684
2685    async fn rollback_session_restore_set(
2686        parent: Option<(&RuntimeAgent, &RuntimeSessionRestorePoint)>,
2687        children: &[(String, Arc<RuntimeAgent>, RuntimeSessionRestorePoint)],
2688    ) -> Vec<String> {
2689        let mut errors = Vec::new();
2690        if let Some((agent, restore_point)) = parent
2691            && let Err(error) = agent.restore_session_restore_point(restore_point).await
2692        {
2693            errors.push(format!("parent: {error}"));
2694        }
2695        for (id, agent, restore_point) in children {
2696            if let Err(error) = agent.restore_session_restore_point(restore_point).await {
2697                errors.push(format!("child '{id}': {error}"));
2698            }
2699        }
2700        errors
2701    }
2702
2703    fn restore_failure(error: impl std::fmt::Display, rollback_errors: Vec<String>) -> AgentError {
2704        if rollback_errors.is_empty() {
2705            AgentError::Other(format!(
2706                "Session restore failed: {error}; runtime state was rolled back"
2707            ))
2708        } else {
2709            AgentError::Other(format!(
2710                "Session restore failed: {error}; rollback also failed for {}",
2711                rollback_errors.join(", ")
2712            ))
2713        }
2714    }
2715
2716    pub async fn load_from(&self, storage: &dyn AgentStorage, session_id: &str) -> Result<bool> {
2717        let Some(stored) = Self::load_session_restore(storage, session_id).await? else {
2718            return Ok(false);
2719        };
2720        self.apply_session_restore(session_id, stored).await?;
2721        Ok(true)
2722    }
2723
2724    pub async fn save_session(&self, session_id: &str) -> Result<()> {
2725        let storage = self.storage.read().clone();
2726        match storage {
2727            Some(s) => {
2728                // Fire on_session_created when this session id is first seen on this runtime.
2729                let is_new = {
2730                    let cur = self.current_session_id.read().clone();
2731                    cur.as_deref() != Some(session_id)
2732                };
2733                if is_new {
2734                    *self.current_session_id.write() = Some(session_id.to_string());
2735                    self.hooks.on_session_created(session_id).await;
2736                }
2737
2738                // Update metadata before persisting.
2739                {
2740                    let now = chrono::Utc::now();
2741                    let msg_count = self
2742                        .memory
2743                        .get_messages(None)
2744                        .await
2745                        .map(|v| v.len())
2746                        .unwrap_or(0);
2747                    let mut meta = self.session_metadata.write();
2748                    meta.last_active = now;
2749                    meta.message_count = msg_count;
2750                    if meta.actor_id.is_none() {
2751                        meta.actor_id = self.actor_id.read().clone();
2752                    }
2753                }
2754
2755                let snapshot = self.save_state().await?;
2756                //
2757                // Metadata-capable backends own atomic snapshot and metadata persistence.
2758                //
2759                if s.supports(StorageCapability::SessionMetadata) {
2760                    let metadata = self.session_metadata.read().clone();
2761                    s.save_snapshot_with_metadata(session_id, &snapshot, &metadata)
2762                        .await
2763                } else {
2764                    s.save(session_id, &snapshot).await
2765                }
2766            }
2767            None => Err(AgentError::Config(
2768                "No storage configured. Use with_storage_config() or with_storage() first".into(),
2769            )),
2770        }
2771    }
2772
2773    pub async fn load_session(&self, session_id: &str) -> Result<bool> {
2774        let storage = self.storage.read().clone();
2775        match storage {
2776            Some(storage) => self.load_from(storage.as_ref(), session_id).await,
2777            None => Err(AgentError::Config(
2778                "No storage configured. Use with_storage_config() or with_storage() first".into(),
2779            )),
2780        }
2781    }
2782
2783    /// Restore this runtime and its complete saved child topology from one named session.
2784    pub async fn restore_session_full(&self, session_id: &str) -> Result<usize> {
2785        self.init_storage().await?;
2786        let storage = self.storage.read().clone().ok_or_else(|| {
2787            AgentError::Config(
2788                "No storage configured. Use with_storage_config() or with_storage() first".into(),
2789            )
2790        })?;
2791        let target_parent = Self::load_session_restore(storage.as_ref(), session_id)
2792            .await?
2793            .ok_or_else(|| AgentError::Persistence(format!("Session not found: {session_id}")))?;
2794        let manifest = target_parent
2795            .snapshot
2796            .spawned_agents
2797            .clone()
2798            .unwrap_or_default();
2799
2800        let registry = self.spawner_registry.as_ref().cloned();
2801        let spawner = if manifest.is_empty() {
2802            self.spawner.as_ref().cloned()
2803        } else {
2804            Some(self.spawner.as_ref().cloned().ok_or_else(|| {
2805                AgentError::Config(
2806                    "Saved session contains child agents but this runtime has no spawner".into(),
2807                )
2808            })?)
2809        };
2810        let registry = if manifest.is_empty() {
2811            registry
2812        } else {
2813            Some(registry.ok_or_else(|| {
2814                AgentError::Config(
2815                    "Saved session contains child agents but this runtime has no registry".into(),
2816                )
2817            })?)
2818        };
2819
2820        let mut target_ids = HashSet::with_capacity(manifest.len());
2821        let mut prepared = Vec::with_capacity(manifest.len());
2822        for entry in manifest {
2823            if !target_ids.insert(entry.id.clone()) {
2824                return Err(AgentError::InvalidSpec(format!(
2825                    "Saved child manifest contains duplicate ID: {}",
2826                    entry.id
2827                )));
2828            }
2829            let spec = crate::spec::AgentSpec::from_yaml_strict(&entry.spec_yaml)?;
2830            spawner
2831                .as_ref()
2832                .expect("non-empty manifests require a spawner")
2833                .validate_explicit_child(&entry.id, &spec)?;
2834            prepared.push((entry.id, spec));
2835        }
2836
2837        let current_ids = registry
2838            .as_ref()
2839            .map(|registry| {
2840                registry
2841                    .list()
2842                    .into_iter()
2843                    .map(|info| info.id)
2844                    .collect::<HashSet<_>>()
2845            })
2846            .unwrap_or_default();
2847        let removal_count = current_ids.difference(&target_ids).count();
2848        let additions = prepared
2849            .iter()
2850            .filter(|(id, _)| !current_ids.contains(id))
2851            .cloned()
2852            .collect::<Vec<_>>();
2853
2854        let mut existing = Vec::new();
2855        if let Some(registry) = registry.as_ref() {
2856            for (id, _) in prepared.iter().filter(|(id, _)| current_ids.contains(id)) {
2857                let agent = registry.get(id).ok_or_else(|| {
2858                    AgentError::Config(format!("Retained child disappeared during restore: {id}"))
2859                })?;
2860                let child_storage = agent.storage().ok_or_else(|| {
2861                    AgentError::Config(format!("Child '{id}' has no storage for session restore"))
2862                })?;
2863                let stored = Self::load_session_restore(child_storage.as_ref(), session_id)
2864                    .await?
2865                    .ok_or_else(|| {
2866                        AgentError::Persistence(format!(
2867                            "Child '{id}' has no saved session '{session_id}'"
2868                        ))
2869                    })?;
2870                existing.push((id.clone(), agent, stored));
2871            }
2872        }
2873
2874        let mut staged = Vec::with_capacity(additions.len());
2875        if !additions.is_empty() {
2876            let spawner = spawner
2877                .as_ref()
2878                .expect("restored additions require a spawner");
2879            let reservations = spawner.reserve_restore_capacity(additions.len(), removal_count)?;
2880            for ((id, spec), reservation) in additions.into_iter().zip(reservations) {
2881                let spawned = spawner
2882                    .spawn_with_reserved_capacity(id.clone(), spec, reservation)
2883                    .await?;
2884                let child_storage = spawned.agent.storage().ok_or_else(|| {
2885                    AgentError::Config(format!("Child '{id}' has no storage for session restore"))
2886                })?;
2887                let stored = Self::load_session_restore(child_storage.as_ref(), session_id)
2888                    .await?
2889                    .ok_or_else(|| {
2890                        AgentError::Persistence(format!(
2891                            "Child '{id}' has no saved session '{session_id}'"
2892                        ))
2893                    })?;
2894                staged.push((spawned, stored));
2895            }
2896        } else if let Some(spawner) = spawner.as_ref() {
2897            spawner.reserve_restore_capacity(0, removal_count)?;
2898        }
2899
2900        let parent_before = self.capture_session_restore_point().await?;
2901        let mut existing_before = Vec::with_capacity(existing.len());
2902        for (id, agent, _) in &existing {
2903            existing_before.push((
2904                id.clone(),
2905                Arc::clone(agent),
2906                agent.capture_session_restore_point().await?,
2907            ));
2908        }
2909
2910        //
2911        // Every record is loaded before runtime state changes. Registry replacement is the final topology commit point.
2912        //
2913        for (_, agent, stored) in &existing {
2914            if let Err(error) = agent
2915                .apply_session_restore_unchecked(session_id, stored.clone())
2916                .await
2917            {
2918                drop(staged);
2919                let rollback_errors =
2920                    Self::rollback_session_restore_set(None, &existing_before).await;
2921                return Err(Self::restore_failure(error, rollback_errors));
2922            }
2923        }
2924        for (spawned, stored) in &staged {
2925            if let Err(error) = spawned
2926                .agent
2927                .apply_session_restore_unchecked(session_id, stored.clone())
2928                .await
2929            {
2930                drop(staged);
2931                let rollback_errors =
2932                    Self::rollback_session_restore_set(None, &existing_before).await;
2933                return Err(Self::restore_failure(error, rollback_errors));
2934            }
2935        }
2936        if let Err(error) = self
2937            .apply_session_restore_unchecked(session_id, target_parent)
2938            .await
2939        {
2940            drop(staged);
2941            let rollback_errors =
2942                Self::rollback_session_restore_set(Some((self, &parent_before)), &existing_before)
2943                    .await;
2944            return Err(Self::restore_failure(error, rollback_errors));
2945        }
2946
2947        if let Some(registry) = registry.as_ref()
2948            && let Err(error) = registry
2949                .reconcile(
2950                    &target_ids,
2951                    staged.into_iter().map(|(spawned, _)| spawned).collect(),
2952                )
2953                .await
2954        {
2955            let rollback_errors =
2956                Self::rollback_session_restore_set(Some((self, &parent_before)), &existing_before)
2957                    .await;
2958            return Err(Self::restore_failure(error, rollback_errors));
2959        }
2960
2961        Ok(target_ids.len())
2962    }
2963
2964    pub async fn delete_session(&self, session_id: &str) -> Result<()> {
2965        let storage = self.storage.read().clone();
2966        match storage {
2967            Some(s) => s.delete(session_id).await,
2968            None => Err(AgentError::Config(
2969                "No storage configured. Use with_storage_config() or with_storage() first".into(),
2970            )),
2971        }
2972    }
2973
2974    pub async fn list_sessions(&self) -> Result<Vec<String>> {
2975        let storage = self.storage.read().clone();
2976        match storage {
2977            Some(s) => s.list_sessions().await,
2978            None => Err(AgentError::Config(
2979                "No storage configured. Use with_storage_config() or with_storage() first".into(),
2980            )),
2981        }
2982    }
2983
2984    fn estimate_tokens(&self, text: &str) -> u32 {
2985        (text.len() as f32 / 4.0).ceil() as u32
2986    }
2987
2988    fn estimate_total_tokens(&self, messages: &[ChatMessage]) -> u32 {
2989        messages
2990            .iter()
2991            .map(|m| self.estimate_tokens(&m.content))
2992            .sum()
2993    }
2994
2995    fn truncate_context(&self, messages: &mut Vec<ChatMessage>, keep_recent: usize) {
2996        if messages.len() <= keep_recent + 1 {
2997            return;
2998        }
2999        let system_msg = messages.remove(0);
3000        let to_remove = messages.len().saturating_sub(keep_recent);
3001        messages.drain(..to_remove);
3002        messages.insert(0, system_msg);
3003    }
3004
3005    fn get_filter(&self, config: &FilterConfig) -> Arc<dyn MessageFilter> {
3006        match config {
3007            FilterConfig::KeepRecent(n) => Arc::new(KeepRecentFilter::new(*n)),
3008            FilterConfig::ByRole { keep_roles } => Arc::new(ByRoleFilter::new(keep_roles.clone())),
3009            FilterConfig::SkipPattern { skip_if_contains } => {
3010                Arc::new(SkipPatternFilter::new(skip_if_contains.clone()))
3011            }
3012            FilterConfig::Custom { name } => {
3013                let filters = self.message_filters.read();
3014                filters
3015                    .get(name)
3016                    .cloned()
3017                    .unwrap_or_else(|| Arc::new(KeepRecentFilter::new(10)))
3018            }
3019        }
3020    }
3021
3022    async fn summarize_context(
3023        &self,
3024        messages: &mut Vec<ChatMessage>,
3025        summarizer_llm: Option<&str>,
3026        max_summary_tokens: u32,
3027        custom_prompt: Option<&str>,
3028        keep_recent: usize,
3029        filter: Option<&FilterConfig>,
3030    ) -> Result<()> {
3031        let system_msg = messages.remove(0);
3032
3033        let to_summarize_count = messages.len().saturating_sub(keep_recent);
3034        if to_summarize_count == 0 {
3035            messages.insert(0, system_msg);
3036            return Ok(());
3037        }
3038
3039        let recent_msgs: Vec<ChatMessage> = messages.drain(to_summarize_count..).collect();
3040        let mut to_summarize = std::mem::take(messages);
3041
3042        if let Some(filter_config) = filter {
3043            let filter = self.get_filter(filter_config);
3044            to_summarize = filter.filter(to_summarize);
3045        }
3046
3047        if to_summarize.is_empty() {
3048            *messages = recent_msgs;
3049            messages.insert(0, system_msg);
3050            return Ok(());
3051        }
3052
3053        let conversation_text = to_summarize
3054            .iter()
3055            .map(|m| format!("{:?}: {}", m.role, m.content))
3056            .collect::<Vec<_>>()
3057            .join("\n");
3058
3059        let default_prompt = format!(
3060            "Summarize the following conversation in under {} tokens, preserving key information:\n\n{}",
3061            max_summary_tokens, conversation_text
3062        );
3063
3064        let summary_prompt = custom_prompt
3065            .map(|p| format!("{}\n\n{}", p, conversation_text))
3066            .unwrap_or(default_prompt);
3067
3068        let summarizer = if let Some(alias) = summarizer_llm {
3069            self.llm_registry
3070                .get(alias)
3071                .map_err(|e| AgentError::Config(e.to_string()))?
3072        } else {
3073            self.llm_registry
3074                .router()
3075                .or_else(|_| self.llm_registry.default())
3076                .map_err(|e| AgentError::Config(e.to_string()))?
3077        };
3078
3079        let summary_msgs = vec![ChatMessage::user(&summary_prompt)];
3080        let response = self
3081            .observe_purpose(
3082                ObservationPurpose::Summarization,
3083                summarizer.complete(&summary_msgs, None),
3084            )
3085            .await?;
3086
3087        let summary_message = ChatMessage::system(format!(
3088            "[Previous conversation summary]\n{}",
3089            response.content
3090        ));
3091
3092        *messages = vec![system_msg, summary_message];
3093        messages.extend(recent_msgs);
3094
3095        debug!(
3096            summarized_count = to_summarize_count,
3097            kept_recent = keep_recent,
3098            "Context summarized"
3099        );
3100
3101        Ok(())
3102    }
3103
3104    fn render_system_prompt(&self) -> Result<String> {
3105        let mut context = self.build_context_with_overlays();
3106
3107        // Inject actor_facts for {{ actor_facts }} template variable.
3108        let facts_text = self.format_actor_facts_for_context();
3109        if !facts_text.is_empty() {
3110            context.insert(
3111                "actor_facts".to_string(),
3112                serde_json::Value::String(facts_text),
3113            );
3114        }
3115
3116        if let Some((key, text)) = self.format_relationship_for_context() {
3117            context.insert(key, serde_json::Value::String(text));
3118        }
3119
3120        self.template_renderer
3121            .render(&self.base_system_prompt, &context)
3122    }
3123
3124    /// Resolves the exact top-level grant carried by a runtime safety snapshot.
3125    fn get_top_level_tool_ids_for_scope(&self, scope_override: Option<&[String]>) -> Vec<String> {
3126        scope_override
3127            .or(self.declared_tool_ids.as_deref())
3128            .map(|ids| {
3129                ids.iter()
3130                    .filter_map(|id| self.tools.canonical_id(id))
3131                    .collect::<Vec<_>>()
3132            })
3133            .unwrap_or_default()
3134    }
3135
3136    async fn get_available_tool_ids(&self) -> Result<Vec<String>> {
3137        let scope_override = self.runtime_control.tool_scope_override.read().clone();
3138        self.get_available_tool_ids_for_scope(scope_override.as_deref())
3139            .await
3140    }
3141
3142    /// Applies state narrowing to the supplied top-level scope instead of reading mutable control again.
3143    async fn get_available_tool_ids_for_scope(
3144        &self,
3145        scope_override: Option<&[String]>,
3146    ) -> Result<Vec<String>> {
3147        let top_level = self.get_top_level_tool_ids_for_scope(scope_override);
3148        if top_level.is_empty() {
3149            return Ok(Vec::new());
3150        }
3151
3152        match self.get_current_tool_refs() {
3153            Some(tool_refs) => {
3154                if tool_refs.is_empty() {
3155                    return Ok(Vec::new());
3156                }
3157
3158                let eval_ctx = self.build_evaluation_context().await?;
3159                let llm_getter = RegistryLLMGetter {
3160                    registry: self.llm_registry.clone(),
3161                };
3162                let evaluator = ConditionEvaluator::new(llm_getter);
3163
3164                let mut available = Vec::new();
3165                for tool_ref in &tool_refs {
3166                    let tool_id = tool_ref.id();
3167                    let Some(canonical_id) = self.tools.canonical_id(tool_id) else {
3168                        continue;
3169                    };
3170                    if !top_level.iter().any(|id| id == &canonical_id) {
3171                        debug!(tool = %canonical_id, "Tool not in top-level grant, skipping");
3172                        continue;
3173                    }
3174
3175                    if let Some(condition) = tool_ref.condition() {
3176                        match evaluator.evaluate(condition, &eval_ctx).await {
3177                            Ok(true) => {
3178                                available.push(canonical_id);
3179                            }
3180                            Ok(false) => {
3181                                debug!(tool = tool_id, "Tool condition not met, skipping");
3182                            }
3183                            Err(e) => {
3184                                warn!(tool = tool_id, error = %e, "Error evaluating tool condition");
3185                            }
3186                        }
3187                    } else {
3188                        available.push(canonical_id);
3189                    }
3190                }
3191
3192                Ok(available)
3193            }
3194            None => Ok(top_level),
3195        }
3196    }
3197
3198    /// Returns `Some(tools)` if the current state explicitly declares tools
3199    /// (including `Some([])` for "no tools"), or `None` if the state doesn't
3200    /// specify tools (meaning: fall back to agent-level declared_tool_ids).
3201    fn get_current_tool_refs(&self) -> Option<Vec<ToolRef>> {
3202        if let Some(ref sm) = self.state_machine
3203            && let Some(state_def) = sm.current_definition()
3204        {
3205            let parent_def = sm.get_parent_definition();
3206            if let Some(effective) = state_def.get_effective_tools(parent_def.as_ref()) {
3207                return Some(effective.into_iter().cloned().collect());
3208            }
3209        }
3210        None
3211    }
3212
3213    async fn build_evaluation_context(&self) -> Result<EvaluationContext> {
3214        let context = self.build_context_with_overlays();
3215        let messages = self.memory.get_messages(Some(10)).await?;
3216        let tool_history = self.tool_call_history.read().clone();
3217
3218        let (state_name, turn_count, previous_state) = if let Some(ref sm) = self.state_machine {
3219            (Some(sm.current()), sm.turn_count(), sm.previous())
3220        } else {
3221            (None, 0, None)
3222        };
3223
3224        Ok(EvaluationContext::default()
3225            .with_context(context)
3226            .with_state(state_name, turn_count, previous_state)
3227            .with_called_tools(tool_history)
3228            .with_messages(messages))
3229    }
3230
3231    fn record_tool_call(&self, tool_id: &str, result: Value) {
3232        self.tool_call_history.write().push(ToolCallRecord {
3233            tool_id: tool_id.to_string(),
3234            result,
3235            timestamp: chrono::Utc::now(),
3236        });
3237    }
3238
3239    async fn get_effective_system_prompt_with_persona_hooks(
3240        &self,
3241        fire_persona_hooks: bool,
3242        include_tool_prompt: bool,
3243    ) -> Result<String> {
3244        let rendered_base = self.render_system_prompt()?;
3245
3246        let persona_prefix = if let Some(ref persona) = self.persona_manager {
3247            let context = self.build_context_with_overlays();
3248            if fire_persona_hooks {
3249                let render_result = persona.render_prompt(&context)?;
3250                for content in &render_result.newly_revealed {
3251                    self.hooks.on_secret_revealed(content).await;
3252                }
3253                render_result.prompt
3254            } else {
3255                persona.render_prompt_preview(&context)?
3256            }
3257        } else {
3258            String::new()
3259        };
3260
3261        if let Some(ref sm) = self.state_machine
3262            && let Some(state_def) = sm.current_definition()
3263        {
3264            let state_prompt = if let Some(ref prompt) = state_def.prompt {
3265                let context = self.build_context_with_overlays();
3266                self.template_renderer.render_with_state(
3267                    prompt,
3268                    &context,
3269                    &sm.current(),
3270                    sm.previous().as_deref(),
3271                    sm.turn_count(),
3272                    state_def.max_turns,
3273                )?
3274            } else {
3275                String::new()
3276            };
3277
3278            let combined = match state_def.prompt_mode {
3279                PromptMode::Append => {
3280                    if state_prompt.is_empty() {
3281                        rendered_base
3282                    } else {
3283                        format!(
3284                            "{}\n\n[Current State: {}]\n{}",
3285                            rendered_base,
3286                            sm.current(),
3287                            state_prompt
3288                        )
3289                    }
3290                }
3291                PromptMode::Replace => {
3292                    if state_prompt.is_empty() {
3293                        rendered_base
3294                    } else {
3295                        state_prompt
3296                    }
3297                }
3298                PromptMode::Prepend => {
3299                    if state_prompt.is_empty() {
3300                        rendered_base
3301                    } else {
3302                        format!("{}\n\n{}", state_prompt, rendered_base)
3303                    }
3304                }
3305            };
3306
3307            // Persona always prepended regardless of prompt_mode.
3308            let with_persona = if persona_prefix.is_empty() {
3309                combined
3310            } else {
3311                format!("{}\n\n{}", persona_prefix, combined)
3312            };
3313
3314            if include_tool_prompt {
3315                let available_tool_ids = self.get_available_tool_ids().await?;
3316                if !available_tool_ids.is_empty() {
3317                    let tools_prompt = self.tools.generate_scoped_prompt_with_mode(
3318                        &available_tool_ids,
3319                        None,
3320                        self.parallel_tools.enabled,
3321                        self.runtime_config.tool_schema_prompt_mode,
3322                    );
3323                    if !tools_prompt.is_empty() {
3324                        return Ok(format!("{}\n\n{}", with_persona, tools_prompt));
3325                    }
3326                }
3327            }
3328            return Ok(with_persona);
3329        }
3330
3331        // No state machine - prepend persona to base.
3332        let with_persona = if persona_prefix.is_empty() {
3333            rendered_base
3334        } else {
3335            format!("{}\n\n{}", persona_prefix, rendered_base)
3336        };
3337
3338        if include_tool_prompt {
3339            let available_tool_ids = self.get_available_tool_ids().await?;
3340            let tools_prompt = self.tools.generate_scoped_prompt_with_mode(
3341                &available_tool_ids,
3342                None,
3343                self.parallel_tools.enabled,
3344                self.runtime_config.tool_schema_prompt_mode,
3345            );
3346            if !tools_prompt.is_empty() {
3347                return Ok(format!("{}\n\n{}", with_persona, tools_prompt));
3348            }
3349        }
3350        Ok(with_persona)
3351    }
3352
3353    fn get_state_llm(&self) -> Result<Arc<dyn LLMProvider>> {
3354        if let Some(ref sm) = self.state_machine
3355            && let Some(state_def) = sm.current_definition()
3356            && let Some(ref llm_alias) = state_def.llm
3357        {
3358            return self
3359                .llm_registry
3360                .get(llm_alias)
3361                .map_err(|e| AgentError::Config(e.to_string()));
3362        }
3363        self.llm_registry
3364            .default()
3365            .map_err(|e| AgentError::Config(e.to_string()))
3366    }
3367
3368    fn get_effective_reasoning_config(&self) -> ReasoningConfig {
3369        if let Some(ref sm) = self.state_machine
3370            && let Some(state_def) = sm.current_definition()
3371            && let Some(ref state_reasoning) = state_def.reasoning
3372        {
3373            return state_reasoning.clone();
3374        }
3375        self.reasoning_config.clone()
3376    }
3377
3378    fn get_effective_reflection_config(&self) -> ReflectionConfig {
3379        if let Some(ref sm) = self.state_machine
3380            && let Some(state_def) = sm.current_definition()
3381            && let Some(ref state_reflection) = state_def.reflection
3382        {
3383            return state_reflection.clone();
3384        }
3385        self.reflection_config.clone()
3386    }
3387
3388    fn get_skill_reasoning_config(&self, skill: &SkillDefinition) -> ReasoningConfig {
3389        skill
3390            .reasoning
3391            .clone()
3392            .unwrap_or_else(|| self.get_effective_reasoning_config())
3393    }
3394
3395    fn get_skill_reflection_config(&self, skill: &SkillDefinition) -> ReflectionConfig {
3396        skill
3397            .reflection
3398            .clone()
3399            .unwrap_or_else(|| self.get_effective_reflection_config())
3400    }
3401
3402    async fn build_disambiguation_context(&self) -> Result<DisambiguationContext> {
3403        let recent_messages: Vec<String> = self
3404            .memory
3405            .get_messages(Some(5))
3406            .await?
3407            .iter()
3408            .rev()
3409            .map(|m| format!("{:?}: {}", m.role, m.content))
3410            .collect();
3411
3412        let current_state = self.current_state().map(|s| s.to_string());
3413
3414        // Include the current state's prompt text so the detector understands
3415        // what kind of input is expected (e.g., "Ask for the order number").
3416        let state_prompt: Option<String> = self
3417            .state_machine
3418            .as_ref()
3419            .and_then(|sm| sm.current_definition())
3420            .and_then(|def| def.prompt.clone());
3421
3422        let available_tools: Vec<String> = self
3423            .get_available_tool_ids()
3424            .await
3425            .unwrap_or_else(|_| self.tools.list_ids());
3426
3427        let available_skills: Vec<String> = self.skills.iter().map(|s| s.id.clone()).collect();
3428
3429        let user_context = self.build_context_with_overlays();
3430
3431        // Extract canonical intent labels from current state's transitions
3432        let available_intents: Vec<String> = if let Some(ref sm) = self.state_machine {
3433            sm.current_definition()
3434                .map(|def| {
3435                    def.transitions
3436                        .iter()
3437                        .filter_map(|t| t.intent.clone())
3438                        .collect()
3439                })
3440                .unwrap_or_default()
3441        } else {
3442            Vec::new()
3443        };
3444
3445        Ok(DisambiguationContext::from_agent_state(
3446            recent_messages,
3447            current_state,
3448            state_prompt,
3449            available_tools,
3450            available_skills,
3451            available_intents,
3452            user_context,
3453        ))
3454    }
3455
3456    fn get_available_skills(&self) -> Vec<&SkillDefinition> {
3457        if let Some(ref sm) = self.state_machine
3458            && let Some(state_def) = sm.current_definition()
3459        {
3460            let parent_def = sm.get_parent_definition();
3461            let effective_skills = state_def.get_effective_skills(parent_def.as_ref());
3462            if !effective_skills.is_empty() {
3463                return self
3464                    .skills
3465                    .iter()
3466                    .filter(|s| effective_skills.contains(&&s.id))
3467                    .collect();
3468            }
3469        }
3470        self.skills.iter().collect()
3471    }
3472
3473    async fn build_messages(&self) -> Result<Vec<ChatMessage>> {
3474        self.build_messages_internal(true, None, true).await
3475    }
3476
3477    async fn build_messages_for_draft(&self, user_message: &str) -> Result<Vec<ChatMessage>> {
3478        self.build_messages_internal(false, Some(user_message), true)
3479            .await
3480    }
3481
3482    async fn build_messages_internal(
3483        &self,
3484        fire_persona_hooks: bool,
3485        ephemeral_user_message: Option<&str>,
3486        include_tool_prompt: bool,
3487    ) -> Result<Vec<ChatMessage>> {
3488        let system_prompt = self
3489            .get_effective_system_prompt_with_persona_hooks(fire_persona_hooks, include_tool_prompt)
3490            .await?;
3491        let mut messages = vec![ChatMessage::system(&system_prompt)];
3492
3493        let context = self.memory.get_context().await?;
3494        let history = if let Some(ref budget) = self.memory_token_budget {
3495            context.to_llm_messages_with_allocation(&budget.allocation)
3496        } else {
3497            context.to_llm_messages()
3498        };
3499        messages.extend(history);
3500        if let Some(user_message) = ephemeral_user_message {
3501            messages.push(ChatMessage::user(user_message));
3502        }
3503
3504        let total_tokens = self.estimate_total_tokens(&messages);
3505
3506        if total_tokens > self.max_context_tokens {
3507            debug!(
3508                total = total_tokens,
3509                limit = self.max_context_tokens,
3510                "Context overflow"
3511            );
3512
3513            match &self.recovery_manager.config().llm.on_context_overflow {
3514                ContextOverflowAction::Error => {
3515                    return Err(AgentError::LLM(format!(
3516                        "Context overflow: {} tokens > {} limit",
3517                        total_tokens, self.max_context_tokens
3518                    )));
3519                }
3520                ContextOverflowAction::Truncate { keep_recent } => {
3521                    self.truncate_context(&mut messages, *keep_recent);
3522                }
3523                ContextOverflowAction::Summarize {
3524                    summarizer_llm,
3525                    max_summary_tokens,
3526                    custom_prompt,
3527                    keep_recent,
3528                    filter,
3529                } => {
3530                    self.summarize_context(
3531                        &mut messages,
3532                        summarizer_llm.as_deref(),
3533                        *max_summary_tokens,
3534                        custom_prompt.as_deref(),
3535                        *keep_recent,
3536                        filter.as_ref(),
3537                    )
3538                    .await?;
3539                }
3540            }
3541        }
3542
3543        Ok(messages)
3544    }
3545
3546    async fn main_tool_protocol(
3547        &self,
3548        llm: &dyn LLMProvider,
3549        ephemeral_new_turn: bool,
3550    ) -> Result<MainToolProtocol> {
3551        let mut choice = llm.configured_tool_choice();
3552        if matches!(choice.as_ref(), Some(ToolChoice::None)) {
3553            return Ok(MainToolProtocol {
3554                choice,
3555                tool_ids: Vec::new(),
3556                definitions: Vec::new(),
3557            });
3558        }
3559
3560        let mut tool_ids = self.get_available_tool_ids().await?;
3561        tool_ids.sort();
3562        tool_ids.dedup();
3563        if let Some(ToolChoice::Specific(expected)) = choice.as_ref() {
3564            let canonical = self.tools.canonical_id(expected).ok_or_else(|| {
3565                AgentError::Config(format!(
3566                    "specific tool choice '{expected}' is not registered"
3567                ))
3568            })?;
3569            if canonical != *expected {
3570                return Err(AgentError::Config(format!(
3571                    "specific tool choice must use canonical ID '{canonical}', not '{expected}'"
3572                )));
3573            }
3574            if !tool_ids.iter().any(|tool_id| tool_id == expected) {
3575                return Err(AgentError::Config(format!(
3576                    "specific tool choice '{expected}' is outside the effective tool grant"
3577                )));
3578            }
3579        }
3580        if matches!(
3581            choice.as_ref(),
3582            Some(ToolChoice::Required | ToolChoice::Specific(_))
3583        ) && tool_ids.is_empty()
3584        {
3585            return Err(AgentError::Config(
3586                "required tool choice has no tool inside the effective grant".to_string(),
3587            ));
3588        }
3589        if !ephemeral_new_turn
3590            && let Some(configured_choice) = choice.as_ref()
3591            && matches!(
3592                configured_choice,
3593                ToolChoice::Required | ToolChoice::Specific(_)
3594            )
3595            && self
3596                .tool_choice_satisfied_in_current_turn(configured_choice, &tool_ids)
3597                .await?
3598        {
3599            choice = Some(ToolChoice::Auto);
3600        }
3601        if let Some(ToolChoice::Specific(expected)) = choice.as_ref() {
3602            tool_ids.retain(|tool_id| tool_id == expected);
3603        }
3604
3605        let definitions = tool_ids
3606            .iter()
3607            .map(|tool_id| {
3608                let tool = self.tools.get(tool_id).ok_or_else(|| {
3609                    AgentError::Config(format!(
3610                        "effective tool '{tool_id}' disappeared before provider exposure"
3611                    ))
3612                })?;
3613                Ok(LLMToolDefinition {
3614                    name: tool_id.clone(),
3615                    description: tool.description().to_string(),
3616                    input_schema: tool.input_schema(),
3617                })
3618            })
3619            .collect::<Result<Vec<_>>>()?;
3620
3621        //
3622        // Provider-visible definitions are derived only from the current effective grant. Tool choice never expands registration, scope, or authorization.
3623        //
3624        Ok(MainToolProtocol {
3625            choice,
3626            tool_ids,
3627            definitions,
3628        })
3629    }
3630
3631    async fn tool_choice_satisfied_in_current_turn(
3632        &self,
3633        choice: &ToolChoice,
3634        effective_tool_ids: &[String],
3635    ) -> Result<bool> {
3636        let messages = self.memory.get_messages(None).await?;
3637        let mut saw_tool_result = false;
3638        for message in messages.iter().rev() {
3639            match message.role {
3640                ai_agents_core::Role::Tool | ai_agents_core::Role::Function => {
3641                    saw_tool_result = true;
3642                }
3643                ai_agents_core::Role::Assistant if saw_tool_result => {
3644                    let Some(calls) = self.parse_tool_calls(&message.content) else {
3645                        continue;
3646                    };
3647                    let calls_are_effective = !calls.is_empty()
3648                        && calls.iter().all(|call| {
3649                            self.tools
3650                                .canonical_id(&call.name)
3651                                .is_some_and(|canonical| effective_tool_ids.contains(&canonical))
3652                        });
3653                    return Ok(calls_are_effective
3654                        && match choice {
3655                            ToolChoice::Required => true,
3656                            ToolChoice::Specific(expected) => calls.iter().all(|call| {
3657                                self.tools.canonical_id(&call.name).as_deref()
3658                                    == Some(expected.as_str())
3659                            }),
3660                            _ => false,
3661                        });
3662                }
3663                ai_agents_core::Role::User => return Ok(false),
3664                _ => {}
3665            }
3666        }
3667        Ok(false)
3668    }
3669
3670    fn provider_can_use_native_tools(
3671        &self,
3672        llm: &dyn LLMProvider,
3673        protocol: &MainToolProtocol,
3674    ) -> bool {
3675        let Some(choice) = protocol.choice.as_ref() else {
3676            return false;
3677        };
3678        if matches!(choice, ToolChoice::None) || protocol.definitions.is_empty() {
3679            return false;
3680        }
3681        llm.supports_tool_choice(choice)
3682            && protocol.definitions.iter().all(|definition| {
3683                !definition.name.is_empty()
3684                    && definition.name.len() <= 64
3685                    && definition
3686                        .name
3687                        .bytes()
3688                        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
3689            })
3690    }
3691
3692    fn prompt_messages_for_tool_protocol(
3693        &self,
3694        messages: &[ChatMessage],
3695        protocol: &MainToolProtocol,
3696        corrective: bool,
3697    ) -> Vec<ChatMessage> {
3698        let mut messages = messages.to_vec();
3699        let Some(choice) = protocol.choice.as_ref() else {
3700            return messages;
3701        };
3702        if matches!(choice, ToolChoice::None) || protocol.tool_ids.is_empty() {
3703            return messages;
3704        }
3705
3706        let mut tool_prompt = self.tools.generate_scoped_prompt_with_mode(
3707            &protocol.tool_ids,
3708            None,
3709            self.parallel_tools.enabled,
3710            self.runtime_config.tool_schema_prompt_mode,
3711        );
3712        match choice {
3713            ToolChoice::Required => tool_prompt.push_str(
3714                "\n\nYou must call at least one listed tool before giving a final answer.",
3715            ),
3716            ToolChoice::Specific(tool_id) => tool_prompt.push_str(&format!(
3717                "\n\nYou must call the '{tool_id}' tool before giving a final answer."
3718            )),
3719            ToolChoice::Auto => {}
3720            ToolChoice::None => return messages,
3721            _ => return messages,
3722        }
3723        if let Some(system) = messages
3724            .iter_mut()
3725            .find(|message| message.role == ai_agents_core::Role::System)
3726        {
3727            system.content.push_str("\n\n");
3728            system.content.push_str(&tool_prompt);
3729        } else {
3730            messages.insert(0, ChatMessage::system(tool_prompt));
3731        }
3732        if corrective {
3733            let instruction = match choice {
3734                ToolChoice::Required => {
3735                    "Your previous response did not call a required tool. Call at least one listed tool now and return only the JSON tool call."
3736                }
3737                ToolChoice::Specific(tool_id) => {
3738                    messages.push(ChatMessage::user(format!(
3739                        "Your previous response did not call the required '{tool_id}' tool. Call it now and return only the JSON tool call."
3740                    )));
3741                    return messages;
3742                }
3743                _ => return messages,
3744            };
3745            messages.push(ChatMessage::user(instruction));
3746        }
3747        messages
3748    }
3749
3750    async fn invoke_main_provider(
3751        &self,
3752        llm: Arc<dyn LLMProvider>,
3753        messages: &[ChatMessage],
3754        protocol: &MainToolProtocol,
3755        corrective: bool,
3756    ) -> std::result::Result<MainProviderResponse, LLMError> {
3757        let use_native = self.provider_can_use_native_tools(llm.as_ref(), protocol);
3758        let response = if use_native {
3759            let request = LLMToolRequest {
3760                tools: protocol.definitions.clone(),
3761                choice: protocol
3762                    .choice
3763                    .clone()
3764                    .expect("native tool requests require an explicit choice"),
3765            };
3766            self.observe_purpose(
3767                ObservationPurpose::MainResponse,
3768                llm.complete_with_tools(messages, None, &request),
3769            )
3770            .await?
3771        } else {
3772            let prompt_messages =
3773                self.prompt_messages_for_tool_protocol(messages, protocol, corrective);
3774            self.observe_purpose(
3775                ObservationPurpose::MainResponse,
3776                llm.complete(&prompt_messages, None),
3777            )
3778            .await?
3779        };
3780        Ok(MainProviderResponse {
3781            response,
3782            used_native_tools: use_native,
3783        })
3784    }
3785
3786    async fn complete_main_attempt_with_recovery(
3787        &self,
3788        llm: Arc<dyn LLMProvider>,
3789        messages: &[ChatMessage],
3790        protocol: &MainToolProtocol,
3791        corrective: bool,
3792    ) -> Result<MainProviderResponse> {
3793        let primary_result = if self.recovery_manager.config().default.max_retries > 0 {
3794            self.recovery_manager
3795                .with_retry("llm_call", None, || {
3796                    let llm = Arc::clone(&llm);
3797                    async move {
3798                        self.invoke_main_provider(llm, messages, protocol, corrective)
3799                            .await
3800                            .map_err(|error| error.classify())
3801                    }
3802                })
3803                .await
3804                .map_err(|error| AgentError::LLM(error.to_string()))
3805        } else {
3806            self.invoke_main_provider(Arc::clone(&llm), messages, protocol, corrective)
3807                .await
3808                .map_err(|error| AgentError::LLM(error.to_string()))
3809        };
3810
3811        match primary_result {
3812            Ok(response) => Ok(response),
3813            Err(primary_error) => match &self.recovery_manager.config().llm.on_failure {
3814                LLMFailureAction::FallbackLlm { fallback_llm } => {
3815                    let fallback = self.llm_registry.get(fallback_llm).map_err(|error| {
3816                        AgentError::Config(format!(
3817                            "Fallback LLM '{fallback_llm}' not found: {error}"
3818                        ))
3819                    })?;
3820                    self.invoke_main_provider(fallback, messages, protocol, corrective)
3821                        .await
3822                        .map_err(|error| AgentError::LLM(error.to_string()))
3823                }
3824                LLMFailureAction::FallbackResponse { message } => {
3825                    if matches!(
3826                        protocol.choice.as_ref(),
3827                        Some(ToolChoice::Required | ToolChoice::Specific(_))
3828                    ) {
3829                        Err(AgentError::LLM(format!(
3830                            "Required tool selection failed and cannot be satisfied by a static fallback response: {primary_error}"
3831                        )))
3832                    } else {
3833                        Ok(MainProviderResponse {
3834                            response: LLMResponse::new(message.clone(), FinishReason::Stop),
3835                            used_native_tools: false,
3836                        })
3837                    }
3838                }
3839                LLMFailureAction::Error => Err(primary_error),
3840            },
3841        }
3842    }
3843
3844    fn normalize_main_provider_response(
3845        &self,
3846        mut response: LLMResponse,
3847        protocol: &MainToolProtocol,
3848    ) -> Result<(LLMResponse, bool)> {
3849        let native_calls = response
3850            .tool_calls()
3851            .map_err(|error| AgentError::LLM(error.to_string()))?;
3852        let calls = match native_calls {
3853            Some(calls) => {
3854                let markers = calls
3855                    .iter()
3856                    .map(|call| {
3857                        serde_json::json!({
3858                            "_ai_agents_native_tool_call": true,
3859                            "id": call.id,
3860                            "tool": call.name,
3861                            "arguments": call.arguments,
3862                        })
3863                    })
3864                    .collect::<Vec<_>>();
3865                response.content = if markers.len() == 1 {
3866                    markers[0].to_string()
3867                } else {
3868                    serde_json::Value::Array(markers).to_string()
3869                };
3870                Some(calls)
3871            }
3872            None if !matches!(protocol.choice.as_ref(), Some(ToolChoice::None)) => {
3873                self.parse_tool_calls(response.content.trim())
3874            }
3875            None => None,
3876        };
3877
3878        if protocol.choice.is_some()
3879            && let Some(calls) = calls.as_ref()
3880            && calls.iter().any(|call| {
3881                self.tools
3882                    .canonical_id(&call.name)
3883                    .is_none_or(|canonical| !protocol.tool_ids.contains(&canonical))
3884            })
3885        {
3886            return Err(AgentError::LLM(
3887                "Provider returned a tool call outside the effective grant".to_string(),
3888            ));
3889        }
3890
3891        let compliant = match protocol.choice.as_ref() {
3892            Some(ToolChoice::Required) => calls.as_ref().is_some_and(|calls| !calls.is_empty()),
3893            Some(ToolChoice::Specific(expected)) => calls.as_ref().is_some_and(|calls| {
3894                !calls.is_empty()
3895                    && calls.iter().all(|call| {
3896                        self.tools.canonical_id(&call.name).as_deref() == Some(expected.as_str())
3897                    })
3898            }),
3899            _ => true,
3900        };
3901        Ok((response, compliant))
3902    }
3903
3904    async fn complete_main_llm_with_recovery(
3905        &self,
3906        llm: Arc<dyn LLMProvider>,
3907        messages: &[ChatMessage],
3908        protocol: &MainToolProtocol,
3909    ) -> Result<LLMResponse> {
3910        let first = self
3911            .complete_main_attempt_with_recovery(Arc::clone(&llm), messages, protocol, false)
3912            .await?;
3913        let (response, compliant) =
3914            self.normalize_main_provider_response(first.response, protocol)?;
3915        if compliant {
3916            return Ok(response);
3917        }
3918        if first.used_native_tools {
3919            return Err(AgentError::LLM(
3920                "Provider returned no compliant native call for required tool choice".to_string(),
3921            ));
3922        }
3923
3924        let corrected = self
3925            .complete_main_attempt_with_recovery(llm, messages, protocol, true)
3926            .await?;
3927        let (response, compliant) =
3928            self.normalize_main_provider_response(corrected.response, protocol)?;
3929        if compliant {
3930            return Ok(response);
3931        }
3932        Err(AgentError::LLM(
3933            "Provider returned no compliant tool call after one corrective retry".to_string(),
3934        ))
3935    }
3936
3937    fn is_native_tool_call_content(content: &str) -> bool {
3938        let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
3939            return false;
3940        };
3941        match value {
3942            serde_json::Value::Array(values) => {
3943                !values.is_empty()
3944                    && values.iter().all(|value| {
3945                        value
3946                            .get("_ai_agents_native_tool_call")
3947                            .and_then(|marker| marker.as_bool())
3948                            == Some(true)
3949                    })
3950            }
3951            serde_json::Value::Object(map) => {
3952                map.get("_ai_agents_native_tool_call")
3953                    .and_then(|marker| marker.as_bool())
3954                    == Some(true)
3955            }
3956            _ => false,
3957        }
3958    }
3959
3960    fn tool_result_message(
3961        tool_call: &ToolCall,
3962        output: &str,
3963        native_tool_call: bool,
3964    ) -> ChatMessage {
3965        if !native_tool_call {
3966            return ChatMessage::function(&tool_call.name, output);
3967        }
3968        let output = serde_json::from_str::<serde_json::Value>(output)
3969            .unwrap_or_else(|_| serde_json::Value::String(output.to_string()));
3970        ChatMessage::function(
3971            &tool_call.name,
3972            serde_json::json!({
3973                "_ai_agents_native_tool_result": true,
3974                "id": tool_call.id,
3975                "tool": tool_call.name,
3976                "output": output,
3977            })
3978            .to_string(),
3979        )
3980    }
3981
3982    fn parse_main_tool_calls(
3983        &self,
3984        content: &str,
3985        protocol: &MainToolProtocol,
3986    ) -> Option<Vec<ToolCall>> {
3987        if matches!(protocol.choice.as_ref(), Some(ToolChoice::None)) {
3988            None
3989        } else {
3990            self.parse_tool_calls(content)
3991        }
3992    }
3993
3994    fn parse_tool_calls(&self, content: &str) -> Option<Vec<ToolCall>> {
3995        // Try direct JSON parse first
3996        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) {
3997            // Handle JSON array of tool calls (parallel tool calling)
3998            if let Some(arr) = parsed.as_array() {
3999                let calls: Vec<ToolCall> = arr
4000                    .iter()
4001                    .filter_map(|v| self.extract_tool_call_from_value(v))
4002                    .collect();
4003                if !calls.is_empty() {
4004                    return Some(calls);
4005                }
4006            }
4007            // Handle single JSON object
4008            if let Some(tool_call) = self.extract_tool_call_from_value(&parsed) {
4009                return Some(vec![tool_call]);
4010            }
4011        }
4012
4013        // Try to extract JSON from content (handles extra text/braces from LLM)
4014        if let Some(json_str) = self.extract_json_from_content(content)
4015            && let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&json_str)
4016        {
4017            // Handle JSON array of tool calls (parallel tool calling)
4018            if let Some(arr) = parsed.as_array() {
4019                let calls: Vec<ToolCall> = arr
4020                    .iter()
4021                    .filter_map(|v| self.extract_tool_call_from_value(v))
4022                    .collect();
4023                if !calls.is_empty() {
4024                    return Some(calls);
4025                }
4026            }
4027            // Handle single JSON object
4028            if let Some(tool_call) = self.extract_tool_call_from_value(&parsed) {
4029                return Some(vec![tool_call]);
4030            }
4031        }
4032
4033        None
4034    }
4035
4036    fn extract_tool_call_from_value(&self, parsed: &serde_json::Value) -> Option<ToolCall> {
4037        if let Some(tool_name) = parsed.get("tool").and_then(|v| v.as_str()) {
4038            let arguments = parsed
4039                .get("arguments")
4040                .cloned()
4041                .unwrap_or(serde_json::json!({}));
4042            return Some(ToolCall {
4043                id: parsed
4044                    .get("id")
4045                    .and_then(|value| value.as_str())
4046                    .filter(|id| !id.is_empty())
4047                    .map(str::to_string)
4048                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
4049                name: tool_name.to_string(),
4050                arguments,
4051            });
4052        }
4053        None
4054    }
4055
4056    // Lite models could generate unmatched braces: this function handles such cases
4057    fn extract_json_from_content(&self, content: &str) -> Option<String> {
4058        // Try array first (for parallel tool calls), then single object
4059        if let Some(result) = self.extract_json_array_from_content(content) {
4060            return Some(result);
4061        }
4062        self.extract_json_object_from_content(content)
4063    }
4064
4065    /// Extract a JSON array `[...]` containing tool calls from mixed content.
4066    fn extract_json_array_from_content(&self, content: &str) -> Option<String> {
4067        let start = content.find('[')?;
4068        let content_from_start = &content[start..];
4069
4070        let mut depth = 0;
4071        let mut end = 0;
4072        for (i, ch) in content_from_start.char_indices() {
4073            match ch {
4074                '[' => depth += 1,
4075                ']' => {
4076                    depth -= 1;
4077                    if depth == 0 {
4078                        end = i + 1;
4079                        break;
4080                    }
4081                }
4082                _ => {}
4083            }
4084        }
4085
4086        if end > 0 {
4087            let json_str = &content_from_start[..end];
4088            // Verify it looks like an array of tool calls
4089            if json_str.contains("\"tool\"") {
4090                return Some(json_str.to_string());
4091            }
4092        }
4093
4094        None
4095    }
4096
4097    /// Extract a JSON object `{...}` containing a tool call from mixed content.
4098    fn extract_json_object_from_content(&self, content: &str) -> Option<String> {
4099        let start = content.find('{')?;
4100        let content_from_start = &content[start..];
4101
4102        // Count braces to find the matching closing brace
4103        let mut depth = 0;
4104        let mut end = 0;
4105        for (i, ch) in content_from_start.char_indices() {
4106            match ch {
4107                '{' => depth += 1,
4108                '}' => {
4109                    depth -= 1;
4110                    if depth == 0 {
4111                        end = i + 1;
4112                        break;
4113                    }
4114                }
4115                _ => {}
4116            }
4117        }
4118
4119        if end > 0 {
4120            let json_str = &content_from_start[..end];
4121            // Verify it looks like a tool call
4122            if json_str.contains("\"tool\"") {
4123                return Some(json_str.to_string());
4124            }
4125        }
4126
4127        None
4128    }
4129
4130    /// Builds a structured record from executor state.
4131    ///
4132    /// The explicit fields preserve one audit boundary for execution, policy, approval, timing, and generation evidence.
4133    #[allow(clippy::too_many_arguments)]
4134    fn record_from_parts(
4135        &self,
4136        request: &ToolExecutionRequest,
4137        canonical_id: String,
4138        executed_arguments: Value,
4139        started_at: chrono::DateTime<chrono::Utc>,
4140        start: Instant,
4141        executed: bool,
4142        success: bool,
4143        output: String,
4144        metadata: HashMap<String, Value>,
4145        policy: ToolPolicyDecisionRecord,
4146        approval: Option<ToolApprovalRecord>,
4147        timed_out: bool,
4148        output_truncated: bool,
4149    ) -> ToolExecutionRecord {
4150        let versions = ToolDecisionVersions {
4151            policy: self.active_tool_security().policy_version(),
4152            registry: self.tools.version(),
4153            runtime_control: self.runtime_control.version.load(Ordering::SeqCst),
4154        };
4155        self.record_from_parts_at(
4156            request,
4157            canonical_id,
4158            executed_arguments,
4159            started_at,
4160            start,
4161            executed,
4162            success,
4163            output,
4164            metadata,
4165            policy,
4166            approval,
4167            timed_out,
4168            output_truncated,
4169            versions,
4170        )
4171    }
4172
4173    /// Builds evidence with the exact generations used by the corresponding decision.
4174    #[allow(clippy::too_many_arguments)]
4175    fn record_from_parts_at(
4176        &self,
4177        request: &ToolExecutionRequest,
4178        canonical_id: String,
4179        executed_arguments: Value,
4180        started_at: chrono::DateTime<chrono::Utc>,
4181        start: Instant,
4182        executed: bool,
4183        success: bool,
4184        output: String,
4185        metadata: HashMap<String, Value>,
4186        policy: ToolPolicyDecisionRecord,
4187        approval: Option<ToolApprovalRecord>,
4188        timed_out: bool,
4189        output_truncated: bool,
4190        versions: ToolDecisionVersions,
4191    ) -> ToolExecutionRecord {
4192        ToolExecutionRecord {
4193            call_id: request.call_id.clone(),
4194            requested_name: request.requested_name.clone(),
4195            canonical_id,
4196            source: request.source.clone(),
4197            arguments: request.arguments.clone(),
4198            executed_arguments,
4199            policy_version: versions.policy,
4200            registry_version: versions.registry,
4201            runtime_config_version: versions.runtime_control,
4202            executed,
4203            success,
4204            output,
4205            metadata,
4206            policy,
4207            approval,
4208            started_at,
4209            duration_ms: start.elapsed().as_millis() as u64,
4210            timed_out,
4211            cancelled: false,
4212            cancellation_reason: None,
4213            output_truncated,
4214        }
4215    }
4216
4217    /// Sends a finalized tool record to hooks, history, and error handling.
4218    async fn finish_tool_record(&self, record: &ToolExecutionRecord) {
4219        let result = ToolResult {
4220            success: record.success,
4221            output: record.model_output_string(),
4222            metadata: if record.metadata.is_empty() {
4223                None
4224            } else {
4225                Some(record.metadata.clone())
4226            },
4227        };
4228        self.hooks
4229            .on_tool_complete(&record.canonical_id, &result, record.duration_ms)
4230            .await;
4231        self.hooks.on_tool_execution_record(record).await;
4232        self.record_tool_call(&record.canonical_id, record.model_output_value());
4233        if !record.success {
4234            self.hooks
4235                .on_error(&AgentError::Tool(record.output.clone()))
4236                .await;
4237        }
4238    }
4239
4240    /// Releases resource locks before hooks run because hooks may invoke another tool.
4241    async fn finish_tool_record_after_resource_guards(
4242        &self,
4243        resource_guards: ToolResourceGuards,
4244        record: &ToolExecutionRecord,
4245    ) {
4246        drop(resource_guards);
4247        self.finish_tool_record(record).await;
4248    }
4249
4250    /// Invokes a resolved tool once with timeout, cancellation, and actor context.
4251    async fn execute_resolved_tool_once(
4252        &self,
4253        tool: Arc<dyn ai_agents_core::Tool>,
4254        args: Value,
4255        ctx: ToolExecutionContext,
4256        timeout_ms: u64,
4257    ) -> Result<(ToolResult, bool, bool, bool)> {
4258        if self.runtime_control.emergency_deny.load(Ordering::SeqCst) {
4259            return Ok((
4260                ToolResult::error("Tool execution cancelled by runtime control"),
4261                false,
4262                true,
4263                false,
4264            ));
4265        }
4266        //
4267        // Mark invocation inside the future so cancellation before the first poll remains executed false.
4268        //
4269        let invoked = Arc::new(AtomicBool::new(false));
4270        let invoked_by_future = Arc::clone(&invoked);
4271        let actor_context = current_turn_actor_context();
4272        let future = async move {
4273            invoked_by_future.store(true, Ordering::SeqCst);
4274            if let Some(actor_context) = actor_context {
4275                scope_actor_context(actor_context, tool.execute(args, ctx)).await
4276            } else {
4277                tool.execute(args, ctx).await
4278            }
4279        };
4280        tokio::pin!(future);
4281        let timeout = tokio::time::sleep(std::time::Duration::from_millis(timeout_ms));
4282        tokio::pin!(timeout);
4283        let mut cancel_tick = tokio::time::interval(std::time::Duration::from_millis(50));
4284
4285        loop {
4286            tokio::select! {
4287                result = &mut future => return Ok((result, false, false, true)),
4288                _ = &mut timeout => {
4289                    return Ok((
4290                        ToolResult::error("Tool execution timed out"),
4291                        true,
4292                        false,
4293                        invoked.load(Ordering::SeqCst),
4294                    ));
4295                }
4296                _ = cancel_tick.tick() => {
4297                    if self.runtime_control.emergency_deny.load(Ordering::SeqCst) {
4298                        return Ok((
4299                            ToolResult::error("Tool execution cancelled by runtime control"),
4300                            false,
4301                            true,
4302                            invoked.load(Ordering::SeqCst),
4303                        ));
4304                    }
4305                }
4306            }
4307        }
4308    }
4309
4310    /// Truncates model-facing tool output on a character boundary.
4311    fn truncate_tool_output(output: String, max_chars: Option<usize>) -> (String, bool) {
4312        let Some(max_chars) = max_chars else {
4313            return (output, false);
4314        };
4315        let mut chars = output.chars();
4316        let truncated: String = chars.by_ref().take(max_chars).collect();
4317        if chars.next().is_some() {
4318            (truncated, true)
4319        } else {
4320            (output, false)
4321        }
4322    }
4323
4324    /// Acquires all declared resource locks in stable key order and supports emergency cancellation while waiting.
4325    async fn acquire_tool_resource_locks(&self, keys: &[String]) -> Option<ToolResourceGuards> {
4326        let locks = {
4327            let mut table = self.resource_locks.write();
4328            table.retain(|_, lock| lock.strong_count() > 0);
4329            keys.iter()
4330                .map(|key| {
4331                    if let Some(lock) = table.get(key).and_then(Weak::upgrade) {
4332                        lock
4333                    } else {
4334                        let lock = Arc::new(tokio::sync::Mutex::new(()));
4335                        table.insert(key.clone(), Arc::downgrade(&lock));
4336                        lock
4337                    }
4338                })
4339                .collect::<Vec<_>>()
4340        };
4341        let mut resource_guards = ToolResourceGuards {
4342            guards: Vec::with_capacity(locks.len()),
4343            locks: Arc::clone(&self.resource_locks),
4344        };
4345        let mut locks = locks.into_iter();
4346        while let Some(lock) = locks.next() {
4347            let mut lock = Box::pin(lock.lock_owned());
4348            loop {
4349                tokio::select! {
4350                    guard = &mut lock => {
4351                        resource_guards.guards.push(guard);
4352                        break;
4353                    }
4354                    _ = tokio::time::sleep(std::time::Duration::from_millis(10)) => {
4355                        if self.runtime_control.emergency_deny.load(Ordering::SeqCst) {
4356                            drop(lock);
4357                            drop(locks);
4358                            drop(resource_guards);
4359                            return None;
4360                        }
4361                    }
4362                }
4363            }
4364        }
4365        Some(resource_guards)
4366    }
4367
4368    /// Executes a resolved tool with retry policy from recovery settings.
4369    async fn run_tool_with_retries(
4370        &self,
4371        canonical_id: &str,
4372        tool: Arc<dyn ai_agents_core::Tool>,
4373        args: Value,
4374        ctx: ToolExecutionContext,
4375        timeout_ms: u64,
4376        max_retries: u32,
4377    ) -> Result<(ToolResult, bool, bool, bool)> {
4378        let max_retries = if ctx.classification.safely_retryable {
4379            max_retries
4380        } else {
4381            0
4382        };
4383        let mut attempts = 0;
4384        let mut invoked = false;
4385        loop {
4386            let (result, timed_out, cancelled, attempt_invoked) = self
4387                .execute_resolved_tool_once(tool.clone(), args.clone(), ctx.clone(), timeout_ms)
4388                .await?;
4389            invoked |= attempt_invoked;
4390            if result.success || timed_out || cancelled || attempts >= max_retries {
4391                return Ok((result, timed_out, cancelled, invoked));
4392            }
4393            attempts += 1;
4394            warn!(tool = %canonical_id, attempt = attempts, error = %result.output, "Retrying failed tool call");
4395        }
4396    }
4397
4398    /// Executes a tool request through scope, policy, HITL, timeout, recovery, and evidence recording.
4399    fn execute_tool_record(
4400        &self,
4401        request: ToolExecutionRequest,
4402    ) -> Pin<Box<dyn Future<Output = Result<ToolExecutionRecord>> + Send + '_>> {
4403        Box::pin(self.execute_tool_record_inner(request))
4404    }
4405
4406    async fn execute_tool_record_inner(
4407        &self,
4408        request: ToolExecutionRequest,
4409    ) -> Result<ToolExecutionRecord> {
4410        let started_at = chrono::Utc::now();
4411        let start = Instant::now();
4412        info!(tool = %request.requested_name, args = %request.arguments, "Executing tool");
4413
4414        if self.runtime_control.emergency_deny.load(Ordering::SeqCst) {
4415            let record = self.record_from_parts(
4416                &request,
4417                request.requested_name.clone(),
4418                request.arguments.clone(),
4419                started_at,
4420                start,
4421                false,
4422                false,
4423                "Tool execution is disabled by runtime control".to_string(),
4424                HashMap::new(),
4425                ToolPolicyDecisionRecord::deny("runtime emergency deny is enabled"),
4426                None,
4427                false,
4428                false,
4429            );
4430            self.finish_tool_record(&record).await;
4431            return Ok(record);
4432        }
4433
4434        let Some(resolved) = self.tools.resolve(&request.requested_name) else {
4435            let record = self.record_from_parts(
4436                &request,
4437                request.requested_name.clone(),
4438                request.arguments.clone(),
4439                started_at,
4440                start,
4441                false,
4442                false,
4443                format!("Tool '{}' is unavailable", request.requested_name),
4444                HashMap::new(),
4445                ToolPolicyDecisionRecord::unavailable(format!(
4446                    "Tool '{}' is not registered",
4447                    request.requested_name
4448                )),
4449                None,
4450                false,
4451                false,
4452            );
4453            self.finish_tool_record(&record).await;
4454            return Ok(record);
4455        };
4456
4457        let canonical_id = resolved.identity.canonical_id.clone();
4458
4459        let available_tool_ids = self.get_available_tool_ids().await?;
4460        if !available_tool_ids.iter().any(|id| id == &canonical_id) {
4461            let record = self.record_from_parts(
4462                &request,
4463                canonical_id.clone(),
4464                request.arguments.clone(),
4465                started_at,
4466                start,
4467                false,
4468                false,
4469                format!(
4470                    "Tool '{}' is not available in the current scope",
4471                    canonical_id
4472                ),
4473                HashMap::new(),
4474                ToolPolicyDecisionRecord::deny(format!(
4475                    "Tool '{}' is not granted by the current top-level and state tool scope",
4476                    canonical_id
4477                )),
4478                None,
4479                false,
4480                false,
4481            );
4482            self.finish_tool_record(&record).await;
4483            return Ok(record);
4484        }
4485
4486        let approval_control_snapshot = self.runtime_safety_snapshot();
4487        let security_engine = approval_control_snapshot.tool_security.clone();
4488        let bindings = resolved.tool.policy_bindings();
4489        let mut executed_arguments = security_engine.prepare_tool_arguments_with_bindings(
4490            &canonical_id,
4491            &request.arguments,
4492            &bindings,
4493        );
4494        self.hooks
4495            .on_tool_start(&canonical_id, &executed_arguments)
4496            .await;
4497
4498        let mut metadata = HashMap::new();
4499        let safety = resolved.tool.safety_metadata();
4500        let classification = resolved.tool.classify_call(&executed_arguments);
4501        let limits = security_engine.effective_limits(&canonical_id, &safety, &classification);
4502        metadata.insert(
4503            "classification".to_string(),
4504            serde_json::to_value(&classification).unwrap_or(Value::Null),
4505        );
4506        metadata.insert(
4507            "effective_limits".to_string(),
4508            serde_json::to_value(&limits).unwrap_or(Value::Null),
4509        );
4510        let policy_snapshot = security_engine.policy_snapshot(&canonical_id);
4511        if !policy_snapshot.is_null() {
4512            metadata.insert("policy_snapshot".to_string(), policy_snapshot.clone());
4513        }
4514
4515        let mut approval_record = Some(ToolApprovalRecord {
4516            status: ToolApprovalStatus::NotRequired,
4517            reason: None,
4518            modified_arguments: None,
4519        });
4520
4521        let mut security_result = security_engine
4522            .validate_tool_execution_with_bindings(&canonical_id, &executed_arguments, &bindings)
4523            .await?;
4524        match &security_result {
4525            SecurityCheckResult::Allow => {}
4526            SecurityCheckResult::Warn { message } => {
4527                warn!(tool = %canonical_id, message = %message, "Tool security warning");
4528            }
4529            SecurityCheckResult::Block { reason } => {
4530                let record = self.record_from_parts(
4531                    &request,
4532                    canonical_id,
4533                    executed_arguments,
4534                    started_at,
4535                    start,
4536                    false,
4537                    false,
4538                    format!("Denied: {}", reason),
4539                    metadata,
4540                    ToolPolicyDecisionRecord::deny(reason.clone()),
4541                    approval_record,
4542                    false,
4543                    false,
4544                );
4545                self.finish_tool_record(&record).await;
4546                return Ok(record);
4547            }
4548            SecurityCheckResult::Unavailable { reason } => {
4549                let record = self.record_from_parts(
4550                    &request,
4551                    canonical_id,
4552                    executed_arguments,
4553                    started_at,
4554                    start,
4555                    false,
4556                    false,
4557                    format!("Unavailable: {}", reason),
4558                    metadata,
4559                    ToolPolicyDecisionRecord::unavailable(reason.clone()),
4560                    approval_record,
4561                    false,
4562                    false,
4563                );
4564                self.finish_tool_record(&record).await;
4565                return Ok(record);
4566            }
4567            SecurityCheckResult::RequireConfirmation { message } => {
4568                if self.hitl_engine.is_none() {
4569                    approval_record = Some(ToolApprovalRecord {
4570                        status: ToolApprovalStatus::Unavailable,
4571                        reason: Some("No HITL engine configured".to_string()),
4572                        modified_arguments: None,
4573                    });
4574                    let record = self.record_from_parts(
4575                        &request,
4576                        canonical_id,
4577                        executed_arguments,
4578                        started_at,
4579                        start,
4580                        false,
4581                        false,
4582                        format!("Approval unavailable: {}", message),
4583                        metadata,
4584                        ToolPolicyDecisionRecord::approval(message.clone()),
4585                        approval_record,
4586                        false,
4587                        false,
4588                    );
4589                    self.finish_tool_record(&record).await;
4590                    return Ok(record);
4591                }
4592
4593                let check_result = HITLCheckResult::required(
4594                    ApprovalTrigger::tool(&canonical_id, executed_arguments.clone()),
4595                    HashMap::new(),
4596                    message.clone(),
4597                    None,
4598                );
4599                match self.request_hitl_approval(check_result).await? {
4600                    ApprovalResult::Approved => {
4601                        merge_approved_record(&mut approval_record);
4602                    }
4603                    ApprovalResult::Modified { changes } => {
4604                        if let Some(obj) = executed_arguments.as_object_mut() {
4605                            for (key, value) in changes {
4606                                obj.insert(key, value);
4607                            }
4608                        }
4609                        security_result = security_engine
4610                            .validate_tool_execution_with_bindings(
4611                                &canonical_id,
4612                                &executed_arguments,
4613                                &bindings,
4614                            )
4615                            .await?;
4616                        if !matches!(
4617                            security_result,
4618                            SecurityCheckResult::Allow
4619                                | SecurityCheckResult::Warn { .. }
4620                                | SecurityCheckResult::RequireConfirmation { .. }
4621                        ) {
4622                            let reason = security_result
4623                                .reason()
4624                                .unwrap_or("modified arguments failed policy")
4625                                .to_string();
4626                            let record = self.record_from_parts(
4627                                &request,
4628                                canonical_id,
4629                                executed_arguments.clone(),
4630                                started_at,
4631                                start,
4632                                false,
4633                                false,
4634                                reason.clone(),
4635                                metadata,
4636                                ToolPolicyDecisionRecord::deny(reason),
4637                                Some(ToolApprovalRecord {
4638                                    status: ToolApprovalStatus::Modified,
4639                                    reason: None,
4640                                    modified_arguments: Some(executed_arguments),
4641                                }),
4642                                false,
4643                                false,
4644                            );
4645                            self.finish_tool_record(&record).await;
4646                            return Ok(record);
4647                        }
4648                        approval_record = Some(ToolApprovalRecord {
4649                            status: ToolApprovalStatus::Modified,
4650                            reason: None,
4651                            modified_arguments: Some(executed_arguments.clone()),
4652                        });
4653                    }
4654                    ApprovalResult::Rejected { reason } => {
4655                        let reason = reason.unwrap_or_else(|| "rejected".to_string());
4656                        approval_record = Some(ToolApprovalRecord {
4657                            status: ToolApprovalStatus::Rejected,
4658                            reason: Some(reason.clone()),
4659                            modified_arguments: None,
4660                        });
4661                        let record = self.record_from_parts(
4662                            &request,
4663                            canonical_id,
4664                            executed_arguments,
4665                            started_at,
4666                            start,
4667                            false,
4668                            false,
4669                            format!("Approval rejected: {}", reason),
4670                            metadata,
4671                            ToolPolicyDecisionRecord::approval(reason),
4672                            approval_record,
4673                            false,
4674                            false,
4675                        );
4676                        self.finish_tool_record(&record).await;
4677                        return Ok(record);
4678                    }
4679                    ApprovalResult::Timeout => {
4680                        approval_record = Some(ToolApprovalRecord {
4681                            status: ToolApprovalStatus::Timeout,
4682                            reason: Some("approval timeout".to_string()),
4683                            modified_arguments: None,
4684                        });
4685                        let record = self.record_from_parts(
4686                            &request,
4687                            canonical_id,
4688                            executed_arguments,
4689                            started_at,
4690                            start,
4691                            false,
4692                            false,
4693                            "Approval timed out".to_string(),
4694                            metadata,
4695                            ToolPolicyDecisionRecord::approval("approval timeout"),
4696                            approval_record,
4697                            false,
4698                            false,
4699                        );
4700                        self.finish_tool_record(&record).await;
4701                        return Ok(record);
4702                    }
4703                }
4704            }
4705        }
4706
4707        if canonical_id == "command" && !self.tools.command_runner_available() {
4708            let record = self.record_from_parts(
4709                &request,
4710                canonical_id.clone(),
4711                executed_arguments.clone(),
4712                started_at,
4713                start,
4714                false,
4715                false,
4716                "Command runner is unavailable".to_string(),
4717                metadata,
4718                ToolPolicyDecisionRecord::unavailable("command runner is unavailable"),
4719                Some(ToolApprovalRecord {
4720                    status: ToolApprovalStatus::Unavailable,
4721                    reason: Some("command runner is unavailable".to_string()),
4722                    modified_arguments: None,
4723                }),
4724                false,
4725                false,
4726            );
4727            self.finish_tool_record(&record).await;
4728            return Ok(record);
4729        }
4730
4731        if approval_record
4732            .as_ref()
4733            .is_some_and(|record| matches!(record.status, ToolApprovalStatus::NotRequired))
4734            && let Some(message) =
4735                security_engine.classification_approval_message(&canonical_id, &classification)
4736        {
4737            if self.hitl_engine.is_none() {
4738                approval_record = Some(ToolApprovalRecord {
4739                    status: ToolApprovalStatus::Unavailable,
4740                    reason: Some("No HITL engine configured".to_string()),
4741                    modified_arguments: None,
4742                });
4743                let record = self.record_from_parts(
4744                    &request,
4745                    canonical_id,
4746                    executed_arguments,
4747                    started_at,
4748                    start,
4749                    false,
4750                    false,
4751                    format!("Approval unavailable: {}", message),
4752                    metadata,
4753                    ToolPolicyDecisionRecord::approval(message),
4754                    approval_record,
4755                    false,
4756                    false,
4757                );
4758                self.finish_tool_record(&record).await;
4759                return Ok(record);
4760            }
4761            let check_result = HITLCheckResult::required(
4762                ApprovalTrigger::tool(&canonical_id, executed_arguments.clone()),
4763                HashMap::new(),
4764                message.clone(),
4765                None,
4766            );
4767            match self.request_hitl_approval(check_result).await? {
4768                ApprovalResult::Approved => {
4769                    merge_approved_record(&mut approval_record);
4770                }
4771                ApprovalResult::Modified { changes } => {
4772                    if let Some(obj) = executed_arguments.as_object_mut() {
4773                        for (key, value) in changes {
4774                            obj.insert(key, value);
4775                        }
4776                    }
4777                    let modified_security = security_engine
4778                        .validate_tool_execution_with_bindings(
4779                            &canonical_id,
4780                            &executed_arguments,
4781                            &bindings,
4782                        )
4783                        .await?;
4784                    if !matches!(
4785                        modified_security,
4786                        SecurityCheckResult::Allow | SecurityCheckResult::Warn { .. }
4787                    ) {
4788                        let reason = modified_security
4789                            .reason()
4790                            .unwrap_or("modified arguments failed policy")
4791                            .to_string();
4792                        let record = self.record_from_parts(
4793                            &request,
4794                            canonical_id,
4795                            executed_arguments.clone(),
4796                            started_at,
4797                            start,
4798                            false,
4799                            false,
4800                            reason.clone(),
4801                            metadata,
4802                            ToolPolicyDecisionRecord::deny(reason),
4803                            Some(ToolApprovalRecord {
4804                                status: ToolApprovalStatus::Modified,
4805                                reason: None,
4806                                modified_arguments: Some(executed_arguments),
4807                            }),
4808                            false,
4809                            false,
4810                        );
4811                        self.finish_tool_record(&record).await;
4812                        return Ok(record);
4813                    }
4814                    approval_record = Some(ToolApprovalRecord {
4815                        status: ToolApprovalStatus::Modified,
4816                        reason: None,
4817                        modified_arguments: Some(executed_arguments.clone()),
4818                    });
4819                }
4820                ApprovalResult::Rejected { reason } => {
4821                    let reason = reason.unwrap_or_else(|| "rejected".to_string());
4822                    let record = self.record_from_parts(
4823                        &request,
4824                        canonical_id,
4825                        executed_arguments,
4826                        started_at,
4827                        start,
4828                        false,
4829                        false,
4830                        format!("Approval rejected: {}", reason),
4831                        metadata,
4832                        ToolPolicyDecisionRecord::approval(reason.clone()),
4833                        Some(ToolApprovalRecord {
4834                            status: ToolApprovalStatus::Rejected,
4835                            reason: Some(reason),
4836                            modified_arguments: None,
4837                        }),
4838                        false,
4839                        false,
4840                    );
4841                    self.finish_tool_record(&record).await;
4842                    return Ok(record);
4843                }
4844                ApprovalResult::Timeout => {
4845                    let record = self.record_from_parts(
4846                        &request,
4847                        canonical_id,
4848                        executed_arguments,
4849                        started_at,
4850                        start,
4851                        false,
4852                        false,
4853                        "Approval timed out".to_string(),
4854                        metadata,
4855                        ToolPolicyDecisionRecord::approval("approval timeout"),
4856                        Some(ToolApprovalRecord {
4857                            status: ToolApprovalStatus::Timeout,
4858                            reason: Some("approval timeout".to_string()),
4859                            modified_arguments: None,
4860                        }),
4861                        false,
4862                        false,
4863                    );
4864                    self.finish_tool_record(&record).await;
4865                    return Ok(record);
4866                }
4867            }
4868        }
4869
4870        if canonical_id == "diagnostics" && !self.tools.diagnostics_available() {
4871            let record = self.record_from_parts(
4872                &request,
4873                canonical_id.clone(),
4874                executed_arguments.clone(),
4875                started_at,
4876                start,
4877                false,
4878                false,
4879                "Diagnostics provider is unavailable".to_string(),
4880                metadata,
4881                ToolPolicyDecisionRecord::unavailable("diagnostics provider is unavailable"),
4882                Some(ToolApprovalRecord {
4883                    status: ToolApprovalStatus::Unavailable,
4884                    reason: Some("diagnostics provider is unavailable".to_string()),
4885                    modified_arguments: None,
4886                }),
4887                false,
4888                false,
4889            );
4890            self.finish_tool_record(&record).await;
4891            return Ok(record);
4892        }
4893
4894        if canonical_id == "web_search" && !self.tools.web_search_available() {
4895            let record = self.record_from_parts(
4896                &request,
4897                canonical_id.clone(),
4898                executed_arguments.clone(),
4899                started_at,
4900                start,
4901                false,
4902                false,
4903                "Web search provider is unavailable".to_string(),
4904                metadata,
4905                ToolPolicyDecisionRecord::unavailable("web search provider is unavailable"),
4906                Some(ToolApprovalRecord {
4907                    status: ToolApprovalStatus::Unavailable,
4908                    reason: Some("web search provider is unavailable".to_string()),
4909                    modified_arguments: None,
4910                }),
4911                false,
4912                false,
4913            );
4914            self.finish_tool_record(&record).await;
4915            return Ok(record);
4916        }
4917
4918        let hitl_lang_ctx = self.build_hitl_language_context();
4919        if let Some(ref hitl_engine) = self.hitl_engine {
4920            let check_result = self
4921                .observe_purpose(
4922                    ObservationPurpose::HitlLocalization,
4923                    hitl_engine.check_tool_with_localization(
4924                        &canonical_id,
4925                        &executed_arguments,
4926                        &hitl_lang_ctx,
4927                        self.approval_handler.as_ref(),
4928                        Some(&self.llm_registry),
4929                    ),
4930                )
4931                .await?;
4932            if check_result.is_required() {
4933                match self.request_hitl_approval(check_result).await? {
4934                    ApprovalResult::Approved => {
4935                        merge_approved_record(&mut approval_record);
4936                    }
4937                    ApprovalResult::Modified { changes } => {
4938                        if let Some(obj) = executed_arguments.as_object_mut() {
4939                            for (key, value) in changes {
4940                                obj.insert(key, value);
4941                            }
4942                        }
4943                        let modified_security = security_engine
4944                            .validate_tool_execution_with_bindings(
4945                                &canonical_id,
4946                                &executed_arguments,
4947                                &bindings,
4948                            )
4949                            .await?;
4950                        if !matches!(
4951                            modified_security,
4952                            SecurityCheckResult::Allow | SecurityCheckResult::Warn { .. }
4953                        ) {
4954                            let reason = modified_security
4955                                .reason()
4956                                .unwrap_or("modified arguments failed policy")
4957                                .to_string();
4958                            let record = self.record_from_parts(
4959                                &request,
4960                                canonical_id,
4961                                executed_arguments.clone(),
4962                                started_at,
4963                                start,
4964                                false,
4965                                false,
4966                                reason.clone(),
4967                                metadata,
4968                                ToolPolicyDecisionRecord::deny(reason),
4969                                Some(ToolApprovalRecord {
4970                                    status: ToolApprovalStatus::Modified,
4971                                    reason: None,
4972                                    modified_arguments: Some(executed_arguments),
4973                                }),
4974                                false,
4975                                false,
4976                            );
4977                            self.finish_tool_record(&record).await;
4978                            return Ok(record);
4979                        }
4980                        approval_record = Some(ToolApprovalRecord {
4981                            status: ToolApprovalStatus::Modified,
4982                            reason: None,
4983                            modified_arguments: Some(executed_arguments.clone()),
4984                        });
4985                    }
4986                    ApprovalResult::Rejected { reason } => {
4987                        let reason = reason.unwrap_or_else(|| "rejected".to_string());
4988                        let record = self.record_from_parts(
4989                            &request,
4990                            canonical_id,
4991                            executed_arguments,
4992                            started_at,
4993                            start,
4994                            false,
4995                            false,
4996                            format!("Approval rejected: {}", reason),
4997                            metadata,
4998                            ToolPolicyDecisionRecord::approval(reason.clone()),
4999                            Some(ToolApprovalRecord {
5000                                status: ToolApprovalStatus::Rejected,
5001                                reason: Some(reason),
5002                                modified_arguments: None,
5003                            }),
5004                            false,
5005                            false,
5006                        );
5007                        self.finish_tool_record(&record).await;
5008                        return Ok(record);
5009                    }
5010                    ApprovalResult::Timeout => {
5011                        let record = self.record_from_parts(
5012                            &request,
5013                            canonical_id,
5014                            executed_arguments,
5015                            started_at,
5016                            start,
5017                            false,
5018                            false,
5019                            "Approval timed out".to_string(),
5020                            metadata,
5021                            ToolPolicyDecisionRecord::approval("approval timeout"),
5022                            Some(ToolApprovalRecord {
5023                                status: ToolApprovalStatus::Timeout,
5024                                reason: Some("approval timeout".to_string()),
5025                                modified_arguments: None,
5026                            }),
5027                            false,
5028                            false,
5029                        );
5030                        self.finish_tool_record(&record).await;
5031                        return Ok(record);
5032                    }
5033                }
5034            }
5035
5036            let condition_check = self
5037                .observe_purpose(
5038                    ObservationPurpose::HitlLocalization,
5039                    hitl_engine.check_conditions_with_localization(
5040                        &executed_arguments,
5041                        &hitl_lang_ctx,
5042                        self.approval_handler.as_ref(),
5043                        Some(&self.llm_registry),
5044                    ),
5045                )
5046                .await?;
5047            if condition_check.is_required() {
5048                match self.request_hitl_approval(condition_check).await? {
5049                    ApprovalResult::Approved => {
5050                        merge_approved_record(&mut approval_record);
5051                    }
5052                    ApprovalResult::Modified { changes } => {
5053                        if let Some(obj) = executed_arguments.as_object_mut() {
5054                            for (key, value) in changes {
5055                                obj.insert(key, value);
5056                            }
5057                        }
5058                        let modified_security = security_engine
5059                            .validate_tool_execution_with_bindings(
5060                                &canonical_id,
5061                                &executed_arguments,
5062                                &bindings,
5063                            )
5064                            .await?;
5065                        if !matches!(
5066                            modified_security,
5067                            SecurityCheckResult::Allow | SecurityCheckResult::Warn { .. }
5068                        ) {
5069                            let reason = modified_security
5070                                .reason()
5071                                .unwrap_or("modified arguments failed policy")
5072                                .to_string();
5073                            let record = self.record_from_parts(
5074                                &request,
5075                                canonical_id,
5076                                executed_arguments,
5077                                started_at,
5078                                start,
5079                                false,
5080                                false,
5081                                reason.clone(),
5082                                metadata,
5083                                ToolPolicyDecisionRecord::deny(reason),
5084                                approval_record,
5085                                false,
5086                                false,
5087                            );
5088                            self.finish_tool_record(&record).await;
5089                            return Ok(record);
5090                        }
5091                        approval_record = Some(ToolApprovalRecord {
5092                            status: ToolApprovalStatus::Modified,
5093                            reason: None,
5094                            modified_arguments: Some(executed_arguments.clone()),
5095                        });
5096                    }
5097                    ApprovalResult::Rejected { reason } => {
5098                        let reason = reason.unwrap_or_else(|| "rejected".to_string());
5099                        let record = self.record_from_parts(
5100                            &request,
5101                            canonical_id,
5102                            executed_arguments,
5103                            started_at,
5104                            start,
5105                            false,
5106                            false,
5107                            format!("Approval rejected: {}", reason),
5108                            metadata,
5109                            ToolPolicyDecisionRecord::approval(reason.clone()),
5110                            Some(ToolApprovalRecord {
5111                                status: ToolApprovalStatus::Rejected,
5112                                reason: Some(reason),
5113                                modified_arguments: None,
5114                            }),
5115                            false,
5116                            false,
5117                        );
5118                        self.finish_tool_record(&record).await;
5119                        return Ok(record);
5120                    }
5121                    ApprovalResult::Timeout => {
5122                        let record = self.record_from_parts(
5123                            &request,
5124                            canonical_id,
5125                            executed_arguments,
5126                            started_at,
5127                            start,
5128                            false,
5129                            false,
5130                            "Approval timed out".to_string(),
5131                            metadata,
5132                            ToolPolicyDecisionRecord::approval("approval timeout"),
5133                            Some(ToolApprovalRecord {
5134                                status: ToolApprovalStatus::Timeout,
5135                                reason: Some("approval timeout".to_string()),
5136                                modified_arguments: None,
5137                            }),
5138                            false,
5139                            false,
5140                        );
5141                        self.finish_tool_record(&record).await;
5142                        return Ok(record);
5143                    }
5144                }
5145            }
5146        }
5147
5148        //
5149        // Freeze the action reviewed by HITL after every approved argument change and policy cap.
5150        // A later plain approval preserves any earlier Modified evidence.
5151        //
5152        executed_arguments = security_engine.prepare_tool_arguments_with_bindings(
5153            &canonical_id,
5154            &executed_arguments,
5155            &bindings,
5156        );
5157        if let Some(record) = approval_record.as_mut()
5158            && matches!(record.status, ToolApprovalStatus::Modified)
5159        {
5160            record.modified_arguments = Some(executed_arguments.clone());
5161        }
5162        let binding_security_result = security_engine
5163            .validate_tool_execution_with_bindings(&canonical_id, &executed_arguments, &bindings)
5164            .await?;
5165        let approval_confirmation_required = matches!(
5166            binding_security_result,
5167            SecurityCheckResult::RequireConfirmation { .. }
5168        ) || security_engine
5169            .classification_approval_message(
5170                &canonical_id,
5171                &resolved.tool.classify_call(&executed_arguments),
5172            )
5173            .is_some();
5174        let approval_binding = approval_record.as_ref().and_then(|record| {
5175            matches!(
5176                record.status,
5177                ToolApprovalStatus::Approved | ToolApprovalStatus::Modified
5178            )
5179            .then(|| ToolApprovalBinding {
5180                canonical_id: canonical_id.clone(),
5181                arguments: executed_arguments.clone(),
5182                confirmation_required: approval_confirmation_required,
5183                policy_version: security_engine.policy_version(),
5184                runtime_control_version: approval_control_snapshot.version,
5185                reviewed_tool: Arc::clone(&resolved.tool),
5186            })
5187        });
5188
5189        //
5190        // Re-resolve once after HITL because registry, scope, and policy may change while approval waits.
5191        // The final Arc must still match the implementation that the approver reviewed.
5192        //
5193        let control_snapshot = self.runtime_safety_snapshot();
5194        let resolved = self.tools.resolve(&request.requested_name);
5195        let registry_version = self.tools.version();
5196        let versions = ToolDecisionVersions {
5197            policy: control_snapshot.tool_security.policy_version(),
5198            registry: registry_version,
5199            runtime_control: control_snapshot.version,
5200        };
5201        metadata.insert(
5202            "runtime_scope_snapshot".to_string(),
5203            serde_json::to_value(&control_snapshot.tool_scope_override).unwrap_or(Value::Null),
5204        );
5205        let resolved = match resolved {
5206            Some(resolved) => resolved,
5207            None => {
5208                let reason = format!(
5209                    "Tool '{}' became unavailable after approval",
5210                    request.requested_name
5211                );
5212                let record = self.record_from_parts_at(
5213                    &request,
5214                    request.requested_name.clone(),
5215                    executed_arguments,
5216                    started_at,
5217                    start,
5218                    false,
5219                    false,
5220                    reason.clone(),
5221                    metadata,
5222                    ToolPolicyDecisionRecord::unavailable(reason),
5223                    approval_record,
5224                    false,
5225                    false,
5226                    versions,
5227                );
5228                self.finish_tool_record(&record).await;
5229                return Ok(record);
5230            }
5231        };
5232
5233        let canonical_id = resolved.identity.canonical_id.clone();
5234        let bindings = resolved.tool.policy_bindings();
5235        let final_arguments = control_snapshot
5236            .tool_security
5237            .prepare_tool_arguments_with_bindings(&canonical_id, &executed_arguments, &bindings);
5238        if let Some(record) = approval_record.as_mut()
5239            && matches!(record.status, ToolApprovalStatus::Modified)
5240        {
5241            record.modified_arguments = Some(final_arguments.clone());
5242        }
5243        let classification = resolved.tool.classify_call(&final_arguments);
5244        let safety = resolved.tool.safety_metadata();
5245        let security_engine = control_snapshot.tool_security;
5246        let limits = security_engine.effective_limits(&canonical_id, &safety, &classification);
5247        let policy_snapshot = security_engine.policy_snapshot(&canonical_id);
5248        let resource_lock_keys =
5249            tool_resource_lock_keys(&canonical_id, &final_arguments, &bindings, &classification);
5250        metadata.insert(
5251            "classification".to_string(),
5252            serde_json::to_value(&classification).unwrap_or(Value::Null),
5253        );
5254        metadata.insert(
5255            "effective_limits".to_string(),
5256            serde_json::to_value(&limits).unwrap_or(Value::Null),
5257        );
5258        metadata.insert(
5259            "resource_lock_keys".to_string(),
5260            serde_json::to_value(&resource_lock_keys).unwrap_or(Value::Null),
5261        );
5262        if policy_snapshot.is_null() {
5263            metadata.remove("policy_snapshot");
5264        } else {
5265            metadata.insert("policy_snapshot".to_string(), policy_snapshot.clone());
5266        }
5267
5268        let final_denial = |canonical_id: String,
5269                            output: String,
5270                            policy: ToolPolicyDecisionRecord,
5271                            metadata: HashMap<String, Value>| {
5272            self.record_from_parts_at(
5273                &request,
5274                canonical_id,
5275                final_arguments.clone(),
5276                started_at,
5277                start,
5278                false,
5279                false,
5280                output,
5281                metadata,
5282                policy,
5283                approval_record.clone(),
5284                false,
5285                false,
5286                versions,
5287            )
5288        };
5289
5290        if control_snapshot.emergency_deny {
5291            let reason = "Tool execution is disabled by runtime control".to_string();
5292            let record = final_denial(
5293                canonical_id,
5294                reason.clone(),
5295                ToolPolicyDecisionRecord::deny(reason),
5296                metadata,
5297            );
5298            self.finish_tool_record(&record).await;
5299            return Ok(record);
5300        }
5301
5302        let available_tool_ids = self
5303            .get_available_tool_ids_for_scope(control_snapshot.tool_scope_override.as_deref())
5304            .await?;
5305        metadata.insert(
5306            "available_tool_ids_snapshot".to_string(),
5307            serde_json::to_value(&available_tool_ids).unwrap_or(Value::Null),
5308        );
5309        if !available_tool_ids
5310            .iter()
5311            .any(|tool_id| tool_id == &canonical_id)
5312        {
5313            let reason = format!(
5314                "Tool '{}' is not available in the final runtime scope",
5315                canonical_id
5316            );
5317            let record = final_denial(
5318                canonical_id,
5319                reason.clone(),
5320                ToolPolicyDecisionRecord::deny(reason),
5321                metadata,
5322            );
5323            self.finish_tool_record(&record).await;
5324            return Ok(record);
5325        }
5326
5327        //
5328        // Validation may run more than once, but it must not consume rate capacity.
5329        // Rate capacity is consumed once after resource locks are held.
5330        //
5331        let final_security_result = security_engine
5332            .validate_tool_execution_with_bindings(&canonical_id, &final_arguments, &bindings)
5333            .await?;
5334        match &final_security_result {
5335            SecurityCheckResult::Block { reason } => {
5336                let record = final_denial(
5337                    canonical_id,
5338                    format!("Denied: {}", reason),
5339                    ToolPolicyDecisionRecord::deny(reason.clone()),
5340                    metadata,
5341                );
5342                self.finish_tool_record(&record).await;
5343                return Ok(record);
5344            }
5345            SecurityCheckResult::Unavailable { reason } => {
5346                let record = final_denial(
5347                    canonical_id,
5348                    format!("Unavailable: {}", reason),
5349                    ToolPolicyDecisionRecord::unavailable(reason.clone()),
5350                    metadata,
5351                );
5352                self.finish_tool_record(&record).await;
5353                return Ok(record);
5354            }
5355            SecurityCheckResult::Warn { message } => {
5356                warn!(tool = %canonical_id, message = %message, "Tool security warning after approval");
5357            }
5358            SecurityCheckResult::Allow | SecurityCheckResult::RequireConfirmation { .. } => {}
5359        }
5360        let final_confirmation_required = matches!(
5361            final_security_result,
5362            SecurityCheckResult::RequireConfirmation { .. }
5363        ) || security_engine
5364            .classification_approval_message(&canonical_id, &classification)
5365            .is_some();
5366        let stale_approval = approval_binding.as_ref().is_some_and(|binding| {
5367            binding.is_stale(
5368                &canonical_id,
5369                &final_arguments,
5370                final_confirmation_required,
5371                versions,
5372                &resolved.tool,
5373            )
5374        });
5375        if stale_approval {
5376            let reason = "Approval became stale before final admission".to_string();
5377            let record = final_denial(
5378                canonical_id,
5379                reason.clone(),
5380                ToolPolicyDecisionRecord::deny(reason),
5381                metadata,
5382            );
5383            self.finish_tool_record(&record).await;
5384            return Ok(record);
5385        }
5386        if final_confirmation_required && approval_binding.is_none() {
5387            let reason = "Final policy requires fresh approval".to_string();
5388            let record = final_denial(
5389                canonical_id,
5390                reason.clone(),
5391                ToolPolicyDecisionRecord::approval(reason),
5392                metadata,
5393            );
5394            self.finish_tool_record(&record).await;
5395            return Ok(record);
5396        }
5397
5398        let unavailable_reason = match canonical_id.as_str() {
5399            "command" if !self.tools.command_runner_available() => {
5400                Some("command runner is unavailable")
5401            }
5402            "diagnostics" if !self.tools.diagnostics_available() => {
5403                Some("diagnostics provider is unavailable")
5404            }
5405            "web_search" if !self.tools.web_search_available() => {
5406                Some("web search provider is unavailable")
5407            }
5408            _ => None,
5409        };
5410        if let Some(reason) = unavailable_reason {
5411            let record = final_denial(
5412                canonical_id,
5413                reason.to_string(),
5414                ToolPolicyDecisionRecord::unavailable(reason),
5415                metadata,
5416            );
5417            self.finish_tool_record(&record).await;
5418            return Ok(record);
5419        }
5420
5421        //
5422        // Hold conflict locks across final generation admission and invocation.
5423        // Completion hooks and fallback execution run only after these guards are released.
5424        //
5425        let Some(resource_guards) = self.acquire_tool_resource_locks(&resource_lock_keys).await
5426        else {
5427            let reason = "Tool execution cancelled while waiting for resource locks".to_string();
5428            let record = final_denial(
5429                canonical_id,
5430                reason.clone(),
5431                ToolPolicyDecisionRecord::deny(reason),
5432                metadata,
5433            );
5434            self.finish_tool_record(&record).await;
5435            return Ok(record);
5436        };
5437
5438        // Policy updates after admission apply to later calls, while emergency cancellation remains live for this call.
5439        let admission =
5440            self.admit_tool_execution(versions.runtime_control, versions.policy, &canonical_id);
5441        if !matches!(admission, SecurityCheckResult::Allow) {
5442            let latest_control = self.runtime_safety_snapshot();
5443            let reason = admission
5444                .reason()
5445                .unwrap_or("tool admission was denied")
5446                .to_string();
5447            let policy = if admission.is_unavailable() {
5448                ToolPolicyDecisionRecord::unavailable(reason.clone())
5449            } else {
5450                ToolPolicyDecisionRecord::deny(reason.clone())
5451            };
5452            let record = self.record_from_parts_at(
5453                &request,
5454                canonical_id,
5455                final_arguments,
5456                started_at,
5457                start,
5458                false,
5459                false,
5460                reason,
5461                metadata,
5462                policy,
5463                approval_record,
5464                false,
5465                false,
5466                ToolDecisionVersions {
5467                    policy: latest_control.tool_security.policy_version(),
5468                    registry: versions.registry,
5469                    runtime_control: latest_control.version,
5470                },
5471            );
5472            self.finish_tool_record_after_resource_guards(resource_guards, &record)
5473                .await;
5474            return Ok(record);
5475        }
5476        let executed_arguments = final_arguments;
5477
5478        let tool_config = self.recovery_manager.get_tool_config(&canonical_id);
5479        let timeout_ms = limits
5480            .timeout_ms
5481            .unwrap_or_else(|| security_engine.get_tool_timeout(&canonical_id));
5482        let deadline = Some(started_at + chrono::Duration::milliseconds(timeout_ms as i64));
5483        let turn_actor = current_turn_actor_context();
5484        let actor = ToolActorContext {
5485            actor_id: turn_actor
5486                .as_ref()
5487                .and_then(|context| context.effective_actor_id().map(str::to_string))
5488                .or_else(|| self.actor_id()),
5489            origin_actor_id: turn_actor
5490                .as_ref()
5491                .and_then(|context| context.origin_actor_id.clone()),
5492            sender_agent_id: turn_actor
5493                .as_ref()
5494                .and_then(|context| context.sender_agent_id.clone()),
5495        };
5496        let tool_context = ToolExecutionContext {
5497            requested_name: request.requested_name.clone(),
5498            canonical_id: canonical_id.clone(),
5499            display_name: resolved.identity.display_name.clone(),
5500            provider_id: resolved.identity.provider_id.clone(),
5501            registry_version: versions.registry,
5502            policy_version: versions.policy,
5503            runtime_control_version: versions.runtime_control,
5504            call_id: request.call_id.clone(),
5505            source: request.source.clone(),
5506            actor,
5507            cancellation: ToolCancellationToken::new(
5508                Arc::clone(&self.runtime_control.emergency_deny),
5509                Some("runtime control cancellation".to_string()),
5510            ),
5511            started_at,
5512            deadline,
5513            permission: ToolPolicyDecisionRecord::allow(),
5514            approval: approval_record.clone(),
5515            classification: classification.clone(),
5516            safety,
5517            limits: limits.clone(),
5518            policy_snapshot,
5519            custom_config: security_engine.custom_config(&canonical_id),
5520        };
5521        let (mut result, timed_out, cancelled, invoked) = self
5522            .run_tool_with_retries(
5523                &canonical_id,
5524                resolved.tool.clone(),
5525                executed_arguments.clone(),
5526                tool_context,
5527                timeout_ms,
5528                tool_config.max_retries,
5529            )
5530            .await?;
5531
5532        if !result.success {
5533            match &tool_config.on_failure {
5534                ToolFailureAction::Skip => {
5535                    result = ToolResult::ok(format!(
5536                        "{{\"skipped\": true, \"reason\": \"Tool '{}' was skipped after failure\"}}",
5537                        canonical_id
5538                    ));
5539                }
5540                ToolFailureAction::Fallback { fallback_tool } => {
5541                    drop(resource_guards);
5542                    let fallback_request = ToolExecutionRequest::new(
5543                        request.call_id.clone(),
5544                        fallback_tool.clone(),
5545                        executed_arguments,
5546                        ToolCallSource::Fallback {
5547                            original_tool: canonical_id,
5548                        },
5549                    );
5550                    return Box::pin(self.execute_tool_record(fallback_request)).await;
5551                }
5552                ToolFailureAction::ReportError => {}
5553            }
5554        }
5555
5556        let output_cap = limits.max_output_chars;
5557        let (output, output_truncated) =
5558            Self::truncate_tool_output(result.output.clone(), output_cap);
5559        if let Some(result_metadata) = result.metadata {
5560            metadata.extend(result_metadata);
5561        }
5562        let mut record = self.record_from_parts_at(
5563            &request,
5564            canonical_id,
5565            executed_arguments,
5566            started_at,
5567            start,
5568            invoked,
5569            result.success,
5570            output,
5571            metadata,
5572            ToolPolicyDecisionRecord::allow(),
5573            approval_record,
5574            timed_out,
5575            output_truncated,
5576            versions,
5577        );
5578        record.cancelled = cancelled;
5579        if cancelled {
5580            record.cancellation_reason = Some("runtime control cancellation".to_string());
5581        }
5582        self.finish_tool_record_after_resource_guards(resource_guards, &record)
5583            .await;
5584        Ok(record)
5585    }
5586
5587    #[instrument(skip(self, tool_call), fields(tool = %tool_call.name))]
5588    async fn execute_tool_smart(&self, tool_call: &ToolCall) -> Result<String> {
5589        let record = self
5590            .execute_tool_record(ToolExecutionRequest::new(
5591                tool_call.id.clone(),
5592                tool_call.name.clone(),
5593                tool_call.arguments.clone(),
5594                ToolCallSource::Model,
5595            ))
5596            .await?;
5597        if record.success {
5598            Ok(record.model_output_string())
5599        } else if matches!(record.policy.outcome, PermissionOutcome::RequiresApproval) {
5600            Err(AgentError::HITLRejected(record.model_output_string()))
5601        } else {
5602            Err(AgentError::Tool(record.model_output_string()))
5603        }
5604    }
5605
5606    //
5607    // This method is branch-safe because it only asks the router which skill matches.
5608    // Do not add disambiguation, pending-skill writes, or skill execution here.
5609    //
5610    /// Selects a skill without executing it.
5611    async fn select_skill_candidate(&self, input: &str) -> Result<Option<SkillCandidate>> {
5612        let Some(ref router) = self.skill_router else {
5613            return Ok(None);
5614        };
5615        let available_skills = self.get_available_skills();
5616        if available_skills.is_empty() {
5617            return Ok(None);
5618        }
5619        let skill_ids: Vec<&str> = available_skills.iter().map(|s| s.id.as_str()).collect();
5620        let Some(skill_id) = self
5621            .observe_purpose(
5622                ObservationPurpose::SkillRouting,
5623                router.select_skill_filtered(input, &skill_ids),
5624            )
5625            .await?
5626        else {
5627            return Ok(None);
5628        };
5629        let skill = router
5630            .get_skill(&skill_id)
5631            .cloned()
5632            .ok_or_else(|| AgentError::Skill(format!("Skill not found: {}", skill_id)))?;
5633        info!(skill_id = %skill_id, "Skill selected");
5634        Ok(Some(SkillCandidate::new(skill_id, skill)))
5635    }
5636
5637    //
5638    // This is the commit half of skill routing.
5639    // It may mutate pending skill state, run clarification, and execute skill steps.
5640    //
5641    async fn commit_skill_candidate_route_result(
5642        &self,
5643        candidate: SkillCandidate,
5644        input: &str,
5645    ) -> Result<SkillRouteResult> {
5646        let skill_id = candidate.skill_id;
5647        let skill = candidate.skill;
5648        if let Some(ref skill_disambig) = skill.disambiguation
5649            && skill_disambig.enabled.unwrap_or(false)
5650            && let Some(ref disambiguator) = self.disambiguation_manager
5651        {
5652            let context = self.build_disambiguation_context().await?;
5653            let state_override = self
5654                .state_machine
5655                .as_ref()
5656                .and_then(|sm| sm.current_definition())
5657                .and_then(|def| def.disambiguation.clone());
5658
5659            match self
5660                .observe_purpose(
5661                    ObservationPurpose::DisambiguationDetection,
5662                    disambiguator.process_input_with_override(
5663                        input,
5664                        &context,
5665                        state_override.as_ref(),
5666                        Some(skill_disambig),
5667                    ),
5668                )
5669                .await?
5670            {
5671                DisambiguationResult::Clear => {
5672                    debug!(skill_id = %skill_id, "Skill disambiguation: clear");
5673                }
5674                DisambiguationResult::NeedsClarification {
5675                    question,
5676                    detection,
5677                } => {
5678                    info!(
5679                        skill_id = %skill_id,
5680                        ambiguity_type = ?detection.ambiguity_type,
5681                        confidence = detection.confidence,
5682                        "Skill requires clarification before execution"
5683                    );
5684                    *self.pending_skill_id.write() = Some(skill_id.clone());
5685                    return Ok(SkillRouteResult::NeedsClarification(
5686                        AgentResponse::new(&question.question).with_metadata(
5687                            "disambiguation",
5688                            serde_json::json!({
5689                                "status": "awaiting_clarification",
5690                                "skill_id": skill_id,
5691                                "options": question.options,
5692                                "clarifying": question.clarifying,
5693                                "detection": {
5694                                    "type": detection.ambiguity_type,
5695                                    "confidence": detection.confidence,
5696                                    "what_is_unclear": detection.what_is_unclear,
5697                                }
5698                            }),
5699                        ),
5700                    ));
5701                }
5702                DisambiguationResult::Clarified { enriched_input, .. } => {
5703                    info!(skill_id = %skill_id, enriched = %enriched_input, "Skill disambiguation clarified");
5704                    return Ok(SkillRouteResult::Response {
5705                        skill_id,
5706                        content: self.execute_skill(&skill, &enriched_input).await?,
5707                    });
5708                }
5709                DisambiguationResult::ProceedWithBestGuess { enriched_input } => {
5710                    info!(skill_id = %skill_id, "Skill disambiguation best guess");
5711                    return Ok(SkillRouteResult::Response {
5712                        skill_id,
5713                        content: self.execute_skill(&skill, &enriched_input).await?,
5714                    });
5715                }
5716                DisambiguationResult::GiveUp { reason } => {
5717                    warn!(skill_id = %skill_id, reason = %reason, "Skill disambiguation gave up");
5718                    let apology = self
5719                                .generate_localized_apology(
5720                                    "Generate a brief, polite apology saying you couldn't understand the request. Be concise.",
5721                                    &reason,
5722                                )
5723                                .await
5724                                .unwrap_or_else(|_| {
5725                                    format!("I'm sorry, I couldn't understand your request: {}", reason)
5726                                });
5727                    return Ok(SkillRouteResult::NeedsClarification(AgentResponse::new(
5728                        &apology,
5729                    )));
5730                }
5731                DisambiguationResult::Escalate { reason } => {
5732                    info!(skill_id = %skill_id, reason = %reason, "Skill disambiguation escalating");
5733                    let apology = self
5734                                .generate_localized_apology(
5735                                    "Explain briefly that you're transferring the user to a human agent for help.",
5736                                    &reason,
5737                                )
5738                                .await
5739                                .unwrap_or_else(|_| {
5740                                    format!("I need human assistance to help with your request: {}", reason)
5741                                });
5742                    return Ok(SkillRouteResult::NeedsClarification(AgentResponse::new(
5743                        &apology,
5744                    )));
5745                }
5746                DisambiguationResult::Abandoned { .. } => {
5747                    debug!(skill_id = %skill_id, "Skill disambiguation abandoned");
5748                    return Ok(SkillRouteResult::NoMatch);
5749                }
5750            }
5751        }
5752        Ok(SkillRouteResult::Response {
5753            skill_id,
5754            content: self.execute_skill(&skill, input).await?,
5755        })
5756    }
5757
5758    /// Result of skill routing.
5759    async fn try_skill_route(&self, input: &str) -> Result<SkillRouteResult> {
5760        if let Some(candidate) = self.select_skill_candidate(input).await? {
5761            self.commit_skill_candidate_route_result(candidate, input)
5762                .await
5763        } else {
5764            Ok(SkillRouteResult::NoMatch)
5765        }
5766    }
5767
5768    /// Execute a skill with reasoning and reflection, returning the response string.
5769    async fn execute_skill(&self, skill: &SkillDefinition, input: &str) -> Result<String> {
5770        if let Some(ref executor) = self.skill_executor {
5771            let skill_reasoning = self.get_skill_reasoning_config(skill);
5772            let skill_reflection = self.get_skill_reflection_config(skill);
5773
5774            debug!(
5775                skill_id = %skill.id,
5776                reasoning_mode = ?skill_reasoning.mode,
5777                reflection_enabled = ?skill_reflection.enabled,
5778                "Skill reasoning/reflection config"
5779            );
5780
5781            let response = self
5782                .observe_purpose(
5783                    ObservationPurpose::SkillPrompt,
5784                    executor.execute_with_invoker(skill, input, serde_json::json!({}), self),
5785                )
5786                .await?;
5787
5788            if skill_reflection.requires_evaluation() && skill_reflection.is_enabled() {
5789                let should_reflect = self
5790                    .should_reflect_with_config(input, &response, &skill_reflection)
5791                    .await?;
5792                if should_reflect {
5793                    let evaluated = self
5794                        .evaluate_and_retry_with_config(input, response, &skill_reflection)
5795                        .await?;
5796                    return Ok(evaluated);
5797                }
5798            }
5799
5800            return Ok(response);
5801        }
5802        Err(AgentError::Skill(
5803            "No skill executor configured".to_string(),
5804        ))
5805    }
5806
5807    /// Execute a skill by ID, bypassing the skill router.
5808    /// Used after skill-triggered disambiguation resolves to route directly to the matched skill.
5809    async fn execute_skill_by_id(&self, skill_id: &str, input: &str) -> Result<String> {
5810        let skill = self
5811            .skill_router
5812            .as_ref()
5813            .and_then(|r| r.get_skill(skill_id).cloned())
5814            .ok_or_else(|| AgentError::Skill(format!("Skill not found: {}", skill_id)))?;
5815        self.execute_skill(&skill, input).await
5816    }
5817
5818    async fn should_reflect_with_config(
5819        &self,
5820        input: &str,
5821        response: &str,
5822        config: &ReflectionConfig,
5823    ) -> Result<bool> {
5824        if !config.requires_evaluation() {
5825            return Ok(false);
5826        }
5827
5828        if config.is_enabled() {
5829            return Ok(true);
5830        }
5831
5832        let evaluator_llm = config
5833            .evaluator_llm
5834            .as_ref()
5835            .and_then(|alias| self.llm_registry.get(alias).ok())
5836            .or_else(|| self.llm_registry.router().ok())
5837            .or_else(|| self.llm_registry.default().ok());
5838
5839        let Some(llm) = evaluator_llm else {
5840            return Ok(false);
5841        };
5842
5843        let response_preview: String = response.chars().take(500).collect();
5844        let prompt = format!(
5845            r#"Should this response be evaluated for quality? Consider if it's a complex or important response.
5846
5847User query: "{}"
5848Response: "{}"
5849
5850Answer YES or NO only."#,
5851            input, response_preview
5852        );
5853
5854        let messages = vec![ChatMessage::user(&prompt)];
5855        let result = self
5856            .observe_purpose(
5857                ObservationPurpose::ReflectionDecision,
5858                llm.complete(&messages, None),
5859            )
5860            .await;
5861
5862        match result {
5863            Ok(resp) => Ok(resp.content.trim().to_uppercase().contains("YES")),
5864            Err(_) => Ok(false),
5865        }
5866    }
5867
5868    async fn evaluate_and_retry_with_config(
5869        &self,
5870        input: &str,
5871        mut response: String,
5872        config: &ReflectionConfig,
5873    ) -> Result<String> {
5874        let llm = self.get_state_llm()?;
5875        let mut attempts = 0u32;
5876        let max_retries = config.max_retries;
5877
5878        loop {
5879            let evaluation = self
5880                .evaluate_response_with_config(input, &response, config)
5881                .await?;
5882
5883            if evaluation.passed || attempts >= max_retries {
5884                info!(
5885                    passed = evaluation.passed,
5886                    confidence = evaluation.confidence,
5887                    attempts = attempts + 1,
5888                    "Skill reflection evaluation complete"
5889                );
5890                return Ok(response);
5891            }
5892
5893            debug!(
5894                attempt = attempts + 1,
5895                failed_criteria = evaluation.failed_criteria().count(),
5896                "Skill response did not meet criteria, retrying"
5897            );
5898
5899            let feedback: Vec<String> = evaluation
5900                .failed_criteria()
5901                .map(|c| format!("- {}", c.criterion))
5902                .collect();
5903
5904            let retry_prompt = format!(
5905                "Your previous response did not meet these criteria:\n{}\n\nPlease provide an improved response to: {}",
5906                feedback.join("\n"),
5907                input
5908            );
5909
5910            let messages = vec![ChatMessage::user(&retry_prompt)];
5911            let retry_response = self
5912                .observe_purpose(
5913                    ObservationPurpose::ReflectionEvaluation,
5914                    llm.complete(&messages, None),
5915                )
5916                .await
5917                .map_err(|e| AgentError::LLM(e.to_string()))?;
5918
5919            response = retry_response.content.trim().to_string();
5920            attempts += 1;
5921        }
5922    }
5923
5924    async fn evaluate_response_with_config(
5925        &self,
5926        input: &str,
5927        response: &str,
5928        config: &ReflectionConfig,
5929    ) -> Result<EvaluationResult> {
5930        let evaluator_llm = config
5931            .evaluator_llm
5932            .as_ref()
5933            .and_then(|alias| self.llm_registry.get(alias).ok())
5934            .or_else(|| self.llm_registry.router().ok())
5935            .or_else(|| self.llm_registry.default().ok())
5936            .ok_or_else(|| AgentError::Config("No LLM available for evaluation".into()))?;
5937
5938        let criteria = &config.criteria;
5939        let criteria_list = criteria
5940            .iter()
5941            .enumerate()
5942            .map(|(i, c)| format!("{}. {}", i + 1, c))
5943            .collect::<Vec<_>>()
5944            .join("\n");
5945
5946        let prompt = format!(
5947            r#"Evaluate this response against the criteria.
5948
5949User query: "{}"
5950
5951Response to evaluate: "{}"
5952
5953Criteria:
5954{}
5955
5956For each criterion, respond with:
5957- criterion number
5958- PASS or FAIL
5959- brief reason
5960
5961Then provide overall confidence (0.0 to 1.0) and whether it passes overall.
5962
5963Format:
59641. PASS/FAIL - reason
59652. PASS/FAIL - reason
5966...
5967CONFIDENCE: 0.X
5968OVERALL: PASS/FAIL"#,
5969            input, response, criteria_list
5970        );
5971
5972        let messages = vec![ChatMessage::user(&prompt)];
5973        let eval_response = self
5974            .observe_purpose(
5975                ObservationPurpose::ReflectionEvaluation,
5976                evaluator_llm.complete(&messages, None),
5977            )
5978            .await
5979            .map_err(|e| AgentError::LLM(format!("Evaluation failed: {}", e)))?;
5980
5981        let content = eval_response.content.to_uppercase();
5982        let llm_pass = content.contains("OVERALL: PASS");
5983
5984        let confidence = content
5985            .lines()
5986            .find(|l| l.contains("CONFIDENCE:"))
5987            .and_then(|l| {
5988                l.split(':')
5989                    .nth(1)
5990                    .and_then(|v| v.trim().parse::<f32>().ok())
5991            })
5992            .unwrap_or(if llm_pass { 0.8 } else { 0.4 });
5993
5994        // Gate pass against confidence threshold.
5995        // LLM may say PASS but with low confidence - the threshold catches this.
5996        let overall_pass = llm_pass && confidence >= config.pass_threshold;
5997
5998        let mut criteria_results = Vec::new();
5999        for (i, criterion) in criteria.iter().enumerate() {
6000            let line_marker = format!("{}.", i + 1);
6001            let passed = eval_response
6002                .content
6003                .lines()
6004                .find(|l| l.contains(&line_marker))
6005                .map(|l| l.to_uppercase().contains("PASS"))
6006                .unwrap_or(overall_pass);
6007
6008            if passed {
6009                criteria_results.push(CriterionResult::pass(criterion));
6010            } else {
6011                criteria_results.push(CriterionResult::fail(criterion, "Did not meet criterion"));
6012            }
6013        }
6014
6015        Ok(EvaluationResult::new(overall_pass, confidence).with_criteria(criteria_results))
6016    }
6017
6018    /// Process input through the pipeline (state-level override or agent-level).
6019    async fn process_input(&self, input: &str) -> Result<ProcessData> {
6020        if let Some(processor) = self.get_state_process_processor() {
6021            let purpose = observation_purpose_for_process(processor.input_purpose_hint());
6022            return self
6023                .observe_purpose(purpose, processor.process_input(input))
6024                .await;
6025        }
6026        if let Some(ref processor) = self.process_processor {
6027            let purpose = observation_purpose_for_process(processor.input_purpose_hint());
6028            self.observe_purpose(purpose, processor.process_input(input))
6029                .await
6030        } else {
6031            Ok(ProcessData::new(input))
6032        }
6033    }
6034
6035    /// Process output through the pipeline (state-level override or agent-level).
6036    async fn process_output(
6037        &self,
6038        output: &str,
6039        input_context: &std::collections::HashMap<String, serde_json::Value>,
6040    ) -> Result<ProcessData> {
6041        if let Some(processor) = self.get_state_process_processor() {
6042            let purpose = observation_purpose_for_process(processor.output_purpose_hint());
6043            return self
6044                .observe_purpose(purpose, processor.process_output(output, input_context))
6045                .await;
6046        }
6047        if let Some(ref processor) = self.process_processor {
6048            let purpose = observation_purpose_for_process(processor.output_purpose_hint());
6049            self.observe_purpose(purpose, processor.process_output(output, input_context))
6050                .await
6051        } else {
6052            Ok(ProcessData::new(output))
6053        }
6054    }
6055
6056    /// Build a ProcessProcessor from the current state's process config, if any.
6057    fn get_state_process_processor(&self) -> Option<ProcessProcessor> {
6058        let sm = self.state_machine.as_ref()?;
6059        let def = sm.current_definition()?;
6060        let config = def.process.as_ref()?;
6061        let mut processor = ProcessProcessor::new(config.clone());
6062        if let Some(ref registry) = Some(self.llm_registry.clone()) {
6063            processor = processor.with_llm_registry(registry.clone());
6064        }
6065        processor = processor.with_stage_observer(Arc::new(ObservabilityProcessStageObserver));
6066        Some(processor)
6067    }
6068
6069    async fn check_turn_timeout(&self) -> Result<()> {
6070        if let Some(ref sm) = self.state_machine
6071            && let Some(timeout_state) = sm.check_timeout()
6072        {
6073            let from_state = sm.current();
6074            let history_before = sm.history();
6075            self.execute_state_exit_actions(&from_state).await;
6076            sm.transition_to(&timeout_state, "max_turns exceeded")?;
6077            let entered = sm.current();
6078            let is_reentry =
6079                Self::state_was_previously_entered(&entered, &from_state, &history_before);
6080            self.execute_state_enter_actions(&entered, is_reentry).await;
6081            info!(to = %entered, "Timeout transition");
6082        }
6083        Ok(())
6084    }
6085
6086    fn increment_turn(&self) {
6087        if let Some(ref sm) = self.state_machine {
6088            sm.increment_turn();
6089        }
6090    }
6091
6092    fn transitions_available_for_commit(&self) -> Option<(Vec<Transition>, String)> {
6093        let sm = self.state_machine.as_ref()?;
6094        let current = sm.current();
6095        let transitions: Vec<_> = sm
6096            .auto_transitions()
6097            .into_iter()
6098            .filter(|t| match t.cooldown_turns {
6099                Some(cd) if cd > 0 => {
6100                    let resolved = sm.config().resolve_full_path(&current, &t.to);
6101                    !sm.is_on_cooldown(&resolved, cd)
6102                }
6103                _ => true,
6104            })
6105            .collect();
6106        Some((transitions, current))
6107    }
6108
6109    fn transition_reason(transition: &Transition) -> String {
6110        if transition.when.is_empty() {
6111            "guard condition met".to_string()
6112        } else {
6113            transition.when.clone()
6114        }
6115    }
6116
6117    /// Builds transition context with optional staged writes overlaid.
6118    fn build_transition_context(
6119        &self,
6120        user_message: &str,
6121        response: &str,
6122        current_state: &str,
6123        staged: Option<&HashMap<String, Value>>,
6124    ) -> TransitionContext {
6125        let context_map = staged
6126            .map(|writes| self.build_context_with_staged(writes))
6127            .unwrap_or_else(|| self.build_context_with_overlays());
6128        TransitionContext::new(user_message, response, current_state).with_context(context_map)
6129    }
6130
6131    /// Selects a post-response transition without committing side effects.
6132    async fn select_transition_candidate(
6133        &self,
6134        user_message: &str,
6135        response: &str,
6136    ) -> Result<Option<TransitionCandidate>> {
6137        let Some((transitions, current_state)) = self.transitions_available_for_commit() else {
6138            return Ok(None);
6139        };
6140        let transitions: Vec<Transition> = transitions
6141            .into_iter()
6142            .filter(|transition| matches!(transition.timing, TransitionTiming::PostResponse))
6143            .collect();
6144        if transitions.is_empty() {
6145            return Ok(None);
6146        }
6147        let Some(evaluator) = self.transition_evaluator.as_ref() else {
6148            return Ok(None);
6149        };
6150        let context = self.build_transition_context(user_message, response, &current_state, None);
6151        let selected = self
6152            .observe_purpose(
6153                ObservationPurpose::StateTransitionEvaluation,
6154                evaluator.select_transition(&transitions, &context),
6155            )
6156            .await?;
6157        Ok(selected.map(|index| {
6158            let transition = transitions[index].clone();
6159            TransitionCandidate::new(
6160                current_state,
6161                transition.clone(),
6162                Self::transition_reason(&transition),
6163            )
6164        }))
6165    }
6166
6167    /// Selects a guard or resolved-intent transition without an LLM call.
6168    fn select_deterministic_transition_candidate(
6169        &self,
6170        user_message: &str,
6171        current_state: &str,
6172        transitions: &[Transition],
6173        staged: &HashMap<String, Value>,
6174    ) -> Option<TransitionCandidate> {
6175        let context = self.build_transition_context(user_message, "", current_state, Some(staged));
6176
6177        for transition in transitions {
6178            if let Some(guard) = transition.guard.as_ref()
6179                && evaluate_guard(guard, &context)
6180            {
6181                return Some(TransitionCandidate::new(
6182                    current_state,
6183                    transition.clone(),
6184                    Self::transition_reason(transition),
6185                ));
6186            }
6187        }
6188
6189        let resolved_intent = context
6190            .context
6191            .get("resolved_intent")
6192            .and_then(Value::as_str)
6193            .filter(|value| !value.is_empty());
6194        if let Some(resolved_intent) = resolved_intent {
6195            for transition in transitions {
6196                if transition.intent.as_deref() == Some(resolved_intent) {
6197                    return Some(TransitionCandidate::new(
6198                        current_state,
6199                        transition.clone(),
6200                        Self::transition_reason(transition),
6201                    ));
6202                }
6203            }
6204        }
6205
6206        None
6207    }
6208
6209    /// Commits a selected transition through the shared post-response path.
6210    async fn commit_transition_candidate(&self, candidate: &TransitionCandidate) -> Result<bool> {
6211        self.commit_transition_target(&candidate.from_state, candidate.target(), &candidate.reason)
6212            .await
6213    }
6214
6215    /// Runs state transition approval before any transition side effects.
6216    async fn approve_transition_target(&self, from_state: &str, target: &str) -> Result<bool> {
6217        let approved = self.check_state_hitl(Some(from_state), target).await?;
6218        if !approved {
6219            info!(to = %target, "State transition rejected by HITL");
6220        }
6221        Ok(approved)
6222    }
6223
6224    /// Applies transition side effects after approval has succeeded.
6225    async fn apply_transition_target(
6226        &self,
6227        from_state: &str,
6228        target: &str,
6229        reason: &str,
6230        staged: Option<&HashMap<String, Value>>,
6231    ) -> Result<bool> {
6232        let Some(ref sm) = self.state_machine else {
6233            return Ok(false);
6234        };
6235
6236        let history_before = sm.history();
6237        self.execute_state_exit_actions(from_state).await;
6238        sm.transition_to(target, reason)?;
6239        sm.reset_no_transition();
6240        if let Some(staged) = staged {
6241            self.commit_staged_context_writes(staged).await;
6242        }
6243        let entered = sm.current();
6244        let is_reentry = Self::state_was_previously_entered(&entered, from_state, &history_before);
6245        self.execute_state_enter_actions(&entered, is_reentry).await;
6246        self.hooks
6247            .on_state_transition(Some(from_state), &entered, reason)
6248            .await;
6249        info!(from = %from_state, to = %entered, "State transition");
6250        Ok(true)
6251    }
6252
6253    /// Approves and applies a transition without staged context writes.
6254    async fn commit_transition_target(
6255        &self,
6256        from_state: &str,
6257        target: &str,
6258        reason: &str,
6259    ) -> Result<bool> {
6260        if !self.approve_transition_target(from_state, target).await? {
6261            return Ok(false);
6262        }
6263        self.apply_transition_target(from_state, target, reason, None)
6264            .await
6265    }
6266
6267    /// Applies an approved pre-response transition before redispatch.
6268    async fn apply_pre_response_transition_candidate(
6269        &self,
6270        candidate: &TransitionCandidate,
6271        staged: &HashMap<String, Value>,
6272        processed_input: &str,
6273    ) -> Result<bool> {
6274        self.commit_root_user_message(processed_input).await?;
6275        self.apply_transition_target(
6276            &candidate.from_state,
6277            candidate.target(),
6278            &candidate.reason,
6279            Some(staged),
6280        )
6281        .await
6282    }
6283
6284    /// Commits a pre-response transition after approval and before redispatch.
6285    async fn commit_pre_response_transition_candidate(
6286        &self,
6287        candidate: &TransitionCandidate,
6288        staged: &HashMap<String, Value>,
6289        processed_input: &str,
6290    ) -> Result<bool> {
6291        if !self
6292            .approve_transition_target(&candidate.from_state, candidate.target())
6293            .await?
6294        {
6295            return Ok(false);
6296        }
6297        self.apply_pre_response_transition_candidate(candidate, staged, processed_input)
6298            .await
6299    }
6300
6301    /// Handles post-response transition misses with fallback counters.
6302    async fn handle_transition_miss(&self, current_state: &str) -> Result<bool> {
6303        let Some(ref sm) = self.state_machine else {
6304            return Ok(false);
6305        };
6306        sm.increment_no_transition();
6307        let Some(fallback) = sm.check_fallback() else {
6308            return Ok(false);
6309        };
6310        self.commit_transition_target(current_state, &fallback, "fallback after no transitions")
6311            .await
6312    }
6313
6314    /// Evaluates and commits post-response transitions for the committed response path.
6315    async fn evaluate_transitions(&self, user_message: &str, response: &str) -> Result<bool> {
6316        let Some((transitions, current_state)) = self.transitions_available_for_commit() else {
6317            return Ok(false);
6318        };
6319        if transitions.is_empty() {
6320            return Ok(false);
6321        }
6322        if let Some(candidate) = self
6323            .select_transition_candidate(user_message, response)
6324            .await?
6325        {
6326            return self.commit_transition_candidate(&candidate).await;
6327        }
6328        self.handle_transition_miss(&current_state).await
6329    }
6330
6331    /// Attempts deterministic pre-response routing before old-state response generation.
6332    async fn try_pre_response_transition(
6333        &self,
6334        processed_input: &str,
6335    ) -> Result<Option<AgentResponse>> {
6336        let optimization = &self.runtime_config.optimization;
6337        if !optimization.enabled || !optimization.pre_response_deterministic_transitions {
6338            return Ok(None);
6339        }
6340        let Some((transitions, current_state)) = self.transitions_available_for_commit() else {
6341            return Ok(None);
6342        };
6343        let eligible: Vec<Transition> = transitions
6344            .into_iter()
6345            .filter(|transition| !transition.requires_response)
6346            .filter(|transition| matches!(transition.timing, TransitionTiming::PreResponse))
6347            .collect();
6348        if eligible.is_empty() {
6349            return Ok(None);
6350        }
6351
6352        let empty_staged = HashMap::new();
6353        let mut extracted_staged: Option<HashMap<String, Value>> = None;
6354        let mut selected: Option<(TransitionCandidate, HashMap<String, Value>)> = None;
6355
6356        for transition in &eligible {
6357            let use_extractors = optimization.pre_response_extractors || transition.run_extractors;
6358            let staged_for_eval = if use_extractors {
6359                if extracted_staged.is_none() {
6360                    extracted_staged =
6361                        Some(self.run_context_extractors_staged(processed_input).await);
6362                }
6363                extracted_staged.as_ref().unwrap_or(&empty_staged)
6364            } else {
6365                &empty_staged
6366            };
6367
6368            if let Some(candidate) = self.select_deterministic_transition_candidate(
6369                processed_input,
6370                &current_state,
6371                std::slice::from_ref(transition),
6372                staged_for_eval,
6373            ) {
6374                let staged_for_commit = if use_extractors {
6375                    staged_for_eval.clone()
6376                } else {
6377                    HashMap::new()
6378                };
6379                selected = Some((candidate, staged_for_commit));
6380                break;
6381            }
6382        }
6383
6384        let Some((candidate, staged)) = selected else {
6385            return Ok(None);
6386        };
6387
6388        if !self
6389            .commit_pre_response_transition_candidate(&candidate, &staged, processed_input)
6390            .await?
6391        {
6392            return Ok(None);
6393        }
6394        self.redispatch_current_state(processed_input)
6395            .await
6396            .map(Some)
6397    }
6398
6399    //
6400    // Speculative branches overlap independent decisions but still commit exactly one path.
6401    // Losing branches must remain data only and must not write memory, run tools, or emit output.
6402    //
6403    async fn try_speculative_branches(
6404        &self,
6405        processed_input: &str,
6406        input_context: &HashMap<String, Value>,
6407    ) -> Result<Option<AgentResponse>> {
6408        let optimization = &self.runtime_config.optimization;
6409        if !optimization.enabled {
6410            return Ok(None);
6411        }
6412
6413        let effective_reasoning_mode = self.get_effective_reasoning_config().mode.clone();
6414        if !matches!(
6415            effective_reasoning_mode,
6416            ReasoningMode::None | ReasoningMode::Auto
6417        ) {
6418            return Ok(None);
6419        }
6420
6421        let mut transition_enabled =
6422            optimization.speculative_state_transitions && self.has_parallel_transition_candidates();
6423        let mut skill_enabled = optimization.speculative_skill_routing
6424            && self.skill_router.is_some()
6425            && self.pending_skill_id.read().is_none();
6426        let mut reasoning_enabled = optimization.speculative_reasoning_auto
6427            && matches!(effective_reasoning_mode, ReasoningMode::Auto);
6428
6429        if matches!(effective_reasoning_mode, ReasoningMode::Auto)
6430            && (!reasoning_enabled || optimization.max_speculative_llm_calls_per_turn < 2)
6431        {
6432            return Ok(None);
6433        }
6434
6435        if !transition_enabled && !skill_enabled && !reasoning_enabled {
6436            return Ok(None);
6437        }
6438
6439        let mut optional_slots = optimization.max_parallel_runtime_tasks.saturating_sub(1);
6440        let mut speculative_call_slots = optimization
6441            .max_speculative_llm_calls_per_turn
6442            .saturating_sub(1);
6443        if reasoning_enabled {
6444            if optional_slots == 0 || speculative_call_slots == 0 {
6445                return Ok(None);
6446            }
6447            optional_slots -= 1;
6448            speculative_call_slots -= 1;
6449        }
6450        if transition_enabled {
6451            if optional_slots == 0 {
6452                transition_enabled = false;
6453            } else {
6454                optional_slots -= 1;
6455            }
6456        }
6457        if skill_enabled && (optional_slots == 0 || speculative_call_slots == 0) {
6458            skill_enabled = false;
6459        }
6460
6461        if !transition_enabled && !skill_enabled && !reasoning_enabled {
6462            return Ok(None);
6463        }
6464
6465        let main_kind = if transition_enabled {
6466            RuntimeOptimizationKind::ParallelStateTransition
6467        } else if skill_enabled {
6468            RuntimeOptimizationKind::SpeculativeSkillRouting
6469        } else {
6470            RuntimeOptimizationKind::SpeculativeReasoningAuto
6471        };
6472        if !self.reserve_active_speculative_llm_call(main_kind) {
6473            return Ok(None);
6474        }
6475
6476        let mut branch_set = ScheduledBranchSet::new(optimization.max_parallel_runtime_tasks)?;
6477        let main_branch = RuntimeBranch::new(
6478            RuntimeTaskPurpose::MainResponse,
6479            main_kind,
6480            RuntimeTaskPriority::Normal,
6481            RuntimeCommitBehavior::FinalResponse,
6482        );
6483        let transition_branch = RuntimeBranch::new(
6484            RuntimeTaskPurpose::StateTransition,
6485            RuntimeOptimizationKind::ParallelStateTransition,
6486            RuntimeTaskPriority::Critical,
6487            RuntimeCommitBehavior::TransitionDecision,
6488        );
6489        let skill_branch = RuntimeBranch::new(
6490            RuntimeTaskPurpose::SkillRouting,
6491            RuntimeOptimizationKind::SpeculativeSkillRouting,
6492            RuntimeTaskPriority::High,
6493            RuntimeCommitBehavior::SkillSelection,
6494        );
6495        let reasoning_branch = RuntimeBranch::new(
6496            RuntimeTaskPurpose::ReasoningJudge,
6497            RuntimeOptimizationKind::SpeculativeReasoningAuto,
6498            RuntimeTaskPriority::Normal,
6499            RuntimeCommitBehavior::ReasoningDecision,
6500        );
6501        let main_id = main_branch.branch_id();
6502        let transition_id = transition_branch.branch_id();
6503        let skill_id = skill_branch.branch_id();
6504        let reasoning_id = reasoning_branch.branch_id();
6505
6506        let main_id_for_future = main_id.clone();
6507        if !branch_set.schedule(
6508            main_branch,
6509            Box::pin(async move {
6510                match crate::optimization::observability::with_branch_observation(
6511                    &main_id_for_future,
6512                    main_kind,
6513                    RuntimeCommitBehavior::FinalResponse,
6514                    self.generate_main_response_draft(processed_input, &ReasoningMode::None),
6515                )
6516                .await
6517                {
6518                    Ok(draft) => RuntimeBranchResult::MainDraft(draft),
6519                    Err(error) => RuntimeBranchResult::Failed(error),
6520                }
6521            }),
6522        ) {
6523            return Ok(None);
6524        }
6525
6526        if transition_enabled {
6527            let transition_id_for_future = transition_id.clone();
6528            if !branch_set.schedule(
6529                transition_branch,
6530                Box::pin(async move {
6531                    match crate::optimization::observability::with_branch_observation(
6532                        &transition_id_for_future,
6533                        RuntimeOptimizationKind::ParallelStateTransition,
6534                        RuntimeCommitBehavior::TransitionDecision,
6535                        self.select_parallel_transition_candidate(processed_input),
6536                    )
6537                    .await
6538                    {
6539                        Ok(ParallelTransitionSelection::Candidate(candidate)) => {
6540                            RuntimeBranchResult::Transition(Some(candidate))
6541                        }
6542                        Ok(ParallelTransitionSelection::NoMatch) => {
6543                            RuntimeBranchResult::Transition(None)
6544                        }
6545                        Ok(ParallelTransitionSelection::ReservationExhausted) => {
6546                            RuntimeBranchResult::Cancelled
6547                        }
6548                        Err(error) => RuntimeBranchResult::Failed(error),
6549                    }
6550                }),
6551            ) {
6552                transition_enabled = false;
6553            }
6554        }
6555
6556        if skill_enabled {
6557            let skill_id_for_future = skill_id.clone();
6558            if !branch_set.schedule(
6559                skill_branch,
6560                Box::pin(async move {
6561                    if !self.reserve_active_speculative_llm_call(
6562                        RuntimeOptimizationKind::SpeculativeSkillRouting,
6563                    ) {
6564                        return RuntimeBranchResult::Cancelled;
6565                    }
6566                    match crate::optimization::observability::with_branch_observation(
6567                        &skill_id_for_future,
6568                        RuntimeOptimizationKind::SpeculativeSkillRouting,
6569                        RuntimeCommitBehavior::SkillSelection,
6570                        self.select_skill_candidate(processed_input),
6571                    )
6572                    .await
6573                    {
6574                        Ok(candidate) => RuntimeBranchResult::Skill(candidate),
6575                        Err(error) => RuntimeBranchResult::Failed(error),
6576                    }
6577                }),
6578            ) {
6579                skill_enabled = false;
6580            }
6581        }
6582
6583        if reasoning_enabled {
6584            let reasoning_id_for_future = reasoning_id.clone();
6585            if !branch_set.schedule(
6586                reasoning_branch,
6587                Box::pin(async move {
6588                    if !self.reserve_active_speculative_llm_call(
6589                        RuntimeOptimizationKind::SpeculativeReasoningAuto,
6590                    ) {
6591                        return RuntimeBranchResult::Cancelled;
6592                    }
6593                    match crate::optimization::observability::with_branch_observation(
6594                        &reasoning_id_for_future,
6595                        RuntimeOptimizationKind::SpeculativeReasoningAuto,
6596                        RuntimeCommitBehavior::ReasoningDecision,
6597                        self.determine_reasoning_mode_strict(processed_input),
6598                    )
6599                    .await
6600                    {
6601                        Ok(mode) => RuntimeBranchResult::Reasoning(mode),
6602                        Err(error) => RuntimeBranchResult::Failed(error),
6603                    }
6604                }),
6605            ) {
6606                reasoning_enabled = false;
6607            }
6608        }
6609
6610        if matches!(effective_reasoning_mode, ReasoningMode::Auto) && !reasoning_enabled {
6611            self.finalize_pending_branches(branch_set.cancel_pending());
6612            return Ok(None);
6613        }
6614
6615        if !transition_enabled && !skill_enabled && !reasoning_enabled {
6616            self.finalize_pending_branches(branch_set.cancel_pending());
6617            return Ok(None);
6618        }
6619
6620        let mut main_pending = true;
6621        let mut skill_pending = skill_enabled;
6622        let mut reasoning_pending = reasoning_enabled;
6623        let mut transition_finalized = !transition_enabled;
6624        let mut skill_finalized = !skill_enabled;
6625        let mut reasoning_finalized = !reasoning_enabled;
6626        let mut main_result: Option<Result<MainResponseDraft>> = None;
6627        let mut transition_candidate: Option<TransitionCandidate> = None;
6628        let mut skill_candidate: Option<SkillCandidate> = None;
6629        let mut reasoning_decision: Option<ReasoningMode> = None;
6630        let mut transition_fallback_required = false;
6631        let mut skill_fallback_required = false;
6632        let mut reasoning_fallback_required = false;
6633
6634        loop {
6635            if let Some(candidate) = transition_candidate.take() {
6636                if self
6637                    .approve_transition_target(&candidate.from_state, candidate.target())
6638                    .await?
6639                {
6640                    // Drop losing provider futures before transition side effects can reuse their shared resources.
6641                    self.finalize_pending_branches(branch_set.cancel_pending());
6642                    if !main_pending {
6643                        self.finalize_branch_loss(
6644                            &main_id,
6645                            main_kind,
6646                            RuntimeCommitBehavior::FinalResponse,
6647                            false,
6648                            main_result.as_ref().map(|result| result.is_err()),
6649                        );
6650                    }
6651                    if skill_enabled && !skill_pending {
6652                        self.finalize_branch_loss(
6653                            &skill_id,
6654                            RuntimeOptimizationKind::SpeculativeSkillRouting,
6655                            RuntimeCommitBehavior::SkillSelection,
6656                            false,
6657                            Some(false),
6658                        );
6659                    }
6660                    if reasoning_enabled && !reasoning_pending {
6661                        self.finalize_branch_loss(
6662                            &reasoning_id,
6663                            RuntimeOptimizationKind::SpeculativeReasoningAuto,
6664                            RuntimeCommitBehavior::ReasoningDecision,
6665                            false,
6666                            Some(false),
6667                        );
6668                    }
6669                    if !self
6670                        .apply_pre_response_transition_candidate(
6671                            &candidate,
6672                            &HashMap::new(),
6673                            processed_input,
6674                        )
6675                        .await?
6676                    {
6677                        self.finalize_optional_branch(
6678                            &transition_id,
6679                            RuntimeOptimizationKind::ParallelStateTransition,
6680                            RuntimeCommitBehavior::TransitionDecision,
6681                            "discarded",
6682                            false,
6683                        );
6684                        return Ok(None);
6685                    }
6686                    self.finalize_optional_branch(
6687                        &transition_id,
6688                        RuntimeOptimizationKind::ParallelStateTransition,
6689                        RuntimeCommitBehavior::TransitionDecision,
6690                        "committed",
6691                        true,
6692                    );
6693                    return self
6694                        .redispatch_current_state(processed_input)
6695                        .await
6696                        .map(Some);
6697                }
6698                self.finalize_optional_branch(
6699                    &transition_id,
6700                    RuntimeOptimizationKind::ParallelStateTransition,
6701                    RuntimeCommitBehavior::TransitionDecision,
6702                    "discarded",
6703                    false,
6704                );
6705                transition_finalized = true;
6706            }
6707
6708            if transition_finalized && skill_candidate.is_some() {
6709                let candidate = skill_candidate.take().unwrap();
6710                self.finalize_optional_branch(
6711                    &skill_id,
6712                    RuntimeOptimizationKind::SpeculativeSkillRouting,
6713                    RuntimeCommitBehavior::SkillSelection,
6714                    "committed",
6715                    true,
6716                );
6717                if !main_pending {
6718                    self.finalize_branch_loss(
6719                        &main_id,
6720                        main_kind,
6721                        RuntimeCommitBehavior::FinalResponse,
6722                        false,
6723                        main_result.as_ref().map(|result| result.is_err()),
6724                    );
6725                }
6726                if reasoning_enabled && !reasoning_pending {
6727                    self.finalize_branch_loss(
6728                        &reasoning_id,
6729                        RuntimeOptimizationKind::SpeculativeReasoningAuto,
6730                        RuntimeCommitBehavior::ReasoningDecision,
6731                        false,
6732                        Some(false),
6733                    );
6734                }
6735                self.finalize_pending_branches(branch_set.cancel_pending());
6736                self.commit_root_user_message(processed_input).await?;
6737                return match self
6738                    .commit_skill_candidate_route_result(candidate, processed_input)
6739                    .await?
6740                {
6741                    SkillRouteResult::Response { skill_id, content } => self
6742                        .handle_skill_response(processed_input, &skill_id, content, input_context)
6743                        .await
6744                        .map(Some),
6745                    SkillRouteResult::NeedsClarification(response) => {
6746                        if response
6747                            .metadata
6748                            .as_ref()
6749                            .and_then(|m| m.get("disambiguation"))
6750                            .and_then(|d| d.get("status"))
6751                            .and_then(|s| s.as_str())
6752                            == Some("awaiting_clarification")
6753                        {
6754                            self.memory
6755                                .add_message(ChatMessage::assistant(&response.content))
6756                                .await?;
6757                        }
6758                        self.finish_turn_if_root(&response).await?;
6759                        Ok(Some(response))
6760                    }
6761                    SkillRouteResult::NoMatch => Ok(None),
6762                };
6763            }
6764
6765            if transition_finalized
6766                && skill_finalized
6767                && let Some(reasoning_mode) = reasoning_decision.take()
6768            {
6769                if !matches!(reasoning_mode, ReasoningMode::None) {
6770                    self.finalize_optional_branch(
6771                        &reasoning_id,
6772                        RuntimeOptimizationKind::SpeculativeReasoningAuto,
6773                        RuntimeCommitBehavior::ReasoningDecision,
6774                        "committed",
6775                        true,
6776                    );
6777                    if !main_pending {
6778                        self.finalize_branch_loss(
6779                            &main_id,
6780                            main_kind,
6781                            RuntimeCommitBehavior::FinalResponse,
6782                            false,
6783                            main_result.as_ref().map(|result| result.is_err()),
6784                        );
6785                    }
6786                    self.finalize_pending_branches(branch_set.cancel_pending());
6787                    self.commit_root_user_message(processed_input).await?;
6788                    return if matches!(reasoning_mode, ReasoningMode::PlanAndExecute) {
6789                        self.handle_plan_and_execute(processed_input, input_context, true)
6790                            .await
6791                            .map(Some)
6792                    } else {
6793                        self.run_committed_response_loop_with_reasoning(
6794                            processed_input,
6795                            input_context,
6796                            reasoning_mode,
6797                            true,
6798                        )
6799                        .await
6800                        .map(Some)
6801                    };
6802                }
6803                self.finalize_optional_branch(
6804                    &reasoning_id,
6805                    RuntimeOptimizationKind::SpeculativeReasoningAuto,
6806                    RuntimeCommitBehavior::ReasoningDecision,
6807                    "committed",
6808                    true,
6809                );
6810                reasoning_finalized = true;
6811            }
6812
6813            if transition_finalized && skill_finalized && reasoning_finalized {
6814                if transition_fallback_required
6815                    || skill_fallback_required
6816                    || reasoning_fallback_required
6817                {
6818                    if !main_pending {
6819                        self.finalize_branch_loss(
6820                            &main_id,
6821                            main_kind,
6822                            RuntimeCommitBehavior::FinalResponse,
6823                            false,
6824                            main_result.as_ref().map(|result| result.is_err()),
6825                        );
6826                    }
6827                    self.finalize_pending_branches(branch_set.cancel_pending());
6828                    return Ok(None);
6829                }
6830
6831                if let Some(result) = main_result.take() {
6832                    let draft = match result {
6833                        Ok(draft) => draft,
6834                        Err(error) => {
6835                            self.finalize_optional_branch(
6836                                &main_id,
6837                                main_kind,
6838                                RuntimeCommitBehavior::FinalResponse,
6839                                "failed",
6840                                false,
6841                            );
6842                            self.finalize_pending_branches(branch_set.cancel_pending());
6843                            return Err(error);
6844                        }
6845                    };
6846                    self.finalize_optional_branch(
6847                        &main_id,
6848                        main_kind,
6849                        RuntimeCommitBehavior::FinalResponse,
6850                        "committed",
6851                        true,
6852                    );
6853                    self.finalize_pending_branches(branch_set.cancel_pending());
6854                    return self
6855                        .commit_main_response_draft(
6856                            processed_input,
6857                            input_context,
6858                            draft,
6859                            ReasoningMode::None,
6860                            reasoning_enabled,
6861                        )
6862                        .await
6863                        .map(Some);
6864                }
6865            }
6866
6867            if branch_set.is_empty() {
6868                return Ok(None);
6869            }
6870
6871            let Some(outcome) = branch_set.next_completed().await else {
6872                return Ok(None);
6873            };
6874            let branch_id = outcome.branch.branch_id();
6875            match outcome.result {
6876                RuntimeBranchResult::MainDraft(draft) => {
6877                    main_pending = false;
6878                    main_result = Some(Ok(draft));
6879                }
6880                RuntimeBranchResult::Transition(candidate) => {
6881                    if let Some(candidate) = candidate {
6882                        transition_candidate = Some(candidate);
6883                    } else {
6884                        self.finalize_optional_branch(
6885                            &transition_id,
6886                            RuntimeOptimizationKind::ParallelStateTransition,
6887                            RuntimeCommitBehavior::TransitionDecision,
6888                            "discarded",
6889                            false,
6890                        );
6891                        transition_finalized = true;
6892                    }
6893                }
6894                RuntimeBranchResult::Skill(candidate) => {
6895                    skill_pending = false;
6896                    if let Some(candidate) = candidate {
6897                        skill_candidate = Some(candidate);
6898                    } else {
6899                        self.finalize_optional_branch(
6900                            &skill_id,
6901                            RuntimeOptimizationKind::SpeculativeSkillRouting,
6902                            RuntimeCommitBehavior::SkillSelection,
6903                            "discarded",
6904                            false,
6905                        );
6906                        skill_finalized = true;
6907                    }
6908                }
6909                RuntimeBranchResult::Reasoning(mode) => {
6910                    reasoning_pending = false;
6911                    reasoning_decision = Some(mode);
6912                }
6913                RuntimeBranchResult::Failed(error) => {
6914                    if branch_id == main_id {
6915                        main_pending = false;
6916                        main_result = Some(Err(error));
6917                    } else if branch_id == transition_id {
6918                        self.finalize_optional_branch(
6919                            &transition_id,
6920                            RuntimeOptimizationKind::ParallelStateTransition,
6921                            RuntimeCommitBehavior::TransitionDecision,
6922                            "failed",
6923                            false,
6924                        );
6925                        transition_finalized = true;
6926                    } else if branch_id == skill_id {
6927                        skill_pending = false;
6928                        self.finalize_optional_branch(
6929                            &skill_id,
6930                            RuntimeOptimizationKind::SpeculativeSkillRouting,
6931                            RuntimeCommitBehavior::SkillSelection,
6932                            "failed",
6933                            false,
6934                        );
6935                        skill_finalized = true;
6936                    } else if branch_id == reasoning_id {
6937                        reasoning_pending = false;
6938                        self.finalize_optional_branch(
6939                            &reasoning_id,
6940                            RuntimeOptimizationKind::SpeculativeReasoningAuto,
6941                            RuntimeCommitBehavior::ReasoningDecision,
6942                            "failed",
6943                            false,
6944                        );
6945                        reasoning_finalized = true;
6946                    }
6947                }
6948                RuntimeBranchResult::Cancelled => {
6949                    self.finalize_optional_branch(
6950                        &branch_id,
6951                        outcome.branch.optimization,
6952                        outcome.branch.commit_behavior,
6953                        "cancelled",
6954                        false,
6955                    );
6956                    if branch_id == main_id {
6957                        main_pending = false;
6958                        main_result =
6959                            Some(Err(AgentError::Other("main branch cancelled".to_string())));
6960                    } else if branch_id == transition_id {
6961                        transition_finalized = true;
6962                        transition_fallback_required = true;
6963                    } else if branch_id == skill_id {
6964                        skill_pending = false;
6965                        skill_finalized = true;
6966                        skill_fallback_required = true;
6967                    } else if branch_id == reasoning_id {
6968                        reasoning_pending = false;
6969                        reasoning_finalized = true;
6970                        reasoning_fallback_required = true;
6971                    }
6972                }
6973            }
6974        }
6975    }
6976
6977    fn finalize_pending_branches(&self, branches: Vec<RuntimeBranch>) {
6978        for branch in branches {
6979            self.finalize_optional_branch(
6980                &branch.branch_id(),
6981                branch.optimization,
6982                branch.commit_behavior,
6983                "cancelled",
6984                false,
6985            );
6986        }
6987    }
6988
6989    //
6990    // Pending losers are reported as cancelled because their futures are dropped before completion.
6991    // Completed losers keep failed or discarded status based on their recorded result.
6992    //
6993    fn finalize_branch_loss(
6994        &self,
6995        branch_id: &str,
6996        optimization: RuntimeOptimizationKind,
6997        commit_behavior: RuntimeCommitBehavior,
6998        pending: bool,
6999        completed_failed: Option<bool>,
7000    ) {
7001        let status = if pending {
7002            "cancelled"
7003        } else if completed_failed.unwrap_or(false) {
7004            "failed"
7005        } else {
7006            "discarded"
7007        };
7008        self.finalize_optional_branch(branch_id, optimization, commit_behavior, status, false);
7009    }
7010
7011    //
7012    // Finalization is separated from commit so losing branches remain observable.
7013    // This helper must not mutate runtime state other than observability.
7014    //
7015    fn finalize_optional_branch(
7016        &self,
7017        branch_id: &str,
7018        optimization: RuntimeOptimizationKind,
7019        commit_behavior: RuntimeCommitBehavior,
7020        status: &str,
7021        winner: bool,
7022    ) {
7023        crate::optimization::observability::finalize_branch(
7024            self.observability_manager.as_ref(),
7025            branch_id,
7026            status,
7027            winner,
7028            optimization,
7029            commit_behavior,
7030        );
7031    }
7032
7033    //
7034    // This is only an eligibility check.
7035    // Actual transition selection happens in select_parallel_transition_candidate.
7036    //
7037    fn has_parallel_transition_candidates(&self) -> bool {
7038        self.transitions_available_for_commit()
7039            .map(|(transitions, _)| {
7040                transitions
7041                    .iter()
7042                    .any(|transition| matches!(transition.timing, TransitionTiming::Parallel))
7043            })
7044            .unwrap_or(false)
7045    }
7046
7047    //
7048    // Parallel transition prompts must not depend on assistant response text.
7049    // Keep this branch response-independent or it can race against invalid context.
7050    //
7051    async fn select_parallel_transition_candidate(
7052        &self,
7053        processed_input: &str,
7054    ) -> Result<ParallelTransitionSelection> {
7055        let Some((transitions, current_state)) = self.transitions_available_for_commit() else {
7056            return Ok(ParallelTransitionSelection::NoMatch);
7057        };
7058        let parallel: Vec<Transition> = transitions
7059            .into_iter()
7060            .filter(|transition| matches!(transition.timing, TransitionTiming::Parallel))
7061            .filter(|transition| !transition.requires_response)
7062            .collect();
7063        if parallel.is_empty() {
7064            return Ok(ParallelTransitionSelection::NoMatch);
7065        }
7066        let empty_staged = HashMap::new();
7067        if let Some(candidate) = self.select_deterministic_transition_candidate(
7068            processed_input,
7069            &current_state,
7070            &parallel,
7071            &empty_staged,
7072        ) {
7073            return Ok(ParallelTransitionSelection::Candidate(candidate));
7074        }
7075        let when_transitions: Vec<(usize, &Transition)> = parallel
7076            .iter()
7077            .enumerate()
7078            .filter(|(_, transition)| !transition.when.trim().is_empty())
7079            .collect();
7080        if when_transitions.is_empty() {
7081            return Ok(ParallelTransitionSelection::NoMatch);
7082        }
7083        let llm = self
7084            .llm_registry
7085            .router()
7086            .or_else(|_| self.llm_registry.default())
7087            .map_err(|e| AgentError::Config(e.to_string()))?;
7088        let conditions = when_transitions
7089            .iter()
7090            .enumerate()
7091            .map(|(display_idx, (_, transition))| {
7092                format!("{}. {}", display_idx + 1, transition.when)
7093            })
7094            .collect::<Vec<_>>()
7095            .join("\n");
7096        if !self
7097            .reserve_active_speculative_llm_call(RuntimeOptimizationKind::ParallelStateTransition)
7098        {
7099            return Ok(ParallelTransitionSelection::ReservationExhausted);
7100        }
7101        let context_preview = self.branch_context_preview();
7102        let prompt = format!(
7103            "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-{}).",
7104            current_state,
7105            processed_input,
7106            context_preview,
7107            conditions,
7108            when_transitions.len()
7109        );
7110        let response = self
7111            .observe_purpose(
7112                ObservationPurpose::StateTransitionEvaluation,
7113                llm.complete(&[ChatMessage::user(prompt)], None),
7114            )
7115            .await
7116            .map_err(|e| AgentError::LLM(e.to_string()))?;
7117        let choice = response.content.trim().parse::<usize>().unwrap_or(0);
7118        if choice == 0 || choice > when_transitions.len() {
7119            return Ok(ParallelTransitionSelection::NoMatch);
7120        }
7121        let transition = when_transitions[choice - 1].1.clone();
7122        Ok(ParallelTransitionSelection::Candidate(
7123            TransitionCandidate::new(
7124                current_state,
7125                transition.clone(),
7126                Self::transition_reason(&transition),
7127            ),
7128        ))
7129    }
7130
7131    /// Re-enters the runtime loop after an optimized transition commits.
7132    async fn redispatch_current_state(&self, processed_input: &str) -> Result<AgentResponse> {
7133        const MAX_REDISPATCH_DEPTH: u32 = 3;
7134        let current_depth = *self.redispatch_depth.read();
7135        if current_depth >= MAX_REDISPATCH_DEPTH {
7136            warn!(depth = current_depth, "Re-dispatch depth limit reached");
7137            let response = AgentResponse::new("");
7138            self.finish_turn_if_root(&response).await?;
7139            return Ok(response);
7140        }
7141        *self.redispatch_depth.write() += 1;
7142        if let Some(context) = self.active_turn_context.write().as_mut() {
7143            context.enter_redispatch();
7144        }
7145        let result = Box::pin(self.run_loop_internal(processed_input)).await;
7146        *self.redispatch_depth.write() -= 1;
7147        if let Some(context) = self.active_turn_context.write().as_mut() {
7148            context.exit_redispatch();
7149        }
7150        let response = result?;
7151        self.finish_turn_if_root(&response).await?;
7152        Ok(response)
7153    }
7154
7155    /// Runs final response hooks and maintenance only for the root dispatch.
7156    async fn finish_turn_if_root(&self, response: &AgentResponse) -> Result<()> {
7157        if *self.redispatch_depth.read() == 0 {
7158            self.post_turn_session_lifecycle().await?;
7159            if let Some(context) = self.active_turn_context.write().as_mut() {
7160                context.mark_post_turn_lifecycle_completed();
7161            }
7162            self.hooks.on_response(response).await;
7163            self.end_root_turn();
7164        }
7165        Ok(())
7166    }
7167
7168    /// Execute on_exit actions for a state being left.
7169    async fn execute_state_exit_actions(&self, state_path: &str) {
7170        if let Some(ref sm) = self.state_machine
7171            && let Some(def) = sm.get_definition(state_path)
7172            && !def.on_exit.is_empty()
7173        {
7174            debug!(state = %state_path, count = def.on_exit.len(), "Executing on_exit actions");
7175            self.execute_state_actions(&def.on_exit).await;
7176        }
7177    }
7178
7179    /// Returns whether a transition target had already been entered before this transition.
7180    fn state_was_previously_entered(
7181        state_path: &str,
7182        from_state: &str,
7183        history_before: &[StateTransitionEvent],
7184    ) -> bool {
7185        state_path == from_state
7186            || history_before
7187                .iter()
7188                .any(|event| event.from == state_path || event.to == state_path)
7189    }
7190
7191    /// Execute on_enter (or on_reenter) actions for a state being entered.
7192    async fn execute_state_enter_actions(&self, state_path: &str, is_reentry: bool) {
7193        if let Some(ref sm) = self.state_machine
7194            && let Some(def) = sm.get_definition(state_path)
7195        {
7196            if is_reentry && !def.on_reenter.is_empty() {
7197                debug!(state = %state_path, count = def.on_reenter.len(), "Executing on_reenter actions");
7198                self.execute_state_actions(&def.on_reenter).await;
7199            } else if !def.on_enter.is_empty() {
7200                debug!(state = %state_path, count = def.on_enter.len(), "Executing on_enter actions");
7201                self.execute_state_actions(&def.on_enter).await;
7202            }
7203        }
7204    }
7205
7206    /// Execute a list of state actions (tool calls, skill invocations, context updates, LLM prompts).
7207    async fn execute_state_actions(&self, actions: &[StateAction]) {
7208        for (action_index, action) in actions.iter().enumerate() {
7209            match action {
7210                StateAction::Tool { tool, args } => {
7211                    let raw_args = args.clone().unwrap_or(Value::Object(Default::default()));
7212                    let args_value = self.render_action_args(&raw_args);
7213                    let state = self.state_machine.as_ref().map(|sm| sm.current());
7214                    let request = ToolExecutionRequest::new(
7215                        uuid::Uuid::new_v4().to_string(),
7216                        tool.clone(),
7217                        args_value,
7218                        ToolCallSource::StateAction {
7219                            state,
7220                            action_index,
7221                        },
7222                    );
7223                    match self.execute_tool_record(request).await {
7224                        Ok(record) if record.success => {
7225                            debug!(tool = %record.canonical_id, "State action: tool executed");
7226                            let _ = self.context_manager.set(
7227                                "last_tool_result",
7228                                serde_json::Value::String(record.model_output_string()),
7229                            );
7230                            let _ = self.context_manager.set(
7231                                "last_tool_record",
7232                                serde_json::to_value(record).unwrap_or(Value::Null),
7233                            );
7234                        }
7235                        Ok(record) => {
7236                            warn!(tool = %record.canonical_id, error = %record.output, "State action: tool failed");
7237                        }
7238                        Err(e) => {
7239                            warn!(tool = %tool, error = %e, "State action: tool failed")
7240                        }
7241                    }
7242                }
7243                StateAction::Skill { skill } => {
7244                    if let Some(ref executor) = self.skill_executor {
7245                        if let Some(def) = self.skills.iter().find(|s| s.id == *skill) {
7246                            match executor
7247                                .execute_with_invoker(def, "", serde_json::json!({}), self)
7248                                .await
7249                            {
7250                                Ok(_) => debug!(skill = %skill, "State action: skill executed"),
7251                                Err(e) => {
7252                                    warn!(skill = %skill, error = %e, "State action: skill failed")
7253                                }
7254                            }
7255                        } else {
7256                            warn!(skill = %skill, "State action: skill not found");
7257                        }
7258                    }
7259                }
7260                StateAction::SetContext { set_context } => {
7261                    for (key, value) in set_context {
7262                        if let Err(e) = self.context_manager.set(key, value.clone()) {
7263                            warn!(key = %key, error = %e, "State action: set_context failed");
7264                        } else {
7265                            debug!(key = %key, "State action: context set");
7266                        }
7267                    }
7268                }
7269                StateAction::Prompt {
7270                    prompt,
7271                    llm,
7272                    store_as,
7273                } => {
7274                    let llm_result = if let Some(alias) = llm {
7275                        self.llm_registry.get(alias)
7276                    } else {
7277                        self.llm_registry.default()
7278                    };
7279                    match llm_result {
7280                        Ok(llm_provider) => {
7281                            // Render template variables and include conversation context
7282                            let context = self.build_context_with_overlays();
7283                            let rendered_prompt = self
7284                                .template_renderer
7285                                .render(prompt, &context)
7286                                .unwrap_or_else(|_| prompt.clone());
7287                            let recent =
7288                                self.memory.get_messages(Some(5)).await.unwrap_or_default();
7289                            let mut messages: Vec<ChatMessage> = recent;
7290                            messages.push(ChatMessage::user(&rendered_prompt));
7291                            match self
7292                                .observe_purpose(
7293                                    ObservationPurpose::StateAction,
7294                                    llm_provider.complete(&messages, None),
7295                                )
7296                                .await
7297                            {
7298                                Ok(response) => {
7299                                    if let Some(key) = store_as {
7300                                        let _ = self
7301                                            .context_manager
7302                                            .set(key, Value::String(response.content));
7303                                        debug!(key = %key, "State action: prompt result stored");
7304                                    }
7305                                }
7306                                Err(e) => {
7307                                    warn!(error = %e, "State action: prompt LLM call failed");
7308                                }
7309                            }
7310                        }
7311                        Err(e) => {
7312                            warn!(error = %e, "State action: LLM not found for prompt");
7313                        }
7314                    }
7315                }
7316            }
7317        }
7318    }
7319
7320    async fn run_context_extractors_staged(&self, user_message: &str) -> HashMap<String, Value> {
7321        let extractors = match &self.state_machine {
7322            Some(sm) => match sm.current_definition() {
7323                Some(def) if !def.extract.is_empty() => def.extract.clone(),
7324                _ => return HashMap::new(),
7325            },
7326            None => return HashMap::new(),
7327        };
7328
7329        let mut staged = HashMap::new();
7330        for extractor in &extractors {
7331            let prompt = if let Some(ref custom) = extractor.llm_extract {
7332                format!(
7333                    "User message:\n\"{}\"\n\nInstruction:\n{}",
7334                    user_message, custom
7335                )
7336            } else if let Some(ref desc) = extractor.description {
7337                format!(
7338                    "From the following message, extract: {}\n\n\
7339                     Message: \"{}\"\n\n\
7340                     If the information is present, return ONLY the extracted value.\n\
7341                     If NOT present, return exactly: __NONE__",
7342                    desc, user_message
7343                )
7344            } else {
7345                continue;
7346            };
7347
7348            let llm = match self
7349                .llm_registry
7350                .get(&extractor.llm)
7351                .or_else(|_| self.llm_registry.get("router"))
7352                .or_else(|_| self.llm_registry.get("default"))
7353            {
7354                Ok(llm) => llm,
7355                Err(e) => {
7356                    warn!(key = %extractor.key, error = %e, "Extractor LLM not found");
7357                    continue;
7358                }
7359            };
7360
7361            let messages = vec![ChatMessage::user(&prompt)];
7362            match self
7363                .observe_purpose(
7364                    ObservationPurpose::ContextExtraction,
7365                    llm.complete(&messages, None),
7366                )
7367                .await
7368            {
7369                Ok(response) => {
7370                    let value = response.content.trim().to_string();
7371                    if value != "__NONE__" && !value.is_empty() {
7372                        staged.insert(
7373                            extractor.key.clone(),
7374                            serde_json::Value::String(value.clone()),
7375                        );
7376                        debug!(key = %extractor.key, value = %value, "Context extracted");
7377                    } else if extractor.required {
7378                        warn!(key = %extractor.key, "Required extraction returned no value");
7379                    }
7380                }
7381                Err(e) => {
7382                    warn!(key = %extractor.key, error = %e, "Context extraction LLM call failed");
7383                }
7384            }
7385        }
7386        staged
7387    }
7388
7389    async fn commit_staged_context_writes(&self, staged: &HashMap<String, Value>) {
7390        for (key, value) in staged {
7391            if let Err(error) = self.context_manager.update(key, value.clone()) {
7392                warn!(key = %key, error = %error, "staged context write failed");
7393            }
7394        }
7395    }
7396
7397    /// Run context extractors for the current state on the user's input.
7398    async fn run_context_extractors(&self, user_message: &str) {
7399        let staged = self.run_context_extractors_staged(user_message).await;
7400        self.commit_staged_context_writes(&staged).await;
7401    }
7402
7403    async fn check_memory_compression(&self) -> Result<()> {
7404        if self.memory.needs_compression() {
7405            let result = self.memory.compress(None).await?;
7406            if let CompressResult::Compressed {
7407                messages_summarized,
7408                new_summary_length,
7409                tokens_saved,
7410            } = result
7411            {
7412                let event = MemoryCompressEvent::new(
7413                    messages_summarized,
7414                    tokens_saved,
7415                    new_summary_length as u32,
7416                );
7417                self.hooks.on_memory_compress(&event).await;
7418                debug!(
7419                    messages = messages_summarized,
7420                    tokens_saved = tokens_saved,
7421                    "Memory compressed"
7422                );
7423            }
7424        }
7425
7426        // Handle overflow AFTER compression, then check warning threshold
7427        self.handle_memory_overflow().await?;
7428        self.check_memory_budget().await;
7429
7430        Ok(())
7431    }
7432
7433    async fn check_memory_budget(&self) {
7434        let Some(ref budget) = self.memory_token_budget else {
7435            return;
7436        };
7437
7438        let context = match self.memory.get_context().await {
7439            Ok(ctx) => ctx,
7440            Err(_) => return,
7441        };
7442
7443        // Overall budget warning
7444        let used_tokens = context.estimated_tokens();
7445        if budget.is_over_warn_threshold(used_tokens) {
7446            let event = MemoryBudgetEvent::new("memory", used_tokens, budget.total);
7447            self.hooks.on_memory_budget_warning(&event).await;
7448            debug!(
7449                used = used_tokens,
7450                total = budget.total,
7451                percent = event.usage_percent,
7452                "Memory budget warning"
7453            );
7454        }
7455
7456        // Per-component warning: summary
7457        if let Some(ref summary) = context.summary {
7458            let summary_tokens = ai_agents_memory::estimate_tokens(summary);
7459            let summary_budget = budget.allocation.summary;
7460            if summary_budget > 0 {
7461                let warn_threshold =
7462                    (summary_budget as f64 * budget.warn_at_percent as f64 / 100.0) as u32;
7463                if summary_tokens >= warn_threshold {
7464                    let event = MemoryBudgetEvent::new("summary", summary_tokens, summary_budget);
7465                    self.hooks.on_memory_budget_warning(&event).await;
7466                }
7467            }
7468        }
7469
7470        // Per-component warning: recent_messages
7471        let recent_tokens: u32 = context
7472            .messages
7473            .iter()
7474            .map(ai_agents_memory::estimate_message_tokens)
7475            .sum();
7476        let recent_budget = budget.allocation.recent_messages;
7477        if recent_budget > 0 {
7478            let warn_threshold =
7479                (recent_budget as f64 * budget.warn_at_percent as f64 / 100.0) as u32;
7480            if recent_tokens >= warn_threshold {
7481                let event = MemoryBudgetEvent::new("recent_messages", recent_tokens, recent_budget);
7482                self.hooks.on_memory_budget_warning(&event).await;
7483            }
7484        }
7485
7486        let relationship_budget = budget.allocation.relationships;
7487        if relationship_budget > 0 {
7488            let relationship_tokens = self
7489                .relationship_memory_text()
7490                .map(|text| ai_agents_memory::estimate_tokens(&text))
7491                .unwrap_or(0);
7492            let warn_threshold =
7493                (relationship_budget as f64 * budget.warn_at_percent as f64 / 100.0) as u32;
7494            if relationship_tokens >= warn_threshold {
7495                let event = MemoryBudgetEvent::new(
7496                    "relationships",
7497                    relationship_tokens,
7498                    relationship_budget,
7499                );
7500                self.hooks.on_memory_budget_warning(&event).await;
7501            }
7502        }
7503    }
7504
7505    async fn handle_memory_overflow(&self) -> Result<()> {
7506        let Some(ref budget) = self.memory_token_budget else {
7507            return Ok(());
7508        };
7509
7510        let context = self.memory.get_context().await?;
7511        let used_tokens = context.estimated_tokens();
7512
7513        if used_tokens <= budget.total {
7514            return Ok(());
7515        }
7516
7517        match budget.overflow_strategy {
7518            OverflowStrategy::TruncateOldest => {
7519                let tokens_to_free = used_tokens - budget.total;
7520                let messages_to_evict = self.calculate_eviction_count(tokens_to_free);
7521                if messages_to_evict > 0 {
7522                    self.evict_messages(messages_to_evict, EvictionReason::TokenBudgetExceeded)
7523                        .await?;
7524                }
7525            }
7526            OverflowStrategy::SummarizeMore => {
7527                let max_attempts = context.total_messages.max(1);
7528                for _ in 0..max_attempts {
7529                    match self.memory.compress(None).await? {
7530                        CompressResult::Compressed {
7531                            messages_summarized,
7532                            ..
7533                        } if messages_summarized > 0 => {
7534                            let context = self.memory.get_context().await?;
7535                            if context.estimated_tokens() <= budget.total {
7536                                return Ok(());
7537                            }
7538                        }
7539                        _ => break,
7540                    }
7541                }
7542                let context = self.memory.get_context().await?;
7543                let used_tokens = context.estimated_tokens();
7544                if used_tokens > budget.total {
7545                    return Err(AgentError::MemoryBudgetExceeded {
7546                        used: used_tokens,
7547                        budget: budget.total,
7548                    });
7549                }
7550            }
7551            OverflowStrategy::Error => {
7552                return Err(AgentError::MemoryBudgetExceeded {
7553                    used: used_tokens,
7554                    budget: budget.total,
7555                });
7556            }
7557        }
7558        Ok(())
7559    }
7560
7561    fn calculate_eviction_count(&self, tokens_to_free: u32) -> usize {
7562        // Estimate ~50 tokens per message on average
7563        ((tokens_to_free as f64 / 50.0).ceil() as usize).max(1)
7564    }
7565
7566    async fn evict_messages(&self, count: usize, reason: EvictionReason) -> Result<()> {
7567        let evicted = self.memory.evict_oldest(count).await?;
7568        if !evicted.is_empty() {
7569            let event = MemoryEvictEvent {
7570                reason,
7571                messages_evicted: evicted.len(),
7572                importance_scores: vec![],
7573            };
7574            self.hooks.on_memory_evict(&event).await;
7575            debug!(count = evicted.len(), "Messages evicted from memory");
7576        }
7577        Ok(())
7578    }
7579
7580    #[instrument(skip(self, input), fields(agent = %self.info.name))]
7581    async fn determine_reasoning_mode(&self, input: &str) -> Result<ReasoningMode> {
7582        match self.determine_reasoning_mode_strict(input).await {
7583            Ok(mode) => Ok(mode),
7584            Err(_) => Ok(ReasoningMode::None),
7585        }
7586    }
7587
7588    async fn determine_reasoning_mode_strict(&self, input: &str) -> Result<ReasoningMode> {
7589        let effective_config = self.get_effective_reasoning_config();
7590
7591        if !matches!(effective_config.mode, ReasoningMode::Auto) {
7592            return Ok(effective_config.mode.clone());
7593        }
7594
7595        let judge_llm = effective_config
7596            .judge_llm
7597            .as_ref()
7598            .and_then(|alias| self.llm_registry.get(alias).ok())
7599            .or_else(|| self.llm_registry.router().ok())
7600            .or_else(|| self.llm_registry.default().ok());
7601
7602        let Some(llm) = judge_llm else {
7603            return Ok(ReasoningMode::None);
7604        };
7605
7606        let prompt = format!(
7607            r#"Analyze this user request and determine the appropriate reasoning mode.
7608
7609User request: "{}"
7610
7611Choose ONE of these modes:
7612- none: Simple queries, greetings, direct answers (fastest)
7613- cot: Complex analysis, multi-step reasoning, math problems
7614- react: Tasks requiring multiple tool calls with observation
7615- plan_and_execute: Complex multi-step tasks requiring coordination
7616
7617Respond with ONLY the mode name (none, cot, react, or plan_and_execute)."#,
7618            input
7619        );
7620
7621        let messages = vec![ChatMessage::user(&prompt)];
7622        let response = self
7623            .observe_purpose(
7624                ObservationPurpose::ReflectionDecision,
7625                llm.complete(&messages, None),
7626            )
7627            .await
7628            .map_err(|e| AgentError::LLM(e.to_string()))?;
7629
7630        let mode_str = response.content.trim().to_lowercase();
7631        Ok(match mode_str.as_str() {
7632            "cot" => ReasoningMode::CoT,
7633            "react" => ReasoningMode::React,
7634            "plan_and_execute" => ReasoningMode::PlanAndExecute,
7635            _ => ReasoningMode::None,
7636        })
7637    }
7638
7639    async fn should_reflect(&self, input: &str, response: &str) -> Result<bool> {
7640        let effective_config = self.get_effective_reflection_config();
7641
7642        if !effective_config.requires_evaluation() {
7643            return Ok(false);
7644        }
7645
7646        if effective_config.is_enabled() {
7647            return Ok(true);
7648        }
7649
7650        let evaluator_llm = effective_config
7651            .evaluator_llm
7652            .as_ref()
7653            .and_then(|alias| self.llm_registry.get(alias).ok())
7654            .or_else(|| self.llm_registry.router().ok())
7655            .or_else(|| self.llm_registry.default().ok());
7656
7657        let Some(llm) = evaluator_llm else {
7658            return Ok(false);
7659        };
7660
7661        let response_preview: String = response.chars().take(500).collect();
7662        let prompt = format!(
7663            r#"Should this response be evaluated for quality? Consider if it's a complex or important response.
7664
7665User query: "{}"
7666Response: "{}"
7667
7668Answer YES or NO only."#,
7669            input, response_preview
7670        );
7671
7672        let messages = vec![ChatMessage::user(&prompt)];
7673        let result = self
7674            .observe_purpose(
7675                ObservationPurpose::ReflectionDecision,
7676                llm.complete(&messages, None),
7677            )
7678            .await;
7679
7680        match result {
7681            Ok(resp) => Ok(resp.content.trim().to_uppercase().contains("YES")),
7682            Err(_) => Ok(false),
7683        }
7684    }
7685
7686    fn build_cot_system_prompt(&self, base_prompt: &str) -> String {
7687        format!(
7688            "{}\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>",
7689            base_prompt
7690        )
7691    }
7692
7693    fn build_react_system_prompt(&self, base_prompt: &str) -> String {
7694        format!(
7695            "{}\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>",
7696            base_prompt
7697        )
7698    }
7699
7700    async fn generate_plan(&self, input: &str) -> Result<Plan> {
7701        let effective = self.get_effective_reasoning_config();
7702        let planning_config = effective.get_planning();
7703
7704        let planner_llm = planning_config
7705            .and_then(|c| c.planner_llm.as_ref())
7706            .and_then(|alias| self.llm_registry.get(alias).ok())
7707            .or_else(|| self.llm_registry.router().ok())
7708            .or_else(|| self.llm_registry.default().ok())
7709            .ok_or_else(|| AgentError::Config("No LLM available for planning".into()))?;
7710
7711        let mut available_tool_ids: Vec<String> = self
7712            .get_available_tool_ids()
7713            .await
7714            .unwrap_or_else(|_| self.tools.list_ids());
7715        let mut available_skills: Vec<String> = self.skills.iter().map(|s| s.id.clone()).collect();
7716
7717        // Apply planning-level tool and skill filters.
7718        if let Some(config) = planning_config {
7719            if !config.available.tools.is_all() {
7720                available_tool_ids.retain(|t| config.available.tools.allows(t));
7721            }
7722            if !config.available.skills.is_all() {
7723                available_skills.retain(|s| config.available.skills.allows(s));
7724            }
7725        }
7726
7727        // Build tool descriptions with argument schemas so the planner
7728        // knows how to construct valid args for each step.
7729        let tool_descriptions: Vec<String> = available_tool_ids
7730            .iter()
7731            .filter_map(|id| {
7732                self.tools.get(id).map(|tool| {
7733                    let schema = tool.input_schema();
7734                    let args_desc = schema
7735                        .get("properties")
7736                        .and_then(|p| serde_json::to_string(p).ok())
7737                        .unwrap_or_else(|| "{}".to_string());
7738                    format!(
7739                        "- {} ({}): {}\n  Arguments: {}",
7740                        id,
7741                        tool.name(),
7742                        tool.description(),
7743                        args_desc
7744                    )
7745                })
7746            })
7747            .collect();
7748
7749        let tools_section = if tool_descriptions.is_empty() {
7750            "Available tools: none".to_string()
7751        } else {
7752            format!("Available tools:\n{}", tool_descriptions.join("\n"))
7753        };
7754
7755        let skills_section = if available_skills.is_empty() {
7756            "Available skills: none".to_string()
7757        } else {
7758            format!("Available skills: {}", available_skills.join(", "))
7759        };
7760
7761        let prompt = format!(
7762            r#"Create a step-by-step plan to accomplish this goal.
7763
7764Goal: "{}"
7765
7766{}
7767
7768{}
7769
7770Create a plan with clear steps. For each step, specify:
7771- description: What this step accomplishes
7772- action_type: "tool", "skill", "think", or "respond"
7773- action_target: The tool/skill id (if applicable)
7774- args: The arguments object matching the tool's schema (if action_type is "tool")
7775- dependencies: List of step IDs this depends on (empty if none)
7776
7777Respond in JSON format:
7778{{
7779  "steps": [
7780    {{"id": "step1", "description": "...", "action_type": "tool", "action_target": "tool_id", "args": {{"required_field": "value"}}, "dependencies": []}},
7781    {{"id": "step2", "description": "...", "action_type": "think", "action_target": "...", "dependencies": ["step1"]}}
7782  ]
7783}}"#,
7784            input, tools_section, skills_section,
7785        );
7786
7787        let messages = vec![ChatMessage::user(&prompt)];
7788        let response = self
7789            .observe_purpose(
7790                ObservationPurpose::PlanGeneration,
7791                planner_llm.complete(&messages, None),
7792            )
7793            .await
7794            .map_err(|e| AgentError::LLM(format!("Planning failed: {}", e)))?;
7795
7796        let mut plan = Plan::new(input);
7797
7798        if let Some(json_start) = response.content.find('{')
7799            && let Some(json_end) = response.content.rfind('}')
7800        {
7801            let json_str = &response.content[json_start..=json_end];
7802            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json_str)
7803                && let Some(steps) = parsed.get("steps").and_then(|s| s.as_array())
7804            {
7805                for step_value in steps {
7806                    let id = step_value
7807                        .get("id")
7808                        .and_then(|v| v.as_str())
7809                        .unwrap_or("step");
7810                    let desc = step_value
7811                        .get("description")
7812                        .and_then(|v| v.as_str())
7813                        .unwrap_or("");
7814                    let action_type = step_value
7815                        .get("action_type")
7816                        .and_then(|v| v.as_str())
7817                        .unwrap_or("think");
7818                    let action_target = step_value
7819                        .get("action_target")
7820                        .and_then(|v| v.as_str())
7821                        .unwrap_or("");
7822                    let args = step_value
7823                        .get("args")
7824                        .cloned()
7825                        .unwrap_or(serde_json::json!({}));
7826                    let deps: Vec<String> = step_value
7827                        .get("dependencies")
7828                        .and_then(|v| v.as_array())
7829                        .map(|arr| {
7830                            arr.iter()
7831                                .filter_map(|v| v.as_str().map(String::from))
7832                                .collect()
7833                        })
7834                        .unwrap_or_default();
7835
7836                    let action = match action_type {
7837                        "tool" => PlanAction::tool(action_target, args),
7838                        "skill" => PlanAction::skill(action_target),
7839                        "respond" => PlanAction::respond(action_target),
7840                        _ => PlanAction::think(desc),
7841                    };
7842
7843                    let step = PlanStep::new(desc, action)
7844                        .with_id(id)
7845                        .with_dependencies(deps);
7846                    plan.add_step(step);
7847                }
7848            }
7849        }
7850
7851        if plan.steps.is_empty() {
7852            plan.add_step(PlanStep::new(
7853                "Process the request",
7854                PlanAction::think(input),
7855            ));
7856            plan.add_step(PlanStep::new(
7857                "Provide response",
7858                PlanAction::respond("Answer based on analysis"),
7859            ));
7860        }
7861
7862        Ok(plan)
7863    }
7864
7865    async fn execute_plan(&self, plan: &mut Plan) -> Result<String> {
7866        let llm = self.get_state_llm()?;
7867        let mut results: HashMap<String, serde_json::Value> = HashMap::new();
7868        let effective = self.get_effective_reasoning_config();
7869        let max_steps = effective.get_planning().map(|c| c.max_steps).unwrap_or(10);
7870
7871        plan.status = PlanStatus::InProgress;
7872
7873        for step_idx in 0..plan.steps.len().min(max_steps as usize) {
7874            let step = &plan.steps[step_idx];
7875
7876            let deps_satisfied = step.dependencies.iter().all(|dep| {
7877                plan.steps
7878                    .iter()
7879                    .find(|s| &s.id == dep)
7880                    .map(|s| s.status.is_completed())
7881                    .unwrap_or(false)
7882            });
7883
7884            if !deps_satisfied {
7885                continue;
7886            }
7887
7888            plan.steps[step_idx].mark_running();
7889
7890            let result = match &plan.steps[step_idx].action {
7891                PlanAction::Tool { tool, args } => {
7892                    // When a tool step has dependency results, ask the LLM to
7893                    // produce the correct arguments given the context and tool schema.
7894                    // This avoids brittle {{stepN}} template substitution and lets
7895                    // the LLM handle type adaptation (e.g. picking the iso field
7896                    // from a datetime result for a downstream format call).
7897                    let has_dep_results = plan.steps[step_idx]
7898                        .dependencies
7899                        .iter()
7900                        .any(|dep| results.contains_key(dep));
7901
7902                    let final_args = if has_dep_results {
7903                        let dep_context: String = plan.steps[step_idx]
7904                            .dependencies
7905                            .iter()
7906                            .filter_map(|dep| results.get(dep).map(|r| format!("{}: {}", dep, r)))
7907                            .collect::<Vec<_>>()
7908                            .join("\n");
7909
7910                        let tool_schema = self
7911                            .tools
7912                            .get(tool)
7913                            .map(|t| {
7914                                let schema = t.input_schema();
7915                                let props = schema
7916                                    .get("properties")
7917                                    .and_then(|p| serde_json::to_string(p).ok())
7918                                    .unwrap_or_else(|| "{}".to_string());
7919                                format!(
7920                                    "{}: {}\nArguments schema: {}",
7921                                    t.id(),
7922                                    t.description(),
7923                                    props
7924                                )
7925                            })
7926                            .unwrap_or_default();
7927
7928                        let step_desc = &plan.steps[step_idx].description;
7929                        let arg_prompt = format!(
7930                            "Generate the JSON arguments for a tool call.\n\n\
7931                             Tool: {}\n\n\
7932                             Task: {}\n\n\
7933                             Previous step results:\n{}\n\n\
7934                             Planner's draft arguments: {}\n\n\
7935                             Produce ONLY a valid JSON object with the correct argument values.\n\
7936                             Use actual values from the previous step results, not template references.",
7937                            tool_schema,
7938                            step_desc,
7939                            dep_context,
7940                            serde_json::to_string(args).unwrap_or_default()
7941                        );
7942                        let messages = vec![ChatMessage::user(&arg_prompt)];
7943                        match self
7944                            .observe_purpose(
7945                                ObservationPurpose::PlanStep,
7946                                llm.complete(&messages, None),
7947                            )
7948                            .await
7949                        {
7950                            Ok(resp) => {
7951                                let content = resp.content.trim();
7952                                // Parse the LLM's JSON response, fall back to planner args.
7953                                let json_start = content.find('{');
7954                                let json_end = content.rfind('}');
7955                                if let (Some(start), Some(end)) = (json_start, json_end) {
7956                                    serde_json::from_str(&content[start..=end])
7957                                        .unwrap_or_else(|_| args.clone())
7958                                } else {
7959                                    args.clone()
7960                                }
7961                            }
7962                            Err(_) => args.clone(),
7963                        }
7964                    } else {
7965                        args.clone()
7966                    };
7967
7968                    let request = ToolExecutionRequest::new(
7969                        uuid::Uuid::new_v4().to_string(),
7970                        tool.clone(),
7971                        final_args,
7972                        ToolCallSource::Plan {
7973                            step_index: step_idx,
7974                        },
7975                    );
7976                    match self.execute_tool_record(request).await {
7977                        Ok(record) if record.success => {
7978                            serde_json::json!({ "output": record.model_output_string() })
7979                        }
7980                        Ok(record) => {
7981                            plan.steps[step_idx].mark_failed(record.model_output_string());
7982                            continue;
7983                        }
7984                        Err(e) => {
7985                            plan.steps[step_idx].mark_failed(e.to_string());
7986                            continue;
7987                        }
7988                    }
7989                }
7990                PlanAction::Skill { skill } => {
7991                    if let Some(skill_def) = self.skills.iter().find(|s| &s.id == skill) {
7992                        if let Some(ref executor) = self.skill_executor {
7993                            match executor
7994                                .execute_with_invoker(skill_def, "", serde_json::json!({}), self)
7995                                .await
7996                            {
7997                                Ok(output) => serde_json::json!({ "output": output }),
7998                                Err(e) => {
7999                                    plan.steps[step_idx].mark_failed(e.to_string());
8000                                    continue;
8001                                }
8002                            }
8003                        } else {
8004                            serde_json::json!({ "output": "Skill executor not available" })
8005                        }
8006                    } else {
8007                        plan.steps[step_idx].mark_failed("Skill not found");
8008                        continue;
8009                    }
8010                }
8011                PlanAction::Think { prompt } => {
8012                    let context: String = results
8013                        .iter()
8014                        .map(|(k, v)| format!("{}: {}", k, v))
8015                        .collect::<Vec<_>>()
8016                        .join("\n");
8017
8018                    let think_prompt = format!("Context:\n{}\n\nTask: {}", context, prompt);
8019                    let messages = vec![ChatMessage::user(&think_prompt)];
8020
8021                    match self
8022                        .observe_purpose(
8023                            ObservationPurpose::PlanStep,
8024                            llm.complete(&messages, None),
8025                        )
8026                        .await
8027                    {
8028                        Ok(resp) => serde_json::json!({ "output": resp.content }),
8029                        Err(e) => {
8030                            plan.steps[step_idx].mark_failed(e.to_string());
8031                            continue;
8032                        }
8033                    }
8034                }
8035                PlanAction::Respond { template } => {
8036                    let context: String = results
8037                        .iter()
8038                        .map(|(k, v)| format!("{}: {}", k, v))
8039                        .collect::<Vec<_>>()
8040                        .join("\n");
8041
8042                    let respond_prompt = format!(
8043                        "Based on this context:\n{}\n\nGenerate a response following this template/instruction: {}",
8044                        context, template
8045                    );
8046                    let messages = vec![ChatMessage::user(&respond_prompt)];
8047
8048                    match self
8049                        .observe_purpose(
8050                            ObservationPurpose::PlanStep,
8051                            llm.complete(&messages, None),
8052                        )
8053                        .await
8054                    {
8055                        Ok(resp) => serde_json::json!({ "output": resp.content }),
8056                        Err(e) => {
8057                            plan.steps[step_idx].mark_failed(e.to_string());
8058                            continue;
8059                        }
8060                    }
8061                }
8062            };
8063
8064            results.insert(plan.steps[step_idx].id.clone(), result.clone());
8065            plan.steps[step_idx].mark_completed(Some(result));
8066        }
8067
8068        // Set plan status based on whether any steps actually failed.
8069        let has_failures = plan.steps.iter().any(|s| s.status.is_failed());
8070        if has_failures {
8071            let failed_ids: Vec<String> = plan
8072                .steps
8073                .iter()
8074                .filter(|s| s.status.is_failed())
8075                .map(|s| s.id.clone())
8076                .collect();
8077            plan.status = PlanStatus::Failed {
8078                error: format!("Steps failed: {}", failed_ids.join(", ")),
8079            };
8080        } else {
8081            plan.status = PlanStatus::Completed;
8082        }
8083
8084        // Synthesize final output from all completed step results.
8085        let all_outputs: Vec<String> = plan
8086            .steps
8087            .iter()
8088            .filter(|s| s.status.is_completed())
8089            .filter_map(|s| {
8090                s.result
8091                    .as_ref()
8092                    .and_then(|r| r.get("output"))
8093                    .and_then(|o| o.as_str())
8094                    .map(|o| format!("{}: {}", s.description, o))
8095            })
8096            .collect();
8097
8098        if all_outputs.is_empty() {
8099            return Ok("Plan execution completed but produced no results.".to_string());
8100        }
8101
8102        if all_outputs.len() == 1 {
8103            return Ok(all_outputs.into_iter().next().unwrap());
8104        }
8105
8106        // Synthesize a coherent summary from multiple step results via LLM.
8107        let context = all_outputs.join("\n\n");
8108        let prompt = format!(
8109            "You completed a multi-step plan for: \"{}\"\n\nStep results:\n{}\n\nProvide a coherent final response that synthesizes these results.",
8110            plan.goal, context
8111        );
8112        let messages = vec![ChatMessage::user(&prompt)];
8113        match self
8114            .observe_purpose(ObservationPurpose::PlanStep, llm.complete(&messages, None))
8115            .await
8116        {
8117            Ok(resp) => Ok(resp.content.trim().to_string()),
8118            Err(_) => Ok(context),
8119        }
8120    }
8121
8122    async fn evaluate_response(&self, input: &str, response: &str) -> Result<EvaluationResult> {
8123        let effective_config = self.get_effective_reflection_config();
8124        self.evaluate_response_with_config(input, response, &effective_config)
8125            .await
8126    }
8127
8128    fn extract_thinking(&self, content: &str) -> (Option<String>, String) {
8129        if let Some(start) = content.find("<thinking>")
8130            && let Some(end) = content.find("</thinking>")
8131        {
8132            let thinking = content[start + 10..end].trim().to_string();
8133            let answer = content[end + 11..].trim().to_string();
8134            return (Some(thinking), answer);
8135        }
8136        (None, content.to_string())
8137    }
8138
8139    fn format_response_with_thinking(&self, thinking: Option<&str>, answer: &str) -> String {
8140        match self.get_effective_reasoning_config().output {
8141            ReasoningOutput::Hidden => answer.to_string(),
8142            ReasoningOutput::Visible => {
8143                if let Some(t) = thinking {
8144                    format!("Thinking:\n{}\n\nAnswer:\n{}", t, answer)
8145                } else {
8146                    answer.to_string()
8147                }
8148            }
8149            ReasoningOutput::Tagged => {
8150                if let Some(t) = thinking {
8151                    format!("<thinking>{}</thinking>\n{}", t, answer)
8152                } else {
8153                    answer.to_string()
8154                }
8155            }
8156        }
8157    }
8158
8159    async fn run_loop(&self, input: &str) -> Result<AgentResponse> {
8160        //
8161        // Blocking execution must fail before a turn starts when required persistence is unavailable.
8162        //
8163        self.init_storage().await?;
8164        self.begin_root_turn();
8165        let _root_cleanup = RootTurnCleanup::new(self);
8166        info!(input_len = input.len(), "Starting chat");
8167
8168        self.hooks.on_message_received(input).await;
8169
8170        // One-shot context initialization: load runtime defaults, resolve env vars,
8171        // populate builtin sources (session, agent), etc.  This must happen before
8172        // the first template render so that {{ context.* }} variables are available.
8173        if !self.context_initialized.swap(true, Ordering::SeqCst) {
8174            self.context_manager.initialize().await?;
8175            debug!("Context manager initialized (defaults, env, builtins)");
8176        }
8177
8178        self.check_turn_timeout().await?;
8179        self.context_manager.refresh_per_turn().await?;
8180
8181        // Clear stale disambiguation context from previous turns.
8182        // This prevents resolved_intent from leaking across turns and causing incorrect deterministic routing on subsequent inputs.
8183        self.clear_disambiguation_context();
8184
8185        // Disambiguation check (before input processing)
8186        if let Some(ref disambiguator) = self.disambiguation_manager {
8187            let disambiguation_context = self.build_disambiguation_context().await?;
8188
8189            // Get state-level disambiguation override
8190            let state_override = self
8191                .state_machine
8192                .as_ref()
8193                .and_then(|sm| sm.current_definition())
8194                .and_then(|def| def.disambiguation.clone());
8195
8196            match self
8197                .observe_purpose(
8198                    ObservationPurpose::DisambiguationDetection,
8199                    disambiguator.process_input_with_override(
8200                        input,
8201                        &disambiguation_context,
8202                        state_override.as_ref(),
8203                        None,
8204                    ),
8205                )
8206                .await?
8207            {
8208                DisambiguationResult::Clear => {
8209                    debug!("Input is clear, proceeding normally");
8210                }
8211                DisambiguationResult::NeedsClarification {
8212                    question,
8213                    detection,
8214                } => {
8215                    info!(
8216                        ambiguity_type = ?detection.ambiguity_type,
8217                        confidence = detection.confidence,
8218                        "Input requires clarification"
8219                    );
8220
8221                    self.commit_root_user_message(input).await?;
8222                    self.memory
8223                        .add_message(ChatMessage::assistant(&question.question))
8224                        .await?;
8225
8226                    let response = AgentResponse::new(&question.question).with_metadata(
8227                        "disambiguation",
8228                        serde_json::json!({
8229                            "status": "awaiting_clarification",
8230                            "options": question.options,
8231                            "clarifying": question.clarifying,
8232                            "detection": {
8233                                "type": detection.ambiguity_type,
8234                                "confidence": detection.confidence,
8235                                "what_is_unclear": detection.what_is_unclear,
8236                            }
8237                        }),
8238                    );
8239                    self.finish_turn_if_root(&response).await?;
8240                    return Ok(response);
8241                }
8242                DisambiguationResult::Clarified {
8243                    enriched_input,
8244                    resolved,
8245                    ..
8246                } => {
8247                    info!(
8248                        resolved_count = resolved.len(),
8249                        enriched = %enriched_input,
8250                        "Input clarified, injecting resolved intent into context"
8251                    );
8252
8253                    // Routing uses `resolved` (structured, deterministic)
8254                    // This is what makes post-disambiguation routing DETERMINISTIC
8255                    for (key, value) in &resolved {
8256                        let context_key = format!("disambiguation.{}", key);
8257                        let _ = self.context_manager.set(&context_key, value.clone());
8258                    }
8259
8260                    if let Some(intent) = resolved.get("intent") {
8261                        let _ = self.context_manager.set("resolved_intent", intent.clone());
8262                    }
8263
8264                    let _ = self
8265                        .context_manager
8266                        .set("disambiguation.resolved", serde_json::Value::Bool(true));
8267
8268                    // Check if this clarification was triggered by a skill-level override.
8269                    // If so, route directly to the matched skill instead of going through
8270                    // skill routing again (which might match a different skill).
8271                    let skill_id = self.pending_skill_id.read().clone();
8272                    if let Some(skill_id) = skill_id {
8273                        info!(skill_id = %skill_id, "Re-checking skill disambiguation on clarified input");
8274                        return self
8275                            .recheck_skill_disambiguation(&skill_id, &enriched_input)
8276                            .await;
8277                    }
8278
8279                    return self.run_loop_internal(&enriched_input).await;
8280                }
8281                DisambiguationResult::ProceedWithBestGuess { enriched_input } => {
8282                    info!("Proceeding with best guess interpretation");
8283
8284                    // Same skill-id re-check for best-guess path
8285                    let skill_id = self.pending_skill_id.read().clone();
8286                    if let Some(skill_id) = skill_id {
8287                        info!(skill_id = %skill_id, "Re-checking skill disambiguation on best-guess input");
8288                        return self
8289                            .recheck_skill_disambiguation(&skill_id, &enriched_input)
8290                            .await;
8291                    }
8292
8293                    return self.run_loop_internal(&enriched_input).await;
8294                }
8295                DisambiguationResult::GiveUp { reason } => {
8296                    *self.pending_skill_id.write() = None;
8297                    warn!(reason = %reason, "Disambiguation gave up");
8298                    let apology = self
8299                        .generate_localized_apology(
8300                            "Generate a brief, polite apology saying you couldn't understand the request. Be concise.",
8301                            &reason,
8302                        )
8303                        .await
8304                        .unwrap_or_else(|_| {
8305                            format!("I'm sorry, I couldn't understand your request: {}", reason)
8306                        });
8307                    let response = AgentResponse::new(&apology);
8308                    self.finish_turn_if_root(&response).await?;
8309                    return Ok(response);
8310                }
8311                DisambiguationResult::Escalate { reason } => {
8312                    *self.pending_skill_id.write() = None;
8313                    info!(reason = %reason, "Escalating to human");
8314                    if let Some(ref hitl) = self.hitl_engine {
8315                        let trigger =
8316                            ApprovalTrigger::condition("disambiguation_escalation", reason.clone());
8317                        let mut context_map = HashMap::new();
8318                        context_map.insert("original_input".to_string(), serde_json::json!(input));
8319                        context_map.insert("reason".to_string(), serde_json::json!(&reason));
8320                        let check_result = HITLCheckResult::required(
8321                            trigger,
8322                            context_map,
8323                            format!("User request needs human assistance: {}", reason),
8324                            Some(hitl.config().default_timeout_seconds),
8325                        );
8326                        let result = self.request_hitl_approval(check_result).await?;
8327                        if matches!(
8328                            result,
8329                            ApprovalResult::Approved | ApprovalResult::Modified { .. }
8330                        ) {
8331                            return self.run_loop_internal(input).await;
8332                        }
8333                    }
8334                    let apology = self
8335                        .generate_localized_apology(
8336                            "Explain briefly that you're transferring the user to a human agent for help.",
8337                            &reason,
8338                        )
8339                        .await
8340                        .unwrap_or_else(|_| {
8341                            format!("I need human assistance to help with your request: {}", reason)
8342                        });
8343                    let response = AgentResponse::new(&apology);
8344                    self.finish_turn_if_root(&response).await?;
8345                    return Ok(response);
8346                }
8347                DisambiguationResult::Abandoned { new_input } => {
8348                    *self.pending_skill_id.write() = None;
8349
8350                    info!(
8351                        has_new_input = new_input.is_some(),
8352                        "Clarification abandoned by user"
8353                    );
8354
8355                    self.commit_root_user_message(input).await?;
8356
8357                    match new_input {
8358                        Some(fresh_input) => {
8359                            // Topic switch: process the user's new input from scratch.
8360                            // The LLM sees full conversation context including the abandoned exchange.
8361                            return self.run_loop_internal(&fresh_input).await;
8362                        }
8363                        None => {
8364                            // Pure abandonment: generate a brief acknowledgment.
8365                            let ack = self
8366                                .generate_localized_apology(
8367                                    "The user changed their mind about their previous request. \
8368                                     Generate a brief, friendly acknowledgment (e.g. 'OK, no problem. What else can I help with?'). \
8369                                     Do NOT apologize excessively. Be concise.",
8370                                    "User abandoned clarification",
8371                                )
8372                                .await
8373                                .unwrap_or_else(|_| {
8374                                    "OK, no problem. What else can I help with?".to_string()
8375                                });
8376
8377                            self.memory
8378                                .add_message(ChatMessage::assistant(&ack))
8379                                .await?;
8380
8381                            let response = AgentResponse::new(&ack);
8382                            self.finish_turn_if_root(&response).await?;
8383                            return Ok(response);
8384                        }
8385                    }
8386                }
8387            }
8388        }
8389
8390        self.run_loop_internal(input).await
8391    }
8392
8393    /// Generate a localized response using the router LLM
8394    async fn generate_localized_apology(&self, instruction: &str, reason: &str) -> Result<String> {
8395        let llm = self.llm_registry.router().map_err(|e| {
8396            AgentError::LLM(format!(
8397                "Router LLM not available for localized response: {}",
8398                e
8399            ))
8400        })?;
8401
8402        let recent: Vec<String> = self
8403            .memory
8404            .get_messages(Some(3))
8405            .await?
8406            .iter()
8407            .map(|m| m.content.clone())
8408            .collect();
8409
8410        let context_hint = if recent.is_empty() {
8411            String::new()
8412        } else {
8413            format!(
8414                "\nRecent conversation (detect the user's language from this):\n{}\n",
8415                recent.join("\n")
8416            )
8417        };
8418
8419        let prompt = format!(
8420            "{}\nReason: {}\n{}Respond in the same language as the user. Output ONLY the message, nothing else.",
8421            instruction, reason, context_hint
8422        );
8423
8424        let messages = vec![ChatMessage::user(&prompt)];
8425        let response = self
8426            .observe_purpose(
8427                ObservationPurpose::DisambiguationClarification,
8428                llm.complete(&messages, None),
8429            )
8430            .await
8431            .map_err(|e| AgentError::LLM(format!("Localized response generation failed: {}", e)))?;
8432
8433        Ok(response.content.trim().to_string())
8434    }
8435
8436    /// Clear disambiguation-related keys from the context manager.
8437    ///
8438    /// Render template variables in state action args using the context manager.
8439    fn render_action_args(&self, args: &Value) -> Value {
8440        let context = self.build_context_with_overlays();
8441        match args {
8442            Value::Object(map) => {
8443                let mut rendered = serde_json::Map::new();
8444                for (k, v) in map {
8445                    match v {
8446                        Value::String(s) if s.contains("{{") => {
8447                            match self.template_renderer.render(s, &context) {
8448                                Ok(rendered_str) => {
8449                                    rendered.insert(k.clone(), Value::String(rendered_str));
8450                                }
8451                                Err(_) => {
8452                                    rendered.insert(k.clone(), v.clone());
8453                                }
8454                            }
8455                        }
8456                        _ => {
8457                            rendered.insert(k.clone(), v.clone());
8458                        }
8459                    }
8460                }
8461                Value::Object(rendered)
8462            }
8463            _ => args.clone(),
8464        }
8465    }
8466
8467    /// Called at the start of each turn to prevent stale `resolved_intent` from leaking across turns.
8468    fn clear_disambiguation_context(&self) {
8469        let _ = self
8470            .context_manager
8471            .set("resolved_intent", serde_json::Value::Null);
8472
8473        let all = self.context_manager.get_all();
8474        for key in all.keys() {
8475            if key.starts_with("disambiguation.") {
8476                let _ = self.context_manager.set(key, serde_json::Value::Null);
8477            }
8478        }
8479    }
8480
8481    /// Re-run skill disambiguation on enriched input before executing the skill.
8482    /// After clarification resolves, the enriched input may still be missing required_clarity fields (e.g. "Transfer money to Jane." still lacks amount).
8483    /// This method re-runs the skill's disambiguation pass.
8484    /// If fields are still missing, it returns the new clarification question and keeps pending_skill_id set.
8485    /// If all fields are present (Clear), it executes the skill and returns the response.
8486    async fn recheck_skill_disambiguation(
8487        &self,
8488        skill_id: &str,
8489        enriched_input: &str,
8490    ) -> Result<AgentResponse> {
8491        let skill = self
8492            .skill_router
8493            .as_ref()
8494            .and_then(|r| r.get_skill(skill_id).cloned());
8495
8496        // If the skill has disambiguation enabled, re-run it on the enriched input.
8497        if let Some(ref skill) = skill
8498            && let Some(ref skill_disambig) = skill.disambiguation
8499            && skill_disambig.enabled.unwrap_or(false)
8500            && let Some(ref disambiguator) = self.disambiguation_manager
8501        {
8502            let context = self.build_disambiguation_context().await?;
8503            let state_override = self
8504                .state_machine
8505                .as_ref()
8506                .and_then(|sm| sm.current_definition())
8507                .and_then(|def| def.disambiguation.clone());
8508
8509            match self
8510                .observe_purpose(
8511                    ObservationPurpose::DisambiguationDetection,
8512                    disambiguator.process_input_with_override(
8513                        enriched_input,
8514                        &context,
8515                        state_override.as_ref(),
8516                        Some(skill_disambig),
8517                    ),
8518                )
8519                .await?
8520            {
8521                DisambiguationResult::Clear => {
8522                    debug!(skill_id = %skill_id, "Skill re-check: all fields present");
8523                }
8524                DisambiguationResult::NeedsClarification {
8525                    question,
8526                    detection,
8527                } => {
8528                    info!(
8529                        skill_id = %skill_id,
8530                        ambiguity_type = ?detection.ambiguity_type,
8531                        what_is_unclear = ?detection.what_is_unclear,
8532                        "Skill re-check: still missing fields, asking again"
8533                    );
8534                    // Keep pending_skill_id set (do NOT clear it).
8535                    // The next turn will resolve this new clarification and
8536                    // re-enter this method until all fields are present.
8537                    self.memory
8538                        .add_message(ChatMessage::user(enriched_input))
8539                        .await?;
8540                    self.memory
8541                        .add_message(ChatMessage::assistant(&question.question))
8542                        .await?;
8543
8544                    let response = AgentResponse::new(&question.question).with_metadata(
8545                        "disambiguation",
8546                        serde_json::json!({
8547                            "status": "awaiting_clarification",
8548                            "skill_id": skill_id,
8549                            "options": question.options,
8550                            "clarifying": question.clarifying,
8551                            "detection": {
8552                                "type": detection.ambiguity_type,
8553                                "confidence": detection.confidence,
8554                                "what_is_unclear": detection.what_is_unclear,
8555                            }
8556                        }),
8557                    );
8558                    self.finish_turn_if_root(&response).await?;
8559                    return Ok(response);
8560                }
8561                DisambiguationResult::Clarified {
8562                    enriched_input: re_enriched,
8563                    ..
8564                } => {
8565                    debug!(skill_id = %skill_id, "Skill re-check: clarified immediately, executing");
8566                    // Fall through to execute with the further-enriched input.
8567                    *self.pending_skill_id.write() = None;
8568                    let skill_response = self.execute_skill_by_id(skill_id, &re_enriched).await?;
8569                    self.memory
8570                        .add_message(ChatMessage::user(&re_enriched))
8571                        .await?;
8572                    return self
8573                        .handle_skill_response(
8574                            &re_enriched,
8575                            skill_id,
8576                            skill_response,
8577                            &HashMap::new(),
8578                        )
8579                        .await;
8580                }
8581                DisambiguationResult::ProceedWithBestGuess {
8582                    enriched_input: re_enriched,
8583                } => {
8584                    debug!(skill_id = %skill_id, "Skill re-check: proceeding with best guess");
8585                    *self.pending_skill_id.write() = None;
8586                    let skill_response = self.execute_skill_by_id(skill_id, &re_enriched).await?;
8587                    self.memory
8588                        .add_message(ChatMessage::user(&re_enriched))
8589                        .await?;
8590                    return self
8591                        .handle_skill_response(
8592                            &re_enriched,
8593                            skill_id,
8594                            skill_response,
8595                            &HashMap::new(),
8596                        )
8597                        .await;
8598                }
8599                DisambiguationResult::GiveUp { reason } => {
8600                    *self.pending_skill_id.write() = None;
8601                    let apology = self
8602                                    .generate_localized_apology(
8603                                        "Generate a brief, polite apology saying you couldn't understand the request. Be concise.",
8604                                        &reason,
8605                                    )
8606                                    .await
8607                                    .unwrap_or_else(|_| {
8608                                        format!("I'm sorry, I couldn't understand your request: {}", reason)
8609                                    });
8610                    let response = AgentResponse::new(&apology);
8611                    self.finish_turn_if_root(&response).await?;
8612                    return Ok(response);
8613                }
8614                DisambiguationResult::Escalate { reason } => {
8615                    *self.pending_skill_id.write() = None;
8616                    let apology = self
8617                                    .generate_localized_apology(
8618                                        "Explain briefly that you're transferring the user to a human agent for help.",
8619                                        &reason,
8620                                    )
8621                                    .await
8622                                    .unwrap_or_else(|_| {
8623                                        format!("I need human assistance to help with your request: {}", reason)
8624                                    });
8625                    let response = AgentResponse::new(&apology);
8626                    self.finish_turn_if_root(&response).await?;
8627                    return Ok(response);
8628                }
8629                DisambiguationResult::Abandoned { new_input } => {
8630                    // User abandoned during skill re-check.
8631                    // Clear skill routing state and fall through to normal execution.
8632                    *self.pending_skill_id.write() = None;
8633                    debug!(skill_id = %skill_id, "Skill re-check: abandoned by user");
8634                    if let Some(fresh) = new_input {
8635                        return self.run_loop_internal(&fresh).await;
8636                    }
8637                    let ack = self
8638                                    .generate_localized_apology(
8639                                        "The user changed their mind about their previous request. \
8640                                         Generate a brief, friendly acknowledgment (e.g. 'OK, no problem. What else can I help with?'). \
8641                                         Do NOT apologize excessively. Be concise.",
8642                                        "User abandoned clarification",
8643                                    )
8644                                    .await
8645                                    .unwrap_or_else(|_| {
8646                                        "OK, no problem. What else can I help with?".to_string()
8647                                    });
8648                    self.memory
8649                        .add_message(ChatMessage::assistant(&ack))
8650                        .await?;
8651                    let response = AgentResponse::new(&ack);
8652                    self.finish_turn_if_root(&response).await?;
8653                    return Ok(response);
8654                }
8655            }
8656        }
8657
8658        // Skill has no disambiguation or all fields present: execute.
8659        *self.pending_skill_id.write() = None;
8660        let skill_response = self.execute_skill_by_id(skill_id, enriched_input).await?;
8661        self.memory
8662            .add_message(ChatMessage::user(enriched_input))
8663            .await?;
8664        self.handle_skill_response(enriched_input, skill_id, skill_response, &HashMap::new())
8665            .await
8666    }
8667
8668    /// Handle skill routing result: output processing, memory, transitions.
8669    /// Returns a fully formed AgentResponse for skill-routed requests.
8670    async fn handle_skill_response(
8671        &self,
8672        processed_input: &str,
8673        skill_id: &str,
8674        skill_response: String,
8675        input_context: &HashMap<String, Value>,
8676    ) -> Result<AgentResponse> {
8677        let output_data = self.process_output(&skill_response, input_context).await?;
8678        let final_response = output_data.content;
8679
8680        self.memory
8681            .add_message(ChatMessage::assistant(&final_response))
8682            .await?;
8683
8684        self.check_memory_compression().await?;
8685
8686        self.increment_turn();
8687        self.evaluate_transitions(processed_input, &final_response)
8688            .await?;
8689
8690        let response = AgentResponse::new(final_response)
8691            .with_metadata("skill_id", serde_json::json!(skill_id));
8692        self.finish_turn_if_root(&response).await?;
8693        Ok(response)
8694    }
8695
8696    /// Run the Plan-and-Execute flow: generate plan, execute steps, finalize.
8697    /// Supports plan-level reflection with replan loop when configured.
8698    async fn handle_plan_and_execute(
8699        &self,
8700        processed_input: &str,
8701        input_context: &HashMap<String, Value>,
8702        auto_detected: bool,
8703    ) -> Result<AgentResponse> {
8704        let effective = self.get_effective_reasoning_config();
8705        let plan_reflection = effective
8706            .get_planning()
8707            .map(|c| c.reflection.clone())
8708            .unwrap_or_default();
8709
8710        let max_attempts = if plan_reflection.enabled {
8711            1 + plan_reflection.max_replans
8712        } else {
8713            1
8714        };
8715
8716        let mut plan = self.generate_plan(processed_input).await?;
8717        info!(
8718            plan_id = %plan.id,
8719            steps = plan.steps.len(),
8720            "Plan generated"
8721        );
8722
8723        let mut plan_result = String::new();
8724
8725        for attempt in 0..max_attempts {
8726            *self.current_plan.write() = Some(plan.clone());
8727            plan_result = self.execute_plan(&mut plan).await?;
8728
8729            info!(
8730                plan_status = ?plan.status,
8731                completed_steps = plan.completed_steps().count(),
8732                attempt = attempt + 1,
8733                "Plan execution completed"
8734            );
8735
8736            if !plan_reflection.enabled {
8737                break;
8738            }
8739
8740            let has_failures = plan.steps.iter().any(|s| s.status.is_failed());
8741            if !has_failures {
8742                break;
8743            }
8744
8745            if attempt + 1 >= max_attempts {
8746                break;
8747            }
8748
8749            match plan_reflection.on_step_failure {
8750                StepFailureAction::Replan => {
8751                    info!(attempt = attempt + 1, "Plan had failures, replanning");
8752                    plan = self.generate_plan(processed_input).await?;
8753                }
8754                StepFailureAction::Abort => {
8755                    warn!("Plan step failed, aborting");
8756                    break;
8757                }
8758                StepFailureAction::Skip | StepFailureAction::Continue => {
8759                    break;
8760                }
8761            }
8762        }
8763
8764        *self.current_plan.write() = Some(plan);
8765
8766        let output_data = self.process_output(&plan_result, input_context).await?;
8767        let final_content = output_data.content;
8768
8769        self.memory
8770            .add_message(ChatMessage::assistant(&final_content))
8771            .await?;
8772
8773        self.check_memory_compression().await?;
8774        self.increment_turn();
8775        self.evaluate_transitions(processed_input, &final_content)
8776            .await?;
8777
8778        let reasoning_metadata =
8779            ReasoningMetadata::new(ReasoningMode::PlanAndExecute).with_auto_detected(auto_detected);
8780
8781        let response = AgentResponse::new(&final_content).with_metadata(
8782            "reasoning",
8783            serde_json::to_value(&reasoning_metadata).unwrap_or_default(),
8784        );
8785
8786        self.finish_turn_if_root(&response).await?;
8787        Ok(response)
8788    }
8789
8790    /// Inject CoT/ReAct reasoning prompt into the system message (first iteration only).
8791    fn inject_reasoning_prompt(
8792        &self,
8793        messages: &mut [ChatMessage],
8794        reasoning_mode: &ReasoningMode,
8795        is_first_iteration: bool,
8796    ) {
8797        if !is_first_iteration {
8798            return;
8799        }
8800        match reasoning_mode {
8801            ReasoningMode::CoT => {
8802                if let Some(msg) = messages.first_mut()
8803                    && matches!(msg.role, ai_agents_core::Role::System)
8804                {
8805                    msg.content = self.build_cot_system_prompt(&msg.content);
8806                    debug!("Applied Chain-of-Thought system prompt");
8807                }
8808            }
8809            ReasoningMode::React => {
8810                if let Some(msg) = messages.first_mut()
8811                    && matches!(msg.role, ai_agents_core::Role::System)
8812                {
8813                    msg.content = self.build_react_system_prompt(&msg.content);
8814                    debug!("Applied ReAct system prompt");
8815                }
8816            }
8817            _ => {}
8818        }
8819    }
8820
8821    //
8822    // Draft generation must not commit user memory or run tools.
8823    // The current user input is added only as an ephemeral message for this LLM call.
8824    //
8825    async fn generate_main_response_draft(
8826        &self,
8827        processed_input: &str,
8828        reasoning_mode: &ReasoningMode,
8829    ) -> Result<MainResponseDraft> {
8830        let llm = self.get_state_llm()?;
8831        let protocol = self.main_tool_protocol(llm.as_ref(), true).await?;
8832        let mut messages = self
8833            .build_messages_internal(false, Some(processed_input), protocol.choice.is_none())
8834            .await?;
8835        self.inject_reasoning_prompt(&mut messages, reasoning_mode, true);
8836        let response = self
8837            .complete_main_llm_with_recovery(llm, &messages, &protocol)
8838            .await?;
8839        let content = response.content.trim().to_string();
8840        let (thinking, answer) = self.extract_thinking(&content);
8841        if let Some(calls) = self.parse_main_tool_calls(&content, &protocol) {
8842            return Ok(MainResponseDraft::ToolCalls {
8843                raw_content: content,
8844                calls,
8845                thinking,
8846            });
8847        }
8848        Ok(MainResponseDraft::Text {
8849            raw_content: answer,
8850            thinking,
8851        })
8852    }
8853
8854    //
8855    // This is the only place where a winning draft is allowed to become runtime state.
8856    // Prompt and native tool calls remain inert until this method commits the draft into the shared executor path.
8857    //
8858    async fn commit_main_response_draft(
8859        &self,
8860        processed_input: &str,
8861        input_context: &HashMap<String, Value>,
8862        draft: MainResponseDraft,
8863        reasoning_mode: ReasoningMode,
8864        auto_detected: bool,
8865    ) -> Result<AgentResponse> {
8866        self.commit_root_user_message(processed_input).await?;
8867        match draft {
8868            MainResponseDraft::Text {
8869                raw_content,
8870                thinking,
8871            } => {
8872                self.finish_text_response_from_model(CommittedTextResponse {
8873                    processed_input,
8874                    input_context,
8875                    answer: raw_content,
8876                    reasoning_mode,
8877                    auto_detected,
8878                    iterations: 1,
8879                    thinking_content: thinking,
8880                    all_tool_calls: Vec::new(),
8881                })
8882                .await
8883            }
8884            MainResponseDraft::ToolCalls {
8885                raw_content,
8886                calls,
8887                thinking: _,
8888            } => {
8889                let mut all_tool_calls = Vec::new();
8890                match self
8891                    .handle_tool_calls(processed_input, &raw_content, calls, &mut all_tool_calls)
8892                    .await?
8893                {
8894                    ToolCallOutcome::Rejected(response) => {
8895                        self.finish_turn_if_root(&response).await?;
8896                        Ok(response)
8897                    }
8898                    ToolCallOutcome::Continue | ToolCallOutcome::TransitionFired => {
8899                        self.continue_after_committed_tool_draft(processed_input)
8900                            .await
8901                    }
8902                }
8903            }
8904        }
8905    }
8906
8907    //
8908    // Tool drafts need a committed continuation after function results are written.
8909    // Redispatch depth suppresses duplicate root lifecycle work during that continuation.
8910    //
8911    async fn continue_after_committed_tool_draft(
8912        &self,
8913        processed_input: &str,
8914    ) -> Result<AgentResponse> {
8915        *self.redispatch_depth.write() += 1;
8916        if let Some(context) = self.active_turn_context.write().as_mut() {
8917            context.enter_redispatch();
8918        }
8919        let result = Box::pin(self.run_loop_internal(processed_input)).await;
8920        *self.redispatch_depth.write() -= 1;
8921        if let Some(context) = self.active_turn_context.write().as_mut() {
8922            context.exit_redispatch();
8923        }
8924        let response = result?;
8925        self.finish_turn_if_root(&response).await?;
8926        Ok(response)
8927    }
8928
8929    //
8930    // Shared committed text finalization for normal responses and winning text drafts.
8931    // Keep output processing, reflection, transitions, hooks, and maintenance behind this commit boundary.
8932    //
8933    async fn finish_text_response_from_model(
8934        &self,
8935        response: CommittedTextResponse<'_>,
8936    ) -> Result<AgentResponse> {
8937        let CommittedTextResponse {
8938            processed_input,
8939            input_context,
8940            answer,
8941            reasoning_mode,
8942            auto_detected,
8943            iterations,
8944            thinking_content,
8945            all_tool_calls,
8946        } = response;
8947        let output_data = self.process_output(&answer, input_context).await?;
8948        let mut final_content = if output_data.metadata.rejected {
8949            output_data
8950                .metadata
8951                .rejection_reason
8952                .unwrap_or_else(|| answer.to_string())
8953        } else {
8954            output_data.content
8955        };
8956        let llm = self.get_state_llm()?;
8957        let reflection_metadata;
8958        (final_content, reflection_metadata) = self
8959            .run_reflection(&*llm, processed_input, final_content)
8960            .await?;
8961        final_content =
8962            self.format_response_with_thinking(thinking_content.as_deref(), &final_content);
8963        let final_content = {
8964            let result = self
8965                .post_loop_processing(processed_input, final_content)
8966                .await?;
8967            self.apply_post_loop_result(processed_input, result).await?
8968        };
8969        let response = self.build_agent_response(AgentResponseParts {
8970            content: final_content,
8971            all_tool_calls,
8972            reasoning_mode,
8973            auto_detected,
8974            iterations,
8975            thinking: thinking_content,
8976            reflection_metadata,
8977        });
8978        self.finish_turn_if_root(&response).await?;
8979        Ok(response)
8980    }
8981
8982    //
8983    // Auto reasoning uses this path after the judge wins with a deeper mode.
8984    // It intentionally uses committed message building instead of the draft overlay.
8985    //
8986    async fn run_committed_response_loop_with_reasoning(
8987        &self,
8988        processed_input: &str,
8989        input_context: &HashMap<String, Value>,
8990        reasoning_mode: ReasoningMode,
8991        auto_detected: bool,
8992    ) -> Result<AgentResponse> {
8993        self.commit_root_user_message(processed_input).await?;
8994        let llm = self.get_state_llm()?;
8995        let mut iterations = 0u32;
8996        let mut all_tool_calls = Vec::new();
8997        let mut thinking_content = None;
8998        loop {
8999            let effective_max = if reasoning_mode != ReasoningMode::None {
9000                let rc = self.get_effective_reasoning_config();
9001                self.max_iterations.min(rc.max_iterations)
9002            } else {
9003                self.max_iterations
9004            };
9005            if iterations >= effective_max {
9006                return Err(AgentError::Other(format!(
9007                    "Max iterations ({}) exceeded",
9008                    effective_max
9009                )));
9010            }
9011            iterations += 1;
9012            *self.iteration_count.write() = iterations;
9013            let protocol = self.main_tool_protocol(llm.as_ref(), false).await?;
9014            let mut messages = self
9015                .build_messages_internal(true, None, protocol.choice.is_none())
9016                .await?;
9017            self.inject_reasoning_prompt(&mut messages, &reasoning_mode, iterations == 1);
9018            self.hooks.on_llm_start(&messages).await;
9019            let llm_start = Instant::now();
9020            let response = self
9021                .complete_main_llm_with_recovery(Arc::clone(&llm), &messages, &protocol)
9022                .await?;
9023            let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
9024            self.hooks.on_llm_complete(&response, llm_duration_ms).await;
9025            let content = response.content.trim();
9026            if let Some(tool_calls) = self.parse_main_tool_calls(content, &protocol) {
9027                match self
9028                    .handle_tool_calls(processed_input, content, tool_calls, &mut all_tool_calls)
9029                    .await?
9030                {
9031                    ToolCallOutcome::Continue | ToolCallOutcome::TransitionFired => continue,
9032                    ToolCallOutcome::Rejected(resp) => {
9033                        self.finish_turn_if_root(&resp).await?;
9034                        return Ok(resp);
9035                    }
9036                }
9037            }
9038            let (extracted_thinking, answer) = self.extract_thinking(content);
9039            if extracted_thinking.is_some() {
9040                thinking_content = extracted_thinking;
9041            }
9042            return self
9043                .finish_text_response_from_model(CommittedTextResponse {
9044                    processed_input,
9045                    input_context,
9046                    answer,
9047                    reasoning_mode,
9048                    auto_detected,
9049                    iterations,
9050                    thinking_content,
9051                    all_tool_calls,
9052                })
9053                .await;
9054        }
9055    }
9056
9057    /// Handle tool calls: check transitions, execute tools in parallel, handle HITL rejection.
9058    async fn handle_tool_calls(
9059        &self,
9060        processed_input: &str,
9061        content: &str,
9062        tool_calls: Vec<ToolCall>,
9063        all_tool_calls: &mut Vec<ToolCall>,
9064    ) -> Result<ToolCallOutcome> {
9065        // Check if a transition should fire before executing the LLM's tool call.
9066        // If a transition fires, on_enter actions handle the tool call correctly
9067        // (with proper URLs from YAML), so skip the LLM's tool call.
9068        let transition_fired = self.evaluate_transitions(processed_input, content).await?;
9069        if transition_fired {
9070            self.memory
9071                .add_message(ChatMessage::assistant(
9072                    "(Transitioned to new state — tool call handled by workflow)",
9073                ))
9074                .await?;
9075            return Ok(ToolCallOutcome::TransitionFired);
9076        }
9077
9078        // 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.
9079        self.memory
9080            .add_message(ChatMessage::assistant(content))
9081            .await?;
9082        let native_tool_call = Self::is_native_tool_call_content(content);
9083
9084        let results = self.execute_tools_parallel(&tool_calls).await;
9085
9086        for ((_id, result), tool_call) in results.into_iter().zip(tool_calls.iter()) {
9087            match result {
9088                Ok(output) => {
9089                    self.memory
9090                        .add_message(Self::tool_result_message(
9091                            tool_call,
9092                            &output,
9093                            native_tool_call,
9094                        ))
9095                        .await?;
9096                }
9097                Err(e) => {
9098                    // Check if this is a HITL rejection - if so, break the loop
9099                    if matches!(e, AgentError::HITLRejected(_)) {
9100                        self.memory
9101                            .add_message(ChatMessage::assistant(format!(
9102                                "The operation was rejected by the approver: {}",
9103                                e
9104                            )))
9105                            .await?;
9106                        // Return the rejection message to user, don't continue loop
9107                        return Ok(ToolCallOutcome::Rejected(AgentResponse {
9108                            content: format!("Operation cancelled: {}", e),
9109                            metadata: None,
9110                            tool_calls: Some(all_tool_calls.clone()),
9111                        }));
9112                    }
9113                    self.memory
9114                        .add_message(Self::tool_result_message(
9115                            tool_call,
9116                            &format!("Error: {}", e),
9117                            native_tool_call,
9118                        ))
9119                        .await?;
9120                }
9121            }
9122            all_tool_calls.push(tool_call.clone());
9123        }
9124        Ok(ToolCallOutcome::Continue)
9125    }
9126
9127    /// Run the reflection loop on a response, returning (improved_content, reflection_metadata).
9128    async fn run_reflection(
9129        &self,
9130        llm: &dyn LLMProvider,
9131        processed_input: &str,
9132        mut content: String,
9133    ) -> Result<(String, Option<ReflectionMetadata>)> {
9134        let should_reflect = self.should_reflect(processed_input, &content).await?;
9135        if !should_reflect {
9136            return Ok((content, None));
9137        }
9138
9139        info!("Starting response reflection evaluation");
9140        let mut attempts = 0u32;
9141        let max_retries = self.reflection_config.max_retries;
9142        let mut history: Vec<ReflectionAttempt> = Vec::new();
9143
9144        loop {
9145            let evaluation = self.evaluate_response(processed_input, &content).await?;
9146
9147            if evaluation.passed || attempts >= max_retries {
9148                info!(
9149                    passed = evaluation.passed,
9150                    confidence = evaluation.confidence,
9151                    attempts = attempts + 1,
9152                    "Reflection evaluation complete"
9153                );
9154                let reflection_metadata = Some(
9155                    ReflectionMetadata::new(evaluation)
9156                        .with_attempts(attempts + 1)
9157                        .with_history(history),
9158                );
9159                return Ok((content, reflection_metadata));
9160            }
9161
9162            debug!(
9163                attempt = attempts + 1,
9164                failed_criteria = evaluation.failed_criteria().count(),
9165                "Response did not meet criteria, retrying"
9166            );
9167
9168            history.push(
9169                ReflectionAttempt::new(&content, evaluation.clone())
9170                    .with_feedback("Response did not meet quality criteria"),
9171            );
9172
9173            let feedback: Vec<String> = evaluation
9174                .failed_criteria()
9175                .map(|c| format!("- {}", c.criterion))
9176                .collect();
9177
9178            let retry_prompt = format!(
9179                "Your previous response did not meet these criteria:\n{}\n\nPlease provide an improved response.",
9180                feedback.join("\n")
9181            );
9182
9183            self.memory
9184                .add_message(ChatMessage::user(&retry_prompt))
9185                .await?;
9186
9187            let retry_messages = self.build_messages().await?;
9188            let retry_response = self
9189                .observe_purpose(
9190                    ObservationPurpose::ReflectionEvaluation,
9191                    llm.complete(&retry_messages, None),
9192                )
9193                .await
9194                .map_err(|e| AgentError::LLM(e.to_string()))?;
9195
9196            content = retry_response.content.trim().to_string();
9197            attempts += 1;
9198        }
9199    }
9200
9201    /// Record the assistant turn, evaluate transitions, and decide what to do next.
9202    /// Returns PostLoopResult so callers can apply_post_loop_result for re-dispatch.
9203    async fn post_loop_processing(
9204        &self,
9205        processed_input: &str,
9206        content: String,
9207    ) -> Result<PostLoopResult> {
9208        // Do NOT add the assistant message to memory yet.
9209        // evaluate_transitions receives content as a direct parameter, so the message does not need to be in memory for transitions to evaluate correctly.
9210        // For NeedsRedispatch we skip adding the stale old-state response entirely, keeping memory clean for the re-dispatched handler.
9211
9212        self.increment_turn();
9213
9214        // Run context extractors so guards can check freshly-extracted values.
9215        self.run_context_extractors(processed_input).await;
9216
9217        let transitioned = self.evaluate_transitions(processed_input, &content).await?;
9218
9219        if !transitioned {
9220            self.memory
9221                .add_message(ChatMessage::assistant(&content))
9222                .await?;
9223            self.check_memory_compression().await?;
9224            return Ok(PostLoopResult::NoTransition(content));
9225        }
9226
9227        // Check if we should skip re-generation after this transition.
9228        if !self.should_regenerate_after_transition() {
9229            self.memory
9230                .add_message(ChatMessage::assistant(&content))
9231                .await?;
9232            self.check_memory_compression().await?;
9233            return Ok(PostLoopResult::Transitioned(content));
9234        }
9235
9236        // Check if the new state needs full dispatch.
9237        // Orchestration states (concurrent, group_chat, pipeline, handoff, delegate) need their dedicated handlers.
9238        // 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.
9239        if self.needs_redispatch_for_new_state() {
9240            info!("Post-transition NeedsRedispatch: new state requires full dispatch");
9241            // Stale old-state content is NOT added to memory.
9242            // apply_post_loop_result will increment redispatch_depth and call run_loop_internal, which produces the correct response and adds it.
9243            return Ok(PostLoopResult::NeedsRedispatch);
9244        }
9245
9246        // Normal post-transition re-generation (plain LLM with optional tool calls).
9247        // Add the stale response to history so the new LLM sees the conversation.
9248        self.memory
9249            .add_message(ChatMessage::assistant(&content))
9250            .await?;
9251        self.check_memory_compression().await?;
9252
9253        // If a transition fired, on_enter actions already executed (e.g., HTTP calls).
9254        // The current content was generated in the OLD state context and is stale.
9255        // Re-generate in the new state context so the LLM can reference on_enter results.
9256        // If the LLM responds with a tool call, execute it in a mini-loop so the
9257        // result is not returned as raw JSON text.
9258        let new_llm = self.get_state_llm()?;
9259        let mut final_content;
9260
9261        for post_iter in 0..self.max_iterations {
9262            let protocol = self.main_tool_protocol(new_llm.as_ref(), false).await?;
9263            let new_messages = self
9264                .build_messages_internal(true, None, protocol.choice.is_none())
9265                .await?;
9266            if post_iter == 0
9267                && let Some(system_msg) = new_messages.first()
9268                && system_msg.role == ai_agents_core::Role::System
9269            {
9270                debug!(
9271                    prompt_preview =
9272                        &system_msg.content[system_msg.content.len().saturating_sub(200)..],
9273                    "Post-transition system prompt (last 200 chars)"
9274                );
9275            }
9276
9277            let new_response = self
9278                .complete_main_llm_with_recovery(Arc::clone(&new_llm), &new_messages, &protocol)
9279                .await?;
9280            final_content = new_response.content.trim().to_string();
9281
9282            // Check if the post-transition response contains tool calls.
9283            // If so, execute them and loop so the LLM can summarize the result.
9284            if let Some(tool_calls) = self.parse_main_tool_calls(&final_content, &protocol) {
9285                let native_tool_call = Self::is_native_tool_call_content(&final_content);
9286                debug!(
9287                    post_iter = post_iter,
9288                    tools = tool_calls.len(),
9289                    "Post-transition tool call detected, executing"
9290                );
9291
9292                self.memory
9293                    .add_message(ChatMessage::assistant(&final_content))
9294                    .await?;
9295
9296                let results = self.execute_tools_parallel(&tool_calls).await;
9297                for ((_id, result), tool_call) in results.into_iter().zip(tool_calls.iter()) {
9298                    match result {
9299                        Ok(output) => {
9300                            self.memory
9301                                .add_message(Self::tool_result_message(
9302                                    tool_call,
9303                                    &output,
9304                                    native_tool_call,
9305                                ))
9306                                .await?;
9307                        }
9308                        Err(e) => {
9309                            self.memory
9310                                .add_message(Self::tool_result_message(
9311                                    tool_call,
9312                                    &format!("Error: {}", e),
9313                                    native_tool_call,
9314                                ))
9315                                .await?;
9316                        }
9317                    }
9318                }
9319                // Loop to let the LLM see the tool result and produce a text response.
9320                continue;
9321            }
9322
9323            // No tool call - this is the final text response.
9324            self.memory
9325                .add_message(ChatMessage::assistant(&final_content))
9326                .await?;
9327            return Ok(PostLoopResult::Transitioned(final_content));
9328        }
9329
9330        // Exhausted post-transition iterations (unlikely). Return last content.
9331        final_content = "Post-transition processing completed.".to_string();
9332        self.memory
9333            .add_message(ChatMessage::assistant(&final_content))
9334            .await?;
9335
9336        Ok(PostLoopResult::Transitioned(final_content))
9337    }
9338
9339    /// Build the final AgentResponse with all metadata.
9340    /// Check whether to re-generate a response after a state transition.
9341    fn should_regenerate_after_transition(&self) -> bool {
9342        if let Some(ref sm) = self.state_machine {
9343            // Global setting
9344            if !sm.config().regenerate_on_transition {
9345                return false;
9346            }
9347            // Per-state override on the new (current) state
9348            if let Some(def) = sm.current_definition()
9349                && let Some(regen) = def.regenerate_on_enter
9350            {
9351                return regen;
9352            }
9353        }
9354        true
9355    }
9356
9357    /// Return true when the new state requires full dispatch via run_loop_internal.
9358    /// Covers orchestration states and any non-None effective reasoning mode.
9359    fn needs_redispatch_for_new_state(&self) -> bool {
9360        if let Some(ref sm) = self.state_machine
9361            && let Some(def) = sm.current_definition()
9362        {
9363            if def.concurrent.is_some()
9364                || def.group_chat.is_some()
9365                || def.pipeline.is_some()
9366                || def.handoff.is_some()
9367                || def.delegate.is_some()
9368            {
9369                return true;
9370            }
9371            // Any non-None effective reasoning mode requires the main dispatch loop.
9372            let effective = self.get_effective_reasoning_config();
9373            if !matches!(effective.mode, ReasoningMode::None) {
9374                return true;
9375            }
9376        }
9377        false
9378    }
9379
9380    /// Consume a PostLoopResult. NeedsRedispatch re-enters run_loop_internal.
9381    /// The user message is already in memory - redispatch_depth suppresses re-adding it.
9382    async fn apply_post_loop_result(
9383        &self,
9384        processed_input: &str,
9385        result: PostLoopResult,
9386    ) -> Result<String> {
9387        match result {
9388            PostLoopResult::NoTransition(content) | PostLoopResult::Transitioned(content) => {
9389                Ok(content)
9390            }
9391            PostLoopResult::NeedsRedispatch => {
9392                const MAX_REDISPATCH_DEPTH: u32 = 3;
9393                let current_depth = *self.redispatch_depth.read();
9394                if current_depth >= MAX_REDISPATCH_DEPTH {
9395                    warn!(
9396                        depth = current_depth,
9397                        "Post-transition re-dispatch depth limit reached, returning empty response"
9398                    );
9399                    let content = String::new();
9400                    self.memory
9401                        .add_message(ChatMessage::assistant(&content))
9402                        .await?;
9403                    return Ok(content);
9404                }
9405                *self.redispatch_depth.write() += 1;
9406                if let Some(context) = self.active_turn_context.write().as_mut() {
9407                    context.enter_redispatch();
9408                }
9409                info!(
9410                    depth = current_depth + 1,
9411                    "Re-dispatching for new state after transition"
9412                );
9413                let resp = Box::pin(self.run_loop_internal(processed_input)).await;
9414                *self.redispatch_depth.write() -= 1;
9415                if let Some(context) = self.active_turn_context.write().as_mut() {
9416                    context.exit_redispatch();
9417                }
9418                resp.map(|r| r.content)
9419            }
9420        }
9421    }
9422
9423    /// Builds the final response metadata after committed output and transition handling complete.
9424    fn build_agent_response(&self, parts: AgentResponseParts) -> AgentResponse {
9425        let AgentResponseParts {
9426            content,
9427            all_tool_calls,
9428            reasoning_mode,
9429            auto_detected,
9430            iterations,
9431            thinking,
9432            reflection_metadata,
9433        } = parts;
9434        let reasoning_metadata = ReasoningMetadata::new(reasoning_mode.clone())
9435            .with_thinking(thinking.clone().unwrap_or_default())
9436            .with_iterations(iterations)
9437            .with_auto_detected(auto_detected);
9438
9439        let mut response = AgentResponse::new(&content);
9440        if !all_tool_calls.is_empty() {
9441            response = response.with_tool_calls(all_tool_calls);
9442        }
9443
9444        if let Some(state) = self.current_state() {
9445            response = response.with_metadata("current_state", serde_json::json!(state));
9446        }
9447
9448        response = response.with_metadata(
9449            "reasoning",
9450            serde_json::to_value(&reasoning_metadata).unwrap_or_default(),
9451        );
9452
9453        if let Some(ref refl_meta) = reflection_metadata {
9454            response = response.with_metadata(
9455                "reflection",
9456                serde_json::to_value(refl_meta).unwrap_or_default(),
9457            );
9458        }
9459
9460        response
9461    }
9462
9463    // Handle delegation: forward user input to a registry agent.
9464    async fn handle_delegated_state(
9465        &self,
9466        input: &str,
9467        delegate_id: &str,
9468        state_def: &ai_agents_state::StateDefinition,
9469    ) -> Result<AgentResponse> {
9470        use std::time::Instant;
9471
9472        let registry = self.spawner_registry.as_ref().ok_or_else(|| {
9473            AgentError::Config(format!(
9474                "State delegates to '{}' but no agent registry is configured. \
9475                 Add a spawner section with auto_spawn to your YAML.",
9476                delegate_id
9477            ))
9478        })?;
9479
9480        let state_name = self
9481            .state_machine
9482            .as_ref()
9483            .map(|sm| sm.current())
9484            .unwrap_or_else(|| "unknown".to_string());
9485
9486        self.hooks.on_delegate_start(delegate_id, &state_name).await;
9487        let start = Instant::now();
9488
9489        let delegate = registry.get(delegate_id).ok_or_else(|| {
9490            AgentError::Other(format!(
9491                "State '{}' delegates to '{}' but no agent with that ID exists in the registry.",
9492                state_name, delegate_id
9493            ))
9494        })?;
9495
9496        // Prepare input based on delegate_context mode.
9497        let context_mode = state_def.delegate_context.clone().unwrap_or_default();
9498        let effective_input = self
9499            .observe_purpose(
9500                ObservationPurpose::OrchestrationRouting,
9501                crate::orchestration::context::prepare_delegate_input(
9502                    input,
9503                    &context_mode,
9504                    &*self.memory,
9505                    self.llm_registry.get("router").ok().as_deref(),
9506                ),
9507            )
9508            .await?;
9509
9510        let response = delegate
9511            .chat_with_actor_context(&effective_input, self.outbound_actor_context())
9512            .await?;
9513
9514        let duration_ms = start.elapsed().as_millis() as u64;
9515        self.hooks
9516            .on_delegate_complete(delegate_id, &state_name, duration_ms)
9517            .await;
9518
9519        // Backward-compatible context key.
9520        let ctx_key = format!("delegation.{}.last_response", delegate_id);
9521        let _ = self.context_manager.set(
9522            &ctx_key,
9523            serde_json::Value::String(response.content.clone()),
9524        );
9525
9526        // Structured orchestration context.
9527        let _ = self.context_manager.set(
9528            "orchestration",
9529            serde_json::json!({
9530                "type": "delegate",
9531                "agent": delegate_id,
9532                "state": state_name,
9533                "response": response.content,
9534                "duration_ms": duration_ms,
9535            }),
9536        );
9537
9538        self.commit_root_user_message(input).await?;
9539
9540        // post_loop_processing records the assistant turn and evaluates transitions.
9541        // apply_post_loop_result handles NeedsRedispatch by re-entering run_loop_internal.
9542        let post_result = self
9543            .post_loop_processing(
9544                input,
9545                format!("[Delegated to {}]: {}", delegate_id, response.content),
9546            )
9547            .await?;
9548        let final_content = self.apply_post_loop_result(input, post_result).await?;
9549
9550        let mut result = AgentResponse::new(final_content);
9551
9552        let metadata = serde_json::json!({
9553            "orchestration": {
9554                "type": "delegate",
9555                "agent": delegate_id,
9556                "state": state_name,
9557                "response": response.content,
9558                "duration_ms": duration_ms,
9559            }
9560        });
9561        result.metadata = Some(
9562            serde_json::from_value::<std::collections::HashMap<String, serde_json::Value>>(
9563                metadata,
9564            )
9565            .unwrap_or_default(),
9566        );
9567
9568        self.finish_turn_if_root(&result).await?;
9569        Ok(result)
9570    }
9571
9572    // Handle concurrent execution: run multiple registry agents in parallel and aggregate.
9573    async fn handle_concurrent_state(
9574        &self,
9575        input: &str,
9576        config: &ai_agents_state::ConcurrentStateConfig,
9577    ) -> Result<AgentResponse> {
9578        use std::time::Instant;
9579
9580        let registry = self.spawner_registry.as_ref().ok_or_else(|| {
9581            AgentError::Config(
9582                "Concurrent state requires an agent registry. Add a spawner section.".into(),
9583            )
9584        })?;
9585
9586        // Render input template if provided, otherwise use the raw input.
9587        // Uses direct minijinja rendering (same approach as pipeline) so variables
9588        // are top-level: {{ user_input }}, not {{ context.user_input }}.
9589        // Enrich input with parent conversation history when context_mode is set.
9590        let context_mode = config.context_mode.clone().unwrap_or_default();
9591        let context_input = self
9592            .observe_purpose(
9593                ObservationPurpose::OrchestrationRouting,
9594                crate::orchestration::context::prepare_delegate_input(
9595                    input,
9596                    &context_mode,
9597                    &*self.memory,
9598                    self.llm_registry.get("router").ok().as_deref(),
9599                ),
9600            )
9601            .await?;
9602
9603        let effective_input = if let Some(ref tmpl) = config.input {
9604            render_concurrent_template(tmpl, &context_input, &self.build_context_with_overlays())
9605                .unwrap_or_else(|_| context_input.clone())
9606        } else {
9607            context_input
9608        };
9609
9610        let start = Instant::now();
9611
9612        let llm_name = config
9613            .aggregation
9614            .synthesizer_llm
9615            .as_deref()
9616            .unwrap_or("router");
9617        let llm_provider = self.llm_registry.get(llm_name).ok();
9618
9619        let vote_parallelism = if self.runtime_config.optimization.enabled
9620            && self
9621                .runtime_config
9622                .optimization
9623                .parallel_orchestration_vote_extraction
9624        {
9625            Some(self.runtime_config.optimization.max_parallel_runtime_tasks)
9626        } else {
9627            None
9628        };
9629
9630        let result = self
9631            .observe_purpose(
9632                ObservationPurpose::OrchestrationAggregation,
9633                scope_actor_context(
9634                    self.outbound_actor_context(),
9635                    crate::orchestration::concurrent(
9636                        registry,
9637                        &effective_input,
9638                        &config.agents,
9639                        &config.aggregation,
9640                        llm_provider.as_deref(),
9641                        config.min_required,
9642                        config.timeout_ms,
9643                        config.on_partial_failure.clone(),
9644                        vote_parallelism,
9645                    ),
9646                ),
9647            )
9648            .await?;
9649
9650        let duration_ms = start.elapsed().as_millis() as u64;
9651        let agent_ids: Vec<String> = config.agents.iter().map(|a| a.id().to_string()).collect();
9652        let strategy = format!("{:?}", config.aggregation.strategy);
9653        self.hooks
9654            .on_concurrent_complete(&agent_ids, &strategy, duration_ms)
9655            .await;
9656
9657        // Backward-compatible context key.
9658        let _ = self.context_manager.set(
9659            "concurrent.result",
9660            serde_json::Value::String(result.response.content.clone()),
9661        );
9662
9663        // Build per-agent result data for context and metadata.
9664        let agents_json: Vec<serde_json::Value> = result
9665            .agent_results
9666            .iter()
9667            .map(|ar| {
9668                serde_json::json!({
9669                    "id": ar.agent_id,
9670                    "response": ar.response.as_ref().map(|r| r.content.as_str()),
9671                    "success": ar.success,
9672                    "error": ar.error,
9673                    "duration_ms": ar.duration_ms,
9674                })
9675            })
9676            .collect();
9677
9678        // Structured orchestration context with per-agent results.
9679        let _ = self.context_manager.set(
9680            "orchestration",
9681            serde_json::json!({
9682                "type": "concurrent",
9683                "result": result.response.content,
9684                "strategy": strategy,
9685                "agents": agents_json,
9686                "duration_ms": duration_ms,
9687            }),
9688        );
9689
9690        self.commit_root_user_message(input).await?;
9691
9692        let post_result = self
9693            .post_loop_processing(input, result.response.content.clone())
9694            .await?;
9695        let final_content = self.apply_post_loop_result(input, post_result).await?;
9696
9697        let mut response = AgentResponse::new(final_content);
9698        let metadata = serde_json::json!({
9699            "orchestration": {
9700                "type": "concurrent",
9701                "result": result.response.content,
9702                "strategy": strategy,
9703                "agents": agents_json,
9704                "duration_ms": duration_ms,
9705            }
9706        });
9707        response.metadata = Some(
9708            serde_json::from_value::<std::collections::HashMap<String, serde_json::Value>>(
9709                metadata,
9710            )
9711            .unwrap_or_default(),
9712        );
9713
9714        self.finish_turn_if_root(&response).await?;
9715        Ok(response)
9716    }
9717
9718    // Handle group chat: run a multi-turn multi-agent conversation.
9719    async fn handle_group_chat_state(
9720        &self,
9721        input: &str,
9722        config: &ai_agents_state::GroupChatStateConfig,
9723    ) -> Result<AgentResponse> {
9724        use std::time::Instant;
9725
9726        let registry = self.spawner_registry.as_ref().ok_or_else(|| {
9727            AgentError::Config(
9728                "Group chat state requires an agent registry. Add a spawner section.".into(),
9729            )
9730        })?;
9731
9732        let start = Instant::now();
9733
9734        let llm_provider = self.llm_registry.get("router").ok();
9735
9736        // Enrich input with parent conversation history when context_mode is set.
9737        let context_mode = config.context_mode.clone().unwrap_or_default();
9738        let context_input = self
9739            .observe_purpose(
9740                ObservationPurpose::OrchestrationRouting,
9741                crate::orchestration::context::prepare_delegate_input(
9742                    input,
9743                    &context_mode,
9744                    &*self.memory,
9745                    self.llm_registry.get("router").ok().as_deref(),
9746                ),
9747            )
9748            .await?;
9749
9750        // Render input template if provided, otherwise use the raw user message as topic.
9751        let effective_topic = if let Some(ref tmpl) = config.input {
9752            render_concurrent_template(tmpl, &context_input, &self.build_context_with_overlays())
9753                .unwrap_or_else(|_| context_input.clone())
9754        } else {
9755            context_input
9756        };
9757
9758        let result = self
9759            .observe_purpose(
9760                ObservationPurpose::OrchestrationConversation,
9761                scope_actor_context(
9762                    self.outbound_actor_context(),
9763                    crate::orchestration::group_chat(
9764                        registry,
9765                        &effective_topic,
9766                        config,
9767                        llm_provider.as_deref(),
9768                        Some(&*self.hooks),
9769                    ),
9770                ),
9771            )
9772            .await?;
9773
9774        let duration_ms = start.elapsed().as_millis() as u64;
9775
9776        // Backward-compatible context key.
9777        let _ = self.context_manager.set(
9778            "group_chat.conclusion",
9779            serde_json::Value::String(result.response.content.clone()),
9780        );
9781
9782        // Build transcript data for context and metadata.
9783        let transcript_json: Vec<serde_json::Value> = result
9784            .transcript
9785            .iter()
9786            .map(|t| {
9787                serde_json::json!({
9788                    "speaker": t.speaker,
9789                    "round": t.round,
9790                    "content": t.content,
9791                })
9792            })
9793            .collect();
9794
9795        // Structured orchestration context with full transcript.
9796        let _ = self.context_manager.set(
9797            "orchestration",
9798            serde_json::json!({
9799                "type": "group_chat",
9800                "conclusion": result.response.content,
9801                "transcript": transcript_json,
9802                "rounds": result.rounds_completed,
9803                "termination": result.termination_reason,
9804                "duration_ms": duration_ms,
9805            }),
9806        );
9807
9808        self.commit_root_user_message(input).await?;
9809
9810        let post_result = self
9811            .post_loop_processing(input, result.response.content.clone())
9812            .await?;
9813        let final_content = self.apply_post_loop_result(input, post_result).await?;
9814
9815        let mut response = AgentResponse::new(final_content);
9816        let metadata = serde_json::json!({
9817            "orchestration": {
9818                "type": "group_chat",
9819                "conclusion": result.response.content,
9820                "transcript": transcript_json,
9821                "rounds": result.rounds_completed,
9822                "termination": result.termination_reason,
9823                "duration_ms": duration_ms,
9824            }
9825        });
9826        response.metadata = Some(
9827            serde_json::from_value::<std::collections::HashMap<String, serde_json::Value>>(
9828                metadata,
9829            )
9830            .unwrap_or_default(),
9831        );
9832
9833        self.finish_turn_if_root(&response).await?;
9834        Ok(response)
9835    }
9836
9837    // Handle pipeline: run agents sequentially with per-stage input templates.
9838    async fn handle_pipeline_state(
9839        &self,
9840        input: &str,
9841        config: &ai_agents_state::PipelineStateConfig,
9842    ) -> Result<AgentResponse> {
9843        use std::time::Instant;
9844
9845        let registry = self.spawner_registry.as_ref().ok_or_else(|| {
9846            AgentError::Config(
9847                "Pipeline state requires an agent registry. Add a spawner section.".into(),
9848            )
9849        })?;
9850
9851        let start = Instant::now();
9852
9853        let stages: Vec<crate::orchestration::PipelineStage> = config
9854            .stages
9855            .iter()
9856            .map(|entry| {
9857                let mut stage = crate::orchestration::PipelineStage::id(entry.id());
9858                if let Some(tmpl) = entry.input() {
9859                    stage = stage.with_input(tmpl);
9860                }
9861                stage
9862            })
9863            .collect();
9864
9865        // Enrich input with parent conversation history when context_mode is set.
9866        let context_mode = config.context_mode.clone().unwrap_or_default();
9867        let context_input = self
9868            .observe_purpose(
9869                ObservationPurpose::OrchestrationRouting,
9870                crate::orchestration::context::prepare_delegate_input(
9871                    input,
9872                    &context_mode,
9873                    &*self.memory,
9874                    self.llm_registry.get("router").ok().as_deref(),
9875                ),
9876            )
9877            .await?;
9878
9879        let context_values = self.build_context_with_overlays();
9880        let result = self
9881            .observe_purpose(
9882                ObservationPurpose::OrchestrationRouting,
9883                scope_actor_context(
9884                    self.outbound_actor_context(),
9885                    crate::orchestration::pipeline(
9886                        registry,
9887                        &context_input,
9888                        &stages,
9889                        config.timeout_ms,
9890                        Some(&*self.hooks),
9891                        Some(&context_values),
9892                    ),
9893                ),
9894            )
9895            .await?;
9896
9897        let duration_ms = start.elapsed().as_millis() as u64;
9898
9899        // Backward-compatible context key.
9900        let _ = self.context_manager.set(
9901            "pipeline.result",
9902            serde_json::Value::String(result.response.content.clone()),
9903        );
9904
9905        // Build per-stage data for context and metadata.
9906        let stages_json: Vec<serde_json::Value> = result
9907            .stage_outputs
9908            .iter()
9909            .map(|s| {
9910                serde_json::json!({
9911                    "agent_id": s.agent_id,
9912                    "output": s.output,
9913                    "duration_ms": s.duration_ms,
9914                    "skipped": s.skipped,
9915                })
9916            })
9917            .collect();
9918
9919        // Structured orchestration context.
9920        let _ = self.context_manager.set(
9921            "orchestration",
9922            serde_json::json!({
9923                "type": "pipeline",
9924                "result": result.response.content,
9925                "stages": stages_json,
9926                "duration_ms": duration_ms,
9927            }),
9928        );
9929
9930        self.commit_root_user_message(input).await?;
9931
9932        let post_result = self
9933            .post_loop_processing(input, result.response.content.clone())
9934            .await?;
9935        let final_content = self.apply_post_loop_result(input, post_result).await?;
9936
9937        let mut response = AgentResponse::new(final_content);
9938        let metadata = serde_json::json!({
9939            "orchestration": {
9940                "type": "pipeline",
9941                "result": result.response.content,
9942                "stages": stages_json,
9943                "duration_ms": duration_ms,
9944            }
9945        });
9946        response.metadata = Some(
9947            serde_json::from_value::<std::collections::HashMap<String, serde_json::Value>>(
9948                metadata,
9949            )
9950            .unwrap_or_default(),
9951        );
9952
9953        self.finish_turn_if_root(&response).await?;
9954        Ok(response)
9955    }
9956
9957    // Handle handoff: LLM-directed agent-to-agent control transfer.
9958    async fn handle_handoff_state(
9959        &self,
9960        input: &str,
9961        config: &ai_agents_state::HandoffStateConfig,
9962    ) -> Result<AgentResponse> {
9963        use std::time::Instant;
9964
9965        let registry = self.spawner_registry.as_ref().ok_or_else(|| {
9966            AgentError::Config(
9967                "Handoff state requires an agent registry. Add a spawner section.".into(),
9968            )
9969        })?;
9970
9971        let llm = self
9972            .llm_registry
9973            .get("router")
9974            .map_err(|_| AgentError::Config("Handoff state requires a router LLM.".into()))?;
9975
9976        let start = Instant::now();
9977
9978        // Enrich input with parent conversation history when context_mode is set.
9979        let context_mode = config.context_mode.clone().unwrap_or_default();
9980        let context_input = self
9981            .observe_purpose(
9982                ObservationPurpose::OrchestrationRouting,
9983                crate::orchestration::context::prepare_delegate_input(
9984                    input,
9985                    &context_mode,
9986                    &*self.memory,
9987                    self.llm_registry.get("router").ok().as_deref(),
9988                ),
9989            )
9990            .await?;
9991
9992        // Render input template if provided, otherwise forward the raw user message.
9993        let effective_input = if let Some(ref tmpl) = config.input {
9994            render_concurrent_template(tmpl, &context_input, &self.build_context_with_overlays())
9995                .unwrap_or_else(|_| context_input.clone())
9996        } else {
9997            context_input
9998        };
9999
10000        let result = self
10001            .observe_purpose(
10002                ObservationPurpose::OrchestrationRouting,
10003                scope_actor_context(
10004                    self.outbound_actor_context(),
10005                    crate::orchestration::handoff(
10006                        registry,
10007                        &effective_input,
10008                        &config.initial_agent,
10009                        &config.available_agents,
10010                        config.max_handoffs,
10011                        llm.as_ref(),
10012                        Some(&*self.hooks),
10013                    ),
10014                ),
10015            )
10016            .await?;
10017
10018        let duration_ms = start.elapsed().as_millis() as u64;
10019
10020        // Backward-compatible context key.
10021        let _ = self.context_manager.set(
10022            "handoff.result",
10023            serde_json::Value::String(result.response.content.clone()),
10024        );
10025
10026        // Build handoff chain data for context and metadata.
10027        let chain_json: Vec<serde_json::Value> = result
10028            .handoff_chain
10029            .iter()
10030            .map(|h| {
10031                serde_json::json!({
10032                    "from": h.from_agent,
10033                    "to": h.to_agent,
10034                    "reason": h.reason,
10035                })
10036            })
10037            .collect();
10038
10039        // Structured orchestration context.
10040        let _ = self.context_manager.set(
10041            "orchestration",
10042            serde_json::json!({
10043                "type": "handoff",
10044                "result": result.response.content,
10045                "final_agent": result.final_agent,
10046                "handoff_chain": chain_json,
10047                "duration_ms": duration_ms,
10048            }),
10049        );
10050
10051        self.commit_root_user_message(input).await?;
10052
10053        let post_result = self
10054            .post_loop_processing(input, result.response.content.clone())
10055            .await?;
10056        let final_content = self.apply_post_loop_result(input, post_result).await?;
10057
10058        let mut response = AgentResponse::new(final_content);
10059        let metadata = serde_json::json!({
10060            "orchestration": {
10061                "type": "handoff",
10062                "result": result.response.content,
10063                "final_agent": result.final_agent,
10064                "handoff_chain": chain_json,
10065                "duration_ms": duration_ms,
10066            }
10067        });
10068        response.metadata = Some(
10069            serde_json::from_value::<std::collections::HashMap<String, serde_json::Value>>(
10070                metadata,
10071            )
10072            .unwrap_or_default(),
10073        );
10074
10075        self.finish_turn_if_root(&response).await?;
10076        Ok(response)
10077    }
10078
10079    // run_loop_internal: blocking (non-streaming) agent pipeline.
10080    async fn run_loop_internal(&self, input: &str) -> Result<AgentResponse> {
10081        self.begin_root_turn();
10082        // Resolve actor_id from context, reload facts if actor changed, bump counter.
10083        self.pre_turn_session_lifecycle().await;
10084
10085        let input_data = self.process_input(input).await?;
10086        self.update_active_turn_context(&input_data.content, input_data.context.clone());
10087
10088        // Inject process context (detect/extract results) into agent context
10089        // so system prompt templates can use {{ context.detected_language }} etc.
10090        for (key, value) in &input_data.context {
10091            let _ = self.context_manager.set(key, value.clone());
10092        }
10093
10094        if input_data.metadata.rejected {
10095            let reason = input_data
10096                .metadata
10097                .rejection_reason
10098                .unwrap_or_else(|| "Input rejected".to_string());
10099            warn!(reason = %reason, "Input rejected");
10100            let response = AgentResponse::new(reason);
10101            self.finish_turn_if_root(&response).await?;
10102            return Ok(response);
10103        }
10104
10105        let processed_input = &input_data.content;
10106
10107        if let Some(response) = self.try_pre_response_transition(processed_input).await? {
10108            return Ok(response);
10109        }
10110
10111        // Handle orchestration states (delegate, concurrent, group_chat, pipeline, handoff).
10112        if let Some(ref sm) = self.state_machine
10113            && let Some(def) = sm.current_definition()
10114        {
10115            if let Some(ref delegate_id) = def.delegate {
10116                return self
10117                    .handle_delegated_state(processed_input, delegate_id, &def)
10118                    .await;
10119            }
10120            if let Some(ref concurrent_config) = def.concurrent {
10121                return self
10122                    .handle_concurrent_state(processed_input, concurrent_config)
10123                    .await;
10124            }
10125            if let Some(ref group_chat_config) = def.group_chat {
10126                return self
10127                    .handle_group_chat_state(processed_input, group_chat_config)
10128                    .await;
10129            }
10130            if let Some(ref pipeline_config) = def.pipeline {
10131                return self
10132                    .handle_pipeline_state(processed_input, pipeline_config)
10133                    .await;
10134            }
10135            if let Some(ref handoff_config) = def.handoff {
10136                return self
10137                    .handle_handoff_state(processed_input, handoff_config)
10138                    .await;
10139            }
10140        }
10141
10142        //
10143        // The speculative future is boxed to keep the runtime future size manageable.
10144        // Removing the box can overflow small test stacks because this function is recursive through redispatch.
10145        //
10146        if let Some(response) =
10147            Box::pin(self.try_speculative_branches(processed_input, &input_data.context)).await?
10148        {
10149            return Ok(response);
10150        }
10151
10152        match self.try_skill_route(processed_input).await? {
10153            SkillRouteResult::Response { skill_id, content } => {
10154                self.commit_root_user_message(processed_input).await?;
10155                return self
10156                    .handle_skill_response(processed_input, &skill_id, content, &input_data.context)
10157                    .await;
10158            }
10159            SkillRouteResult::NeedsClarification(response) => {
10160                self.commit_root_user_message(processed_input).await?;
10161                if let Some(q) = response
10162                    .metadata
10163                    .as_ref()
10164                    .and_then(|m| m.get("disambiguation"))
10165                    .and_then(|d| d.get("status"))
10166                    .and_then(|s| s.as_str())
10167                    && q == "awaiting_clarification"
10168                {
10169                    // Store the clarification question in memory so the next turn
10170                    // can be handled as a clarification response.
10171                    self.memory
10172                        .add_message(ChatMessage::assistant(&response.content))
10173                        .await?;
10174                }
10175                self.finish_turn_if_root(&response).await?;
10176                return Ok(response);
10177            }
10178            SkillRouteResult::NoMatch => {} // continue to normal LLM chat
10179        }
10180
10181        let effective_reasoning = self.get_effective_reasoning_config();
10182        let reasoning_mode = self.determine_reasoning_mode(processed_input).await?;
10183        let auto_detected = matches!(effective_reasoning.mode, ReasoningMode::Auto);
10184
10185        info!(
10186            reasoning_mode = ?reasoning_mode,
10187            auto_detected = auto_detected,
10188            reflection_enabled = ?self.reflection_config.enabled,
10189            "Reasoning mode determined"
10190        );
10191
10192        if matches!(reasoning_mode, ReasoningMode::PlanAndExecute) {
10193            self.commit_root_user_message(processed_input).await?;
10194            return self
10195                .handle_plan_and_execute(processed_input, &input_data.context, auto_detected)
10196                .await;
10197        }
10198
10199        self.commit_root_user_message(processed_input).await?;
10200
10201        let mut iterations = 0u32;
10202        let mut all_tool_calls: Vec<ToolCall> = Vec::new();
10203        let mut thinking_content: Option<String> = None;
10204
10205        let llm = self.get_state_llm()?;
10206
10207        loop {
10208            // When reasoning is active, cap iterations at the reasoning-specific limit.
10209            let effective_max = if reasoning_mode != ReasoningMode::None {
10210                let rc = self.get_effective_reasoning_config();
10211                self.max_iterations.min(rc.max_iterations)
10212            } else {
10213                self.max_iterations
10214            };
10215
10216            if iterations >= effective_max {
10217                let err = AgentError::Other(format!("Max iterations ({}) exceeded", effective_max));
10218                self.hooks.on_error(&err).await;
10219                error!(iterations = iterations, "Max iterations exceeded");
10220                return Err(err);
10221            }
10222            iterations += 1;
10223            *self.iteration_count.write() = iterations;
10224
10225            debug!(iteration = iterations, max = effective_max, "LLM call");
10226
10227            let protocol = self.main_tool_protocol(llm.as_ref(), false).await?;
10228            let mut messages = self
10229                .build_messages_internal(true, None, protocol.choice.is_none())
10230                .await?;
10231            self.inject_reasoning_prompt(&mut messages, &reasoning_mode, iterations == 1);
10232
10233            self.hooks.on_llm_start(&messages).await;
10234            let llm_start = Instant::now();
10235            let response = self
10236                .complete_main_llm_with_recovery(Arc::clone(&llm), &messages, &protocol)
10237                .await?;
10238
10239            let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
10240            self.hooks.on_llm_complete(&response, llm_duration_ms).await;
10241
10242            let content = response.content.trim();
10243
10244            if let Some(tool_calls) = self.parse_main_tool_calls(content, &protocol) {
10245                match self
10246                    .handle_tool_calls(processed_input, content, tool_calls, &mut all_tool_calls)
10247                    .await?
10248                {
10249                    ToolCallOutcome::Continue | ToolCallOutcome::TransitionFired => continue,
10250                    ToolCallOutcome::Rejected(resp) => {
10251                        self.finish_turn_if_root(&resp).await?;
10252                        return Ok(resp);
10253                    }
10254                }
10255            }
10256
10257            let (extracted_thinking, answer) = self.extract_thinking(content);
10258            if extracted_thinking.is_some() {
10259                thinking_content = extracted_thinking;
10260            }
10261
10262            let output_data = self.process_output(&answer, &input_data.context).await?;
10263
10264            let mut final_content = if output_data.metadata.rejected {
10265                output_data
10266                    .metadata
10267                    .rejection_reason
10268                    .unwrap_or_else(|| answer.to_string())
10269            } else {
10270                output_data.content
10271            };
10272
10273            // Run reflection (blocking LLM calls for retries)
10274            let reflection_metadata;
10275            (final_content, reflection_metadata) = self
10276                .run_reflection(&*llm, processed_input, final_content)
10277                .await?;
10278
10279            final_content =
10280                self.format_response_with_thinking(thinking_content.as_deref(), &final_content);
10281
10282            // Post-loop: memory, transitions, post-transition re-generation.
10283            // apply_post_loop_result handles NeedsRedispatch by re-entering
10284            // run_loop_internal so the new state's full dispatch activates.
10285            let final_content = {
10286                let result = self
10287                    .post_loop_processing(processed_input, final_content)
10288                    .await?;
10289                self.apply_post_loop_result(processed_input, result).await?
10290            };
10291
10292            let reflected = reflection_metadata.is_some();
10293            let reasoning_mode_debug = format!("{:?}", reasoning_mode);
10294
10295            let response = self.build_agent_response(AgentResponseParts {
10296                content: final_content,
10297                all_tool_calls,
10298                reasoning_mode,
10299                auto_detected,
10300                iterations,
10301                thinking: thinking_content,
10302                reflection_metadata,
10303            });
10304
10305            self.finish_turn_if_root(&response).await?;
10306
10307            let tool_call_count = response.tool_calls.as_ref().map(|tc| tc.len()).unwrap_or(0);
10308            info!(
10309                tool_calls = tool_call_count,
10310                response_len = response.content.len(),
10311                reasoning_mode = %reasoning_mode_debug,
10312                reflected = reflected,
10313                "Chat completed"
10314            );
10315            return Ok(response);
10316        }
10317    }
10318
10319    async fn generate_buffered_streaming_draft(
10320        &self,
10321        processed_input: &str,
10322        routing_resolved: Arc<AtomicBool>,
10323    ) -> Result<StreamingDraftResult> {
10324        let llm = self.get_state_llm()?;
10325        if llm.configured_tool_choice().is_some() {
10326            let draft = self
10327                .generate_main_response_draft(processed_input, &ReasoningMode::None)
10328                .await?;
10329            return Ok(StreamingDraftResult::new(draft, Vec::new()));
10330        }
10331        let messages = self.build_messages_for_draft(processed_input).await?;
10332        let mut stream = self
10333            .observe_purpose(
10334                ObservationPurpose::MainResponse,
10335                llm.complete_stream(&messages, None),
10336            )
10337            .await
10338            .map_err(|e| AgentError::LLM(e.to_string()))?;
10339        let mut buffer = crate::optimization::StreamBranchBuffer::new(self.streaming.buffer_size)?;
10340        let mut chunks = Vec::new();
10341        let mut accumulated = String::new();
10342        while let Some(chunk_result) = stream.next().await {
10343            let chunk = chunk_result.map_err(|e| AgentError::LLM(e.to_string()))?;
10344            accumulated.push_str(&chunk.delta);
10345            let stream_chunk = StreamChunk::content(chunk.delta);
10346            if routing_resolved.load(Ordering::SeqCst) {
10347                chunks.push(stream_chunk);
10348            } else {
10349                buffer.push(stream_chunk)?;
10350            }
10351        }
10352        chunks.splice(0..0, buffer.drain());
10353        let content = accumulated.trim().to_string();
10354        let draft = if let Some(calls) = self.parse_tool_calls(&content) {
10355            MainResponseDraft::ToolCalls {
10356                raw_content: content,
10357                calls,
10358                thinking: None,
10359            }
10360        } else {
10361            MainResponseDraft::Text {
10362                raw_content: content,
10363                thinking: None,
10364            }
10365        };
10366        Ok(StreamingDraftResult::new(draft, chunks))
10367    }
10368
10369    async fn try_buffered_streaming_branches(
10370        &self,
10371        processed_input: &str,
10372        input_context: &HashMap<String, Value>,
10373    ) -> Result<Option<(AgentResponse, Vec<StreamChunk>)>> {
10374        let optimization = &self.runtime_config.optimization;
10375        if !optimization.enabled {
10376            return Ok(None);
10377        }
10378        let transition_enabled =
10379            optimization.speculative_state_transitions && self.has_parallel_transition_candidates();
10380        if !transition_enabled {
10381            return Ok(None);
10382        }
10383        let mut branch_scheduler =
10384            TurnBranchScheduler::new(optimization.max_parallel_runtime_tasks)?;
10385        if !branch_scheduler.reserve_task() {
10386            return Ok(None);
10387        }
10388        if !self
10389            .reserve_active_speculative_llm_call(RuntimeOptimizationKind::BufferedStreamingRouting)
10390        {
10391            branch_scheduler.release_task();
10392            return Ok(None);
10393        }
10394        if !branch_scheduler.reserve_task() {
10395            branch_scheduler.release_task();
10396            return Ok(None);
10397        }
10398        let mut main_branch = RuntimeBranch::new(
10399            RuntimeTaskPurpose::MainResponse,
10400            RuntimeOptimizationKind::BufferedStreamingRouting,
10401            RuntimeTaskPriority::Normal,
10402            RuntimeCommitBehavior::FinalResponse,
10403        );
10404        let mut transition_branch = RuntimeBranch::new(
10405            RuntimeTaskPurpose::StateTransition,
10406            RuntimeOptimizationKind::ParallelStateTransition,
10407            RuntimeTaskPriority::Critical,
10408            RuntimeCommitBehavior::TransitionDecision,
10409        );
10410        let main_id = main_branch.branch_id();
10411        let transition_id = transition_branch.branch_id();
10412        let routing_resolved = Arc::new(AtomicBool::new(false));
10413        let mut main_future =
10414            Box::pin(crate::optimization::observability::with_branch_observation(
10415                &main_id,
10416                RuntimeOptimizationKind::BufferedStreamingRouting,
10417                RuntimeCommitBehavior::FinalResponse,
10418                self.generate_buffered_streaming_draft(
10419                    processed_input,
10420                    Arc::clone(&routing_resolved),
10421                ),
10422            ));
10423        let mut transition_future =
10424            Box::pin(crate::optimization::observability::with_branch_observation(
10425                &transition_id,
10426                RuntimeOptimizationKind::ParallelStateTransition,
10427                RuntimeCommitBehavior::TransitionDecision,
10428                self.select_parallel_transition_candidate(processed_input),
10429            ));
10430        let mut main_pending = true;
10431        let mut transition_pending = true;
10432        let mut main_result: Option<Result<StreamingDraftResult>> = None;
10433        let mut transition_finalized = false;
10434        let mut transition_candidate: Option<TransitionCandidate> = None;
10435        loop {
10436            if let Some(candidate) = transition_candidate.take() {
10437                if self
10438                    .approve_transition_target(&candidate.from_state, candidate.target())
10439                    .await?
10440                {
10441                    // Drop the stale stream future before transition side effects or redispatch reuse the provider.
10442                    drop(main_future);
10443                    drop(transition_future);
10444                    self.finalize_branch_loss(
10445                        &main_id,
10446                        RuntimeOptimizationKind::BufferedStreamingRouting,
10447                        RuntimeCommitBehavior::FinalResponse,
10448                        main_pending,
10449                        main_result.as_ref().map(|result| result.is_err()),
10450                    );
10451                    if !self
10452                        .apply_pre_response_transition_candidate(
10453                            &candidate,
10454                            &HashMap::new(),
10455                            processed_input,
10456                        )
10457                        .await?
10458                    {
10459                        self.finalize_optional_branch(
10460                            &transition_id,
10461                            RuntimeOptimizationKind::ParallelStateTransition,
10462                            RuntimeCommitBehavior::TransitionDecision,
10463                            "discarded",
10464                            false,
10465                        );
10466                        return Ok(None);
10467                    }
10468                    self.finalize_optional_branch(
10469                        &transition_id,
10470                        RuntimeOptimizationKind::ParallelStateTransition,
10471                        RuntimeCommitBehavior::TransitionDecision,
10472                        "committed",
10473                        true,
10474                    );
10475                    let response = self.redispatch_current_state(processed_input).await?;
10476                    return Ok(Some((
10477                        response.clone(),
10478                        vec![StreamChunk::content(response.content)],
10479                    )));
10480                }
10481                self.finalize_optional_branch(
10482                    &transition_id,
10483                    RuntimeOptimizationKind::ParallelStateTransition,
10484                    RuntimeCommitBehavior::TransitionDecision,
10485                    "discarded",
10486                    false,
10487                );
10488                routing_resolved.store(true, Ordering::SeqCst);
10489                transition_finalized = true;
10490            }
10491            if transition_finalized && let Some(result) = main_result.take() {
10492                let stream_draft = match result {
10493                    Ok(stream_draft) => stream_draft,
10494                    Err(error) => {
10495                        self.finalize_optional_branch(
10496                            &main_id,
10497                            RuntimeOptimizationKind::BufferedStreamingRouting,
10498                            RuntimeCommitBehavior::FinalResponse,
10499                            "failed",
10500                            false,
10501                        );
10502                        return Err(error);
10503                    }
10504                };
10505                let raw_draft_content = stream_draft.draft.raw_content().to_string();
10506                let buffered_chunks = stream_draft.chunks;
10507                self.finalize_optional_branch(
10508                    &main_id,
10509                    RuntimeOptimizationKind::BufferedStreamingRouting,
10510                    RuntimeCommitBehavior::FinalResponse,
10511                    "committed",
10512                    true,
10513                );
10514                let response = self
10515                    .commit_main_response_draft(
10516                        processed_input,
10517                        input_context,
10518                        stream_draft.draft,
10519                        ReasoningMode::None,
10520                        false,
10521                    )
10522                    .await?;
10523                let chunks = if response.content == raw_draft_content {
10524                    buffered_chunks
10525                } else {
10526                    vec![StreamChunk::content(response.content.clone())]
10527                };
10528                return Ok(Some((response, chunks)));
10529            }
10530            tokio::select! {
10531                result = &mut main_future, if main_pending => {
10532                    main_pending = false;
10533                    main_branch.transition_to(RuntimeBranchStatus::Completed)?;
10534                    main_result = Some(result);
10535                }
10536                result = &mut transition_future, if transition_pending => {
10537                    transition_pending = false;
10538                    transition_branch.transition_to(RuntimeBranchStatus::Completed)?;
10539                    match result {
10540                        Ok(ParallelTransitionSelection::Candidate(candidate)) => {
10541                            transition_candidate = Some(candidate)
10542                        }
10543                        Ok(ParallelTransitionSelection::NoMatch) => {
10544                            self.finalize_optional_branch(
10545                                &transition_id,
10546                                RuntimeOptimizationKind::ParallelStateTransition,
10547                                RuntimeCommitBehavior::TransitionDecision,
10548                                "discarded",
10549                                false,
10550                            );
10551                            routing_resolved.store(true, Ordering::SeqCst);
10552                            transition_finalized = true;
10553                        }
10554                        Ok(ParallelTransitionSelection::ReservationExhausted) => {
10555                            self.finalize_optional_branch(
10556                                &transition_id,
10557                                RuntimeOptimizationKind::ParallelStateTransition,
10558                                RuntimeCommitBehavior::TransitionDecision,
10559                                "cancelled",
10560                                false,
10561                            );
10562                            routing_resolved.store(true, Ordering::SeqCst);
10563                            self.finalize_branch_loss(
10564                                &main_id,
10565                                RuntimeOptimizationKind::BufferedStreamingRouting,
10566                                RuntimeCommitBehavior::FinalResponse,
10567                                main_pending,
10568                                main_result.as_ref().map(|result| result.is_err()),
10569                            );
10570                            return Ok(None);
10571                        }
10572                        Err(_) => {
10573                            self.finalize_optional_branch(
10574                                &transition_id,
10575                                RuntimeOptimizationKind::ParallelStateTransition,
10576                                RuntimeCommitBehavior::TransitionDecision,
10577                                "failed",
10578                                false,
10579                            );
10580                            routing_resolved.store(true, Ordering::SeqCst);
10581                            transition_finalized = true;
10582                        }
10583                    }
10584                }
10585            }
10586        }
10587    }
10588
10589    /// Streaming agent pipeline
10590    /// Uses all the same shared helpers as run_loop_internal.
10591    /// The ONLY difference: LLM calls use complete_stream() + yield deltas.
10592    fn run_loop_internal_stream<'a>(
10593        &'a self,
10594        input: &'a str,
10595    ) -> Pin<Box<dyn Stream<Item = StreamChunk> + Send + 'a>> {
10596        let include_tool_events = self.streaming.include_tool_events;
10597        let include_state_events = self.streaming.include_state_events;
10598
10599        Box::pin(async_stream::stream! {
10600            self.begin_root_turn();
10601            // Parity with non-stream: resolve actor from context and load facts if changed.
10602            self.pre_turn_session_lifecycle().await;
10603
10604            let input_data = match self.process_input(input).await {
10605                Ok(data) => data,
10606                Err(e) => {
10607                    yield StreamChunk::error(e.to_string());
10608                    return;
10609                }
10610            };
10611            self.update_active_turn_context(&input_data.content, input_data.context.clone());
10612
10613            // Inject process context (detect/extract results) into agent context
10614            for (key, value) in &input_data.context {
10615                let _ = self.context_manager.set(key, value.clone());
10616            }
10617
10618            if input_data.metadata.rejected {
10619                let reason = input_data
10620                    .metadata
10621                    .rejection_reason
10622                    .unwrap_or_else(|| "Input rejected".to_string());
10623                warn!(reason = %reason, "Input rejected (stream)");
10624                yield StreamChunk::error(reason);
10625                return;
10626            }
10627
10628            let processed_input = &input_data.content;
10629
10630            if self.runtime_config.optimization.enabled
10631                && matches!(
10632                    self.runtime_config.optimization.streaming_policy,
10633                    crate::optimization::StreamingOptimizationPolicy::BufferUntilRoutingDone
10634                )
10635            {
10636                //
10637                // Buffered routing keeps stale stream output hidden until a branch winner is known.
10638                // The boxed future prevents this stream state machine from becoming too large.
10639                //
10640                match Box::pin(self.try_buffered_streaming_branches(processed_input, &input_data.context)).await {
10641                    Ok(Some((_response, chunks))) => {
10642                        for chunk in chunks {
10643                            yield chunk;
10644                        }
10645                        yield StreamChunk::Done {};
10646                        return;
10647                    }
10648                    Ok(None) => {}
10649                    Err(e) => {
10650                        yield StreamChunk::error(e.to_string());
10651                        return;
10652                    }
10653                }
10654            }
10655
10656            if self.runtime_config.optimization.enabled
10657                && matches!(
10658                    self.runtime_config.optimization.streaming_policy,
10659                    crate::optimization::StreamingOptimizationPolicy::PreflightOnly
10660                )
10661            {
10662                match self.try_pre_response_transition(processed_input).await {
10663                    Ok(Some(response)) => {
10664                        yield StreamChunk::content(&response.content);
10665                        yield StreamChunk::Done {};
10666                        return;
10667                    }
10668                    Ok(None) => {}
10669                    Err(e) => {
10670                        yield StreamChunk::error(e.to_string());
10671                        return;
10672                    }
10673                }
10674            }
10675
10676            // Handle orchestration states in streaming mode.
10677            if let Some(ref sm) = self.state_machine
10678                && let Some(def) = sm.current_definition()
10679            {
10680                    let orchestration_result = if let Some(ref delegate_id) = def.delegate {
10681                        Some(self.handle_delegated_state(processed_input, delegate_id, &def).await)
10682                    } else if let Some(ref concurrent_config) = def.concurrent {
10683                        Some(self.handle_concurrent_state(processed_input, concurrent_config).await)
10684                    } else if let Some(ref group_chat_config) = def.group_chat {
10685                        Some(self.handle_group_chat_state(processed_input, group_chat_config).await)
10686                    } else if let Some(ref pipeline_config) = def.pipeline {
10687                        Some(self.handle_pipeline_state(processed_input, pipeline_config).await)
10688                    } else if let Some(ref handoff_config) = def.handoff {
10689                        Some(self.handle_handoff_state(processed_input, handoff_config).await)
10690                    } else {
10691                        None
10692                    };
10693
10694                    if let Some(result) = orchestration_result {
10695                        match result {
10696                            Ok(response) => {
10697                                yield StreamChunk::content(&response.content);
10698                                yield StreamChunk::Done {};
10699                            }
10700                            Err(e) => {
10701                                yield StreamChunk::error(e.to_string());
10702                            }
10703                        }
10704                        return;
10705                    }
10706                }
10707
10708            // Skill routing
10709            match self.try_skill_route(processed_input).await {
10710                Ok(SkillRouteResult::Response { skill_id, content }) => {
10711                    if let Err(e) = self.commit_root_user_message(processed_input).await {
10712                        yield StreamChunk::error(e.to_string());
10713                        return;
10714                    }
10715                    match self.handle_skill_response(processed_input, &skill_id, content, &input_data.context).await {
10716                        Ok(resp) => {
10717                            yield StreamChunk::content(&resp.content);
10718                            yield StreamChunk::Done {};
10719                            return;
10720                        }
10721                        Err(e) => {
10722                            yield StreamChunk::error(e.to_string());
10723                            return;
10724                        }
10725                    }
10726                }
10727                Ok(SkillRouteResult::NeedsClarification(response)) => {
10728                    if let Err(e) = self.commit_root_user_message(processed_input).await {
10729                        yield StreamChunk::error(e.to_string());
10730                        return;
10731                    }
10732                    let _ = self.memory.add_message(ChatMessage::assistant(&response.content)).await;
10733                    if let Err(e) = self.finish_turn_if_root(&response).await {
10734                        yield StreamChunk::error(e.to_string());
10735                        return;
10736                    }
10737                    yield StreamChunk::content(&response.content);
10738                    yield StreamChunk::Done {};
10739                    return;
10740                }
10741                Ok(SkillRouteResult::NoMatch) => {} // no skill matched, continue
10742                Err(e) => {
10743                    yield StreamChunk::error(e.to_string());
10744                    return;
10745                }
10746            }
10747
10748            // Reasoning mode determination
10749            let effective_reasoning = self.get_effective_reasoning_config();
10750            let reasoning_mode = match self.determine_reasoning_mode(processed_input).await {
10751                Ok(mode) => mode,
10752                Err(e) => {
10753                    yield StreamChunk::error(e.to_string());
10754                    return;
10755                }
10756            };
10757            let auto_detected = matches!(effective_reasoning.mode, ReasoningMode::Auto);
10758
10759            info!(
10760                reasoning_mode = ?reasoning_mode,
10761                auto_detected = auto_detected,
10762                "Reasoning mode determined (stream)"
10763            );
10764
10765            // Plan-and-Execute: yield final result as single chunk (not token-by-token)
10766            if matches!(reasoning_mode, ReasoningMode::PlanAndExecute) {
10767                if let Err(e) = self.commit_root_user_message(processed_input).await {
10768                    yield StreamChunk::error(e.to_string());
10769                    return;
10770                }
10771                match self.handle_plan_and_execute(processed_input, &input_data.context, auto_detected).await {
10772                    Ok(resp) => {
10773                        yield StreamChunk::content(&resp.content);
10774                        yield StreamChunk::Done {};
10775                        return;
10776                    }
10777                    Err(e) => {
10778                        yield StreamChunk::error(e.to_string());
10779                        return;
10780                    }
10781                }
10782            }
10783
10784            if let Err(e) = self.commit_root_user_message(processed_input).await {
10785                yield StreamChunk::error(e.to_string());
10786                return;
10787            }
10788
10789            let llm = match self.get_state_llm() {
10790                Ok(llm) => llm,
10791                Err(e) => {
10792                    yield StreamChunk::error(e.to_string());
10793                    return;
10794                }
10795            };
10796
10797            let mut iterations = 0u32;
10798            let mut all_tool_calls: Vec<ToolCall> = Vec::new();
10799            let mut thinking_content: Option<String> = None;
10800
10801            loop {
10802                // When reasoning is active, cap iterations at the reasoning-specific limit.
10803                let effective_max = if reasoning_mode != ReasoningMode::None {
10804                    let rc = self.get_effective_reasoning_config();
10805                    self.max_iterations.min(rc.max_iterations)
10806                } else {
10807                    self.max_iterations
10808                };
10809
10810                if iterations >= effective_max {
10811                    let err_msg = format!("Max iterations ({}) exceeded", effective_max);
10812                    let err = AgentError::Other(err_msg.clone());
10813                    self.hooks.on_error(&err).await;
10814                    error!(iterations = iterations, "Max iterations exceeded (stream)");
10815                    yield StreamChunk::error(err_msg);
10816                    return;
10817                }
10818                iterations += 1;
10819                *self.iteration_count.write() = iterations;
10820
10821                debug!(iteration = iterations, max = effective_max, "LLM call (stream)");
10822
10823                let protocol = match self.main_tool_protocol(llm.as_ref(), false).await {
10824                    Ok(protocol) => protocol,
10825                    Err(e) => {
10826                        yield StreamChunk::error(e.to_string());
10827                        return;
10828                    }
10829                };
10830                let mut messages = match self
10831                    .build_messages_internal(true, None, protocol.choice.is_none())
10832                    .await
10833                {
10834                    Ok(m) => m,
10835                    Err(e) => {
10836                        yield StreamChunk::error(e.to_string());
10837                        return;
10838                    }
10839                };
10840                self.inject_reasoning_prompt(&mut messages, &reasoning_mode, iterations == 1);
10841
10842                self.hooks.on_llm_start(&messages).await;
10843                let llm_start = Instant::now();
10844
10845                // Check if reflection is active — if so, suppress streaming for this LLM call
10846                // because we may need to retry and the user would see a stale first attempt.
10847                let reflection_active = self
10848                    .should_reflect(processed_input, "")
10849                    .await
10850                    .unwrap_or_default();
10851
10852                let buffered_decision = reflection_active || protocol.choice.is_some();
10853                let content = if buffered_decision {
10854                    //
10855                    // Explicit tool choice buffers the provider decision so no text or tool call is visible before the runtime validates and commits it.
10856                    //
10857                    let response = match self
10858                        .complete_main_llm_with_recovery(
10859                            Arc::clone(&llm),
10860                            &messages,
10861                            &protocol,
10862                        )
10863                        .await
10864                    {
10865                        Ok(r) => r,
10866                        Err(e) => {
10867                            yield StreamChunk::error(e.to_string());
10868                            return;
10869                        }
10870                    };
10871                    let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
10872                    self.hooks.on_llm_complete(&response, llm_duration_ms).await;
10873                    response.content.trim().to_string()
10874                } else {
10875                    // Streaming LLM call
10876                    let llm_stream = match self
10877                        .observe_purpose(
10878                            ObservationPurpose::MainResponse,
10879                            llm.complete_stream(&messages, None),
10880                        )
10881                        .await
10882                    {
10883                        Ok(s) => s,
10884                        Err(e) => {
10885                            yield StreamChunk::error(e.to_string());
10886                            return;
10887                        }
10888                    };
10889                    let mut accumulated = String::new();
10890                    let mut stream_inner = llm_stream;
10891                    while let Some(chunk_result) = stream_inner.next().await {
10892                        match chunk_result {
10893                            Ok(chunk) => {
10894                                accumulated.push_str(&chunk.delta);
10895                                yield StreamChunk::content(chunk.delta);
10896                            }
10897                            Err(e) => {
10898                                yield StreamChunk::error(e.to_string());
10899                                return;
10900                            }
10901                        }
10902                    }
10903                    let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
10904                    // Construct LLMResponse for hooks
10905                    let llm_response = ai_agents_core::LLMResponse::new(
10906                        accumulated.trim(),
10907                        ai_agents_core::FinishReason::Stop,
10908                    );
10909                    self.hooks.on_llm_complete(&llm_response, llm_duration_ms).await;
10910                    accumulated.trim().to_string()
10911                };
10912
10913                // Tool call handling
10914                if let Some(tool_calls) = self.parse_main_tool_calls(&content, &protocol) {
10915                    let native_tool_call = Self::is_native_tool_call_content(&content);
10916                    // Emit tool events for streaming
10917                    // First check transitions (same as blocking path)
10918                    let transition_fired = match self.evaluate_transitions(processed_input, &content).await {
10919                        Ok(v) => v,
10920                        Err(e) => {
10921                            yield StreamChunk::error(e.to_string());
10922                            return;
10923                        }
10924                    };
10925                    if transition_fired {
10926                        let _ = self.memory.add_message(ChatMessage::assistant(
10927                            "(Transitioned to new state — tool call handled by workflow)",
10928                        )).await;
10929
10930                        if include_state_events
10931                            && let Some(state) = self.current_state()
10932                        {
10933                                yield StreamChunk::state_transition(None, state);
10934                            }
10935                        continue;
10936                    }
10937
10938                    // Store the assistant's tool-call message (same as blocking path)
10939                    let _ = self.memory.add_message(ChatMessage::assistant(&content)).await;
10940
10941                    // Execute tools with streaming events
10942                    let results = self.execute_tools_parallel(&tool_calls).await;
10943
10944                    for ((_id, result), tool_call) in results.into_iter().zip(tool_calls.iter()) {
10945                        if include_tool_events {
10946                            yield StreamChunk::tool_start(&tool_call.id, &tool_call.name);
10947                        }
10948
10949                        match result {
10950                            Ok(output) => {
10951                                if include_tool_events {
10952                                    yield StreamChunk::tool_result(
10953                                        &tool_call.id,
10954                                        &tool_call.name,
10955                                        &output,
10956                                        true,
10957                                    );
10958                                }
10959                                let _ = self.memory
10960                                    .add_message(Self::tool_result_message(
10961                                        tool_call,
10962                                        &output,
10963                                        native_tool_call,
10964                                    ))
10965                                    .await;
10966                            }
10967                            Err(e) => {
10968                                if matches!(e, AgentError::HITLRejected(_)) {
10969                                    let _ = self.memory.add_message(ChatMessage::assistant(
10970                                        format!("The operation was rejected by the approver: {}", e),
10971                                    )).await;
10972                                    let response = AgentResponse {
10973                                        content: format!("Operation cancelled: {}", e),
10974                                        metadata: None,
10975                                        tool_calls: Some(all_tool_calls.clone()),
10976                                    };
10977                                    if let Err(finalize_error) = self.finish_turn_if_root(&response).await {
10978                                        yield StreamChunk::error(finalize_error.to_string());
10979                                        return;
10980                                    }
10981                                    yield StreamChunk::error(response.content);
10982                                    yield StreamChunk::Done {};
10983                                    return;
10984                                }
10985                                if include_tool_events {
10986                                    yield StreamChunk::tool_result(
10987                                        &tool_call.id,
10988                                        &tool_call.name,
10989                                        e.to_string(),
10990                                        false,
10991                                    );
10992                                }
10993                                let _ = self.memory
10994                                    .add_message(Self::tool_result_message(
10995                                        tool_call,
10996                                        &format!("Error: {}", e),
10997                                        native_tool_call,
10998                                    ))
10999                                    .await;
11000                            }
11001                        }
11002                        all_tool_calls.push(tool_call.clone());
11003
11004                        if include_tool_events {
11005                            yield StreamChunk::tool_end(&tool_call.id);
11006                        }
11007                    }
11008                    continue;
11009                }
11010
11011                // Extract thinking, process output
11012                let (extracted_thinking, answer) = self.extract_thinking(&content);
11013                if extracted_thinking.is_some() {
11014                    thinking_content = extracted_thinking;
11015                }
11016
11017                let output_data = match self.process_output(&answer, &input_data.context).await {
11018                    Ok(d) => d,
11019                    Err(e) => {
11020                        yield StreamChunk::error(e.to_string());
11021                        return;
11022                    }
11023                };
11024
11025                let final_content = if output_data.metadata.rejected {
11026                    output_data
11027                        .metadata
11028                        .rejection_reason
11029                        .unwrap_or_else(|| answer.to_string())
11030                } else {
11031                    output_data.content
11032                };
11033
11034                // Reflection (uses blocking LLM calls for retries)
11035                let (final_content, _reflection_metadata) = match self
11036                    .run_reflection(&*llm, processed_input, final_content)
11037                    .await
11038                {
11039                    Ok(r) => r,
11040                    Err(e) => {
11041                        yield StreamChunk::error(e.to_string());
11042                        return;
11043                    }
11044                };
11045
11046                let final_content = self.format_response_with_thinking(
11047                    thinking_content.as_deref(),
11048                    &final_content,
11049                );
11050
11051                // Buffered decisions emit only the accepted final text.
11052                if buffered_decision {
11053                    yield StreamChunk::content(&final_content);
11054                }
11055
11056                // Post-loop: memory, transitions, post-transition re-generation.
11057                // For NeedsRedispatch, run_loop_internal handles the new state's full
11058                // dispatch and its result is yielded as a single non-streamed chunk.
11059                let post_result = match self
11060                    .post_loop_processing(processed_input, final_content)
11061                    .await
11062                {
11063                    Ok(r) => r,
11064                    Err(e) => {
11065                        yield StreamChunk::error(e.to_string());
11066                        return;
11067                    }
11068                };
11069
11070                let (final_content, transitioned) = match post_result {
11071                    PostLoopResult::NoTransition(content) => (content, false),
11072                    PostLoopResult::Transitioned(content) => (content, true),
11073                    PostLoopResult::NeedsRedispatch => {
11074                        const MAX_REDISPATCH_DEPTH: u32 = 3;
11075                        let current_depth = *self.redispatch_depth.read();
11076                        let content = if current_depth >= MAX_REDISPATCH_DEPTH {
11077                            warn!(
11078                                depth = current_depth,
11079                                "Post-transition re-dispatch depth limit reached (stream)"
11080                            );
11081                            let c = String::new();
11082                            let _ = self.memory.add_message(ChatMessage::assistant(&c)).await;
11083                            c
11084                        } else {
11085                            *self.redispatch_depth.write() += 1;
11086                            if let Some(context) = self.active_turn_context.write().as_mut() {
11087                                context.enter_redispatch();
11088                            }
11089                            info!(
11090                                depth = current_depth + 1,
11091                                "Re-dispatching for new state after transition (stream)"
11092                            );
11093                            let result = self.run_loop_internal(processed_input).await;
11094                            *self.redispatch_depth.write() -= 1;
11095                            if let Some(context) = self.active_turn_context.write().as_mut() {
11096                                context.exit_redispatch();
11097                            }
11098                            match result {
11099                                Ok(resp) => resp.content,
11100                                Err(e) => {
11101                                    yield StreamChunk::error(e.to_string());
11102                                    return;
11103                                }
11104                            }
11105                        };
11106                        (content, true)
11107                    }
11108                };
11109
11110                if transitioned {
11111                    if include_state_events
11112                        && let Some(state) = self.current_state()
11113                    {
11114                            yield StreamChunk::state_transition(None, state);
11115                        }
11116                    // Yield the post-transition re-generated or re-dispatched content.
11117                    yield StreamChunk::content(&final_content);
11118                }
11119
11120                // Parity with non-stream: finalize the committed response once.
11121                let final_response = AgentResponse::new(&final_content);
11122                if let Err(e) = self.finish_turn_if_root(&final_response).await {
11123                    yield StreamChunk::error(e.to_string());
11124                    return;
11125                }
11126
11127                yield StreamChunk::Done {};
11128                return;
11129            }
11130        })
11131    }
11132
11133    /// Streaming entry point with disambiguation
11134    /// Mirrors run_loop but yields StreamChunks instead of AgentResponse.
11135    fn run_loop_stream<'a>(
11136        &'a self,
11137        input: &'a str,
11138    ) -> Pin<Box<dyn Stream<Item = StreamChunk> + Send + 'a>> {
11139        Box::pin(async_stream::stream! {
11140            self.begin_root_turn();
11141            let _root_cleanup = RootTurnCleanup::new(self);
11142            self.hooks.on_message_received(input).await;
11143
11144            // One-shot context initialization (mirrors run_loop)
11145            if !self.context_initialized.swap(true, Ordering::SeqCst) {
11146                if let Err(e) = self.context_manager.initialize().await {
11147                    yield StreamChunk::error(e.to_string());
11148                    return;
11149                }
11150                debug!("Context manager initialized (defaults, env, builtins)");
11151            }
11152
11153            if let Err(e) = self.check_turn_timeout().await {
11154                yield StreamChunk::error(e.to_string());
11155                return;
11156            }
11157            if let Err(e) = self.context_manager.refresh_per_turn().await {
11158                yield StreamChunk::error(e.to_string());
11159                return;
11160            }
11161
11162            // Clear stale disambiguation context from previous turns.
11163            self.clear_disambiguation_context();
11164
11165            // Disambiguation check (before input processing)
11166            if let Some(ref disambiguator) = self.disambiguation_manager {
11167                let disambiguation_context = match self.build_disambiguation_context().await {
11168                    Ok(ctx) => ctx,
11169                    Err(e) => {
11170                        yield StreamChunk::error(e.to_string());
11171                        return;
11172                    }
11173                };
11174
11175                let state_override = self
11176                    .state_machine
11177                    .as_ref()
11178                    .and_then(|sm| sm.current_definition())
11179                    .and_then(|def| def.disambiguation.clone());
11180
11181                let result = match self
11182                    .observe_purpose(
11183                        ObservationPurpose::DisambiguationDetection,
11184                        disambiguator.process_input_with_override(
11185                            input,
11186                            &disambiguation_context,
11187                            state_override.as_ref(),
11188                            None,
11189                        ),
11190                    )
11191                    .await
11192                {
11193                    Ok(r) => r,
11194                    Err(e) => {
11195                        yield StreamChunk::error(e.to_string());
11196                        return;
11197                    }
11198                };
11199
11200                match result {
11201                    DisambiguationResult::Clear => {
11202                        debug!("Input is clear, proceeding normally (stream)");
11203                    }
11204                    DisambiguationResult::NeedsClarification {
11205                        question,
11206                        detection,
11207                    } => {
11208                        info!(
11209                            ambiguity_type = ?detection.ambiguity_type,
11210                            confidence = detection.confidence,
11211                            "Input requires clarification (stream)"
11212                        );
11213                        if let Err(e) = self.commit_root_user_message(input).await {
11214                            yield StreamChunk::error(e.to_string());
11215                            return;
11216                        }
11217                        let _ = self
11218                            .memory
11219                            .add_message(ChatMessage::assistant(&question.question))
11220                            .await;
11221                        let response = AgentResponse::new(&question.question);
11222                        if let Err(e) = self.finish_turn_if_root(&response).await {
11223                            yield StreamChunk::error(e.to_string());
11224                            return;
11225                        }
11226                        yield StreamChunk::content(&question.question);
11227                        yield StreamChunk::Done {};
11228                        return;
11229                    }
11230                    DisambiguationResult::Clarified {
11231                        enriched_input,
11232                        resolved,
11233                        ..
11234                    } => {
11235                        info!(
11236                            resolved_count = resolved.len(),
11237                            enriched = %enriched_input,
11238                            "Input clarified (stream)"
11239                        );
11240                        for (key, value) in &resolved {
11241                            let context_key = format!("disambiguation.{}", key);
11242                            let _ = self.context_manager.set(&context_key, value.clone());
11243                        }
11244                        if let Some(intent) = resolved.get("intent") {
11245                            let _ = self.context_manager.set("resolved_intent", intent.clone());
11246                        }
11247                        let _ = self
11248                            .context_manager
11249                            .set("disambiguation.resolved", serde_json::Value::Bool(true));
11250
11251                        // Check if this clarification was triggered by a skill-level override.
11252                        // Re-run skill disambiguation to verify all required_clarity fields
11253                        // are present before executing.
11254                        let skill_id = self.pending_skill_id.read().clone();
11255                        if let Some(skill_id) = skill_id {
11256                            info!(skill_id = %skill_id, "Re-checking skill disambiguation on clarified input (stream)");
11257                            match self.recheck_skill_disambiguation(&skill_id, &enriched_input).await {
11258                                Ok(resp) => {
11259                                    yield StreamChunk::content(&resp.content);
11260                                    yield StreamChunk::Done {};
11261                                    return;
11262                                }
11263                                Err(e) => {
11264                                    yield StreamChunk::error(e.to_string());
11265                                    return;
11266                                }
11267                            }
11268                        }
11269
11270                        // Forward to internal stream with enriched input
11271                        let mut inner = self.run_loop_internal_stream(&enriched_input);
11272                        while let Some(chunk) = inner.next().await {
11273                            yield chunk;
11274                        }
11275                        return;
11276                    }
11277                    DisambiguationResult::ProceedWithBestGuess { enriched_input } => {
11278                        info!("Proceeding with best guess (stream)");
11279
11280                        // Same skill-id re-check for best-guess path
11281                        let skill_id = self.pending_skill_id.read().clone();
11282                        if let Some(skill_id) = skill_id {
11283                            info!(skill_id = %skill_id, "Re-checking skill disambiguation on best-guess input (stream)");
11284                            match self.recheck_skill_disambiguation(&skill_id, &enriched_input).await {
11285                                Ok(resp) => {
11286                                    yield StreamChunk::content(&resp.content);
11287                                    yield StreamChunk::Done {};
11288                                    return;
11289                                }
11290                                Err(e) => {
11291                                    yield StreamChunk::error(e.to_string());
11292                                    return;
11293                                }
11294                            }
11295                        }
11296
11297                        let mut inner = self.run_loop_internal_stream(&enriched_input);
11298                        while let Some(chunk) = inner.next().await {
11299                            yield chunk;
11300                        }
11301                        return;
11302                    }
11303                    DisambiguationResult::GiveUp { reason } => {
11304                        *self.pending_skill_id.write() = None;
11305                        warn!(reason = %reason, "Disambiguation gave up (stream)");
11306                        let apology = self
11307                            .generate_localized_apology(
11308                                "Generate a brief, polite apology saying you couldn't understand the request. Be concise.",
11309                                &reason,
11310                            )
11311                            .await
11312                            .unwrap_or_else(|_| {
11313                                format!("I'm sorry, I couldn't understand your request: {}", reason)
11314                            });
11315                        let response = AgentResponse::new(&apology);
11316                        if let Err(e) = self.finish_turn_if_root(&response).await {
11317                            yield StreamChunk::error(e.to_string());
11318                            return;
11319                        }
11320                        yield StreamChunk::content(&apology);
11321                        yield StreamChunk::Done {};
11322                        return;
11323                    }
11324                    DisambiguationResult::Escalate { reason } => {
11325                        *self.pending_skill_id.write() = None;
11326                        info!(reason = %reason, "Escalating to human (stream)");
11327                        if let Some(ref hitl) = self.hitl_engine {
11328                            let trigger =
11329                                ApprovalTrigger::condition("disambiguation_escalation", reason.clone());
11330                            let mut context_map = HashMap::new();
11331                            context_map.insert("original_input".to_string(), serde_json::json!(input));
11332                            context_map.insert("reason".to_string(), serde_json::json!(&reason));
11333                            let check_result = HITLCheckResult::required(
11334                                trigger,
11335                                context_map,
11336                                format!("User request needs human assistance: {}", reason),
11337                                Some(hitl.config().default_timeout_seconds),
11338                            );
11339                            match self.request_hitl_approval(check_result).await {
11340                                Ok(ApprovalResult::Approved | ApprovalResult::Modified { .. }) => {
11341                                    let mut inner = self.run_loop_internal_stream(input);
11342                                    while let Some(chunk) = inner.next().await {
11343                                        yield chunk;
11344                                    }
11345                                    return;
11346                                }
11347                                Ok(_) => {}
11348                                Err(e) => {
11349                                    yield StreamChunk::error(e.to_string());
11350                                    return;
11351                                }
11352                            }
11353                        }
11354                        let apology = self
11355                            .generate_localized_apology(
11356                                "Explain briefly that you're transferring the user to a human agent for help.",
11357                                &reason,
11358                            )
11359                            .await
11360                            .unwrap_or_else(|_| {
11361                                format!("I need human assistance to help with your request: {}", reason)
11362                            });
11363                        let response = AgentResponse::new(&apology);
11364                        if let Err(e) = self.finish_turn_if_root(&response).await {
11365                            yield StreamChunk::error(e.to_string());
11366                            return;
11367                        }
11368                        yield StreamChunk::content(&apology);
11369                        yield StreamChunk::Done {};
11370                        return;
11371                    }
11372                    DisambiguationResult::Abandoned { new_input } => {
11373                        *self.pending_skill_id.write() = None;
11374
11375                        info!(
11376                            has_new_input = new_input.is_some(),
11377                            "Clarification abandoned by user (stream)"
11378                        );
11379
11380                        if let Err(e) = self.commit_root_user_message(input).await {
11381                            yield StreamChunk::error(e.to_string());
11382                            return;
11383                        }
11384
11385                        match new_input {
11386                            Some(fresh_input) => {
11387                                // Topic switch: forward to internal stream with fresh input.
11388                                let mut inner = self.run_loop_internal_stream(&fresh_input);
11389                                while let Some(chunk) = inner.next().await {
11390                                    yield chunk;
11391                                }
11392                                return;
11393                            }
11394                            None => {
11395                                // Pure abandonment: generate a brief acknowledgment.
11396                                let ack = self
11397                                    .generate_localized_apology(
11398                                        "The user changed their mind about their previous request. \
11399                                         Generate a brief, friendly acknowledgment (e.g. 'OK, no problem. What else can I help with?'). \
11400                                         Do NOT apologize excessively. Be concise.",
11401                                        "User abandoned clarification",
11402                                    )
11403                                    .await
11404                                    .unwrap_or_else(|_| {
11405                                        "OK, no problem. What else can I help with?".to_string()
11406                                    });
11407
11408                                let _ = self
11409                                    .memory
11410                                    .add_message(ChatMessage::assistant(&ack))
11411                                    .await;
11412
11413                                let response = AgentResponse::new(&ack);
11414                                if let Err(e) = self.finish_turn_if_root(&response).await {
11415                                    yield StreamChunk::error(e.to_string());
11416                                    return;
11417                                }
11418                                yield StreamChunk::content(&ack);
11419                                yield StreamChunk::Done {};
11420                                return;
11421                            }
11422                        }
11423                    }
11424                }
11425            }
11426
11427            // No disambiguation or Clear result — proceed with internal stream
11428            let mut inner = self.run_loop_internal_stream(input);
11429            while let Some(chunk) = inner.next().await {
11430                yield chunk;
11431            }
11432        })
11433    }
11434
11435    pub fn info(&self) -> AgentInfo {
11436        self.info.clone()
11437    }
11438
11439    pub fn skills(&self) -> &[SkillDefinition] {
11440        &self.skills
11441    }
11442
11443    pub async fn reset(&self) -> Result<()> {
11444        self.memory.clear().await?;
11445        *self.iteration_count.write() = 0;
11446        self.tool_call_history.write().clear();
11447        *self.pending_skill_id.write() = None;
11448        if let Some(ref sm) = self.state_machine {
11449            sm.reset();
11450        }
11451        Ok(())
11452    }
11453
11454    pub fn max_context_tokens(&self) -> u32 {
11455        self.max_context_tokens
11456    }
11457
11458    pub fn llm_registry(&self) -> &Arc<LLMRegistry> {
11459        &self.llm_registry
11460    }
11461
11462    pub fn state_machine(&self) -> Option<&Arc<StateMachine>> {
11463        self.state_machine.as_ref()
11464    }
11465
11466    pub fn context_manager(&self) -> &Arc<ContextManager> {
11467        &self.context_manager
11468    }
11469
11470    pub fn tool_call_history(&self) -> Vec<ToolCallRecord> {
11471        self.tool_call_history.read().clone()
11472    }
11473
11474    pub fn memory_token_budget(&self) -> Option<&MemoryTokenBudget> {
11475        self.memory_token_budget.as_ref()
11476    }
11477
11478    pub fn parallel_tools_config(&self) -> &ParallelToolsConfig {
11479        &self.parallel_tools
11480    }
11481
11482    pub fn streaming_config(&self) -> &StreamingConfig {
11483        &self.streaming
11484    }
11485
11486    pub fn hooks(&self) -> &Arc<dyn AgentHooks> {
11487        &self.hooks
11488    }
11489
11490    pub fn hitl_engine(&self) -> Option<&HITLEngine> {
11491        self.hitl_engine.as_ref()
11492    }
11493
11494    pub fn approval_handler(&self) -> &Arc<dyn ApprovalHandler> {
11495        &self.approval_handler
11496    }
11497
11498    /// Build a context map with language hints from context_manager for HITL message localization.
11499    fn build_hitl_language_context(&self) -> HashMap<String, Value> {
11500        let mut ctx = HashMap::new();
11501        for key in &["user.language", "input.detected.language", "language"] {
11502            if let Some(val) = self.context_manager.get(key) {
11503                ctx.insert(key.to_string(), val);
11504            }
11505        }
11506        ctx
11507    }
11508
11509    /// Send a HITL check result through the approval flow and return the full ApprovalResult.
11510    async fn request_hitl_approval(&self, check_result: HITLCheckResult) -> Result<ApprovalResult> {
11511        let Some(request) = check_result.into_request() else {
11512            return Ok(ApprovalResult::Approved);
11513        };
11514
11515        self.hooks.on_approval_requested(&request).await;
11516
11517        let timeout = request.timeout;
11518
11519        let raw_result = if let Some(duration) = timeout {
11520            match tokio::time::timeout(
11521                duration,
11522                self.approval_handler.request_approval(request.clone()),
11523            )
11524            .await
11525            {
11526                Ok(result) => result,
11527                Err(_) => ApprovalResult::timeout(),
11528            }
11529        } else {
11530            self.approval_handler
11531                .request_approval(request.clone())
11532                .await
11533        };
11534
11535        self.hooks
11536            .on_approval_result(&request.id, &raw_result)
11537            .await;
11538
11539        let (outcome, effective_result): (ApprovalResolvedOutcome, Result<ApprovalResult>) =
11540            match &raw_result {
11541                ApprovalResult::Approved => (
11542                    ApprovalResolvedOutcome::Approved,
11543                    Ok(ApprovalResult::Approved),
11544                ),
11545                ApprovalResult::Rejected { reason } => (
11546                    ApprovalResolvedOutcome::Rejected {
11547                        reason: reason.clone(),
11548                    },
11549                    Ok(ApprovalResult::Rejected {
11550                        reason: reason.clone(),
11551                    }),
11552                ),
11553                ApprovalResult::Modified { changes } => (
11554                    ApprovalResolvedOutcome::Modified {
11555                        changes: changes.clone(),
11556                    },
11557                    Ok(ApprovalResult::Modified {
11558                        changes: changes.clone(),
11559                    }),
11560                ),
11561                ApprovalResult::Timeout => {
11562                    if let Some(ref engine) = self.hitl_engine {
11563                        match engine.config().on_timeout {
11564                            TimeoutAction::Approve => (
11565                                ApprovalResolvedOutcome::Approved,
11566                                Ok(ApprovalResult::Approved),
11567                            ),
11568                            TimeoutAction::Reject => {
11569                                let reason = Some("Timeout".to_string());
11570                                (
11571                                    ApprovalResolvedOutcome::Rejected {
11572                                        reason: reason.clone(),
11573                                    },
11574                                    Ok(ApprovalResult::Rejected { reason }),
11575                                )
11576                            }
11577                            TimeoutAction::Error => {
11578                                let message = "HITL approval timeout".to_string();
11579                                (
11580                                    ApprovalResolvedOutcome::Error {
11581                                        message: message.clone(),
11582                                    },
11583                                    Err(AgentError::Other(message)),
11584                                )
11585                            }
11586                        }
11587                    } else {
11588                        let reason = Some("Timeout (no engine)".to_string());
11589                        (
11590                            ApprovalResolvedOutcome::Rejected {
11591                                reason: reason.clone(),
11592                            },
11593                            Ok(ApprovalResult::Rejected { reason }),
11594                        )
11595                    }
11596                }
11597            };
11598
11599        self.hooks
11600            .on_approval_resolved(&request, &raw_result, &outcome)
11601            .await;
11602
11603        effective_result
11604    }
11605
11606    pub async fn check_state_hitl(&self, from: Option<&str>, to: &str) -> Result<bool> {
11607        if let Some(ref hitl_engine) = self.hitl_engine {
11608            let hitl_lang_ctx = self.build_hitl_language_context();
11609            let check_result = self
11610                .observe_purpose(
11611                    ObservationPurpose::HitlLocalization,
11612                    hitl_engine.check_state_transition_with_localization(
11613                        from,
11614                        to,
11615                        &hitl_lang_ctx,
11616                        self.approval_handler.as_ref(),
11617                        Some(&self.llm_registry),
11618                    ),
11619                )
11620                .await?;
11621            if check_result.is_required() {
11622                let result = self.request_hitl_approval(check_result).await?;
11623                return Ok(matches!(
11624                    result,
11625                    ApprovalResult::Approved | ApprovalResult::Modified { .. }
11626                ));
11627            }
11628        }
11629        Ok(true)
11630    }
11631
11632    /// Execute multiple tools in parallel
11633    async fn execute_tools_parallel(
11634        &self,
11635        tool_calls: &[ToolCall],
11636    ) -> Vec<(String, Result<String>)> {
11637        let can_run_parallel = tool_calls.iter().all(|tc| {
11638            self.tools
11639                .resolve(&tc.name)
11640                .map(|resolved| resolved.tool.classify_call(&tc.arguments).concurrency_safe)
11641                .unwrap_or(false)
11642        });
11643
11644        if !self.parallel_tools.enabled || tool_calls.len() <= 1 || !can_run_parallel {
11645            let mut results = Vec::new();
11646            for tc in tool_calls {
11647                let result = self
11648                    .observe_purpose(
11649                        current_observation_context()
11650                            .map(|context| context.purpose)
11651                            .unwrap_or_default(),
11652                        self.execute_tool_smart(tc),
11653                    )
11654                    .await;
11655                results.push((tc.id.clone(), result));
11656            }
11657            return results;
11658        }
11659
11660        let chunks: Vec<_> = tool_calls
11661            .chunks(self.parallel_tools.max_parallel)
11662            .collect();
11663
11664        let mut all_results = Vec::new();
11665
11666        for chunk in chunks {
11667            let futures: Vec<_> = chunk
11668                .iter()
11669                .map(|tc| {
11670                    let tc = tc.clone();
11671                    async move {
11672                        let result = self.execute_tool_smart(&tc).await;
11673                        (tc.id.clone(), result)
11674                    }
11675                })
11676                .collect();
11677
11678            let results = futures::future::join_all(futures).await;
11679            all_results.extend(results);
11680        }
11681
11682        all_results
11683    }
11684
11685    /// Stream a chat response with real-time updates
11686    pub async fn chat_stream<'a>(
11687        &'a self,
11688        input: &'a str,
11689    ) -> Result<Pin<Box<dyn Stream<Item = StreamChunk> + Send + 'a>>> {
11690        //
11691        // Streaming readiness is checked before returning a stream so capability failures cannot appear as partial output.
11692        //
11693        self.init_storage().await?;
11694        info!(input_len = input.len(), "Starting streaming chat");
11695        let inner = self.run_loop_stream(input);
11696        if let Some(context) = self.build_observation_context(None) {
11697            let stream: Pin<Box<dyn Stream<Item = StreamChunk> + Send + 'a>> =
11698                Box::pin(async_stream::stream! {
11699                    let mut inner = inner;
11700                    loop {
11701                        let next = with_observation_context(context.clone(), inner.next()).await;
11702                        match next {
11703                            Some(chunk) => yield chunk,
11704                            None => break,
11705                        }
11706                    }
11707                    self.export_observability_if_configured().await;
11708                });
11709            Ok(stream)
11710        } else {
11711            Ok(inner)
11712        }
11713    }
11714}
11715
11716#[async_trait]
11717impl ToolInvoker for RuntimeAgent {
11718    async fn invoke_tool(&self, request: ToolExecutionRequest) -> Result<ToolExecutionRecord> {
11719        self.execute_tool_record(request).await
11720    }
11721}
11722
11723#[async_trait]
11724impl Agent for RuntimeAgent {
11725    async fn chat(&self, input: &str) -> Result<AgentResponse> {
11726        let result = if let Some(context) = self.build_observation_context(None) {
11727            with_observation_context(context, self.run_loop(input)).await
11728        } else {
11729            self.run_loop(input).await
11730        };
11731        self.export_observability_if_configured().await;
11732        result
11733    }
11734
11735    fn info(&self) -> AgentInfo {
11736        self.info.clone()
11737    }
11738
11739    async fn reset(&self) -> Result<()> {
11740        self.memory.clear().await?;
11741        *self.iteration_count.write() = 0;
11742        self.tool_call_history.write().clear();
11743        if let Some(ref sm) = self.state_machine {
11744            sm.reset();
11745        }
11746        Ok(())
11747    }
11748}
11749
11750//
11751// Render a concurrent input template using direct minijinja.
11752// Same approach as pipeline's render_stage_template so variables are top-level.
11753//
11754// Available variables:
11755//   {{ user_input }}    - the user's actual message
11756//   {{ context.<key> }} - values from the context manager
11757//
11758/// Builds safe runtime tags for background maintenance lifecycle events.
11759fn background_maintenance_tags(
11760    label: &str,
11761    stage: &str,
11762    reason: Option<&str>,
11763    policy: Option<&crate::optimization::config::MaintenanceTaskPolicy>,
11764) -> HashMap<String, String> {
11765    let mut tags = HashMap::new();
11766    tags.insert("runtime.background".to_string(), "true".to_string());
11767    tags.insert("runtime.maintenance".to_string(), label.to_string());
11768    tags.insert("runtime.maintenance_stage".to_string(), stage.to_string());
11769    if let Some(policy) = policy {
11770        tags.insert(
11771            "runtime.await_before_next_turn".to_string(),
11772            await_before_next_turn_label(policy.await_before_next_turn).to_string(),
11773        );
11774        tags.insert(
11775            "runtime.maintenance_mode".to_string(),
11776            maintenance_mode_label(policy.mode).to_string(),
11777        );
11778    }
11779    if let Some(reason) = reason {
11780        tags.insert("runtime.reason".to_string(), reason.to_string());
11781    }
11782    tags
11783}
11784
11785fn await_before_next_turn_label(policy: AwaitBeforeNextTurn) -> &'static str {
11786    match policy {
11787        AwaitBeforeNextTurn::Never => "never",
11788        AwaitBeforeNextTurn::SameActor => "same_actor",
11789        AwaitBeforeNextTurn::Always => "always",
11790    }
11791}
11792
11793fn maintenance_mode_label(mode: MaintenanceMode) -> &'static str {
11794    match mode {
11795        MaintenanceMode::InlineSerial => "inline_serial",
11796        MaintenanceMode::InlineParallel => "inline_parallel",
11797        MaintenanceMode::Background => "background",
11798    }
11799}
11800
11801/// Records a background maintenance lifecycle event when observability is enabled.
11802fn record_background_maintenance_event(
11803    manager: Option<&Arc<ObservabilityManager>>,
11804    label: &str,
11805    status: EventStatus,
11806    duration_ms: u64,
11807    stage: &str,
11808    reason: Option<String>,
11809    policy: Option<&crate::optimization::config::MaintenanceTaskPolicy>,
11810) {
11811    if let Some(manager) = manager {
11812        manager.record_lifecycle_event(
11813            EventType::MemoryOperation {
11814                operation: format!("{}_background_{}", label, stage),
11815            },
11816            ObservationPurpose::Other(format!("{}_maintenance", label)),
11817            status,
11818            duration_ms,
11819            background_maintenance_tags(label, stage, reason.as_deref(), policy),
11820            None,
11821        );
11822    }
11823}
11824
11825fn effective_maintenance_mode(mode: MaintenanceMode, force_parallel: bool) -> MaintenanceMode {
11826    if force_parallel && matches!(mode, MaintenanceMode::InlineSerial) {
11827        MaintenanceMode::InlineParallel
11828    } else {
11829        mode
11830    }
11831}
11832
11833fn observation_purpose_for_process(hint: ProcessPurposeHint) -> ObservationPurpose {
11834    match hint {
11835        ProcessPurposeHint::Detect => ObservationPurpose::ProcessDetect,
11836        ProcessPurposeHint::Extract => ObservationPurpose::ProcessExtract,
11837        ProcessPurposeHint::Validate => ObservationPurpose::ProcessValidate,
11838        ProcessPurposeHint::Transform | ProcessPurposeHint::Other => {
11839            ObservationPurpose::ProcessTransform
11840        }
11841    }
11842}
11843
11844fn new_tool_resource_locks() -> ToolResourceLocks {
11845    Arc::new(RwLock::new(HashMap::new()))
11846}
11847
11848//
11849// Path-bound mutations share one conservative lock so aliases and parent-child paths cannot bypass serialization.
11850// Exact domain keys remain available for non-path side effects, and unbound effects share one fallback lock.
11851//
11852fn tool_resource_lock_keys(
11853    _canonical_id: &str,
11854    args: &Value,
11855    bindings: &ai_agents_core::ToolPolicyBindings,
11856    classification: &ai_agents_core::ToolCallClassification,
11857) -> Vec<String> {
11858    if classification.concurrency_safe {
11859        return Vec::new();
11860    }
11861
11862    let mut keys = Vec::new();
11863    let mut has_path_resource = false;
11864    for binding in &bindings.path_fields {
11865        let value = value_at_argument_path(args, &binding.field)
11866            .cloned()
11867            .or_else(|| {
11868                binding
11869                    .default_path
11870                    .as_ref()
11871                    .map(|path| Value::String(path.clone()))
11872            });
11873        if let Some(value) = value {
11874            collect_resource_strings(&value, |_| {
11875                has_path_resource = true;
11876            });
11877        }
11878    }
11879    for binding in &bindings.domain_fields {
11880        if let Some(value) = value_at_argument_path(args, &binding.field) {
11881            collect_resource_strings(value, |domain| {
11882                let normalized = if binding.is_url {
11883                    normalized_url_resource_key(domain)
11884                } else {
11885                    domain.trim().trim_end_matches('.').to_ascii_lowercase()
11886                };
11887                keys.push(format!("domain:{}", normalized));
11888            });
11889        }
11890    }
11891    for binding in &bindings.command_fields {
11892        if !matches!(binding.kind, ai_agents_core::CommandBindingKind::Cwd) {
11893            continue;
11894        }
11895        if let Some(value) = value_at_argument_path(args, &binding.field) {
11896            collect_resource_strings(value, |_| {
11897                has_path_resource = true;
11898            });
11899        }
11900    }
11901    if has_path_resource {
11902        keys.push("path-mutation:global".to_string());
11903    }
11904    if keys.is_empty() {
11905        keys.push("side-effect:unbound".to_string());
11906    }
11907    keys.sort();
11908    keys.dedup();
11909    keys
11910}
11911
11912fn value_at_argument_path<'a>(value: &'a Value, field: &str) -> Option<&'a Value> {
11913    let mut current = value;
11914    for segment in field.split('.') {
11915        if segment.is_empty() {
11916            return None;
11917        }
11918        current = current.get(segment)?;
11919    }
11920    Some(current)
11921}
11922
11923fn collect_resource_strings(value: &Value, mut collect: impl FnMut(&str)) {
11924    match value {
11925        Value::String(value) => collect(value),
11926        Value::Array(values) => {
11927            for value in values {
11928                if let Some(value) = value.as_str() {
11929                    collect(value);
11930                }
11931            }
11932        }
11933        _ => {}
11934    }
11935}
11936
11937fn normalized_url_resource_key(value: &str) -> String {
11938    let value = value.trim();
11939    let Some((scheme, remainder)) = value.split_once("://") else {
11940        return value.to_ascii_lowercase();
11941    };
11942    let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
11943    let (authority, suffix) = remainder.split_at(authority_end);
11944    format!(
11945        "{}://{}{}",
11946        scheme.to_ascii_lowercase(),
11947        authority.to_ascii_lowercase(),
11948        suffix
11949    )
11950}
11951
11952fn render_concurrent_template(
11953    template: &str,
11954    user_input: &str,
11955    context_values: &std::collections::HashMap<String, serde_json::Value>,
11956) -> Result<String> {
11957    let mut env = minijinja::Environment::new();
11958    env.add_template("concurrent", template)
11959        .map_err(|e| AgentError::Other(format!("Concurrent template parse error: {}", e)))?;
11960
11961    let mut ctx = std::collections::BTreeMap::new();
11962    ctx.insert("user_input".to_string(), minijinja::Value::from(user_input));
11963
11964    // Expose context manager values under {{ context.<key> }}.
11965    let context_obj = minijinja::Value::from_serialize(context_values);
11966    ctx.insert("context".to_string(), context_obj);
11967
11968    let tmpl = env
11969        .get_template("concurrent")
11970        .map_err(|e| AgentError::Other(format!("Concurrent template error: {}", e)))?;
11971
11972    tmpl.render(minijinja::Value::from_serialize(&ctx))
11973        .map_err(|e| AgentError::Other(format!("Concurrent template render error: {}", e)))
11974}
11975
11976#[cfg(test)]
11977mod tests {
11978    use super::*;
11979    use crate::AgentBuilder;
11980    use ai_agents_core::{LLMChunk, LLMConfig, LLMError, LLMFeature, Tool};
11981    use ai_agents_llm::mock::MockLLMProvider;
11982    use ai_agents_tools::{
11983        CalculatorTool, CopyPathTool, DeletePathTool, FileWriteTool, MovePathTool,
11984    };
11985
11986    fn mock_with_response(response: &str) -> MockLLMProvider {
11987        let mut mock = MockLLMProvider::new("test");
11988        mock.set_response(response);
11989        mock
11990    }
11991
11992    fn mock_with_responses(responses: Vec<&str>) -> MockLLMProvider {
11993        let mut mock = MockLLMProvider::new("test");
11994        mock.set_responses(responses.into_iter().map(String::from).collect(), true);
11995        mock
11996    }
11997
11998    #[tokio::test]
11999    async fn native_required_choice_executes_through_the_shared_tool_path() {
12000        let mut mock = MockLLMProvider::new("native-required");
12001        mock.set_tool_choice(Some(ToolChoice::Required));
12002        mock.add_response(
12003            LLMResponse::new("", FinishReason::ToolCall)
12004                .with_tool_calls(vec![ToolCall {
12005                    id: "provider-call-1".to_string(),
12006                    name: "calculator".to_string(),
12007                    arguments: serde_json::json!({"expression": "2 + 2"}),
12008                }])
12009                .unwrap(),
12010        );
12011        mock.add_response(LLMResponse::new("The answer is 4.", FinishReason::Stop));
12012        let observed = mock.clone();
12013        let agent = AgentBuilder::new()
12014            .system_prompt("Use the calculator when needed.")
12015            .llm(Arc::new(mock))
12016            .tool(Arc::new(CalculatorTool::new()))
12017            .build()
12018            .unwrap();
12019
12020        let response = agent.chat("What is 2 + 2?").await.unwrap();
12021
12022        assert_eq!(response.content, "The answer is 4.");
12023        assert_eq!(
12024            response.tool_calls.as_ref().unwrap()[0].id,
12025            "provider-call-1"
12026        );
12027        let calls = observed.call_history();
12028        assert_eq!(calls.len(), 2);
12029        assert!(matches!(
12030            calls[0].request.as_ref().map(|request| &request.choice),
12031            Some(ToolChoice::Required)
12032        ));
12033        assert!(matches!(
12034            calls[1].request.as_ref().map(|request| &request.choice),
12035            Some(ToolChoice::Auto)
12036        ));
12037    }
12038
12039    #[tokio::test]
12040    async fn prompt_fallback_uses_one_corrective_retry() {
12041        let mut mock = MockLLMProvider::new("prompt-required");
12042        mock.set_tool_choice(Some(ToolChoice::Required));
12043        mock.set_native_tool_support(false);
12044        mock.set_responses(
12045            vec![
12046                "I can calculate that.".to_string(),
12047                r#"{"tool":"calculator","arguments":{"expression":"2 + 2"}}"#.to_string(),
12048                "The answer is 4.".to_string(),
12049            ],
12050            false,
12051        );
12052        let observed = mock.clone();
12053        let agent = AgentBuilder::new()
12054            .system_prompt("Use tools.")
12055            .llm(Arc::new(mock))
12056            .tool(Arc::new(CalculatorTool::new()))
12057            .build()
12058            .unwrap();
12059
12060        let response = agent.chat("What is 2 + 2?").await.unwrap();
12061
12062        assert_eq!(response.content, "The answer is 4.");
12063        assert_eq!(observed.call_count(), 3);
12064        let corrective = &observed.call_history()[1].messages;
12065        assert!(
12066            corrective
12067                .last()
12068                .unwrap()
12069                .content
12070                .contains("previous response")
12071        );
12072    }
12073
12074    #[tokio::test]
12075    async fn prompt_fallback_fails_after_one_noncompliant_retry() {
12076        let mut mock = MockLLMProvider::new("prompt-required-failure");
12077        mock.set_tool_choice(Some(ToolChoice::Required));
12078        mock.set_native_tool_support(false);
12079        mock.set_responses(
12080            vec!["No tool.".to_string(), "Still no tool.".to_string()],
12081            false,
12082        );
12083        let observed = mock.clone();
12084        let agent = AgentBuilder::new()
12085            .system_prompt("Use tools.")
12086            .llm(Arc::new(mock))
12087            .tool(Arc::new(CalculatorTool::new()))
12088            .build()
12089            .unwrap();
12090
12091        let error = agent.chat("What is 2 + 2?").await.unwrap_err();
12092
12093        assert!(error.to_string().contains("one corrective retry"));
12094        assert_eq!(observed.call_count(), 2);
12095    }
12096
12097    #[tokio::test]
12098    async fn specific_choice_cannot_widen_the_effective_grant() {
12099        let mut mock = MockLLMProvider::new("specific-outside-grant");
12100        mock.set_tool_choice(Some(ToolChoice::Specific("random".to_string())));
12101        let observed = mock.clone();
12102        let agent = AgentBuilder::new()
12103            .system_prompt("Use tools.")
12104            .llm(Arc::new(mock))
12105            .tool(Arc::new(CalculatorTool::new()))
12106            .build()
12107            .unwrap();
12108
12109        let error = agent.chat("Generate a value.").await.unwrap_err();
12110
12111        assert!(error.to_string().contains("is not registered"));
12112        assert_eq!(observed.call_count(), 0);
12113    }
12114
12115    #[tokio::test]
12116    async fn none_choice_exposes_no_tool_protocol() {
12117        let mut mock = MockLLMProvider::new("no-tools");
12118        mock.set_tool_choice(Some(ToolChoice::None));
12119        mock.set_response(r#"{"tool":"calculator","arguments":{"expression":"2 + 2"}}"#);
12120        let observed = mock.clone();
12121        let agent = AgentBuilder::new()
12122            .system_prompt("Answer directly.")
12123            .llm(Arc::new(mock))
12124            .tool(Arc::new(CalculatorTool::new()))
12125            .build()
12126            .unwrap();
12127
12128        let response = agent.chat("Hello").await.unwrap();
12129
12130        assert!(response.tool_calls.is_none());
12131        assert_eq!(observed.call_count(), 1);
12132        let call = observed.last_call().unwrap();
12133        assert!(call.request.is_none());
12134        assert!(
12135            call.messages
12136                .iter()
12137                .all(|message| !message.content.contains("Available tools:"))
12138        );
12139    }
12140
12141    struct RuntimeStorage {
12142        capabilities: Box<[StorageCapability]>,
12143        snapshots: RwLock<HashMap<String, AgentSnapshot>>,
12144        metadata: RwLock<HashMap<String, ai_agents_core::SessionMetadata>>,
12145        metadata_save_calls: AtomicU64,
12146        metadata_load_calls: AtomicU64,
12147        fail_metadata_save: AtomicBool,
12148        fail_metadata_load: AtomicBool,
12149    }
12150
12151    impl RuntimeStorage {
12152        fn new(capabilities: impl IntoIterator<Item = StorageCapability>) -> Self {
12153            Self {
12154                capabilities: capabilities.into_iter().collect(),
12155                snapshots: RwLock::new(HashMap::new()),
12156                metadata: RwLock::new(HashMap::new()),
12157                metadata_save_calls: AtomicU64::new(0),
12158                metadata_load_calls: AtomicU64::new(0),
12159                fail_metadata_save: AtomicBool::new(false),
12160                fail_metadata_load: AtomicBool::new(false),
12161            }
12162        }
12163    }
12164
12165    #[async_trait]
12166    impl AgentStorage for RuntimeStorage {
12167        fn supports(&self, capability: StorageCapability) -> bool {
12168            self.capabilities.contains(&capability)
12169        }
12170
12171        async fn save(&self, session_id: &str, snapshot: &AgentSnapshot) -> Result<()> {
12172            self.snapshots
12173                .write()
12174                .insert(session_id.to_string(), snapshot.clone());
12175            Ok(())
12176        }
12177
12178        async fn load(&self, session_id: &str) -> Result<Option<AgentSnapshot>> {
12179            Ok(self.snapshots.read().get(session_id).cloned())
12180        }
12181
12182        async fn delete(&self, session_id: &str) -> Result<()> {
12183            self.snapshots.write().remove(session_id);
12184            Ok(())
12185        }
12186
12187        async fn list_sessions(&self) -> Result<Vec<String>> {
12188            Ok(self.snapshots.read().keys().cloned().collect())
12189        }
12190
12191        async fn save_snapshot_with_metadata(
12192            &self,
12193            session_id: &str,
12194            snapshot: &AgentSnapshot,
12195            metadata: &ai_agents_core::SessionMetadata,
12196        ) -> Result<()> {
12197            self.metadata_save_calls.fetch_add(1, Ordering::SeqCst);
12198            if self.fail_metadata_save.load(Ordering::SeqCst) {
12199                return Err(AgentError::Persistence("metadata save failed".into()));
12200            }
12201            self.snapshots
12202                .write()
12203                .insert(session_id.to_string(), snapshot.clone());
12204            self.metadata
12205                .write()
12206                .insert(session_id.to_string(), metadata.clone());
12207            Ok(())
12208        }
12209
12210        async fn save_metadata(
12211            &self,
12212            session_id: &str,
12213            metadata: &ai_agents_core::SessionMetadata,
12214        ) -> Result<()> {
12215            self.metadata_save_calls.fetch_add(1, Ordering::SeqCst);
12216            if self.fail_metadata_save.load(Ordering::SeqCst) {
12217                return Err(AgentError::Persistence("metadata save failed".into()));
12218            }
12219            self.metadata
12220                .write()
12221                .insert(session_id.to_string(), metadata.clone());
12222            Ok(())
12223        }
12224
12225        async fn load_metadata(
12226            &self,
12227            session_id: &str,
12228        ) -> Result<Option<ai_agents_core::SessionMetadata>> {
12229            self.metadata_load_calls.fetch_add(1, Ordering::SeqCst);
12230            if self.fail_metadata_load.load(Ordering::SeqCst) {
12231                return Err(AgentError::Persistence("metadata load failed".into()));
12232            }
12233            Ok(self.metadata.read().get(session_id).cloned())
12234        }
12235    }
12236
12237    fn runtime_storage_agent() -> RuntimeAgent {
12238        AgentBuilder::new()
12239            .system_prompt("Test runtime storage integration.")
12240            .llm(Arc::new(mock_with_response("done")))
12241            .build()
12242            .unwrap()
12243    }
12244
12245    fn restore_spec(id: &str) -> crate::spec::AgentSpec {
12246        crate::spec::AgentSpec {
12247            name: id.to_string(),
12248            system_prompt: format!("Restore child {id}."),
12249            ..crate::spec::AgentSpec::default()
12250        }
12251    }
12252
12253    fn restore_entry(id: &str) -> ai_agents_core::SpawnedAgentEntry {
12254        ai_agents_core::SpawnedAgentEntry {
12255            id: id.to_string(),
12256            name: id.to_string(),
12257            spec_yaml: serde_yaml::to_string(&restore_spec(id)).unwrap(),
12258        }
12259    }
12260
12261    fn restore_spawner(
12262        storage: Arc<RuntimeStorage>,
12263        max_agents: usize,
12264    ) -> (
12265        Arc<crate::spawner::AgentSpawner>,
12266        Arc<crate::spawner::AgentRegistry>,
12267    ) {
12268        let mut llms = LLMRegistry::new();
12269        llms.register("default", Arc::new(mock_with_response("done")));
12270        (
12271            Arc::new(
12272                crate::spawner::AgentSpawner::new()
12273                    .with_shared_llms(llms)
12274                    .with_shared_storage(storage)
12275                    .with_max_agents(max_agents),
12276            ),
12277            Arc::new(crate::spawner::AgentRegistry::new()),
12278        )
12279    }
12280
12281    async fn save_restore_target(
12282        parent: &RuntimeAgent,
12283        storage: &RuntimeStorage,
12284        session_id: &str,
12285        entries: Vec<ai_agents_core::SpawnedAgentEntry>,
12286    ) {
12287        let mut snapshot = parent.save_state().await.unwrap();
12288        snapshot.spawned_agents = Some(entries);
12289        storage.save(session_id, &snapshot).await.unwrap();
12290        storage
12291            .save_metadata(session_id, &ai_agents_core::SessionMetadata::default())
12292            .await
12293            .unwrap();
12294    }
12295
12296    #[tokio::test]
12297    async fn storage_init_requires_storage_for_actor_facts() {
12298        let facts = ai_agents_facts::FactsConfig {
12299            enabled: true,
12300            ..Default::default()
12301        };
12302        let agent = runtime_storage_agent().with_facts_config(None, Some(facts));
12303
12304        let error = agent.init_storage().await.unwrap_err();
12305        assert!(matches!(
12306            error,
12307            AgentError::Config(message)
12308                if message.contains("actor facts or actor memory")
12309                    && message.contains("none is configured or injected")
12310        ));
12311    }
12312
12313    #[tokio::test]
12314    async fn storage_init_validates_actor_facts_capability() {
12315        let storage = Arc::new(RuntimeStorage::new([StorageCapability::Snapshot]));
12316        let actor_memory = ai_agents_facts::ActorMemoryConfig {
12317            enabled: true,
12318            ..Default::default()
12319        };
12320        let agent = runtime_storage_agent()
12321            .with_storage(storage)
12322            .with_facts_config(Some(actor_memory), None);
12323
12324        assert!(matches!(
12325            agent.init_storage().await,
12326            Err(AgentError::UnsupportedStorageCapability(
12327                StorageCapability::ActorFacts
12328            ))
12329        ));
12330    }
12331
12332    #[tokio::test]
12333    async fn blocking_chat_rejects_unsupported_required_storage() {
12334        let storage = Arc::new(RuntimeStorage::new([StorageCapability::Snapshot]));
12335        let facts = ai_agents_facts::FactsConfig {
12336            enabled: true,
12337            ..Default::default()
12338        };
12339        let agent = runtime_storage_agent()
12340            .with_storage(storage)
12341            .with_facts_config(None, Some(facts));
12342
12343        assert!(matches!(
12344            agent.chat("hello").await,
12345            Err(AgentError::UnsupportedStorageCapability(
12346                StorageCapability::ActorFacts
12347            ))
12348        ));
12349    }
12350
12351    #[tokio::test]
12352    async fn streaming_chat_rejects_unsupported_required_storage_before_stream_creation() {
12353        let storage = Arc::new(RuntimeStorage::new([StorageCapability::Snapshot]));
12354        let config = ai_agents_relationships::RelationshipConfig {
12355            enabled: true,
12356            ..Default::default()
12357        };
12358        let manager = Arc::new(RelationshipManager::from_config(config).unwrap());
12359        let agent = runtime_storage_agent()
12360            .with_storage(storage)
12361            .with_relationships(manager);
12362
12363        assert!(matches!(
12364            agent.chat_stream("hello").await,
12365            Err(AgentError::UnsupportedStorageCapability(
12366                StorageCapability::ActorRelationships
12367            ))
12368        ));
12369    }
12370
12371    #[tokio::test]
12372    async fn storage_init_completes_facts_for_injected_storage() {
12373        let storage = Arc::new(RuntimeStorage::new([
12374            StorageCapability::Snapshot,
12375            StorageCapability::ActorFacts,
12376        ]));
12377        let facts = ai_agents_facts::FactsConfig {
12378            enabled: true,
12379            ..Default::default()
12380        };
12381        let agent = runtime_storage_agent()
12382            .with_storage(storage)
12383            .with_facts_config(None, Some(facts));
12384
12385        agent.init_storage().await.unwrap();
12386        assert!(agent.fact_store().is_some());
12387    }
12388
12389    #[tokio::test]
12390    async fn storage_init_requires_storage_for_persistent_relationships() {
12391        let config = ai_agents_relationships::RelationshipConfig {
12392            enabled: true,
12393            ..Default::default()
12394        };
12395        let manager = Arc::new(RelationshipManager::from_config(config).unwrap());
12396        let agent = runtime_storage_agent().with_relationships(manager);
12397
12398        let error = agent.init_storage().await.unwrap_err();
12399        assert!(matches!(
12400            error,
12401            AgentError::Config(message)
12402                if message.contains("persistent relationships")
12403                    && message.contains("none is configured or injected")
12404        ));
12405    }
12406
12407    #[tokio::test]
12408    async fn storage_init_validates_persistent_relationships_capability() {
12409        let storage = Arc::new(RuntimeStorage::new([StorageCapability::Snapshot]));
12410        let config = ai_agents_relationships::RelationshipConfig {
12411            enabled: true,
12412            ..Default::default()
12413        };
12414        let manager = Arc::new(RelationshipManager::from_config(config).unwrap());
12415        let agent = runtime_storage_agent()
12416            .with_storage(storage)
12417            .with_relationships(manager);
12418
12419        assert!(matches!(
12420            agent.init_storage().await,
12421            Err(AgentError::UnsupportedStorageCapability(
12422                StorageCapability::ActorRelationships
12423            ))
12424        ));
12425    }
12426
12427    #[tokio::test]
12428    async fn session_restore_updates_identity_and_clears_stale_actor_binding() {
12429        let storage = Arc::new(RuntimeStorage::new([
12430            StorageCapability::Snapshot,
12431            StorageCapability::SessionMetadata,
12432        ]));
12433        let agent = runtime_storage_agent().with_storage(storage.clone());
12434        agent.set_actor_id("old-actor").unwrap();
12435        agent.save_session("old").await.unwrap();
12436        storage
12437            .save("target", &agent.save_state().await.unwrap())
12438            .await
12439            .unwrap();
12440        storage
12441            .save_metadata("target", &ai_agents_core::SessionMetadata::default())
12442            .await
12443            .unwrap();
12444
12445        assert!(agent.load_session("target").await.unwrap());
12446
12447        assert_eq!(agent.current_session_id.read().as_deref(), Some("target"));
12448        assert_eq!(agent.actor_id(), None);
12449    }
12450
12451    #[tokio::test]
12452    async fn complete_restore_reconciles_growth_shrink_and_empty_topologies() {
12453        let storage = Arc::new(RuntimeStorage::new([
12454            StorageCapability::Snapshot,
12455            StorageCapability::SessionMetadata,
12456        ]));
12457        let (spawner, registry) = restore_spawner(storage.clone(), 3);
12458        let parent = runtime_storage_agent()
12459            .with_storage(storage.clone())
12460            .with_spawner_handles(Arc::clone(&spawner), Arc::clone(&registry));
12461
12462        for id in ["a", "b"] {
12463            let spawned = spawner
12464                .spawn_with_id(id.to_string(), restore_spec(id))
12465                .await
12466                .unwrap();
12467            spawned.agent.save_session("grow").await.unwrap();
12468            registry.register(spawned).await.unwrap();
12469        }
12470        let staged_c = crate::spawner::storage::NamespacedStorage::new(storage.clone(), "c");
12471        staged_c
12472            .save("grow", &AgentSnapshot::new("c".into()))
12473            .await
12474            .unwrap();
12475        staged_c
12476            .save_metadata("grow", &ai_agents_core::SessionMetadata::default())
12477            .await
12478            .unwrap();
12479        save_restore_target(
12480            &parent,
12481            storage.as_ref(),
12482            "grow",
12483            vec![restore_entry("a"), restore_entry("b"), restore_entry("c")],
12484        )
12485        .await;
12486
12487        assert_eq!(parent.restore_session_full("grow").await.unwrap(), 3);
12488        assert_eq!(registry.count(), 3);
12489        assert!(registry.contains("c"));
12490        assert_eq!(spawner.spawned_count(), 3);
12491
12492        for id in ["a", "b"] {
12493            registry
12494                .get(id)
12495                .unwrap()
12496                .save_session("shrink")
12497                .await
12498                .unwrap();
12499        }
12500        save_restore_target(
12501            &parent,
12502            storage.as_ref(),
12503            "shrink",
12504            vec![restore_entry("a"), restore_entry("b")],
12505        )
12506        .await;
12507
12508        assert_eq!(parent.restore_session_full("shrink").await.unwrap(), 2);
12509        assert_eq!(registry.count(), 2);
12510        assert!(!registry.contains("c"));
12511        assert_eq!(spawner.spawned_count(), 2);
12512
12513        save_restore_target(&parent, storage.as_ref(), "empty", Vec::new()).await;
12514
12515        assert_eq!(parent.restore_session_full("empty").await.unwrap(), 0);
12516        assert_eq!(registry.count(), 0);
12517        assert_eq!(spawner.spawned_count(), 0);
12518        assert_eq!(parent.current_session_id.read().as_deref(), Some("empty"));
12519    }
12520
12521    #[tokio::test]
12522    async fn storage_session_metadata_is_called_only_when_advertised() {
12523        let storage = Arc::new(RuntimeStorage::new([StorageCapability::Snapshot]));
12524        storage.fail_metadata_save.store(true, Ordering::SeqCst);
12525        storage.fail_metadata_load.store(true, Ordering::SeqCst);
12526        let agent = runtime_storage_agent().with_storage(storage.clone());
12527
12528        agent.save_session("session").await.unwrap();
12529        assert!(agent.load_session("session").await.unwrap());
12530        assert_eq!(storage.metadata_save_calls.load(Ordering::SeqCst), 0);
12531        assert_eq!(storage.metadata_load_calls.load(Ordering::SeqCst), 0);
12532    }
12533
12534    #[cfg(feature = "sqlite")]
12535    #[tokio::test]
12536    async fn sqlite_runtime_save_filter_reopen_and_reload_stay_consistent() {
12537        let directory =
12538            std::env::temp_dir().join(format!("ai-agents-runtime-sqlite-{}", uuid::Uuid::new_v4()));
12539        let path = directory.join("sessions.sqlite");
12540        let path_string = path.to_string_lossy().into_owned();
12541        let storage = Arc::new(
12542            ai_agents_storage::SqliteStorage::new(&path_string)
12543                .await
12544                .unwrap(),
12545        );
12546        let agent = runtime_storage_agent().with_storage(storage.clone());
12547        agent.set_session_metadata(ai_agents_core::SessionMetadata {
12548            tags: vec!["initial".into()],
12549            ..Default::default()
12550        });
12551        agent.chat("persist this turn").await.unwrap();
12552        agent.save_session("session").await.unwrap();
12553
12554        agent.set_session_metadata(ai_agents_core::SessionMetadata {
12555            tags: vec!["updated".into()],
12556            ..Default::default()
12557        });
12558        agent.save_session("session").await.unwrap();
12559        assert!(
12560            agent
12561                .list_sessions_filtered(&ai_agents_core::SessionFilter {
12562                    tags: Some(vec!["initial".into()]),
12563                    ..Default::default()
12564                })
12565                .await
12566                .unwrap()
12567                .is_empty()
12568        );
12569        assert_eq!(
12570            agent
12571                .list_sessions_filtered(&ai_agents_core::SessionFilter {
12572                    tags: Some(vec!["updated".into()]),
12573                    ..Default::default()
12574                })
12575                .await
12576                .unwrap()
12577                .len(),
12578            1
12579        );
12580        drop(agent);
12581        storage.close().await;
12582        drop(storage);
12583
12584        let reopened_storage = Arc::new(
12585            ai_agents_storage::SqliteStorage::new(&path_string)
12586                .await
12587                .unwrap(),
12588        );
12589        let restored = runtime_storage_agent().with_storage(reopened_storage.clone());
12590        assert!(restored.load_session("session").await.unwrap());
12591        assert_eq!(restored.session_metadata().tags, vec!["updated"]);
12592        assert_eq!(
12593            restored.current_session_id.read().as_deref(),
12594            Some("session")
12595        );
12596        assert!(restored.save_state().await.unwrap().memory.messages.len() >= 2);
12597        assert_eq!(
12598            restored
12599                .list_sessions_filtered(&ai_agents_core::SessionFilter {
12600                    tags: Some(vec!["updated".into()]),
12601                    ..Default::default()
12602                })
12603                .await
12604                .unwrap()
12605                .len(),
12606            1
12607        );
12608
12609        drop(restored);
12610        reopened_storage.close().await;
12611        drop(reopened_storage);
12612        std::fs::remove_dir_all(directory).unwrap();
12613    }
12614
12615    #[tokio::test]
12616    async fn storage_session_metadata_backend_failures_propagate() {
12617        let storage = Arc::new(RuntimeStorage::new([
12618            StorageCapability::Snapshot,
12619            StorageCapability::SessionMetadata,
12620        ]));
12621        let agent = runtime_storage_agent().with_storage(storage.clone());
12622
12623        agent.save_session("session").await.unwrap();
12624        storage
12625            .save("target", &agent.save_state().await.unwrap())
12626            .await
12627            .unwrap();
12628        storage.fail_metadata_load.store(true, Ordering::SeqCst);
12629        assert!(matches!(
12630            agent.load_session("target").await,
12631            Err(AgentError::Persistence(message)) if message == "metadata load failed"
12632        ));
12633        assert_eq!(agent.current_session_id.read().as_deref(), Some("session"));
12634
12635        storage.fail_metadata_save.store(true, Ordering::SeqCst);
12636        assert!(matches!(
12637            agent.save_session("session").await,
12638            Err(AgentError::Persistence(message)) if message == "metadata save failed"
12639        ));
12640    }
12641
12642    struct ProviderFutureDropSignal {
12643        dropped: Arc<AtomicBool>,
12644    }
12645
12646    impl Drop for ProviderFutureDropSignal {
12647        fn drop(&mut self) {
12648            self.dropped.store(true, Ordering::SeqCst);
12649        }
12650    }
12651
12652    struct BufferedLockingProvider {
12653        lock: Arc<tokio::sync::Mutex<()>>,
12654        stream_started: Arc<tokio::sync::Notify>,
12655        stream_dropped: Arc<AtomicBool>,
12656        committed_after_drop: Arc<AtomicBool>,
12657    }
12658
12659    #[async_trait]
12660    impl LLMProvider for BufferedLockingProvider {
12661        async fn complete(
12662            &self,
12663            _messages: &[ChatMessage],
12664            _config: Option<&LLMConfig>,
12665        ) -> std::result::Result<LLMResponse, LLMError> {
12666            let _guard = self.lock.lock().await;
12667            self.committed_after_drop
12668                .store(self.stream_dropped.load(Ordering::SeqCst), Ordering::SeqCst);
12669            Ok(LLMResponse::new(
12670                "Committed technical response.",
12671                FinishReason::Stop,
12672            ))
12673        }
12674
12675        async fn complete_stream(
12676            &self,
12677            _messages: &[ChatMessage],
12678            _config: Option<&LLMConfig>,
12679        ) -> std::result::Result<
12680            Box<dyn Stream<Item = std::result::Result<LLMChunk, LLMError>> + Unpin + Send>,
12681            LLMError,
12682        > {
12683            let _guard = self.lock.lock().await;
12684            let _drop_signal = ProviderFutureDropSignal {
12685                dropped: Arc::clone(&self.stream_dropped),
12686            };
12687            self.stream_started.notify_one();
12688            std::future::pending().await
12689        }
12690
12691        fn provider_name(&self) -> &str {
12692            "buffered-locking"
12693        }
12694
12695        fn supports(&self, _feature: LLMFeature) -> bool {
12696            false
12697        }
12698    }
12699
12700    struct PendingDropStream {
12701        dropped: Arc<AtomicBool>,
12702        dropped_notify: Arc<tokio::sync::Notify>,
12703    }
12704
12705    impl Stream for PendingDropStream {
12706        type Item = std::result::Result<LLMChunk, LLMError>;
12707
12708        fn poll_next(
12709            self: Pin<&mut Self>,
12710            _cx: &mut std::task::Context<'_>,
12711        ) -> std::task::Poll<Option<Self::Item>> {
12712            std::task::Poll::Pending
12713        }
12714    }
12715
12716    impl Drop for PendingDropStream {
12717        fn drop(&mut self) {
12718            self.dropped.store(true, Ordering::SeqCst);
12719            self.dropped_notify.notify_one();
12720        }
12721    }
12722
12723    struct EstablishedStreamProvider {
12724        stream_started: Arc<tokio::sync::Notify>,
12725        stream_dropped: Arc<AtomicBool>,
12726        stream_dropped_notify: Arc<tokio::sync::Notify>,
12727        committed_after_drop: Arc<AtomicBool>,
12728    }
12729
12730    #[async_trait]
12731    impl LLMProvider for EstablishedStreamProvider {
12732        async fn complete(
12733            &self,
12734            _messages: &[ChatMessage],
12735            _config: Option<&LLMConfig>,
12736        ) -> std::result::Result<LLMResponse, LLMError> {
12737            if !self.stream_dropped.load(Ordering::SeqCst) {
12738                self.stream_dropped_notify.notified().await;
12739            }
12740            self.committed_after_drop
12741                .store(self.stream_dropped.load(Ordering::SeqCst), Ordering::SeqCst);
12742            Ok(LLMResponse::new(
12743                "Committed technical response.",
12744                FinishReason::Stop,
12745            ))
12746        }
12747
12748        async fn complete_stream(
12749            &self,
12750            _messages: &[ChatMessage],
12751            _config: Option<&LLMConfig>,
12752        ) -> std::result::Result<
12753            Box<dyn Stream<Item = std::result::Result<LLMChunk, LLMError>> + Unpin + Send>,
12754            LLMError,
12755        > {
12756            self.stream_started.notify_one();
12757            Ok(Box::new(PendingDropStream {
12758                dropped: Arc::clone(&self.stream_dropped),
12759                dropped_notify: Arc::clone(&self.stream_dropped_notify),
12760            }))
12761        }
12762
12763        fn provider_name(&self) -> &str {
12764            "established-stream"
12765        }
12766
12767        fn supports(&self, _feature: LLMFeature) -> bool {
12768            false
12769        }
12770    }
12771
12772    struct FirstCallLockingProvider {
12773        lock: Arc<tokio::sync::Mutex<()>>,
12774        first_started: Arc<tokio::sync::Notify>,
12775        first_dropped: Arc<AtomicBool>,
12776        committed_after_drop: Arc<AtomicBool>,
12777        calls: AtomicU64,
12778    }
12779
12780    #[async_trait]
12781    impl LLMProvider for FirstCallLockingProvider {
12782        async fn complete(
12783            &self,
12784            _messages: &[ChatMessage],
12785            _config: Option<&LLMConfig>,
12786        ) -> std::result::Result<LLMResponse, LLMError> {
12787            let _guard = self.lock.lock().await;
12788            let call = self.calls.fetch_add(1, Ordering::SeqCst);
12789            if call == 0 {
12790                let _drop_signal = ProviderFutureDropSignal {
12791                    dropped: Arc::clone(&self.first_dropped),
12792                };
12793                self.first_started.notify_one();
12794                return std::future::pending().await;
12795            }
12796            self.committed_after_drop
12797                .store(self.first_dropped.load(Ordering::SeqCst), Ordering::SeqCst);
12798            Ok(LLMResponse::new(
12799                "Committed technical response.",
12800                FinishReason::Stop,
12801            ))
12802        }
12803
12804        async fn complete_stream(
12805            &self,
12806            _messages: &[ChatMessage],
12807            _config: Option<&LLMConfig>,
12808        ) -> std::result::Result<
12809            Box<dyn Stream<Item = std::result::Result<LLMChunk, LLMError>> + Unpin + Send>,
12810            LLMError,
12811        > {
12812            Err(LLMError::Other(
12813                "streaming is not used in this test".to_string(),
12814            ))
12815        }
12816
12817        fn provider_name(&self) -> &str {
12818            "first-call-locking"
12819        }
12820
12821        fn supports(&self, _feature: LLMFeature) -> bool {
12822            false
12823        }
12824    }
12825
12826    struct RoutingAfterProviderStart {
12827        provider_started: Arc<tokio::sync::Notify>,
12828    }
12829
12830    #[async_trait]
12831    impl LLMProvider for RoutingAfterProviderStart {
12832        async fn complete(
12833            &self,
12834            _messages: &[ChatMessage],
12835            _config: Option<&LLMConfig>,
12836        ) -> std::result::Result<LLMResponse, LLMError> {
12837            self.provider_started.notified().await;
12838            Ok(LLMResponse::new("1", FinishReason::Stop))
12839        }
12840
12841        async fn complete_stream(
12842            &self,
12843            _messages: &[ChatMessage],
12844            _config: Option<&LLMConfig>,
12845        ) -> std::result::Result<
12846            Box<dyn Stream<Item = std::result::Result<LLMChunk, LLMError>> + Unpin + Send>,
12847            LLMError,
12848        > {
12849            Err(LLMError::Other(
12850                "streaming is not used in this test".to_string(),
12851            ))
12852        }
12853
12854        fn provider_name(&self) -> &str {
12855            "routing-after-start"
12856        }
12857
12858        fn supports(&self, _feature: LLMFeature) -> bool {
12859            false
12860        }
12861    }
12862
12863    /// Test hook that counts completed responses.
12864    struct ResponseCountingHooks {
12865        responses: Arc<std::sync::atomic::AtomicUsize>,
12866    }
12867
12868    /// Test tool that returns the execution context it received.
12869    struct ContextEchoTool;
12870
12871    #[async_trait]
12872    impl ai_agents_core::Tool for ContextEchoTool {
12873        fn id(&self) -> &str {
12874            "context_echo"
12875        }
12876
12877        fn name(&self) -> &str {
12878            "Context Echo"
12879        }
12880
12881        fn description(&self) -> &str {
12882            "Returns selected execution context fields."
12883        }
12884
12885        fn input_schema(&self) -> Value {
12886            serde_json::json!({"type": "object"})
12887        }
12888
12889        fn policy_bindings(&self) -> ai_agents_core::ToolPolicyBindings {
12890            ai_agents_core::ToolPolicyBindings {
12891                path_fields: vec![ai_agents_core::PathPolicyBinding::read("path")],
12892                result_limit_fields: vec![ai_agents_core::ResultLimitBinding::new(
12893                    "max_results",
12894                    ai_agents_core::ResultLimitKind::MaxResults,
12895                )],
12896                ..Default::default()
12897            }
12898        }
12899
12900        async fn execute(
12901            &self,
12902            _args: Value,
12903            ctx: ai_agents_core::ToolExecutionContext,
12904        ) -> ToolResult {
12905            ToolResult::ok(
12906                serde_json::json!({
12907                    "requested_name": ctx.requested_name,
12908                    "canonical_id": ctx.canonical_id,
12909                    "display_name": ctx.display_name,
12910                    "max_results": ctx.limits.max_results,
12911                    "custom_config": ctx.custom_config,
12912                })
12913                .to_string(),
12914            )
12915        }
12916    }
12917
12918    /// Test tool that stays active long enough for runtime cancellation.
12919    struct SlowTool;
12920
12921    /// Test tool that fails once and must not be retried for writes.
12922    struct FlakyWriteTool {
12923        calls: Arc<std::sync::atomic::AtomicUsize>,
12924    }
12925
12926    /// Test tool that tracks concurrent execution on one path.
12927    struct LockedWriteTool {
12928        active: Arc<std::sync::atomic::AtomicUsize>,
12929        max_active: Arc<std::sync::atomic::AtomicUsize>,
12930    }
12931
12932    struct MultiResourceWriteTool {
12933        active: Arc<std::sync::atomic::AtomicUsize>,
12934        max_active: Arc<std::sync::atomic::AtomicUsize>,
12935    }
12936
12937    #[derive(Clone)]
12938    struct PathMutationGate {
12939        entered: Arc<AtomicBool>,
12940        entered_notify: Arc<tokio::sync::Notify>,
12941        release: Arc<tokio::sync::Notify>,
12942    }
12943
12944    impl PathMutationGate {
12945        fn new() -> Self {
12946            Self {
12947                entered: Arc::new(AtomicBool::new(false)),
12948                entered_notify: Arc::new(tokio::sync::Notify::new()),
12949                release: Arc::new(tokio::sync::Notify::new()),
12950            }
12951        }
12952
12953        async fn wait_until_entered(&self) {
12954            if !self.entered.load(Ordering::SeqCst) {
12955                self.entered_notify.notified().await;
12956            }
12957        }
12958
12959        fn release(&self) {
12960            self.release.notify_one();
12961        }
12962    }
12963
12964    struct BlockingPathMutationTool {
12965        id: &'static str,
12966        path_fields: Vec<ai_agents_core::PathPolicyBinding>,
12967        gate: PathMutationGate,
12968    }
12969
12970    struct NoBindingWriteTool {
12971        active: Arc<std::sync::atomic::AtomicUsize>,
12972        max_active: Arc<std::sync::atomic::AtomicUsize>,
12973    }
12974
12975    struct RecoveryTestTool {
12976        id: String,
12977        succeeds: bool,
12978        calls: Arc<std::sync::atomic::AtomicUsize>,
12979    }
12980
12981    struct BlockingApprovalHandler {
12982        entered: Arc<tokio::sync::Barrier>,
12983        release: Arc<tokio::sync::Notify>,
12984        result: ApprovalResult,
12985    }
12986
12987    struct ReentrantToolHooks {
12988        agent: parking_lot::Mutex<Option<Weak<RuntimeAgent>>>,
12989        invoked: AtomicBool,
12990        nested_success: AtomicBool,
12991    }
12992
12993    #[async_trait]
12994    impl ai_agents_core::Tool for SlowTool {
12995        fn id(&self) -> &str {
12996            "slow"
12997        }
12998
12999        fn name(&self) -> &str {
13000            "Slow"
13001        }
13002
13003        fn description(&self) -> &str {
13004            "Waits until cancelled or timed out."
13005        }
13006
13007        fn input_schema(&self) -> Value {
13008            serde_json::json!({"type": "object"})
13009        }
13010
13011        async fn execute(
13012            &self,
13013            _args: Value,
13014            _ctx: ai_agents_core::ToolExecutionContext,
13015        ) -> ToolResult {
13016            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
13017            ToolResult::ok("done")
13018        }
13019    }
13020
13021    #[async_trait]
13022    impl ai_agents_core::Tool for FlakyWriteTool {
13023        fn id(&self) -> &str {
13024            "flaky_write"
13025        }
13026
13027        fn name(&self) -> &str {
13028            "Flaky Write"
13029        }
13030
13031        fn description(&self) -> &str {
13032            "Fails on the first write attempt."
13033        }
13034
13035        fn input_schema(&self) -> Value {
13036            serde_json::json!({"type": "object", "properties": {"path": {"type": "string"}}})
13037        }
13038
13039        fn policy_bindings(&self) -> ai_agents_core::ToolPolicyBindings {
13040            ai_agents_core::ToolPolicyBindings {
13041                path_fields: vec![ai_agents_core::PathPolicyBinding::write("path")],
13042                ..Default::default()
13043            }
13044        }
13045
13046        fn safety_metadata(&self) -> ai_agents_core::ToolSafetyMetadata {
13047            ai_agents_core::ToolSafetyMetadata {
13048                read_only: false,
13049                concurrency_safe: false,
13050                operation: ai_agents_core::ToolOperationKind::Write,
13051                side_effect_level: ai_agents_core::ToolSideEffectLevel::LocalWrite,
13052                requires_network: false,
13053                destructive: false,
13054                open_world: false,
13055                host_dependent: false,
13056                requires_user_interaction: false,
13057                supports_cancellation: true,
13058                default_requires_approval: false,
13059                should_defer_schema: false,
13060                max_output_chars: Some(1024),
13061                max_result_size_chars: Some(1024),
13062            }
13063        }
13064
13065        fn classify_call(&self, _args: &Value) -> ai_agents_core::ToolCallClassification {
13066            let mut classification =
13067                ai_agents_core::ToolCallClassification::from_metadata(&self.safety_metadata());
13068            classification.safely_retryable = false;
13069            classification
13070        }
13071
13072        async fn execute(
13073            &self,
13074            _args: Value,
13075            _ctx: ai_agents_core::ToolExecutionContext,
13076        ) -> ToolResult {
13077            let call = self.calls.fetch_add(1, Ordering::SeqCst);
13078            if call == 0 {
13079                ToolResult::error("first failure")
13080            } else {
13081                ToolResult::ok("second success")
13082            }
13083        }
13084    }
13085
13086    #[async_trait]
13087    impl ai_agents_core::Tool for LockedWriteTool {
13088        fn id(&self) -> &str {
13089            "locked_write"
13090        }
13091
13092        fn name(&self) -> &str {
13093            "Locked Write"
13094        }
13095
13096        fn description(&self) -> &str {
13097            "Tracks concurrent execution on one resource."
13098        }
13099
13100        fn input_schema(&self) -> Value {
13101            serde_json::json!({"type": "object", "properties": {"path": {"type": "string"}}})
13102        }
13103
13104        fn policy_bindings(&self) -> ai_agents_core::ToolPolicyBindings {
13105            ai_agents_core::ToolPolicyBindings {
13106                path_fields: vec![ai_agents_core::PathPolicyBinding::write("path")],
13107                ..Default::default()
13108            }
13109        }
13110
13111        fn safety_metadata(&self) -> ai_agents_core::ToolSafetyMetadata {
13112            ai_agents_core::ToolSafetyMetadata {
13113                read_only: false,
13114                concurrency_safe: false,
13115                operation: ai_agents_core::ToolOperationKind::Write,
13116                side_effect_level: ai_agents_core::ToolSideEffectLevel::LocalWrite,
13117                requires_network: false,
13118                destructive: false,
13119                open_world: false,
13120                host_dependent: false,
13121                requires_user_interaction: false,
13122                supports_cancellation: true,
13123                default_requires_approval: false,
13124                should_defer_schema: false,
13125                max_output_chars: Some(1024),
13126                max_result_size_chars: Some(1024),
13127            }
13128        }
13129
13130        async fn execute(
13131            &self,
13132            _args: Value,
13133            _ctx: ai_agents_core::ToolExecutionContext,
13134        ) -> ToolResult {
13135            let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
13136            loop {
13137                let current_max = self.max_active.load(Ordering::SeqCst);
13138                if active <= current_max {
13139                    break;
13140                }
13141                if self
13142                    .max_active
13143                    .compare_exchange(current_max, active, Ordering::SeqCst, Ordering::SeqCst)
13144                    .is_ok()
13145                {
13146                    break;
13147                }
13148            }
13149            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
13150            self.active.fetch_sub(1, Ordering::SeqCst);
13151            ToolResult::ok("done")
13152        }
13153    }
13154
13155    #[async_trait]
13156    impl ai_agents_core::Tool for MultiResourceWriteTool {
13157        fn id(&self) -> &str {
13158            "multi_resource_write"
13159        }
13160
13161        fn name(&self) -> &str {
13162            "Multi Resource Write"
13163        }
13164
13165        fn description(&self) -> &str {
13166            "Tracks concurrent execution across source and destination resources."
13167        }
13168
13169        fn input_schema(&self) -> Value {
13170            serde_json::json!({"type": "object"})
13171        }
13172
13173        fn policy_bindings(&self) -> ai_agents_core::ToolPolicyBindings {
13174            ai_agents_core::ToolPolicyBindings {
13175                path_fields: vec![
13176                    ai_agents_core::PathPolicyBinding::read_write("source_path"),
13177                    ai_agents_core::PathPolicyBinding::write("destination_path"),
13178                ],
13179                ..Default::default()
13180            }
13181        }
13182
13183        fn safety_metadata(&self) -> ai_agents_core::ToolSafetyMetadata {
13184            LockedWriteTool {
13185                active: Arc::clone(&self.active),
13186                max_active: Arc::clone(&self.max_active),
13187            }
13188            .safety_metadata()
13189        }
13190
13191        async fn execute(
13192            &self,
13193            _args: Value,
13194            _ctx: ai_agents_core::ToolExecutionContext,
13195        ) -> ToolResult {
13196            let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
13197            self.max_active.fetch_max(active, Ordering::SeqCst);
13198            tokio::time::sleep(std::time::Duration::from_millis(75)).await;
13199            self.active.fetch_sub(1, Ordering::SeqCst);
13200            ToolResult::ok("done")
13201        }
13202    }
13203
13204    #[async_trait]
13205    impl ai_agents_core::Tool for BlockingPathMutationTool {
13206        fn id(&self) -> &str {
13207            self.id
13208        }
13209
13210        fn name(&self) -> &str {
13211            self.id
13212        }
13213
13214        fn description(&self) -> &str {
13215            "Blocks a path mutation until the test releases it."
13216        }
13217
13218        fn input_schema(&self) -> Value {
13219            serde_json::json!({"type": "object"})
13220        }
13221
13222        fn policy_bindings(&self) -> ai_agents_core::ToolPolicyBindings {
13223            ai_agents_core::ToolPolicyBindings {
13224                path_fields: self.path_fields.clone(),
13225                ..Default::default()
13226            }
13227        }
13228
13229        fn safety_metadata(&self) -> ai_agents_core::ToolSafetyMetadata {
13230            ai_agents_core::ToolSafetyMetadata {
13231                read_only: false,
13232                concurrency_safe: false,
13233                operation: ai_agents_core::ToolOperationKind::Write,
13234                side_effect_level: ai_agents_core::ToolSideEffectLevel::LocalWrite,
13235                requires_network: false,
13236                destructive: false,
13237                open_world: false,
13238                host_dependent: false,
13239                requires_user_interaction: false,
13240                supports_cancellation: true,
13241                default_requires_approval: false,
13242                should_defer_schema: false,
13243                max_output_chars: Some(1024),
13244                max_result_size_chars: Some(1024),
13245            }
13246        }
13247
13248        async fn execute(
13249            &self,
13250            _args: Value,
13251            _ctx: ai_agents_core::ToolExecutionContext,
13252        ) -> ToolResult {
13253            self.gate.entered.store(true, Ordering::SeqCst);
13254            self.gate.entered_notify.notify_one();
13255            self.gate.release.notified().await;
13256            ToolResult::ok("done")
13257        }
13258    }
13259
13260    #[async_trait]
13261    impl ai_agents_core::Tool for NoBindingWriteTool {
13262        fn id(&self) -> &str {
13263            "no_binding_write"
13264        }
13265
13266        fn name(&self) -> &str {
13267            "No Binding Write"
13268        }
13269
13270        fn description(&self) -> &str {
13271            "Tracks concurrent execution without resource bindings."
13272        }
13273
13274        fn input_schema(&self) -> Value {
13275            serde_json::json!({"type": "object"})
13276        }
13277
13278        fn safety_metadata(&self) -> ai_agents_core::ToolSafetyMetadata {
13279            LockedWriteTool {
13280                active: Arc::clone(&self.active),
13281                max_active: Arc::clone(&self.max_active),
13282            }
13283            .safety_metadata()
13284        }
13285
13286        async fn execute(
13287            &self,
13288            _args: Value,
13289            _ctx: ai_agents_core::ToolExecutionContext,
13290        ) -> ToolResult {
13291            let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
13292            self.max_active.fetch_max(active, Ordering::SeqCst);
13293            tokio::time::sleep(std::time::Duration::from_millis(75)).await;
13294            self.active.fetch_sub(1, Ordering::SeqCst);
13295            ToolResult::ok("done")
13296        }
13297    }
13298
13299    #[async_trait]
13300    impl ai_agents_core::Tool for RecoveryTestTool {
13301        fn id(&self) -> &str {
13302            &self.id
13303        }
13304
13305        fn name(&self) -> &str {
13306            &self.id
13307        }
13308
13309        fn description(&self) -> &str {
13310            "Records recovery execution and returns a configured result."
13311        }
13312
13313        fn input_schema(&self) -> Value {
13314            serde_json::json!({"type": "object"})
13315        }
13316
13317        fn policy_bindings(&self) -> ai_agents_core::ToolPolicyBindings {
13318            ai_agents_core::ToolPolicyBindings {
13319                path_fields: vec![ai_agents_core::PathPolicyBinding::write("path")],
13320                ..Default::default()
13321            }
13322        }
13323
13324        fn safety_metadata(&self) -> ai_agents_core::ToolSafetyMetadata {
13325            ai_agents_core::ToolSafetyMetadata {
13326                read_only: false,
13327                concurrency_safe: false,
13328                operation: ai_agents_core::ToolOperationKind::Write,
13329                side_effect_level: ai_agents_core::ToolSideEffectLevel::LocalWrite,
13330                requires_network: false,
13331                destructive: false,
13332                open_world: false,
13333                host_dependent: false,
13334                requires_user_interaction: false,
13335                supports_cancellation: true,
13336                default_requires_approval: false,
13337                should_defer_schema: false,
13338                max_output_chars: Some(1024),
13339                max_result_size_chars: Some(1024),
13340            }
13341        }
13342
13343        async fn execute(
13344            &self,
13345            _args: Value,
13346            _ctx: ai_agents_core::ToolExecutionContext,
13347        ) -> ToolResult {
13348            self.calls.fetch_add(1, Ordering::SeqCst);
13349            if self.succeeds {
13350                ToolResult::ok(format!("{} succeeded", self.id))
13351            } else {
13352                ToolResult::error(format!("{} failed", self.id))
13353            }
13354        }
13355    }
13356
13357    #[async_trait]
13358    impl ApprovalHandler for BlockingApprovalHandler {
13359        async fn request_approval(
13360            &self,
13361            _request: ai_agents_hitl::ApprovalRequest,
13362        ) -> ApprovalResult {
13363            self.entered.wait().await;
13364            self.release.notified().await;
13365            self.result.clone()
13366        }
13367    }
13368
13369    #[async_trait]
13370    impl AgentHooks for ReentrantToolHooks {
13371        async fn on_tool_complete(&self, tool: &str, _result: &ToolResult, _duration_ms: u64) {
13372            if tool != "reentrant_write" || self.invoked.swap(true, Ordering::SeqCst) {
13373                return;
13374            }
13375            let agent = self.agent.lock().as_ref().and_then(Weak::upgrade);
13376            if let Some(agent) = agent {
13377                let result = agent
13378                    .invoke_tool(ToolExecutionRequest::new(
13379                        "nested-hook-call",
13380                        "reentrant_write",
13381                        serde_json::json!({"path": "./hook.txt"}),
13382                        ToolCallSource::Manual,
13383                    ))
13384                    .await;
13385                self.nested_success
13386                    .store(result.is_ok_and(|record| record.success), Ordering::SeqCst);
13387            }
13388        }
13389    }
13390
13391    #[async_trait]
13392    impl AgentHooks for ResponseCountingHooks {
13393        async fn on_response(&self, _response: &AgentResponse) {
13394            self.responses.fetch_add(1, Ordering::SeqCst);
13395        }
13396    }
13397
13398    struct ApprovalRecordingHooks {
13399        events: parking_lot::Mutex<Vec<String>>,
13400    }
13401
13402    impl ApprovalRecordingHooks {
13403        fn new() -> Self {
13404            Self {
13405                events: parking_lot::Mutex::new(Vec::new()),
13406            }
13407        }
13408
13409        fn events(&self) -> Vec<String> {
13410            self.events.lock().clone()
13411        }
13412    }
13413
13414    #[async_trait]
13415    impl AgentHooks for ApprovalRecordingHooks {
13416        async fn on_approval_result(&self, request_id: &str, result: &ApprovalResult) {
13417            self.events.lock().push(format!(
13418                "raw:{}:{}",
13419                request_id,
13420                approval_result_name(result)
13421            ));
13422        }
13423
13424        async fn on_approval_resolved(
13425            &self,
13426            request: &ai_agents_hitl::ApprovalRequest,
13427            raw_result: &ApprovalResult,
13428            outcome: &ApprovalResolvedOutcome,
13429        ) {
13430            self.events.lock().push(format!(
13431                "resolved:{}:{}:{}",
13432                request.id,
13433                approval_result_name(raw_result),
13434                approval_outcome_name(outcome)
13435            ));
13436        }
13437    }
13438
13439    fn approval_result_name(result: &ApprovalResult) -> &'static str {
13440        match result {
13441            ApprovalResult::Approved => "approved",
13442            ApprovalResult::Rejected { .. } => "rejected",
13443            ApprovalResult::Modified { .. } => "modified",
13444            ApprovalResult::Timeout => "timeout",
13445        }
13446    }
13447
13448    fn approval_outcome_name(outcome: &ApprovalResolvedOutcome) -> &'static str {
13449        match outcome {
13450            ApprovalResolvedOutcome::Approved => "approved",
13451            ApprovalResolvedOutcome::Rejected { .. } => "rejected",
13452            ApprovalResolvedOutcome::Modified { .. } => "modified",
13453            ApprovalResolvedOutcome::Error { .. } => "error",
13454        }
13455    }
13456
13457    fn assert_correlated_approval_events(
13458        events: &[String],
13459        raw_status: &str,
13460        outcome_status: &str,
13461    ) {
13462        assert_eq!(events.len(), 2);
13463        let raw: Vec<_> = events[0].split(':').collect();
13464        let resolved: Vec<_> = events[1].split(':').collect();
13465        assert_eq!(raw[0], "raw");
13466        assert_eq!(resolved[0], "resolved");
13467        assert_eq!(raw[1], resolved[1]);
13468        assert_eq!(raw[2], raw_status);
13469        assert_eq!(resolved[2], raw_status);
13470        assert_eq!(resolved[3], outcome_status);
13471    }
13472
13473    fn approval_security_config(policy_enabled: bool) -> ToolSecurityConfig {
13474        let mut security = ToolSecurityConfig {
13475            enabled: true,
13476            fail_closed: true,
13477            ..Default::default()
13478        };
13479        let policy = ai_agents_tools::ToolPolicyConfig {
13480            enabled: policy_enabled,
13481            write_paths: vec![".".to_string()],
13482            require_confirmation: true,
13483            ..Default::default()
13484        };
13485        security.tools.insert("locked_write".to_string(), policy);
13486        security
13487    }
13488
13489    struct MutationTestWorkspace {
13490        root: std::path::PathBuf,
13491    }
13492
13493    impl MutationTestWorkspace {
13494        fn new() -> Self {
13495            let root = std::env::temp_dir().join(format!(
13496                "ai-agents-runtime-mutation-{}",
13497                uuid::Uuid::new_v4()
13498            ));
13499            std::fs::create_dir_all(&root).unwrap();
13500            Self { root }
13501        }
13502    }
13503
13504    impl Drop for MutationTestWorkspace {
13505        fn drop(&mut self) {
13506            let _ = std::fs::remove_dir_all(&self.root);
13507        }
13508    }
13509
13510    async fn wait_for_resource_lock_strong_count(locks: &ToolResourceLocks, minimum: usize) {
13511        tokio::time::timeout(std::time::Duration::from_secs(2), async {
13512            loop {
13513                let strong_count = locks
13514                    .read()
13515                    .get("path-mutation:global")
13516                    .map_or(0, |lock| lock.strong_count());
13517                if strong_count >= minimum {
13518                    break;
13519                }
13520                tokio::task::yield_now().await;
13521            }
13522        })
13523        .await
13524        .expect("path mutation call did not reach the shared lock");
13525    }
13526
13527    async fn assert_path_mutation_pair_serialized(
13528        first_id: &'static str,
13529        first_fields: Vec<ai_agents_core::PathPolicyBinding>,
13530        first_args: Value,
13531        second_id: &'static str,
13532        second_fields: Vec<ai_agents_core::PathPolicyBinding>,
13533        second_args: Value,
13534    ) {
13535        let locks = new_tool_resource_locks();
13536        let first_gate = PathMutationGate::new();
13537        let second_gate = PathMutationGate::new();
13538        second_gate.release();
13539        let agent = Arc::new(
13540            AgentBuilder::new()
13541                .system_prompt("Test global path mutation locking.")
13542                .llm(Arc::new(mock_with_response("done")))
13543                .tool(Arc::new(BlockingPathMutationTool {
13544                    id: first_id,
13545                    path_fields: first_fields,
13546                    gate: first_gate.clone(),
13547                }))
13548                .tool(Arc::new(BlockingPathMutationTool {
13549                    id: second_id,
13550                    path_fields: second_fields,
13551                    gate: second_gate.clone(),
13552                }))
13553                .build()
13554                .unwrap()
13555                .with_shared_resource_locks(Arc::clone(&locks)),
13556        );
13557
13558        let first = {
13559            let agent = Arc::clone(&agent);
13560            tokio::spawn(async move {
13561                agent
13562                    .invoke_tool(ToolExecutionRequest::new(
13563                        format!("{}-first", first_id),
13564                        first_id,
13565                        first_args,
13566                        ToolCallSource::Manual,
13567                    ))
13568                    .await
13569                    .unwrap()
13570            })
13571        };
13572        first_gate.wait_until_entered().await;
13573
13574        let second = {
13575            let agent = Arc::clone(&agent);
13576            tokio::spawn(async move {
13577                agent
13578                    .invoke_tool(ToolExecutionRequest::new(
13579                        format!("{}-second", second_id),
13580                        second_id,
13581                        second_args,
13582                        ToolCallSource::Manual,
13583                    ))
13584                    .await
13585                    .unwrap()
13586            })
13587        };
13588        wait_for_resource_lock_strong_count(&locks, 2).await;
13589        assert!(!second_gate.entered.load(Ordering::SeqCst));
13590        assert!(!second.is_finished());
13591
13592        first_gate.release();
13593        let (first, second) = tokio::time::timeout(std::time::Duration::from_secs(2), async {
13594            tokio::join!(first, second)
13595        })
13596        .await
13597        .expect("serialized path mutation calls did not finish");
13598        assert!(first.unwrap().success);
13599        assert!(second.unwrap().success);
13600        assert!(second_gate.entered.load(Ordering::SeqCst));
13601        assert!(locks.read().is_empty());
13602    }
13603
13604    #[derive(Clone, Copy)]
13605    enum MutationDenial {
13606        Policy,
13607        Approval,
13608    }
13609
13610    fn mutation_denial_security_config(
13611        tool_id: &str,
13612        workspace: &std::path::Path,
13613        denial: MutationDenial,
13614    ) -> ToolSecurityConfig {
13615        let workspace = workspace.to_string_lossy().into_owned();
13616        let mut policy = ai_agents_tools::ToolPolicyConfig {
13617            read_paths: vec![workspace.clone()],
13618            write_paths: vec![workspace.clone()],
13619            ..Default::default()
13620        };
13621        match denial {
13622            MutationDenial::Policy => policy.blocked_paths = vec![workspace],
13623            MutationDenial::Approval => policy.require_confirmation = true,
13624        }
13625
13626        let mut security = ToolSecurityConfig {
13627            enabled: true,
13628            fail_closed: true,
13629            ..Default::default()
13630        };
13631        security.tools.insert(tool_id.to_string(), policy);
13632        security
13633    }
13634
13635    async fn assert_path_mutation_denied(tool: Arc<dyn Tool>, denial: MutationDenial) {
13636        let workspace = MutationTestWorkspace::new();
13637        let tool_id = tool.id().to_string();
13638        let preserved = workspace.root.join(format!("{}-preserved.txt", tool_id));
13639        let destination = workspace.root.join(format!("{}-destination.txt", tool_id));
13640        std::fs::write(&preserved, "preserved").unwrap();
13641        let arguments = match tool_id.as_str() {
13642            "copy_path" | "move_path" => serde_json::json!({
13643                "source_path": preserved.to_string_lossy(),
13644                "destination_path": destination.to_string_lossy(),
13645                "dry_run": false
13646            }),
13647            "delete_path" => serde_json::json!({
13648                "path": preserved.to_string_lossy(),
13649                "recursive": false,
13650                "dry_run": false
13651            }),
13652            _ => panic!("unsupported mutation tool: {}", tool_id),
13653        };
13654        let security = mutation_denial_security_config(&tool_id, &workspace.root, denial);
13655        let builder = AgentBuilder::new()
13656            .system_prompt("Test mutation denial.")
13657            .llm(Arc::new(mock_with_response("done")))
13658            .tool(tool)
13659            .tool_security(ToolSecurityEngine::new(security));
13660        let builder = match denial {
13661            MutationDenial::Policy => builder,
13662            MutationDenial::Approval => builder
13663                .hitl_engine(HITLEngine::new(ai_agents_hitl::HITLConfig::default()))
13664                .approval_handler(Arc::new(RejectAllHandler::new())),
13665        };
13666        let agent = builder.build().unwrap();
13667
13668        let record = agent
13669            .invoke_tool(ToolExecutionRequest::new(
13670                format!("{}-denied", tool_id),
13671                tool_id.clone(),
13672                arguments,
13673                ToolCallSource::Manual,
13674            ))
13675            .await
13676            .unwrap();
13677
13678        assert!(!record.executed, "{} must not be invoked", tool_id);
13679        assert!(!record.success);
13680        match denial {
13681            MutationDenial::Policy => {
13682                assert_eq!(record.policy.outcome, PermissionOutcome::Deny);
13683                assert!(record.approval.as_ref().is_some_and(|approval| matches!(
13684                    &approval.status,
13685                    ToolApprovalStatus::NotRequired
13686                )));
13687            }
13688            MutationDenial::Approval => {
13689                assert_eq!(record.policy.outcome, PermissionOutcome::RequiresApproval);
13690                assert!(record.approval.as_ref().is_some_and(|approval| matches!(
13691                    &approval.status,
13692                    ToolApprovalStatus::Rejected
13693                )));
13694            }
13695        }
13696        assert_eq!(std::fs::read_to_string(&preserved).unwrap(), "preserved");
13697        assert!(!destination.exists());
13698    }
13699
13700    fn recovery_manager_with_fallbacks(
13701        fallbacks: impl IntoIterator<Item = (String, String)>,
13702    ) -> RecoveryManager {
13703        use ai_agents_recovery::{ErrorRecoveryConfig, ToolRecoveryConfig, ToolRetryConfig};
13704
13705        let per_tool = fallbacks
13706            .into_iter()
13707            .map(|(tool, fallback_tool)| {
13708                (
13709                    tool,
13710                    ToolRetryConfig {
13711                        max_retries: 0,
13712                        timeout_ms: Some(1_000),
13713                        on_failure: ToolFailureAction::Fallback { fallback_tool },
13714                    },
13715                )
13716            })
13717            .collect();
13718        RecoveryManager::new(ErrorRecoveryConfig {
13719            tools: ToolRecoveryConfig {
13720                per_tool,
13721                ..Default::default()
13722            },
13723            ..Default::default()
13724        })
13725    }
13726
13727    fn approval_check() -> HITLCheckResult {
13728        HITLCheckResult::required(
13729            ApprovalTrigger::tool("test", serde_json::json!({})),
13730            HashMap::new(),
13731            "Approve?",
13732            None,
13733        )
13734    }
13735
13736    fn agent_with_approval_result(
13737        raw_result: ApprovalResult,
13738        timeout_action: TimeoutAction,
13739        hooks: Arc<ApprovalRecordingHooks>,
13740    ) -> RuntimeAgent {
13741        use ai_agents_hitl::{CallbackHandler, HITLConfig};
13742
13743        let config = HITLConfig {
13744            on_timeout: timeout_action,
13745            ..Default::default()
13746        };
13747        let handler = CallbackHandler::new(move |_| raw_result.clone());
13748        AgentBuilder::new()
13749            .system_prompt("Test HITL hooks.")
13750            .llm(Arc::new(mock_with_response("done")))
13751            .build()
13752            .unwrap()
13753            .with_hooks(hooks)
13754            .with_hitl(HITLEngine::new(config), Arc::new(handler))
13755    }
13756
13757    #[tokio::test]
13758    async fn approval_hooks_expose_direct_effective_decisions_after_raw_results() {
13759        let cases = vec![
13760            (ApprovalResult::Approved, "approved"),
13761            (
13762                ApprovalResult::Rejected {
13763                    reason: Some("denied".to_string()),
13764                },
13765                "rejected",
13766            ),
13767            (
13768                ApprovalResult::Modified {
13769                    changes: HashMap::from([("value".to_string(), serde_json::json!(2))]),
13770                },
13771                "modified",
13772            ),
13773        ];
13774
13775        for (raw_result, expected) in cases {
13776            let hooks = Arc::new(ApprovalRecordingHooks::new());
13777            let agent =
13778                agent_with_approval_result(raw_result, TimeoutAction::Reject, hooks.clone());
13779
13780            let result = agent.request_hitl_approval(approval_check()).await.unwrap();
13781
13782            assert_eq!(approval_result_name(&result), expected);
13783            assert_correlated_approval_events(&hooks.events(), expected, expected);
13784        }
13785    }
13786
13787    #[tokio::test]
13788    async fn approval_hooks_expose_timeout_policy_decisions() {
13789        for (timeout_action, expected) in [
13790            (TimeoutAction::Approve, "approved"),
13791            (TimeoutAction::Reject, "rejected"),
13792        ] {
13793            let hooks = Arc::new(ApprovalRecordingHooks::new());
13794            let agent =
13795                agent_with_approval_result(ApprovalResult::Timeout, timeout_action, hooks.clone());
13796
13797            let result = agent.request_hitl_approval(approval_check()).await.unwrap();
13798
13799            assert_eq!(approval_result_name(&result), expected);
13800            assert_correlated_approval_events(&hooks.events(), "timeout", expected);
13801        }
13802    }
13803
13804    #[tokio::test]
13805    async fn timeout_error_fires_correlated_resolved_error_before_returning() {
13806        let hooks = Arc::new(ApprovalRecordingHooks::new());
13807        let agent = agent_with_approval_result(
13808            ApprovalResult::Timeout,
13809            TimeoutAction::Error,
13810            hooks.clone(),
13811        );
13812
13813        let error = agent
13814            .request_hitl_approval(approval_check())
13815            .await
13816            .unwrap_err();
13817
13818        assert!(error.to_string().contains("HITL approval timeout"));
13819        assert_correlated_approval_events(&hooks.events(), "timeout", "error");
13820    }
13821
13822    // Basic YAML → Build → Chat flow
13823    #[tokio::test]
13824    async fn test_integration_yaml_to_chat_basic() {
13825        let mock = mock_with_response("Hello! How can I help you?");
13826        let agent = AgentBuilder::new()
13827            .system_prompt("You are a test assistant.")
13828            .llm(Arc::new(mock))
13829            .build()
13830            .unwrap();
13831
13832        let response = agent.chat("Hi").await.unwrap();
13833        assert!(!response.content.is_empty());
13834        assert_eq!(response.content, "Hello! How can I help you?");
13835    }
13836
13837    // Multi-turn conversation
13838    #[tokio::test]
13839    async fn test_integration_multi_turn_conversation() {
13840        let mock = mock_with_responses(vec![
13841            "Hello! I'm your assistant.",
13842            "The weather is sunny today.",
13843            "Goodbye!",
13844        ]);
13845        let agent = AgentBuilder::new()
13846            .system_prompt("You are helpful.")
13847            .llm(Arc::new(mock))
13848            .build()
13849            .unwrap();
13850
13851        let r1 = agent.chat("Hi").await.unwrap();
13852        assert_eq!(r1.content, "Hello! I'm your assistant.");
13853
13854        let r2 = agent.chat("What's the weather?").await.unwrap();
13855        assert_eq!(r2.content, "The weather is sunny today.");
13856
13857        let r3 = agent.chat("Bye").await.unwrap();
13858        assert_eq!(r3.content, "Goodbye!");
13859
13860        // Verify memory accumulated messages
13861        let messages = agent.memory.get_messages(None).await.unwrap();
13862        // 3 user + 3 assistant = 6 messages
13863        assert_eq!(messages.len(), 6);
13864    }
13865
13866    #[test]
13867    fn later_approval_preserves_modified_evidence() {
13868        let arguments = serde_json::json!({"dry_run": true});
13869        let mut record = Some(ToolApprovalRecord {
13870            status: ToolApprovalStatus::Modified,
13871            reason: None,
13872            modified_arguments: Some(arguments.clone()),
13873        });
13874
13875        merge_approved_record(&mut record);
13876
13877        let record = record.unwrap();
13878        assert!(matches!(record.status, ToolApprovalStatus::Modified));
13879        assert_eq!(record.modified_arguments, Some(arguments));
13880    }
13881
13882    #[test]
13883    fn approval_binding_rejects_replaced_tool_implementation() {
13884        let reviewed_tool: Arc<dyn ai_agents_core::Tool> = Arc::new(ContextEchoTool);
13885        let same_tool = Arc::clone(&reviewed_tool);
13886        let replacement_tool: Arc<dyn ai_agents_core::Tool> = Arc::new(ContextEchoTool);
13887        let arguments = serde_json::json!({"path": "."});
13888        let versions = ToolDecisionVersions {
13889            policy: 2,
13890            registry: 3,
13891            runtime_control: 4,
13892        };
13893        let binding = ToolApprovalBinding {
13894            canonical_id: "context_echo".to_string(),
13895            arguments: arguments.clone(),
13896            confirmation_required: true,
13897            policy_version: versions.policy,
13898            runtime_control_version: versions.runtime_control,
13899            reviewed_tool,
13900        };
13901
13902        assert!(!binding.is_stale("context_echo", &arguments, true, versions, &same_tool,));
13903        assert!(binding.is_stale(
13904            "context_echo",
13905            &arguments,
13906            true,
13907            versions,
13908            &replacement_tool,
13909        ));
13910    }
13911
13912    #[tokio::test]
13913    async fn approved_mutation_to_dry_run_remains_executable() {
13914        use ai_agents_hitl::CallbackHandler;
13915
13916        let handler = CallbackHandler::new(|_| ApprovalResult::Modified {
13917            changes: HashMap::from([("dry_run".to_string(), serde_json::json!(true))]),
13918        });
13919        let agent = AgentBuilder::new()
13920            .system_prompt("Test safer approval modifications.")
13921            .llm(Arc::new(mock_with_response("done")))
13922            .tool(Arc::new(ai_agents_tools::FileWriteTool::new()))
13923            .hitl_engine(HITLEngine::new(ai_agents_hitl::HITLConfig::default()))
13924            .approval_handler(Arc::new(handler))
13925            .build()
13926            .unwrap();
13927
13928        let record = agent
13929            .invoke_tool(ToolExecutionRequest::new(
13930                "approved-dry-run",
13931                "file_write",
13932                serde_json::json!({
13933                    "path": "./approval-dry-run.txt",
13934                    "content": "not written"
13935                }),
13936                ToolCallSource::Manual,
13937            ))
13938            .await
13939            .unwrap();
13940
13941        assert!(record.executed);
13942        assert!(record.success);
13943        assert_eq!(record.executed_arguments["dry_run"], true);
13944        assert!(matches!(
13945            record.approval.as_ref().map(|approval| &approval.status),
13946            Some(ToolApprovalStatus::Modified)
13947        ));
13948        let output: Value = serde_json::from_str(&record.output).unwrap();
13949        assert_eq!(output["mutation_performed"], false);
13950    }
13951
13952    #[tokio::test]
13953    async fn context_preserves_requested_and_canonical_identity() {
13954        let mock = mock_with_response("hello");
13955        let mut tools = ai_agents_tools::ToolRegistry::new();
13956        tools.register(Arc::new(ContextEchoTool)).unwrap();
13957
13958        let mut security = ToolSecurityConfig {
13959            enabled: true,
13960            fail_closed: true,
13961            ..Default::default()
13962        };
13963        let mut policy = ai_agents_tools::ToolPolicyConfig {
13964            read_paths: vec![".".to_string()],
13965            max_results: Some(7),
13966            ..Default::default()
13967        };
13968        policy
13969            .config
13970            .insert("backend".to_string(), serde_json::json!("memory"));
13971        security.tools.insert("context_echo".to_string(), policy);
13972
13973        let agent = AgentBuilder::new()
13974            .system_prompt("You are helpful.")
13975            .llm(Arc::new(mock))
13976            .tools(tools)
13977            .tool_security(ToolSecurityEngine::new(security))
13978            .build()
13979            .unwrap();
13980
13981        let record = agent
13982            .invoke_tool(ToolExecutionRequest::new(
13983                "ctx-call",
13984                "Context Echo",
13985                serde_json::json!({"path": ".", "max_results": 99}),
13986                ToolCallSource::Manual,
13987            ))
13988            .await
13989            .unwrap();
13990
13991        assert!(record.success);
13992        assert!(matches!(&record.source, ToolCallSource::Manual));
13993        assert_eq!(record.requested_name, "Context Echo");
13994        assert_eq!(record.canonical_id, "context_echo");
13995        assert_eq!(record.policy.outcome, PermissionOutcome::Allow);
13996        assert_eq!(record.executed_arguments["max_results"], 7);
13997        let output: Value = serde_json::from_str(&record.output).unwrap();
13998        assert_eq!(output["requested_name"], "Context Echo");
13999        assert_eq!(output["canonical_id"], "context_echo");
14000        assert_eq!(output["max_results"], 7);
14001        assert_eq!(output["custom_config"]["backend"], "memory");
14002        assert!(record.metadata.contains_key("effective_limits"));
14003        assert!(record.metadata.contains_key("policy_snapshot"));
14004    }
14005
14006    #[tokio::test]
14007    async fn test_runtime_control_cancels_active_tool_call() {
14008        let mock = mock_with_response("hello");
14009        let agent = Arc::new(
14010            AgentBuilder::new()
14011                .system_prompt("You are helpful.")
14012                .llm(Arc::new(mock))
14013                .tool(Arc::new(SlowTool))
14014                .build()
14015                .unwrap(),
14016        );
14017        let control = agent.runtime_control();
14018        let running_agent = Arc::clone(&agent);
14019        let handle = tokio::spawn(async move {
14020            running_agent
14021                .invoke_tool(ToolExecutionRequest::new(
14022                    "slow-call",
14023                    "slow",
14024                    serde_json::json!({}),
14025                    ToolCallSource::Manual,
14026                ))
14027                .await
14028                .unwrap()
14029        });
14030
14031        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
14032        control.cancel_all();
14033        let record = handle.await.unwrap();
14034
14035        assert!(record.executed);
14036        assert!(record.cancelled);
14037        assert!(!record.success);
14038        assert!(record.cancellation_reason.is_some());
14039    }
14040
14041    #[tokio::test]
14042    async fn non_idempotent_tool_calls_are_not_retried() {
14043        use ai_agents_recovery::{ErrorRecoveryConfig, ToolRecoveryConfig, ToolRetryConfig};
14044
14045        let mock = mock_with_response("hello");
14046        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14047        let agent = AgentBuilder::new()
14048            .system_prompt("You are helpful.")
14049            .llm(Arc::new(mock))
14050            .tool(Arc::new(FlakyWriteTool {
14051                calls: Arc::clone(&calls),
14052            }))
14053            .recovery_manager(RecoveryManager::new(ErrorRecoveryConfig {
14054                tools: ToolRecoveryConfig {
14055                    default: ToolRetryConfig {
14056                        max_retries: 2,
14057                        ..Default::default()
14058                    },
14059                    ..Default::default()
14060                },
14061                ..Default::default()
14062            }))
14063            .build()
14064            .unwrap();
14065
14066        let record = agent
14067            .invoke_tool(ToolExecutionRequest::new(
14068                "flaky-call",
14069                "flaky_write",
14070                serde_json::json!({"path": "./tmp.txt"}),
14071                ToolCallSource::Manual,
14072            ))
14073            .await
14074            .unwrap();
14075
14076        assert!(!record.success);
14077        assert_eq!(calls.load(Ordering::SeqCst), 1);
14078    }
14079
14080    #[tokio::test]
14081    async fn side_effecting_tools_are_serialized_per_resource() {
14082        let mock = mock_with_response("hello");
14083        let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14084        let max_active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14085        let agent = Arc::new(
14086            AgentBuilder::new()
14087                .system_prompt("You are helpful.")
14088                .llm(Arc::new(mock))
14089                .tool(Arc::new(LockedWriteTool {
14090                    active: Arc::clone(&active),
14091                    max_active: Arc::clone(&max_active),
14092                }))
14093                .build()
14094                .unwrap(),
14095        );
14096
14097        let left = {
14098            let agent = Arc::clone(&agent);
14099            tokio::spawn(async move {
14100                agent
14101                    .invoke_tool(ToolExecutionRequest::new(
14102                        "lock-1",
14103                        "locked_write",
14104                        serde_json::json!({"path": "./same.txt"}),
14105                        ToolCallSource::Manual,
14106                    ))
14107                    .await
14108                    .unwrap()
14109            })
14110        };
14111        let right = {
14112            let agent = Arc::clone(&agent);
14113            tokio::spawn(async move {
14114                agent
14115                    .invoke_tool(ToolExecutionRequest::new(
14116                        "lock-2",
14117                        "locked_write",
14118                        serde_json::json!({"path": "./same.txt"}),
14119                        ToolCallSource::Manual,
14120                    ))
14121                    .await
14122                    .unwrap()
14123            })
14124        };
14125
14126        let left = left.await.unwrap();
14127        let right = right.await.unwrap();
14128        assert!(left.success);
14129        assert!(right.success);
14130        assert_eq!(max_active.load(Ordering::SeqCst), 1);
14131    }
14132
14133    #[tokio::test]
14134    async fn path_resources_use_shared_global_lock_and_cleanup() {
14135        let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14136        let max_active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14137        let bindings = ai_agents_core::ToolPolicyBindings {
14138            path_fields: vec![
14139                ai_agents_core::PathPolicyBinding::read_write("source_path"),
14140                ai_agents_core::PathPolicyBinding::write("destination_path"),
14141            ],
14142            ..Default::default()
14143        };
14144        let classification = ai_agents_core::ToolCallClassification::from_metadata(
14145            &MultiResourceWriteTool {
14146                active: Arc::clone(&active),
14147                max_active: Arc::clone(&max_active),
14148            }
14149            .safety_metadata(),
14150        );
14151        let left_args = serde_json::json!({
14152            "source_path": "./a/../first.txt",
14153            "destination_path": "./second.txt"
14154        });
14155        let right_args = serde_json::json!({
14156            "source_path": "./second.txt",
14157            "destination_path": "./first.txt"
14158        });
14159        let left_keys = tool_resource_lock_keys(
14160            "multi_resource_write",
14161            &left_args,
14162            &bindings,
14163            &classification,
14164        );
14165        let right_keys = tool_resource_lock_keys(
14166            "multi_resource_write",
14167            &right_args,
14168            &bindings,
14169            &classification,
14170        );
14171        assert_eq!(left_keys, right_keys);
14172        assert_eq!(left_keys, vec!["path-mutation:global".to_string()]);
14173
14174        let locks = new_tool_resource_locks();
14175        let build_agent = || {
14176            AgentBuilder::new()
14177                .system_prompt("Test shared resource locks.")
14178                .llm(Arc::new(mock_with_response("done")))
14179                .tool(Arc::new(MultiResourceWriteTool {
14180                    active: Arc::clone(&active),
14181                    max_active: Arc::clone(&max_active),
14182                }))
14183                .build()
14184                .unwrap()
14185                .with_shared_resource_locks(Arc::clone(&locks))
14186        };
14187        let left_agent = Arc::new(build_agent());
14188        let right_agent = Arc::new(build_agent());
14189        let left = tokio::spawn(async move {
14190            left_agent
14191                .invoke_tool(ToolExecutionRequest::new(
14192                    "multi-left",
14193                    "multi_resource_write",
14194                    left_args,
14195                    ToolCallSource::Manual,
14196                ))
14197                .await
14198                .unwrap()
14199        });
14200        let right = tokio::spawn(async move {
14201            right_agent
14202                .invoke_tool(ToolExecutionRequest::new(
14203                    "multi-right",
14204                    "multi_resource_write",
14205                    right_args,
14206                    ToolCallSource::Manual,
14207                ))
14208                .await
14209                .unwrap()
14210        });
14211        let (left, right) = tokio::time::timeout(std::time::Duration::from_secs(2), async {
14212            tokio::join!(left, right)
14213        })
14214        .await
14215        .expect("reversed resource acquisition must not deadlock");
14216
14217        assert!(left.unwrap().success);
14218        assert!(right.unwrap().success);
14219        assert_eq!(max_active.load(Ordering::SeqCst), 1);
14220        assert!(locks.read().is_empty());
14221    }
14222
14223    #[tokio::test]
14224    async fn global_path_lock_serializes_copy_destination_with_file_write() {
14225        assert_path_mutation_pair_serialized(
14226            "copy_path",
14227            CopyPathTool::new().policy_bindings().path_fields,
14228            serde_json::json!({
14229                "source_path": "./source.txt",
14230                "destination_path": "./shared.txt"
14231            }),
14232            "file_write",
14233            FileWriteTool::new().policy_bindings().path_fields,
14234            serde_json::json!({"path": "./shared.txt"}),
14235        )
14236        .await;
14237    }
14238
14239    #[tokio::test]
14240    async fn parent_and_spawned_runtime_share_global_path_lock() {
14241        let workspace = MutationTestWorkspace::new();
14242        let destination = workspace.root.join("spawned.txt");
14243        let parent_gate = PathMutationGate::new();
14244        let parent = Arc::new(
14245            AgentBuilder::from_yaml(
14246                r#"
14247name: LockParent
14248system_prompt: parent
14249llm:
14250  default: default
14251tools:
14252  - parent_path_write
14253spawner:
14254  shared_llms: true
14255"#,
14256            )
14257            .unwrap()
14258            .llm(Arc::new(mock_with_response("done")))
14259            .auto_configure_spawner()
14260            .await
14261            .unwrap()
14262            .tool(Arc::new(BlockingPathMutationTool {
14263                id: "parent_path_write",
14264                path_fields: vec![ai_agents_core::PathPolicyBinding::write("path")],
14265                gate: parent_gate.clone(),
14266            }))
14267            .build()
14268            .unwrap(),
14269        );
14270
14271        let mut child_spec = crate::spec::AgentSpec {
14272            name: "LockChild".to_string(),
14273            system_prompt: "child".to_string(),
14274            tools: Some(vec![crate::spec::ToolEntry::Simple(
14275                "file_write".to_string(),
14276            )]),
14277            ..Default::default()
14278        };
14279        child_spec.tool_security.enabled = true;
14280        child_spec.tool_security.fail_closed = true;
14281        let file_write_policy = ai_agents_tools::ToolPolicyConfig {
14282            write_paths: vec![workspace.root.to_string_lossy().into_owned()],
14283            allow_without_confirmation: true,
14284            ..Default::default()
14285        };
14286        child_spec
14287            .tool_security
14288            .tools
14289            .insert("file_write".to_string(), file_write_policy);
14290        let spawned = parent
14291            .spawner()
14292            .unwrap()
14293            .spawn_from_spec(child_spec)
14294            .await
14295            .unwrap();
14296        assert!(Arc::ptr_eq(
14297            &parent.resource_locks,
14298            &spawned.agent.resource_locks
14299        ));
14300
14301        let parent_call = {
14302            let parent = Arc::clone(&parent);
14303            let destination = destination.clone();
14304            tokio::spawn(async move {
14305                parent
14306                    .invoke_tool(ToolExecutionRequest::new(
14307                        "parent-lock-holder",
14308                        "parent_path_write",
14309                        serde_json::json!({"path": destination}),
14310                        ToolCallSource::Manual,
14311                    ))
14312                    .await
14313                    .unwrap()
14314            })
14315        };
14316        parent_gate.wait_until_entered().await;
14317
14318        let child_call = {
14319            let child = Arc::clone(&spawned.agent);
14320            let destination = destination.clone();
14321            tokio::spawn(async move {
14322                child
14323                    .invoke_tool(ToolExecutionRequest::new(
14324                        "spawned-file-write",
14325                        "file_write",
14326                        serde_json::json!({
14327                            "path": destination,
14328                            "content": "spawned",
14329                            "dry_run": false
14330                        }),
14331                        ToolCallSource::Manual,
14332                    ))
14333                    .await
14334                    .unwrap()
14335            })
14336        };
14337        wait_for_resource_lock_strong_count(&parent.resource_locks, 2).await;
14338        assert!(!child_call.is_finished());
14339
14340        parent_gate.release();
14341        let (parent_record, child_record) =
14342            tokio::time::timeout(std::time::Duration::from_secs(2), async {
14343                tokio::join!(parent_call, child_call)
14344            })
14345            .await
14346            .expect("parent and spawned path mutations did not finish");
14347        assert!(parent_record.unwrap().success);
14348        assert!(child_record.unwrap().success);
14349        assert_eq!(std::fs::read_to_string(destination).unwrap(), "spawned");
14350        assert!(parent.resource_locks.read().is_empty());
14351    }
14352
14353    #[tokio::test]
14354    async fn cancelled_global_path_lock_waiter_does_not_retain_weak_entry() {
14355        let locks = new_tool_resource_locks();
14356        let holder_gate = PathMutationGate::new();
14357        let waiter_gate = PathMutationGate::new();
14358        waiter_gate.release();
14359        let holder = Arc::new(
14360            AgentBuilder::new()
14361                .system_prompt("Hold the global path lock.")
14362                .llm(Arc::new(mock_with_response("done")))
14363                .tool(Arc::new(BlockingPathMutationTool {
14364                    id: "holder_write",
14365                    path_fields: vec![ai_agents_core::PathPolicyBinding::write("path")],
14366                    gate: holder_gate.clone(),
14367                }))
14368                .build()
14369                .unwrap()
14370                .with_shared_resource_locks(Arc::clone(&locks)),
14371        );
14372        let waiter = Arc::new(
14373            AgentBuilder::new()
14374                .system_prompt("Wait for the global path lock.")
14375                .llm(Arc::new(mock_with_response("done")))
14376                .tool(Arc::new(BlockingPathMutationTool {
14377                    id: "waiter_write",
14378                    path_fields: vec![ai_agents_core::PathPolicyBinding::write("path")],
14379                    gate: waiter_gate.clone(),
14380                }))
14381                .build()
14382                .unwrap()
14383                .with_shared_resource_locks(Arc::clone(&locks)),
14384        );
14385
14386        let holder_call = {
14387            let holder = Arc::clone(&holder);
14388            tokio::spawn(async move {
14389                holder
14390                    .invoke_tool(ToolExecutionRequest::new(
14391                        "holder-call",
14392                        "holder_write",
14393                        serde_json::json!({"path": "./shared.txt"}),
14394                        ToolCallSource::Manual,
14395                    ))
14396                    .await
14397                    .unwrap()
14398            })
14399        };
14400        holder_gate.wait_until_entered().await;
14401
14402        let waiter_call = {
14403            let waiter = Arc::clone(&waiter);
14404            tokio::spawn(async move {
14405                waiter
14406                    .invoke_tool(ToolExecutionRequest::new(
14407                        "waiter-call",
14408                        "waiter_write",
14409                        serde_json::json!({"path": "./shared.txt"}),
14410                        ToolCallSource::Manual,
14411                    ))
14412                    .await
14413                    .unwrap()
14414            })
14415        };
14416        wait_for_resource_lock_strong_count(&locks, 2).await;
14417        waiter.runtime_control().cancel_all();
14418
14419        let waiter_record = tokio::time::timeout(std::time::Duration::from_secs(2), waiter_call)
14420            .await
14421            .expect("cancelled lock waiter did not finish")
14422            .unwrap();
14423        assert!(!waiter_record.executed);
14424        assert!(!waiter_gate.entered.load(Ordering::SeqCst));
14425        assert_eq!(
14426            locks
14427                .read()
14428                .get("path-mutation:global")
14429                .map_or(0, |lock| lock.strong_count()),
14430            1
14431        );
14432
14433        holder_gate.release();
14434        let holder_record = tokio::time::timeout(std::time::Duration::from_secs(2), holder_call)
14435            .await
14436            .expect("lock holder did not finish")
14437            .unwrap();
14438        assert!(holder_record.success);
14439        assert!(locks.read().is_empty());
14440    }
14441
14442    #[tokio::test]
14443    async fn path_mutation_policy_and_approval_denials_do_not_invoke_tools() {
14444        for denial in [MutationDenial::Policy, MutationDenial::Approval] {
14445            let tools: [Arc<dyn Tool>; 3] = [
14446                Arc::new(CopyPathTool::new()),
14447                Arc::new(MovePathTool::new()),
14448                Arc::new(DeletePathTool::new()),
14449            ];
14450            for tool in tools {
14451                assert_path_mutation_denied(tool, denial).await;
14452            }
14453        }
14454    }
14455
14456    #[tokio::test]
14457    async fn approval_argument_changes_are_rechecked_against_final_scope() {
14458        let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14459        let max_active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14460        let entered = Arc::new(tokio::sync::Barrier::new(2));
14461        let release = Arc::new(tokio::sync::Notify::new());
14462        let handler = Arc::new(BlockingApprovalHandler {
14463            entered: Arc::clone(&entered),
14464            release: Arc::clone(&release),
14465            result: ApprovalResult::Modified {
14466                changes: HashMap::from([(
14467                    "path".to_string(),
14468                    Value::String("./after-approval.txt".to_string()),
14469                )]),
14470            },
14471        });
14472        let agent = Arc::new(
14473            AgentBuilder::new()
14474                .system_prompt("Test final scope validation.")
14475                .llm(Arc::new(mock_with_response("done")))
14476                .tool(Arc::new(LockedWriteTool {
14477                    active: Arc::clone(&active),
14478                    max_active: Arc::clone(&max_active),
14479                }))
14480                .tool_security(ToolSecurityEngine::new(approval_security_config(true)))
14481                .hitl_engine(HITLEngine::new(ai_agents_hitl::HITLConfig::default()))
14482                .approval_handler(handler)
14483                .build()
14484                .unwrap(),
14485        );
14486        let control = agent.runtime_control();
14487        let running = Arc::clone(&agent);
14488        let call = tokio::spawn(async move {
14489            running
14490                .invoke_tool(ToolExecutionRequest::new(
14491                    "approval-scope",
14492                    "locked_write",
14493                    serde_json::json!({"path": "./before-approval.txt"}),
14494                    ToolCallSource::Manual,
14495                ))
14496                .await
14497                .unwrap()
14498        });
14499        entered.wait().await;
14500        let expected_version = control.set_tool_scope(Vec::new());
14501        release.notify_one();
14502        let record = call.await.unwrap();
14503
14504        assert!(!record.executed);
14505        assert!(!record.success);
14506        assert_eq!(record.runtime_config_version, expected_version);
14507        assert_eq!(record.executed_arguments["path"], "./after-approval.txt");
14508        assert_eq!(max_active.load(Ordering::SeqCst), 0);
14509        assert_eq!(
14510            record.metadata["runtime_scope_snapshot"],
14511            serde_json::json!([])
14512        );
14513    }
14514
14515    #[tokio::test]
14516    async fn approval_is_rechecked_against_final_policy_snapshot() {
14517        let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14518        let max_active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14519        let entered = Arc::new(tokio::sync::Barrier::new(2));
14520        let release = Arc::new(tokio::sync::Notify::new());
14521        let handler = Arc::new(BlockingApprovalHandler {
14522            entered: Arc::clone(&entered),
14523            release: Arc::clone(&release),
14524            result: ApprovalResult::Approved,
14525        });
14526        let agent = Arc::new(
14527            AgentBuilder::new()
14528                .system_prompt("Test final policy validation.")
14529                .llm(Arc::new(mock_with_response("done")))
14530                .tool(Arc::new(LockedWriteTool {
14531                    active: Arc::clone(&active),
14532                    max_active: Arc::clone(&max_active),
14533                }))
14534                .tool_security(ToolSecurityEngine::new(approval_security_config(true)))
14535                .hitl_engine(HITLEngine::new(ai_agents_hitl::HITLConfig::default()))
14536                .approval_handler(handler)
14537                .build()
14538                .unwrap(),
14539        );
14540        let control = agent.runtime_control();
14541        let running = Arc::clone(&agent);
14542        let call = tokio::spawn(async move {
14543            running
14544                .invoke_tool(ToolExecutionRequest::new(
14545                    "approval-policy",
14546                    "locked_write",
14547                    serde_json::json!({"path": "./policy.txt"}),
14548                    ToolCallSource::Manual,
14549                ))
14550                .await
14551                .unwrap()
14552        });
14553        entered.wait().await;
14554        let expected_version = control.set_tool_security(approval_security_config(false));
14555        release.notify_one();
14556        let record = call.await.unwrap();
14557
14558        assert!(!record.executed);
14559        assert!(!record.success);
14560        assert_eq!(record.runtime_config_version, expected_version);
14561        assert_eq!(record.policy.outcome, PermissionOutcome::Unavailable);
14562        assert_eq!(max_active.load(Ordering::SeqCst), 0);
14563        assert!(record.metadata.contains_key("policy_snapshot"));
14564    }
14565
14566    #[tokio::test]
14567    async fn persistent_override_preserves_rate_history_within_generation() {
14568        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14569        let agent = AgentBuilder::new()
14570            .system_prompt("Test persistent policy overrides.")
14571            .llm(Arc::new(mock_with_response("done")))
14572            .tool(Arc::new(RecoveryTestTool {
14573                id: "limited_override".to_string(),
14574                succeeds: true,
14575                calls: Arc::clone(&calls),
14576            }))
14577            .build()
14578            .unwrap();
14579        let mut security = ToolSecurityConfig {
14580            enabled: true,
14581            fail_closed: true,
14582            ..Default::default()
14583        };
14584        let policy = ai_agents_tools::ToolPolicyConfig {
14585            write_paths: vec![".".to_string()],
14586            rate_limit: Some(1),
14587            ..Default::default()
14588        };
14589        security
14590            .tools
14591            .insert("limited_override".to_string(), policy);
14592        let generation = agent.runtime_control().set_tool_security(security);
14593
14594        let first = agent
14595            .invoke_tool(ToolExecutionRequest::new(
14596                "limited-first",
14597                "limited_override",
14598                serde_json::json!({"path": "./limited.txt"}),
14599                ToolCallSource::Manual,
14600            ))
14601            .await
14602            .unwrap();
14603        let second = agent
14604            .invoke_tool(ToolExecutionRequest::new(
14605                "limited-second",
14606                "limited_override",
14607                serde_json::json!({"path": "./limited.txt"}),
14608                ToolCallSource::Manual,
14609            ))
14610            .await
14611            .unwrap();
14612
14613        assert!(first.success);
14614        assert_eq!(first.policy_version, generation);
14615        assert!(!second.executed);
14616        assert!(second.output.contains("Rate limit exceeded"));
14617        assert_eq!(second.policy_version, generation);
14618        assert_eq!(calls.load(Ordering::SeqCst), 1);
14619    }
14620
14621    #[tokio::test]
14622    async fn concurrent_rate_admission_consumes_capacity_atomically() {
14623        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14624        let tool = Arc::new(RecoveryTestTool {
14625            id: "atomic_rate".to_string(),
14626            succeeds: true,
14627            calls: Arc::clone(&calls),
14628        });
14629        let arguments = serde_json::json!({"path": "./atomic-rate.txt"});
14630        let bindings = tool.policy_bindings();
14631        let classification = tool.classify_call(&arguments);
14632        let resource_keys =
14633            tool_resource_lock_keys(tool.id(), &arguments, &bindings, &classification);
14634        let mut security = ToolSecurityConfig {
14635            enabled: true,
14636            fail_closed: true,
14637            ..Default::default()
14638        };
14639        let policy = ai_agents_tools::ToolPolicyConfig {
14640            write_paths: vec![".".to_string()],
14641            rate_limit: Some(1),
14642            ..Default::default()
14643        };
14644        security.tools.insert(tool.id().to_string(), policy);
14645        let agent = Arc::new(
14646            AgentBuilder::new()
14647                .system_prompt("Test atomic rate admission.")
14648                .llm(Arc::new(mock_with_response("done")))
14649                .tool(tool)
14650                .tool_security(ToolSecurityEngine::new(security))
14651                .build()
14652                .unwrap(),
14653        );
14654        let held = agent
14655            .acquire_tool_resource_locks(&resource_keys)
14656            .await
14657            .unwrap();
14658        let left = {
14659            let agent = Arc::clone(&agent);
14660            let arguments = arguments.clone();
14661            tokio::spawn(async move {
14662                agent
14663                    .invoke_tool(ToolExecutionRequest::new(
14664                        "atomic-rate-left",
14665                        "atomic_rate",
14666                        arguments,
14667                        ToolCallSource::Manual,
14668                    ))
14669                    .await
14670                    .unwrap()
14671            })
14672        };
14673        let right = {
14674            let agent = Arc::clone(&agent);
14675            tokio::spawn(async move {
14676                agent
14677                    .invoke_tool(ToolExecutionRequest::new(
14678                        "atomic-rate-right",
14679                        "atomic_rate",
14680                        arguments,
14681                        ToolCallSource::Manual,
14682                    ))
14683                    .await
14684                    .unwrap()
14685            })
14686        };
14687        tokio::time::sleep(std::time::Duration::from_millis(25)).await;
14688        drop(held);
14689        let (left, right) = tokio::join!(left, right);
14690        let records = [left.unwrap(), right.unwrap()];
14691
14692        assert_eq!(records.iter().filter(|record| record.success).count(), 1);
14693        assert_eq!(records.iter().filter(|record| record.executed).count(), 1);
14694        assert!(
14695            records.iter().any(|record| {
14696                !record.executed && record.output.contains("Rate limit exceeded")
14697            })
14698        );
14699        assert_eq!(calls.load(Ordering::SeqCst), 1);
14700    }
14701
14702    #[tokio::test]
14703    async fn changed_policy_generation_invalidates_pending_approval() {
14704        let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14705        let max_active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14706        let entered = Arc::new(tokio::sync::Barrier::new(2));
14707        let release = Arc::new(tokio::sync::Notify::new());
14708        let handler = Arc::new(BlockingApprovalHandler {
14709            entered: Arc::clone(&entered),
14710            release: Arc::clone(&release),
14711            result: ApprovalResult::Approved,
14712        });
14713        let agent = Arc::new(
14714            AgentBuilder::new()
14715                .system_prompt("Test stale approval denial.")
14716                .llm(Arc::new(mock_with_response("done")))
14717                .tool(Arc::new(LockedWriteTool {
14718                    active: Arc::clone(&active),
14719                    max_active: Arc::clone(&max_active),
14720                }))
14721                .tool_security(ToolSecurityEngine::new(approval_security_config(true)))
14722                .hitl_engine(HITLEngine::new(ai_agents_hitl::HITLConfig::default()))
14723                .approval_handler(handler)
14724                .build()
14725                .unwrap(),
14726        );
14727        let running = Arc::clone(&agent);
14728        let call = tokio::spawn(async move {
14729            running
14730                .invoke_tool(ToolExecutionRequest::new(
14731                    "stale-approval",
14732                    "locked_write",
14733                    serde_json::json!({"path": "./stale.txt"}),
14734                    ToolCallSource::Manual,
14735                ))
14736                .await
14737                .unwrap()
14738        });
14739        entered.wait().await;
14740        let generation = agent
14741            .runtime_control()
14742            .set_tool_security(approval_security_config(true));
14743        release.notify_one();
14744        let record = call.await.unwrap();
14745
14746        assert!(!record.executed);
14747        assert!(record.output.contains("Approval became stale"));
14748        assert_eq!(record.policy_version, generation);
14749        assert_eq!(max_active.load(Ordering::SeqCst), 0);
14750    }
14751
14752    #[tokio::test]
14753    async fn final_policy_reapplies_argument_caps_after_approval_changes() {
14754        use ai_agents_hitl::CallbackHandler;
14755
14756        let mut security = ToolSecurityConfig {
14757            enabled: true,
14758            fail_closed: true,
14759            ..Default::default()
14760        };
14761        let policy = ai_agents_tools::ToolPolicyConfig {
14762            read_paths: vec![".".to_string()],
14763            max_results: Some(5),
14764            require_confirmation: true,
14765            ..Default::default()
14766        };
14767        security.tools.insert("context_echo".to_string(), policy);
14768        let handler = CallbackHandler::new(|_| ApprovalResult::Modified {
14769            changes: HashMap::from([("max_results".to_string(), serde_json::json!(99))]),
14770        });
14771        let agent = AgentBuilder::new()
14772            .system_prompt("Test final argument caps.")
14773            .llm(Arc::new(mock_with_response("done")))
14774            .tool(Arc::new(ContextEchoTool))
14775            .tool_security(ToolSecurityEngine::new(security))
14776            .hitl_engine(HITLEngine::new(ai_agents_hitl::HITLConfig::default()))
14777            .approval_handler(Arc::new(handler))
14778            .build()
14779            .unwrap();
14780
14781        let record = agent
14782            .invoke_tool(ToolExecutionRequest::new(
14783                "final-cap",
14784                "context_echo",
14785                serde_json::json!({"path": ".", "max_results": 1}),
14786                ToolCallSource::Manual,
14787            ))
14788            .await
14789            .unwrap();
14790
14791        assert!(record.success);
14792        assert_eq!(record.executed_arguments["max_results"], 5);
14793        assert_eq!(
14794            record.approval.unwrap().modified_arguments.unwrap()["max_results"],
14795            5
14796        );
14797    }
14798
14799    #[tokio::test]
14800    async fn no_binding_writes_use_canonical_fallback_lock() {
14801        let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14802        let max_active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14803        let agent = Arc::new(
14804            AgentBuilder::new()
14805                .system_prompt("Test fallback resource locks.")
14806                .llm(Arc::new(mock_with_response("done")))
14807                .tool(Arc::new(NoBindingWriteTool {
14808                    active: Arc::clone(&active),
14809                    max_active: Arc::clone(&max_active),
14810                }))
14811                .build()
14812                .unwrap(),
14813        );
14814        let left = {
14815            let agent = Arc::clone(&agent);
14816            tokio::spawn(async move {
14817                agent
14818                    .invoke_tool(ToolExecutionRequest::new(
14819                        "no-binding-left",
14820                        "no_binding_write",
14821                        serde_json::json!({}),
14822                        ToolCallSource::Manual,
14823                    ))
14824                    .await
14825                    .unwrap()
14826            })
14827        };
14828        let right = {
14829            let agent = Arc::clone(&agent);
14830            tokio::spawn(async move {
14831                agent
14832                    .invoke_tool(ToolExecutionRequest::new(
14833                        "no-binding-right",
14834                        "no_binding_write",
14835                        serde_json::json!({}),
14836                        ToolCallSource::Manual,
14837                    ))
14838                    .await
14839                    .unwrap()
14840            })
14841        };
14842        let (left, right) = tokio::join!(left, right);
14843
14844        assert!(left.unwrap().success);
14845        assert!(right.unwrap().success);
14846        assert_eq!(max_active.load(Ordering::SeqCst), 1);
14847    }
14848
14849    #[tokio::test]
14850    async fn parent_and_child_paths_share_a_resource_lock() {
14851        let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14852        let max_active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14853        let agent = Arc::new(
14854            AgentBuilder::new()
14855                .system_prompt("Test parent child resource locks.")
14856                .llm(Arc::new(mock_with_response("done")))
14857                .tool(Arc::new(LockedWriteTool {
14858                    active: Arc::clone(&active),
14859                    max_active: Arc::clone(&max_active),
14860                }))
14861                .build()
14862                .unwrap(),
14863        );
14864        let parent = format!("./lock-parent-{}", uuid::Uuid::new_v4());
14865        let child = format!("{}/child.txt", parent);
14866        let left = {
14867            let agent = Arc::clone(&agent);
14868            tokio::spawn(async move {
14869                agent
14870                    .invoke_tool(ToolExecutionRequest::new(
14871                        "parent-lock",
14872                        "locked_write",
14873                        serde_json::json!({"path": parent}),
14874                        ToolCallSource::Manual,
14875                    ))
14876                    .await
14877                    .unwrap()
14878            })
14879        };
14880        let right = {
14881            let agent = Arc::clone(&agent);
14882            tokio::spawn(async move {
14883                agent
14884                    .invoke_tool(ToolExecutionRequest::new(
14885                        "child-lock",
14886                        "locked_write",
14887                        serde_json::json!({"path": child}),
14888                        ToolCallSource::Manual,
14889                    ))
14890                    .await
14891                    .unwrap()
14892            })
14893        };
14894        let (left, right) = tokio::join!(left, right);
14895
14896        assert!(left.unwrap().success);
14897        assert!(right.unwrap().success);
14898        assert_eq!(max_active.load(Ordering::SeqCst), 1);
14899    }
14900
14901    #[tokio::test]
14902    async fn tool_hooks_can_reenter_after_resource_guards_are_dropped() {
14903        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14904        let hooks = Arc::new(ReentrantToolHooks {
14905            agent: parking_lot::Mutex::new(None),
14906            invoked: AtomicBool::new(false),
14907            nested_success: AtomicBool::new(false),
14908        });
14909        let agent = Arc::new(
14910            AgentBuilder::new()
14911                .system_prompt("Test hook reentrancy.")
14912                .llm(Arc::new(mock_with_response("done")))
14913                .tool(Arc::new(RecoveryTestTool {
14914                    id: "reentrant_write".to_string(),
14915                    succeeds: true,
14916                    calls: Arc::clone(&calls),
14917                }))
14918                .hooks(hooks.clone())
14919                .build()
14920                .unwrap(),
14921        );
14922        *hooks.agent.lock() = Some(Arc::downgrade(&agent));
14923        let record = tokio::time::timeout(
14924            std::time::Duration::from_secs(2),
14925            agent.invoke_tool(ToolExecutionRequest::new(
14926                "outer-hook-call",
14927                "reentrant_write",
14928                serde_json::json!({"path": "./hook.txt"}),
14929                ToolCallSource::Manual,
14930            )),
14931        )
14932        .await
14933        .expect("tool completion hook must not retain resource guards")
14934        .unwrap();
14935
14936        assert!(record.success);
14937        assert!(hooks.nested_success.load(Ordering::SeqCst));
14938        assert_eq!(calls.load(Ordering::SeqCst), 2);
14939    }
14940
14941    #[tokio::test]
14942    async fn fallback_releases_primary_resource_locks() {
14943        let primary_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14944        let fallback_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
14945        let agent = AgentBuilder::new()
14946            .system_prompt("Test fallback execution.")
14947            .llm(Arc::new(mock_with_response("done")))
14948            .tool(Arc::new(RecoveryTestTool {
14949                id: "primary".to_string(),
14950                succeeds: false,
14951                calls: Arc::clone(&primary_calls),
14952            }))
14953            .tool(Arc::new(RecoveryTestTool {
14954                id: "fallback".to_string(),
14955                succeeds: true,
14956                calls: Arc::clone(&fallback_calls),
14957            }))
14958            .recovery_manager(recovery_manager_with_fallbacks([(
14959                "primary".to_string(),
14960                "fallback".to_string(),
14961            )]))
14962            .build()
14963            .unwrap();
14964        let record = tokio::time::timeout(
14965            std::time::Duration::from_secs(2),
14966            agent.invoke_tool(ToolExecutionRequest::new(
14967                "fallback-call",
14968                "primary",
14969                serde_json::json!({"path": "./shared.txt"}),
14970                ToolCallSource::Manual,
14971            )),
14972        )
14973        .await
14974        .expect("fallback must not retain the primary resource guard")
14975        .unwrap();
14976
14977        assert!(record.success);
14978        assert_eq!(record.canonical_id, "fallback");
14979        assert_eq!(record.call_id, "fallback-call");
14980        assert!(matches!(record.source, ToolCallSource::Fallback { .. }));
14981        assert_eq!(primary_calls.load(Ordering::SeqCst), 1);
14982        assert_eq!(fallback_calls.load(Ordering::SeqCst), 1);
14983    }
14984
14985    #[tokio::test]
14986    async fn diagnostics_without_provider_records_unavailable_without_execution() {
14987        let mock = mock_with_response("hello");
14988        let yaml = r#"
14989name: DiagnosticsNoProviderAgent
14990system_prompt: "Review diagnostics."
14991tools: [diagnostics]
14992"#;
14993        let agent = AgentBuilder::from_yaml(yaml)
14994            .unwrap()
14995            .llm(Arc::new(mock))
14996            .auto_configure_features()
14997            .unwrap()
14998            .build()
14999            .unwrap();
15000
15001        let record = agent
15002            .invoke_tool(ToolExecutionRequest::new(
15003                "diagnostics-call",
15004                "diagnostics",
15005                serde_json::json!({}),
15006                ToolCallSource::Manual,
15007            ))
15008            .await
15009            .unwrap();
15010
15011        assert!(!record.executed);
15012        assert!(!record.success);
15013        assert_eq!(record.policy.outcome, PermissionOutcome::Unavailable);
15014    }
15015
15016    #[tokio::test]
15017    async fn web_search_without_provider_records_unavailable_without_execution() {
15018        let mock = mock_with_response("hello");
15019        let yaml = r#"
15020name: WebSearchNoProviderAgent
15021system_prompt: "You search the web."
15022tools: [web_search]
15023"#;
15024        let agent = AgentBuilder::from_yaml(yaml)
15025            .unwrap()
15026            .llm(Arc::new(mock))
15027            .auto_configure_features()
15028            .unwrap()
15029            .build()
15030            .unwrap();
15031
15032        let record = agent
15033            .invoke_tool(ToolExecutionRequest::new(
15034                "web-search-call",
15035                "web_search",
15036                serde_json::json!({"query": "rust async"}),
15037                ToolCallSource::Manual,
15038            ))
15039            .await
15040            .unwrap();
15041
15042        assert!(!record.executed);
15043        assert!(!record.success);
15044        assert_eq!(record.policy.outcome, PermissionOutcome::Unavailable);
15045    }
15046
15047    #[tokio::test]
15048    async fn test_spawner_section_does_not_grant_core_tools_when_top_level_tools_omitted() {
15049        let mock = mock_with_response("hello");
15050        let yaml = r#"
15051name: SpawnerNoGrantAgent
15052system_prompt: "You manage agents."
15053spawner:
15054  max_agents: 2
15055"#;
15056        let agent = AgentBuilder::from_yaml(yaml)
15057            .unwrap()
15058            .llm(Arc::new(mock))
15059            .auto_configure_features()
15060            .unwrap()
15061            .auto_configure_spawner()
15062            .await
15063            .unwrap()
15064            .build()
15065            .unwrap();
15066
15067        let available = agent.get_available_tool_ids().await.unwrap();
15068        assert!(available.is_empty());
15069    }
15070
15071    #[tokio::test]
15072    async fn test_spawner_section_does_not_grant_core_tools_when_top_level_tools_empty() {
15073        let mock = mock_with_response("hello");
15074        let yaml = r#"
15075name: EmptySpawnerNoGrantAgent
15076system_prompt: "You manage agents."
15077tools: []
15078spawner:
15079  max_agents: 2
15080"#;
15081        let agent = AgentBuilder::from_yaml(yaml)
15082            .unwrap()
15083            .llm(Arc::new(mock))
15084            .auto_configure_features()
15085            .unwrap()
15086            .auto_configure_spawner()
15087            .await
15088            .unwrap()
15089            .build()
15090            .unwrap();
15091
15092        let available = agent.get_available_tool_ids().await.unwrap();
15093        assert!(available.is_empty());
15094    }
15095
15096    #[tokio::test]
15097    async fn test_management_tools_flag_grants_core_tools_when_top_level_tools_empty() {
15098        let mock = mock_with_response("hello");
15099        let yaml = r#"
15100name: ManagementGrantAgent
15101system_prompt: "You manage agents."
15102tools: []
15103spawner:
15104  management_tools: true
15105"#;
15106        let agent = AgentBuilder::from_yaml(yaml)
15107            .unwrap()
15108            .llm(Arc::new(mock))
15109            .auto_configure_features()
15110            .unwrap()
15111            .auto_configure_spawner()
15112            .await
15113            .unwrap()
15114            .build()
15115            .unwrap();
15116
15117        let available = agent.get_available_tool_ids().await.unwrap();
15118        assert_eq!(available.len(), 4);
15119        assert!(available.contains(&"spawn_agent".to_string()));
15120        assert!(available.contains(&"send_agent_message".to_string()));
15121        assert!(available.contains(&"list_agents".to_string()));
15122        assert!(available.contains(&"remove_agent".to_string()));
15123    }
15124
15125    #[tokio::test]
15126    async fn test_management_tools_flag_grants_core_tools_when_top_level_tools_omitted() {
15127        let mock = mock_with_response("hello");
15128        let yaml = r#"
15129name: ManagementOmittedToolsGrantAgent
15130system_prompt: "You manage agents."
15131spawner:
15132  management_tools: true
15133"#;
15134        let agent = AgentBuilder::from_yaml(yaml)
15135            .unwrap()
15136            .llm(Arc::new(mock))
15137            .auto_configure_features()
15138            .unwrap()
15139            .auto_configure_spawner()
15140            .await
15141            .unwrap()
15142            .build()
15143            .unwrap();
15144
15145        let available = agent.get_available_tool_ids().await.unwrap();
15146        assert_eq!(available.len(), 4);
15147        assert!(available.contains(&"spawn_agent".to_string()));
15148        assert!(available.contains(&"send_agent_message".to_string()));
15149        assert!(available.contains(&"list_agents".to_string()));
15150        assert!(available.contains(&"remove_agent".to_string()));
15151    }
15152
15153    #[tokio::test]
15154    async fn test_management_tools_selected_grants_only_selected_tools() {
15155        let mock = mock_with_response("hello");
15156        let yaml = r#"
15157name: ManagementSelectedGrantAgent
15158system_prompt: "You manage agents."
15159tools: []
15160spawner:
15161  management_tools:
15162    - spawn_agent
15163    - send_agent_message
15164    - list_agents
15165"#;
15166        let agent = AgentBuilder::from_yaml(yaml)
15167            .unwrap()
15168            .llm(Arc::new(mock))
15169            .auto_configure_features()
15170            .unwrap()
15171            .auto_configure_spawner()
15172            .await
15173            .unwrap()
15174            .build()
15175            .unwrap();
15176
15177        let available = agent.get_available_tool_ids().await.unwrap();
15178        assert_eq!(available.len(), 3);
15179        assert!(available.contains(&"spawn_agent".to_string()));
15180        assert!(available.contains(&"send_agent_message".to_string()));
15181        assert!(available.contains(&"list_agents".to_string()));
15182        assert!(!available.contains(&"remove_agent".to_string()));
15183    }
15184
15185    #[tokio::test]
15186    async fn test_orchestration_tools_flag_grants_tools_when_top_level_tools_empty() {
15187        let mock = mock_with_response("hello");
15188        let yaml = r#"
15189name: OrchestrationGrantAgent
15190system_prompt: "You coordinate agents."
15191llms:
15192  default:
15193    provider: openai
15194    model: gpt-4
15195  router:
15196    provider: openai
15197    model: gpt-4
15198llm:
15199  default: default
15200  router: router
15201tools: []
15202spawner:
15203  orchestration_tools: true
15204"#;
15205        let agent = AgentBuilder::from_yaml(yaml)
15206            .unwrap()
15207            .llm(Arc::new(mock))
15208            .auto_configure_features()
15209            .unwrap()
15210            .auto_configure_spawner()
15211            .await
15212            .unwrap()
15213            .build()
15214            .unwrap();
15215
15216        let available = agent.get_available_tool_ids().await.unwrap();
15217        assert_eq!(available.len(), 5);
15218        assert!(available.contains(&"route_to_agent".to_string()));
15219        assert!(available.contains(&"pipeline_process".to_string()));
15220        assert!(available.contains(&"concurrent_ask".to_string()));
15221        assert!(available.contains(&"group_discussion".to_string()));
15222        assert!(available.contains(&"handoff_conversation".to_string()));
15223    }
15224
15225    #[tokio::test]
15226    async fn test_persona_evolve_flag_grants_tool_when_top_level_tools_empty() {
15227        let mock = mock_with_response("hello");
15228        let yaml = r#"
15229name: PersonaGrantAgent
15230system_prompt: "You can evolve persona."
15231llm:
15232  provider: openai
15233  model: gpt-4
15234tools: []
15235persona:
15236  identity:
15237    name: "Guide"
15238    role: "Helper"
15239  evolution:
15240    enabled: true
15241    allow_llm_evolve: true
15242    mutable_fields:
15243      - traits.personality
15244"#;
15245        let agent = AgentBuilder::from_yaml(yaml)
15246            .unwrap()
15247            .llm(Arc::new(mock))
15248            .build()
15249            .unwrap();
15250
15251        let available = agent.get_available_tool_ids().await.unwrap();
15252        assert_eq!(available, vec!["persona_evolve".to_string()]);
15253    }
15254
15255    #[tokio::test]
15256    async fn test_persona_evolve_flag_grants_tool_when_top_level_tools_omitted() {
15257        let mock = mock_with_response("hello");
15258        let yaml = r#"
15259name: PersonaOmittedToolsGrantAgent
15260system_prompt: "You can evolve persona."
15261llm:
15262  provider: openai
15263  model: gpt-4
15264persona:
15265  identity:
15266    name: "Guide"
15267    role: "Helper"
15268  evolution:
15269    enabled: true
15270    allow_llm_evolve: true
15271    mutable_fields:
15272      - traits.personality
15273"#;
15274        let agent = AgentBuilder::from_yaml(yaml)
15275            .unwrap()
15276            .llm(Arc::new(mock))
15277            .build()
15278            .unwrap();
15279
15280        let available = agent.get_available_tool_ids().await.unwrap();
15281        assert_eq!(available, vec!["persona_evolve".to_string()]);
15282    }
15283
15284    #[tokio::test]
15285    async fn test_omitted_yaml_tools_exposes_no_tools() {
15286        let mock = mock_with_response("hello");
15287        let yaml = r#"
15288name: NoToolsAgent
15289system_prompt: "You are helpful."
15290"#;
15291        let agent = AgentBuilder::from_yaml(yaml)
15292            .unwrap()
15293            .llm(Arc::new(mock))
15294            .auto_configure_features()
15295            .unwrap()
15296            .build()
15297            .unwrap();
15298
15299        let available = agent.get_available_tool_ids().await.unwrap();
15300        assert!(available.is_empty());
15301    }
15302
15303    #[tokio::test]
15304    async fn test_state_tools_cannot_widen_top_level_grant() {
15305        let mock = mock_with_response("hello");
15306        let yaml = r#"
15307name: NarrowToolsAgent
15308system_prompt: "You are helpful."
15309tools:
15310  - calculator
15311states:
15312  initial: current
15313  states:
15314    current:
15315      tools: [datetime]
15316"#;
15317        let agent = AgentBuilder::from_yaml(yaml)
15318            .unwrap()
15319            .llm(Arc::new(mock))
15320            .auto_configure_features()
15321            .unwrap()
15322            .build()
15323            .unwrap();
15324
15325        let available = agent.get_available_tool_ids().await.unwrap();
15326        assert!(available.is_empty());
15327    }
15328
15329    // Tool execution in chat flow
15330    #[tokio::test]
15331    async fn test_integration_tool_execution() {
15332        // Mock LLM that returns a tool call then a final answer
15333        let mock = mock_with_responses(vec![
15334            // First response: tool call
15335            r#"I'll calculate that for you.
15336[TOOL_CALL: {"name": "calculator", "arguments": {"expression": "2+2"}}]"#,
15337            // After tool result: final answer
15338            "The answer is 4.",
15339        ]);
15340        let mut tools = ai_agents_tools::ToolRegistry::new();
15341        tools
15342            .register(Arc::new(ai_agents_tools::CalculatorTool))
15343            .unwrap();
15344
15345        let agent = AgentBuilder::new()
15346            .system_prompt("You are a calculator assistant.")
15347            .llm(Arc::new(mock))
15348            .tools(tools)
15349            .build()
15350            .unwrap();
15351
15352        let response = agent.chat("What is 2+2?").await.unwrap();
15353        // The agent should eventually produce a response
15354        assert!(!response.content.is_empty());
15355    }
15356
15357    #[tokio::test]
15358    async fn test_tool_hitl_rejection_finalizes_blocking_turn() {
15359        let responses = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15360        let hooks = Arc::new(ResponseCountingHooks {
15361            responses: Arc::clone(&responses),
15362        });
15363        let mock = mock_with_response(r#"{"tool":"echo","arguments":{"message":"hello"}}"#);
15364        let yaml = r#"
15365name: ToolRejectAgent
15366system_prompt: "You use tools when requested."
15367tools:
15368  - echo
15369hitl:
15370  tools:
15371    echo:
15372      require_approval: true
15373      approval_message: "Approve echo?"
15374"#;
15375        let agent = AgentBuilder::from_yaml(yaml)
15376            .unwrap()
15377            .llm(Arc::new(mock))
15378            .auto_configure_features()
15379            .unwrap()
15380            .hooks(hooks)
15381            .build()
15382            .unwrap();
15383
15384        let response = agent.chat("echo hello").await.unwrap();
15385
15386        assert!(
15387            response.content.contains("Operation cancelled"),
15388            "unexpected response: {}",
15389            response.content
15390        );
15391        assert_eq!(responses.load(Ordering::SeqCst), 1);
15392        let messages = agent.memory.get_messages(None).await.unwrap();
15393        assert_eq!(messages.len(), 3);
15394        assert_eq!(messages[0].content, "echo hello");
15395        assert!(messages[1].content.contains("\"tool\":\"echo\""));
15396        assert!(messages[2].content.contains("rejected by the approver"));
15397    }
15398
15399    #[tokio::test]
15400    async fn test_tool_hitl_rejection_finalizes_streaming_turn() {
15401        use futures::StreamExt;
15402
15403        let responses = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15404        let hooks = Arc::new(ResponseCountingHooks {
15405            responses: Arc::clone(&responses),
15406        });
15407        let mock = mock_with_response(r#"{"tool":"echo","arguments":{"message":"hello"}}"#);
15408        let yaml = r#"
15409name: ToolRejectStreamingAgent
15410system_prompt: "You use tools when requested."
15411tools:
15412  - echo
15413streaming:
15414  enabled: true
15415hitl:
15416  tools:
15417    echo:
15418      require_approval: true
15419      approval_message: "Approve echo?"
15420"#;
15421        let agent = AgentBuilder::from_yaml(yaml)
15422            .unwrap()
15423            .llm(Arc::new(mock))
15424            .auto_configure_features()
15425            .unwrap()
15426            .hooks(hooks)
15427            .build()
15428            .unwrap();
15429
15430        let mut stream = agent.chat_stream("echo hello").await.unwrap();
15431        let mut terminal_error = String::new();
15432        let mut done = false;
15433        while let Some(chunk) = stream.next().await {
15434            match chunk {
15435                StreamChunk::Error { message } => terminal_error = message,
15436                StreamChunk::Done {} => {
15437                    done = true;
15438                    break;
15439                }
15440                _ => {}
15441            }
15442        }
15443
15444        assert!(done);
15445        assert!(
15446            terminal_error.contains("Operation cancelled"),
15447            "unexpected terminal error: {}",
15448            terminal_error
15449        );
15450        assert_eq!(responses.load(Ordering::SeqCst), 1);
15451        let messages = agent.memory.get_messages(None).await.unwrap();
15452        assert_eq!(messages.len(), 3);
15453        assert_eq!(messages[0].content, "echo hello");
15454        assert!(messages[1].content.contains("\"tool\":\"echo\""));
15455        assert!(messages[2].content.contains("rejected by the approver"));
15456    }
15457
15458    #[tokio::test]
15459    async fn test_pre_response_guard_transition_skips_old_state_llm() {
15460        let mock = mock_with_response("Billing state response");
15461        let call_counter = mock.clone();
15462        let yaml = r#"
15463name: OptimizedStateAgent
15464system_prompt: "You route before answering."
15465runtime:
15466  optimization:
15467    enabled: true
15468    pre_response_deterministic_transitions: true
15469states:
15470  initial: greeting
15471  states:
15472    greeting:
15473      prompt: "Old state prompt that should be skipped."
15474      transitions:
15475        - to: billing
15476          guard:
15477            context:
15478              topic:
15479                eq: billing
15480          timing: pre_response
15481    billing:
15482      prompt: "Answer from the billing state."
15483"#;
15484        let agent = AgentBuilder::from_yaml(yaml)
15485            .unwrap()
15486            .llm(Arc::new(mock))
15487            .build()
15488            .unwrap();
15489        agent
15490            .set_context("topic", serde_json::json!("billing"))
15491            .unwrap();
15492
15493        let response = agent.chat("I need billing help").await.unwrap();
15494
15495        assert_eq!(agent.current_state().as_deref(), Some("billing"));
15496        assert_eq!(response.content, "Billing state response");
15497        assert_eq!(call_counter.call_count(), 1);
15498        assert_eq!(agent.actor_facts().len(), 0);
15499    }
15500
15501    #[tokio::test]
15502    async fn test_set_context_supports_dotted_paths_for_pre_response_guards() {
15503        let mock = mock_with_response("Billing state response");
15504        let call_counter = mock.clone();
15505        let yaml = r#"
15506name: OptimizedStateAgent
15507system_prompt: "You route before answering."
15508runtime:
15509  optimization:
15510    enabled: true
15511    pre_response_deterministic_transitions: true
15512context:
15513  request:
15514    type: runtime
15515    default:
15516      topic: general
15517states:
15518  initial: greeting
15519  states:
15520    greeting:
15521      prompt: "Old state prompt that should be skipped."
15522      transitions:
15523        - to: billing
15524          guard:
15525            context:
15526              request.topic:
15527                eq: billing
15528          timing: pre_response
15529    billing:
15530      prompt: "Answer from the billing state."
15531"#;
15532        let agent = AgentBuilder::from_yaml(yaml)
15533            .unwrap()
15534            .llm(Arc::new(mock))
15535            .build()
15536            .unwrap();
15537        agent
15538            .set_context("request.topic", serde_json::json!("billing"))
15539            .unwrap();
15540
15541        let response = agent.chat("I need billing help").await.unwrap();
15542
15543        assert_eq!(agent.current_state().as_deref(), Some("billing"));
15544        assert_eq!(response.content, "Billing state response");
15545        assert_eq!(call_counter.call_count(), 1);
15546        assert_eq!(
15547            agent.get_context().get("request"),
15548            Some(&serde_json::json!({"topic": "billing"}))
15549        );
15550    }
15551
15552    #[tokio::test]
15553    async fn test_pre_response_rejection_does_not_commit_staged_context_or_user() {
15554        let mock = mock_with_response("billing");
15555        let yaml = r#"
15556name: OptimizedStateAgent
15557system_prompt: "You route before answering."
15558runtime:
15559  optimization:
15560    enabled: true
15561    pre_response_deterministic_transitions: true
15562hitl:
15563  states:
15564    billing:
15565      on_enter: require_approval
15566      approval_message: "Approve billing route?"
15567states:
15568  initial: greeting
15569  states:
15570    greeting:
15571      prompt: "Old state prompt."
15572      extract:
15573        - key: topic
15574          description: "Support topic"
15575      transitions:
15576        - to: billing
15577          guard:
15578            context:
15579              topic:
15580                eq: billing
15581          timing: pre_response
15582          run_extractors: true
15583    billing:
15584      prompt: "Billing state."
15585"#;
15586        let agent = AgentBuilder::from_yaml(yaml)
15587            .unwrap()
15588            .llm(Arc::new(mock))
15589            .build()
15590            .unwrap();
15591
15592        let response = agent
15593            .try_pre_response_transition("billing please")
15594            .await
15595            .unwrap();
15596
15597        assert!(response.is_none());
15598        assert_eq!(agent.current_state().as_deref(), Some("greeting"));
15599        assert!(!agent.get_context().contains_key("topic"));
15600        assert_eq!(agent.memory.get_messages(None).await.unwrap().len(), 0);
15601    }
15602
15603    #[tokio::test]
15604    async fn test_pre_response_extractor_commits_context_on_winning_path() {
15605        let mock = mock_with_responses(vec!["billing", "Billing response"]);
15606        let yaml = r#"
15607name: OptimizedStateAgent
15608system_prompt: "You route before answering."
15609runtime:
15610  optimization:
15611    enabled: true
15612    pre_response_deterministic_transitions: true
15613states:
15614  initial: greeting
15615  states:
15616    greeting:
15617      prompt: "Old state prompt."
15618      extract:
15619        - key: topic
15620          description: "Support topic"
15621      transitions:
15622        - to: billing
15623          guard:
15624            context:
15625              topic:
15626                eq: billing
15627          timing: pre_response
15628          run_extractors: true
15629    billing:
15630      prompt: "Billing state."
15631"#;
15632        let agent = AgentBuilder::from_yaml(yaml)
15633            .unwrap()
15634            .llm(Arc::new(mock))
15635            .build()
15636            .unwrap();
15637
15638        let response = agent.chat("billing please").await.unwrap();
15639
15640        assert_eq!(agent.current_state().as_deref(), Some("billing"));
15641        assert_eq!(response.content, "Billing response");
15642        assert_eq!(
15643            agent.get_context().get("topic"),
15644            Some(&serde_json::json!("billing"))
15645        );
15646    }
15647
15648    #[tokio::test]
15649    async fn test_pre_response_extractor_miss_does_not_mutate_context() {
15650        let mock = mock_with_response("__NONE__");
15651        let yaml = r#"
15652name: OptimizedStateAgent
15653system_prompt: "You route before answering."
15654runtime:
15655  optimization:
15656    enabled: true
15657    pre_response_deterministic_transitions: true
15658states:
15659  initial: greeting
15660  states:
15661    greeting:
15662      prompt: "Old state prompt."
15663      extract:
15664        - key: topic
15665          description: "Support topic"
15666      transitions:
15667        - to: billing
15668          guard:
15669            context:
15670              topic:
15671                eq: billing
15672          timing: pre_response
15673          run_extractors: true
15674    billing:
15675      prompt: "Billing state."
15676"#;
15677        let agent = AgentBuilder::from_yaml(yaml)
15678            .unwrap()
15679            .llm(Arc::new(mock))
15680            .build()
15681            .unwrap();
15682
15683        let response = agent.try_pre_response_transition("hello").await.unwrap();
15684
15685        assert!(response.is_none());
15686        assert_eq!(agent.current_state().as_deref(), Some("greeting"));
15687        assert!(!agent.get_context().contains_key("topic"));
15688    }
15689
15690    #[tokio::test]
15691    async fn test_default_guard_transition_stays_post_response() {
15692        let mock = mock_with_responses(vec!["Greeting response", "Billing response"]);
15693        let call_counter = mock.clone();
15694        let yaml = r#"
15695name: TimingAgent
15696system_prompt: "You route carefully."
15697runtime:
15698  optimization:
15699    enabled: true
15700    pre_response_deterministic_transitions: true
15701states:
15702  initial: greeting
15703  states:
15704    greeting:
15705      prompt: "Old state prompt."
15706      transitions:
15707        - to: billing
15708          guard:
15709            context:
15710              topic:
15711                eq: billing
15712    billing:
15713      prompt: "Billing state."
15714"#;
15715        let agent = AgentBuilder::from_yaml(yaml)
15716            .unwrap()
15717            .llm(Arc::new(mock))
15718            .build()
15719            .unwrap();
15720        agent
15721            .set_context("topic", serde_json::json!("billing"))
15722            .unwrap();
15723
15724        let response = agent.chat("billing please").await.unwrap();
15725
15726        assert_eq!(agent.current_state().as_deref(), Some("billing"));
15727        assert_eq!(response.content, "Billing response");
15728        assert_eq!(call_counter.call_count(), 2);
15729    }
15730
15731    #[tokio::test]
15732    async fn test_explicit_post_response_guard_transition_stays_post_response() {
15733        let mock = mock_with_responses(vec!["Greeting response", "Billing response"]);
15734        let call_counter = mock.clone();
15735        let yaml = r#"
15736name: TimingAgent
15737system_prompt: "You route carefully."
15738runtime:
15739  optimization:
15740    enabled: true
15741    pre_response_deterministic_transitions: true
15742states:
15743  initial: greeting
15744  states:
15745    greeting:
15746      prompt: "Old state prompt."
15747      transitions:
15748        - to: billing
15749          guard:
15750            context:
15751              topic:
15752                eq: billing
15753          timing: post_response
15754    billing:
15755      prompt: "Billing state."
15756"#;
15757        let agent = AgentBuilder::from_yaml(yaml)
15758            .unwrap()
15759            .llm(Arc::new(mock))
15760            .build()
15761            .unwrap();
15762        agent
15763            .set_context("topic", serde_json::json!("billing"))
15764            .unwrap();
15765
15766        let response = agent.chat("billing please").await.unwrap();
15767
15768        assert_eq!(agent.current_state().as_deref(), Some("billing"));
15769        assert_eq!(response.content, "Billing response");
15770        assert_eq!(call_counter.call_count(), 2);
15771    }
15772
15773    #[tokio::test]
15774    async fn test_pre_response_extractors_are_transition_scoped() {
15775        let mock = mock_with_responses(vec!["billing", "Billing response"]);
15776        let yaml = r#"
15777name: ScopedExtractorAgent
15778system_prompt: "You route carefully."
15779runtime:
15780  optimization:
15781    enabled: true
15782    pre_response_deterministic_transitions: true
15783states:
15784  initial: greeting
15785  states:
15786    greeting:
15787      prompt: "Old state prompt."
15788      extract:
15789        - key: topic
15790          description: "Support topic"
15791      transitions:
15792        - to: wrong
15793          guard:
15794            context:
15795              topic:
15796                eq: billing
15797          timing: pre_response
15798        - to: billing
15799          guard:
15800            context:
15801              topic:
15802                eq: billing
15803          timing: pre_response
15804          run_extractors: true
15805    wrong:
15806      prompt: "Wrong state."
15807    billing:
15808      prompt: "Billing state."
15809"#;
15810        let agent = AgentBuilder::from_yaml(yaml)
15811            .unwrap()
15812            .llm(Arc::new(mock))
15813            .build()
15814            .unwrap();
15815
15816        let response = agent.chat("billing please").await.unwrap();
15817
15818        assert_eq!(agent.current_state().as_deref(), Some("billing"));
15819        assert_eq!(response.content, "Billing response");
15820    }
15821
15822    #[tokio::test]
15823    async fn test_pre_response_resolved_intent_routes_early() {
15824        let mock = mock_with_response("Billing response");
15825        let yaml = r#"
15826name: IntentAgent
15827system_prompt: "You route carefully."
15828runtime:
15829  optimization:
15830    enabled: true
15831    pre_response_deterministic_transitions: true
15832states:
15833  initial: greeting
15834  states:
15835    greeting:
15836      prompt: "Old state prompt."
15837      transitions:
15838        - to: billing
15839          intent: billing
15840          timing: pre_response
15841    billing:
15842      prompt: "Billing state."
15843"#;
15844        let agent = AgentBuilder::from_yaml(yaml)
15845            .unwrap()
15846            .llm(Arc::new(mock))
15847            .build()
15848            .unwrap();
15849        agent
15850            .set_context("resolved_intent", serde_json::json!("billing"))
15851            .unwrap();
15852
15853        let response = agent
15854            .try_pre_response_transition("I need billing help")
15855            .await
15856            .unwrap()
15857            .unwrap();
15858
15859        assert_eq!(agent.current_state().as_deref(), Some("billing"));
15860        assert_eq!(response.content, "Billing response");
15861    }
15862
15863    #[tokio::test]
15864    async fn test_background_overflow_error_surfaces() {
15865        let mut config = RuntimeConfig::default();
15866        config.optimization.enabled = true;
15867        config.optimization.post_turn.max_background_tasks = 1;
15868        config.optimization.post_turn.on_background_overflow = BackgroundOverflowPolicy::Error;
15869        let policy = crate::optimization::MaintenanceTaskPolicy {
15870            mode: MaintenanceMode::Background,
15871            await_before_next_turn: AwaitBeforeNextTurn::Always,
15872        };
15873        let agent = AgentBuilder::new()
15874            .system_prompt("You are helpful.")
15875            .llm(Arc::new(mock_with_response("ok")))
15876            .build()
15877            .unwrap()
15878            .with_runtime_config(config);
15879        agent
15880            .background_maintenance
15881            .spawn(None, async { std::future::pending::<Result<()>>().await })
15882            .unwrap();
15883
15884        let result = agent
15885            .spawn_or_handle_background(None, async { Ok(()) }, "facts", &policy)
15886            .await;
15887
15888        assert!(result.is_err());
15889    }
15890
15891    #[tokio::test]
15892    async fn test_speculative_reasoning_low_cap_uses_serial_reasoning() {
15893        let default_mock = mock_with_response("Plain draft response");
15894        let router_mock = mock_with_response("cot");
15895        let router_counter = router_mock.clone();
15896        let yaml = r#"
15897name: ReasoningReservationAgent
15898system_prompt: "You answer plainly unless reasoning wins."
15899llm:
15900  default: default
15901  router: router
15902observability:
15903  enabled: true
15904  export:
15905    write_raw_events: true
15906reasoning:
15907  mode: auto
15908  judge_llm: router
15909runtime:
15910  optimization:
15911    enabled: true
15912    max_speculative_llm_calls_per_turn: 1
15913    speculative_reasoning_auto: true
15914    max_parallel_runtime_tasks: 2
15915"#;
15916        let agent = AgentBuilder::from_yaml(yaml)
15917            .unwrap()
15918            .llm_alias("default", Arc::new(default_mock))
15919            .llm_alias("router", Arc::new(router_mock))
15920            .build()
15921            .unwrap();
15922
15923        let response = agent.chat("hello").await.unwrap();
15924
15925        assert_eq!(response.content, "Plain draft response");
15926        assert_eq!(router_counter.call_count(), 1);
15927        let events = agent.observability().unwrap().raw_events();
15928        assert!(!events.iter().any(|event| {
15929            event.dimensions.get("commit_behavior") == Some(&"reasoning_decision".to_string())
15930        }));
15931    }
15932
15933    #[tokio::test]
15934    async fn test_forced_reasoning_skips_plain_speculative_draft() {
15935        let mock = mock_with_response("Reasoned response");
15936        let yaml = r#"
15937name: ForcedReasoningAgent
15938system_prompt: "You reason before answering."
15939observability:
15940  enabled: true
15941  export:
15942    write_raw_events: true
15943reasoning:
15944  mode: cot
15945runtime:
15946  optimization:
15947    enabled: true
15948    max_speculative_llm_calls_per_turn: 2
15949    speculative_state_transitions: true
15950    max_parallel_runtime_tasks: 2
15951states:
15952  initial: triage
15953  states:
15954    triage:
15955      prompt: "Answer from triage."
15956      transitions:
15957        - to: billing
15958          guard:
15959            context:
15960              route:
15961                eq: billing
15962          timing: parallel
15963    billing:
15964      prompt: "Billing state."
15965"#;
15966        let agent = AgentBuilder::from_yaml(yaml)
15967            .unwrap()
15968            .llm(Arc::new(mock))
15969            .build()
15970            .unwrap();
15971
15972        let response = agent.chat("hello").await.unwrap();
15973
15974        assert_eq!(response.content, "Reasoned response");
15975        let events = agent.observability().unwrap().raw_events();
15976        assert!(
15977            !events
15978                .iter()
15979                .any(|event| event.dimensions.contains_key("branch_status"))
15980        );
15981    }
15982
15983    #[tokio::test]
15984    async fn test_speculative_skill_low_cap_uses_serial_skill_route() {
15985        let default_mock = mock_with_response("Skill committed response");
15986        let router_mock = mock_with_response("helper");
15987        let router_counter = router_mock.clone();
15988        let yaml = r#"
15989name: SkillReservationAgent
15990system_prompt: "Use skills when they match."
15991llm:
15992  default: default
15993  router: router
15994observability:
15995  enabled: true
15996  export:
15997    write_raw_events: true
15998runtime:
15999  optimization:
16000    enabled: true
16001    max_speculative_llm_calls_per_turn: 1
16002    speculative_skill_routing: true
16003    max_parallel_runtime_tasks: 2
16004skills:
16005  - id: helper
16006    description: "Answer helper requests"
16007    trigger: "User asks for helper"
16008    steps:
16009      - prompt: "Answer the helper request: {{ user_input }}"
16010"#;
16011        let agent = AgentBuilder::from_yaml(yaml)
16012            .unwrap()
16013            .llm_alias("default", Arc::new(default_mock))
16014            .llm_alias("router", Arc::new(router_mock))
16015            .build()
16016            .unwrap();
16017
16018        let response = agent.chat("please use helper").await.unwrap();
16019
16020        assert_eq!(response.content, "Skill committed response");
16021        assert_eq!(router_counter.call_count(), 1);
16022        let events = agent.observability().unwrap().raw_events();
16023        assert!(
16024            !events
16025                .iter()
16026                .any(|event| event.dimensions.contains_key("branch_status"))
16027        );
16028    }
16029
16030    #[tokio::test]
16031    async fn test_parallel_transition_low_cap_allows_deterministic_route() {
16032        let mock = mock_with_response("unused");
16033        let call_counter = mock.clone();
16034        let yaml = r#"
16035name: ParallelTransitionLowCapAgent
16036system_prompt: "Route before stale responses when safe."
16037runtime:
16038  optimization:
16039    enabled: true
16040    max_speculative_llm_calls_per_turn: 1
16041    speculative_state_transitions: true
16042    max_parallel_runtime_tasks: 2
16043states:
16044  initial: triage
16045  states:
16046    triage:
16047      prompt: "Triage state."
16048      transitions:
16049        - to: billing
16050          guard:
16051            context:
16052              route:
16053                eq: billing
16054          timing: parallel
16055    billing:
16056      prompt: "Billing state."
16057"#;
16058        let agent = AgentBuilder::from_yaml(yaml)
16059            .unwrap()
16060            .llm(Arc::new(mock))
16061            .build()
16062            .unwrap();
16063        agent
16064            .set_context("route", serde_json::json!("billing"))
16065            .unwrap();
16066        agent.update_active_turn_context("billing help", HashMap::new());
16067        assert!(
16068            agent.reserve_active_speculative_llm_call(
16069                RuntimeOptimizationKind::ParallelStateTransition
16070            )
16071        );
16072
16073        let selection = agent
16074            .select_parallel_transition_candidate("billing help")
16075            .await
16076            .unwrap();
16077        agent.end_root_turn();
16078
16079        match selection {
16080            ParallelTransitionSelection::Candidate(candidate) => {
16081                assert_eq!(candidate.target(), "billing");
16082            }
16083            ParallelTransitionSelection::NoMatch => panic!("deterministic route did not match"),
16084            ParallelTransitionSelection::ReservationExhausted => {
16085                panic!("deterministic route consumed LLM budget")
16086            }
16087        }
16088        assert_eq!(call_counter.call_count(), 0);
16089    }
16090
16091    #[tokio::test]
16092    async fn speculative_transition_drops_loser_before_state_actions() {
16093        let lock = Arc::new(tokio::sync::Mutex::new(()));
16094        let first_started = Arc::new(tokio::sync::Notify::new());
16095        let first_dropped = Arc::new(AtomicBool::new(false));
16096        let committed_after_drop = Arc::new(AtomicBool::new(false));
16097        let default = Arc::new(FirstCallLockingProvider {
16098            lock,
16099            first_started: Arc::clone(&first_started),
16100            first_dropped: Arc::clone(&first_dropped),
16101            committed_after_drop: Arc::clone(&committed_after_drop),
16102            calls: AtomicU64::new(0),
16103        });
16104        let router = Arc::new(RoutingAfterProviderStart {
16105            provider_started: first_started,
16106        });
16107        let yaml = r#"
16108name: SpeculativeCancellationAgent
16109system_prompt: "Route before committed work."
16110llm:
16111  default: default
16112  router: router
16113runtime:
16114  optimization:
16115    enabled: true
16116    max_speculative_llm_calls_per_turn: 2
16117    speculative_state_transitions: true
16118    max_parallel_runtime_tasks: 2
16119states:
16120  initial: triage
16121  states:
16122    triage:
16123      prompt: "Triage state."
16124      transitions:
16125        - to: technical
16126          when: "The request needs technical support"
16127          timing: parallel
16128    technical:
16129      prompt: "Technical state."
16130      on_enter:
16131        - prompt: "Prepare technical context."
16132          llm: default
16133          store_as: preparation
16134"#;
16135        let agent = AgentBuilder::from_yaml(yaml)
16136            .unwrap()
16137            .llm_alias("default", default)
16138            .llm_alias("router", router)
16139            .build()
16140            .unwrap();
16141
16142        let response = tokio::time::timeout(
16143            std::time::Duration::from_secs(2),
16144            agent.chat("I cannot log in because of AUTH-17."),
16145        )
16146        .await
16147        .expect("committed work must not wait on the losing provider future")
16148        .unwrap();
16149
16150        assert_eq!(response.content, "Committed technical response.");
16151        assert_eq!(agent.current_state().as_deref(), Some("technical"));
16152        assert!(first_dropped.load(Ordering::SeqCst));
16153        assert!(committed_after_drop.load(Ordering::SeqCst));
16154    }
16155
16156    #[tokio::test]
16157    async fn buffered_transition_drops_stale_stream_before_redispatch() {
16158        use futures::StreamExt;
16159
16160        let lock = Arc::new(tokio::sync::Mutex::new(()));
16161        let stream_started = Arc::new(tokio::sync::Notify::new());
16162        let stream_dropped = Arc::new(AtomicBool::new(false));
16163        let committed_after_drop = Arc::new(AtomicBool::new(false));
16164        let default = Arc::new(BufferedLockingProvider {
16165            lock,
16166            stream_started: Arc::clone(&stream_started),
16167            stream_dropped: Arc::clone(&stream_dropped),
16168            committed_after_drop: Arc::clone(&committed_after_drop),
16169        });
16170        let router = Arc::new(RoutingAfterProviderStart {
16171            provider_started: stream_started,
16172        });
16173        let yaml = r#"
16174name: BufferedCancellationAgent
16175system_prompt: "Hide stale streamed output."
16176llm:
16177  default: default
16178  router: router
16179streaming:
16180  enabled: true
16181  buffer_size: 8
16182runtime:
16183  optimization:
16184    enabled: true
16185    max_speculative_llm_calls_per_turn: 2
16186    speculative_state_transitions: true
16187    streaming_policy: buffer_until_routing_done
16188    max_parallel_runtime_tasks: 2
16189states:
16190  initial: triage
16191  states:
16192    triage:
16193      prompt: "Triage state."
16194      transitions:
16195        - to: technical
16196          when: "The request needs technical support"
16197          timing: parallel
16198    technical:
16199      prompt: "Technical state."
16200"#;
16201        let agent = AgentBuilder::from_yaml(yaml)
16202            .unwrap()
16203            .llm_alias("default", default)
16204            .llm_alias("router", router)
16205            .build()
16206            .unwrap();
16207
16208        let content = tokio::time::timeout(std::time::Duration::from_secs(2), async {
16209            let mut stream = agent
16210                .chat_stream("AUTH-17 needs technical help.")
16211                .await
16212                .unwrap();
16213            let mut content = String::new();
16214            while let Some(chunk) = stream.next().await {
16215                match chunk {
16216                    StreamChunk::Content { text } => content.push_str(&text),
16217                    StreamChunk::Done {} => break,
16218                    StreamChunk::Error { message } => panic!("unexpected stream error: {message}"),
16219                    _ => {}
16220                }
16221            }
16222            content
16223        })
16224        .await
16225        .expect("redispatch must not wait on the stale streaming future");
16226
16227        assert_eq!(content, "Committed technical response.");
16228        assert_eq!(agent.current_state().as_deref(), Some("technical"));
16229        assert!(stream_dropped.load(Ordering::SeqCst));
16230        assert!(committed_after_drop.load(Ordering::SeqCst));
16231    }
16232
16233    #[tokio::test]
16234    async fn buffered_transition_drops_established_stream_before_redispatch() {
16235        use futures::StreamExt;
16236
16237        let stream_started = Arc::new(tokio::sync::Notify::new());
16238        let stream_dropped = Arc::new(AtomicBool::new(false));
16239        let stream_dropped_notify = Arc::new(tokio::sync::Notify::new());
16240        let committed_after_drop = Arc::new(AtomicBool::new(false));
16241        let default = Arc::new(EstablishedStreamProvider {
16242            stream_started: Arc::clone(&stream_started),
16243            stream_dropped: Arc::clone(&stream_dropped),
16244            stream_dropped_notify,
16245            committed_after_drop: Arc::clone(&committed_after_drop),
16246        });
16247        let router = Arc::new(RoutingAfterProviderStart {
16248            provider_started: stream_started,
16249        });
16250        let yaml = r#"
16251name: EstablishedStreamCancellationAgent
16252system_prompt: "Hide stale streamed output."
16253llm:
16254  default: default
16255  router: router
16256streaming:
16257  enabled: true
16258  buffer_size: 8
16259runtime:
16260  optimization:
16261    enabled: true
16262    max_speculative_llm_calls_per_turn: 2
16263    speculative_state_transitions: true
16264    streaming_policy: buffer_until_routing_done
16265    max_parallel_runtime_tasks: 2
16266states:
16267  initial: triage
16268  states:
16269    triage:
16270      prompt: "Triage state."
16271      transitions:
16272        - to: technical
16273          when: "The request needs technical support"
16274          timing: parallel
16275    technical:
16276      prompt: "Technical state."
16277"#;
16278        let agent = AgentBuilder::from_yaml(yaml)
16279            .unwrap()
16280            .llm_alias("default", default)
16281            .llm_alias("router", router)
16282            .build()
16283            .unwrap();
16284
16285        let content = tokio::time::timeout(std::time::Duration::from_secs(2), async {
16286            let mut stream = agent
16287                .chat_stream("AUTH-17 needs technical help.")
16288                .await
16289                .unwrap();
16290            let mut content = String::new();
16291            while let Some(chunk) = stream.next().await {
16292                match chunk {
16293                    StreamChunk::Content { text } => content.push_str(&text),
16294                    StreamChunk::Done {} => break,
16295                    StreamChunk::Error { message } => panic!("unexpected stream error: {message}"),
16296                    _ => {}
16297                }
16298            }
16299            content
16300        })
16301        .await
16302        .expect("redispatch must wait for the established stale stream to be dropped");
16303
16304        assert_eq!(content, "Committed technical response.");
16305        assert_eq!(agent.current_state().as_deref(), Some("technical"));
16306        assert!(stream_dropped.load(Ordering::SeqCst));
16307        assert!(committed_after_drop.load(Ordering::SeqCst));
16308    }
16309
16310    #[tokio::test]
16311    async fn test_buffered_streaming_transition_reservation_falls_back() {
16312        use futures::StreamExt;
16313
16314        let mock = mock_with_responses(vec![
16315            "Serial streaming response",
16316            "Serial streaming response",
16317        ]);
16318        let router_mock = mock_with_response("1");
16319        let router_counter = router_mock.clone();
16320        let yaml = r#"
16321name: BufferedReservationFallbackAgent
16322system_prompt: "Stream normally if speculative routing cannot be evaluated."
16323llm:
16324  default: default
16325  router: router
16326observability:
16327  enabled: true
16328  export:
16329    write_raw_events: true
16330streaming:
16331  enabled: true
16332  buffer_size: 8
16333runtime:
16334  optimization:
16335    enabled: true
16336    max_speculative_llm_calls_per_turn: 1
16337    speculative_state_transitions: true
16338    streaming_policy: buffer_until_routing_done
16339    max_parallel_runtime_tasks: 2
16340states:
16341  initial: triage
16342  states:
16343    triage:
16344      prompt: "Triage state."
16345      transitions:
16346        - to: billing
16347          guard:
16348            context:
16349              route:
16350                eq: billing
16351          when: "User asks about billing"
16352          timing: parallel
16353    billing:
16354      prompt: "Billing state."
16355"#;
16356        let agent = AgentBuilder::from_yaml(yaml)
16357            .unwrap()
16358            .llm_alias("default", Arc::new(mock))
16359            .llm_alias("router", Arc::new(router_mock))
16360            .build()
16361            .unwrap();
16362
16363        let mut stream = agent.chat_stream("hello").await.unwrap();
16364        let mut content = String::new();
16365        let mut error = None;
16366        while let Some(chunk) = stream.next().await {
16367            match chunk {
16368                StreamChunk::Content { text } => content.push_str(&text),
16369                StreamChunk::Error { message } => error = Some(message),
16370                StreamChunk::Done {} => break,
16371                _ => {}
16372            }
16373        }
16374
16375        assert_eq!(error, None);
16376        assert_eq!(content, "Serial streaming response");
16377        assert_eq!(router_counter.call_count(), 0);
16378        let events = agent.observability().unwrap().raw_events();
16379        assert!(events.iter().any(|event| {
16380            event.dimensions.get("branch_status") == Some(&"cancelled".to_string())
16381                && event.dimensions.get("commit_behavior")
16382                    == Some(&"transition_decision".to_string())
16383        }));
16384    }
16385
16386    #[tokio::test]
16387    async fn test_blocking_error_cleanup_resets_root_turn_for_next_chat() {
16388        let mut mock = mock_with_response("Recovered response");
16389        mock.set_error("boom");
16390        let mut handle = mock.clone();
16391        let agent = AgentBuilder::new()
16392            .system_prompt("You are helpful.")
16393            .llm(Arc::new(mock))
16394            .build()
16395            .unwrap();
16396
16397        assert!(agent.chat("first").await.is_err());
16398        handle.clear_error();
16399        let response = agent.chat("second").await.unwrap();
16400
16401        assert_eq!(response.content, "Recovered response");
16402        let messages = agent.memory.get_messages(None).await.unwrap();
16403        let user_count = messages
16404            .iter()
16405            .filter(|message| message.role == ai_agents_core::Role::User)
16406            .count();
16407        assert_eq!(user_count, 2);
16408    }
16409
16410    #[tokio::test]
16411    async fn test_streaming_error_cleanup_resets_root_turn_for_next_chat() {
16412        use futures::StreamExt;
16413
16414        let mut mock = mock_with_response("Recovered response");
16415        mock.set_error("stream boom");
16416        let mut handle = mock.clone();
16417        let agent = AgentBuilder::new()
16418            .system_prompt("You are helpful.")
16419            .llm(Arc::new(mock))
16420            .build()
16421            .unwrap();
16422
16423        let mut stream = agent.chat_stream("first").await.unwrap();
16424        let mut saw_error = false;
16425        while let Some(chunk) = stream.next().await {
16426            if matches!(chunk, StreamChunk::Error { .. }) {
16427                saw_error = true;
16428            }
16429        }
16430        assert!(saw_error);
16431
16432        handle.clear_error();
16433        let response = agent.chat("second").await.unwrap();
16434
16435        assert_eq!(response.content, "Recovered response");
16436        let messages = agent.memory.get_messages(None).await.unwrap();
16437        let user_count = messages
16438            .iter()
16439            .filter(|message| message.role == ai_agents_core::Role::User)
16440            .count();
16441        assert_eq!(user_count, 2);
16442    }
16443
16444    #[tokio::test]
16445    async fn test_buffered_streaming_route_miss_releases_buffer_limit() {
16446        use futures::StreamExt;
16447
16448        let mut mock = mock_with_response("one two three");
16449        mock.set_latency(10);
16450        let yaml = r#"
16451name: BufferedMissAgent
16452system_prompt: "You stream safely."
16453llm:
16454  default: default
16455streaming:
16456  enabled: true
16457  buffer_size: 1
16458runtime:
16459  optimization:
16460    enabled: true
16461    max_speculative_llm_calls_per_turn: 2
16462    speculative_state_transitions: true
16463    streaming_policy: buffer_until_routing_done
16464    max_parallel_runtime_tasks: 2
16465states:
16466  initial: triage
16467  states:
16468    triage:
16469      prompt: "Answer from triage."
16470      transitions:
16471        - to: billing
16472          guard:
16473            context:
16474              route:
16475                eq: billing
16476          timing: parallel
16477    billing:
16478      prompt: "Billing state."
16479"#;
16480        let agent = AgentBuilder::from_yaml(yaml)
16481            .unwrap()
16482            .llm_alias("default", Arc::new(mock))
16483            .build()
16484            .unwrap();
16485
16486        let mut stream = agent.chat_stream("hello").await.unwrap();
16487        let mut content = String::new();
16488        let mut error = None;
16489        while let Some(chunk) = stream.next().await {
16490            match chunk {
16491                StreamChunk::Content { text } => content.push_str(&text),
16492                StreamChunk::Error { message } => error = Some(message),
16493                StreamChunk::Done {} => break,
16494                _ => {}
16495            }
16496        }
16497
16498        assert_eq!(error, None);
16499        assert_eq!(content, "one two three");
16500    }
16501
16502    #[tokio::test]
16503    async fn test_buffered_streaming_main_failure_finalizes_branch() {
16504        use futures::StreamExt;
16505
16506        let mock = mock_with_response("one two");
16507        let mut router_mock = mock_with_response("0");
16508        router_mock.set_latency(50);
16509        let yaml = r#"
16510name: BufferedFailureAgent
16511system_prompt: "You stream safely."
16512llm:
16513  default: default
16514  router: router
16515observability:
16516  enabled: true
16517  export:
16518    write_raw_events: true
16519streaming:
16520  enabled: true
16521  buffer_size: 1
16522runtime:
16523  optimization:
16524    enabled: true
16525    max_speculative_llm_calls_per_turn: 2
16526    speculative_state_transitions: true
16527    streaming_policy: buffer_until_routing_done
16528    max_parallel_runtime_tasks: 2
16529states:
16530  initial: triage
16531  states:
16532    triage:
16533      prompt: "Ask for the category."
16534      transitions:
16535        - to: billing
16536          when: "User asks about billing"
16537          timing: parallel
16538    billing:
16539      prompt: "Billing state."
16540"#;
16541        let agent = AgentBuilder::from_yaml(yaml)
16542            .unwrap()
16543            .llm_alias("default", Arc::new(mock))
16544            .llm_alias("router", Arc::new(router_mock))
16545            .build()
16546            .unwrap();
16547
16548        let mut stream = agent.chat_stream("hello").await.unwrap();
16549        let mut error = String::new();
16550        while let Some(chunk) = stream.next().await {
16551            if let StreamChunk::Error { message } = chunk {
16552                error = message;
16553            }
16554        }
16555
16556        assert!(
16557            error.contains("stream buffer filled"),
16558            "unexpected stream error: {}",
16559            error
16560        );
16561        let events = agent.observability().unwrap().raw_events();
16562        assert!(events.iter().any(|event| {
16563            event.dimensions.get("branch_status") == Some(&"failed".to_string())
16564                && event.dimensions.get("commit_behavior") == Some(&"final_response".to_string())
16565                && event.dimensions.get("optimization")
16566                    == Some(&"buffered_streaming_routing".to_string())
16567        }));
16568    }
16569
16570    #[tokio::test]
16571    async fn test_streaming_preflight_does_not_emit_old_state_content() {
16572        use futures::StreamExt;
16573
16574        let mock = mock_with_response("Billing streamed response");
16575        let yaml = r#"
16576name: StreamingOptimizedAgent
16577system_prompt: "You route before streaming."
16578runtime:
16579  optimization:
16580    enabled: true
16581    pre_response_deterministic_transitions: true
16582streaming:
16583  enabled: true
16584states:
16585  initial: greeting
16586  states:
16587    greeting:
16588      prompt: "OLD_STATE_SENTINEL"
16589      transitions:
16590        - to: billing
16591          guard:
16592            context:
16593              topic:
16594                eq: billing
16595          timing: pre_response
16596    billing:
16597      prompt: "Billing state."
16598"#;
16599        let agent = AgentBuilder::from_yaml(yaml)
16600            .unwrap()
16601            .llm(Arc::new(mock))
16602            .build()
16603            .unwrap();
16604        agent
16605            .set_context("topic", serde_json::json!("billing"))
16606            .unwrap();
16607
16608        let mut stream = agent.chat_stream("billing please").await.unwrap();
16609        let mut content = String::new();
16610        while let Some(chunk) = stream.next().await {
16611            match chunk {
16612                StreamChunk::Content { text } => content.push_str(&text),
16613                StreamChunk::Error { message } => panic!("stream error: {}", message),
16614                StreamChunk::Done {} => break,
16615                _ => {}
16616            }
16617        }
16618
16619        assert_eq!(agent.current_state().as_deref(), Some("billing"));
16620        assert!(content.contains("Billing streamed response"));
16621        assert!(!content.contains("OLD_STATE_SENTINEL"));
16622    }
16623
16624    // State machine transitions
16625    #[tokio::test]
16626    async fn test_integration_state_machine_basic() {
16627        let yaml = r#"
16628name: StateAgent
16629system_prompt: "You are a support agent."
16630states:
16631  initial: greeting
16632  states:
16633    greeting:
16634      prompt: "Welcome the user warmly."
16635      transitions:
16636        - to: support
16637          when: "User needs help"
16638          auto: true
16639    support:
16640      prompt: "Help solve the user's problem."
16641"#;
16642        let mock = mock_with_responses(vec![
16643            "Welcome! How can I help?", // greeting response
16644            "1",                        // transition evaluator picks first (index 0)
16645            "I'll help you with that.", // support response
16646        ]);
16647        let builder = AgentBuilder::from_yaml(yaml).unwrap();
16648        let agent = builder.llm(Arc::new(mock)).build().unwrap();
16649
16650        assert_eq!(agent.current_state(), Some("greeting".to_string()));
16651        let _ = agent.chat("I need help").await.unwrap();
16652        // After transition evaluation, state may or may not have changed
16653        // depending on mock evaluator response - the key is that it doesn't crash
16654    }
16655
16656    // State on_enter/on_exit actions
16657    #[tokio::test]
16658    async fn test_integration_state_on_enter_set_context() {
16659        let yaml = r#"
16660name: ActionAgent
16661system_prompt: "You are helpful."
16662states:
16663  initial: step1
16664  states:
16665    step1:
16666      prompt: "Step 1"
16667      on_exit:
16668        - set_context:
16669            step1_exited: true
16670      transitions:
16671        - to: step2
16672          when: "always"
16673          auto: true
16674    step2:
16675      prompt: "Step 2"
16676      on_enter:
16677        - set_context:
16678            step2_entered: true
16679"#;
16680        // The transition evaluator will pick the first transition (index 0)
16681        let mock = mock_with_responses(vec![
16682            "Processing step 1.",
16683            "0", // transition evaluator response: select first transition
16684        ]);
16685        let builder = AgentBuilder::from_yaml(yaml).unwrap();
16686        let agent = builder.llm(Arc::new(mock)).build().unwrap();
16687
16688        assert_eq!(agent.current_state(), Some("step1".to_string()));
16689
16690        // Manually transition to test on_enter/on_exit
16691        agent.transition_to("step2").await.unwrap();
16692
16693        assert_eq!(agent.current_state(), Some("step2".to_string()));
16694
16695        // Verify context was set by on_exit and on_enter actions
16696        let ctx = agent.get_context();
16697        assert_eq!(ctx.get("step1_exited"), Some(&serde_json::json!(true)));
16698        assert_eq!(ctx.get("step2_entered"), Some(&serde_json::json!(true)));
16699    }
16700
16701    #[tokio::test]
16702    async fn state_action_tool_preserves_source_in_stored_record() {
16703        let yaml = r#"
16704name: StateActionToolAgent
16705system_prompt: "You are helpful."
16706tools:
16707  - context_echo
16708states:
16709  initial: idle
16710  states:
16711    idle:
16712      prompt: "Idle"
16713    active:
16714      prompt: "Active"
16715      on_enter:
16716        - set_context:
16717            action_started: true
16718        - tool: context_echo
16719          args: {}
16720"#;
16721        let agent = AgentBuilder::from_yaml(yaml)
16722            .unwrap()
16723            .llm(Arc::new(mock_with_response("unused")))
16724            .tool(Arc::new(ContextEchoTool))
16725            .build()
16726            .unwrap();
16727
16728        agent.transition_to("active").await.unwrap();
16729
16730        let record: ToolExecutionRecord = serde_json::from_value(
16731            agent
16732                .get_context()
16733                .get("last_tool_record")
16734                .cloned()
16735                .expect("successful state action must store its execution record"),
16736        )
16737        .unwrap();
16738        assert!(record.executed);
16739        assert!(record.success);
16740        assert_eq!(record.canonical_id, "context_echo");
16741        assert!(matches!(
16742            &record.source,
16743            ToolCallSource::StateAction {
16744                state: Some(state),
16745                action_index: 1,
16746            } if state == "active"
16747        ));
16748    }
16749
16750    #[tokio::test]
16751    async fn test_ordinary_transition_uses_on_enter_then_on_reenter() {
16752        let yaml = r#"
16753name: OrdinaryLifecycleAgent
16754system_prompt: "You are helpful."
16755states:
16756  initial: intake
16757  regenerate_on_transition: false
16758  states:
16759    intake:
16760      prompt: "Intake"
16761      transitions:
16762        - to: drafting
16763          guard:
16764            context:
16765              route:
16766                eq: drafting
16767    drafting:
16768      prompt: "Drafting"
16769      on_enter:
16770        - set_context:
16771            draft_version: 1
16772      on_reenter:
16773        - set_context:
16774            draft_version: 2
16775      transitions:
16776        - to: review
16777          guard:
16778            context:
16779              route:
16780                eq: review
16781    review:
16782      prompt: "Review"
16783      on_enter:
16784        - set_context:
16785            review_entry: first
16786      transitions:
16787        - to: drafting
16788          guard:
16789            context:
16790              route:
16791                eq: drafting
16792"#;
16793        let agent = AgentBuilder::from_yaml(yaml)
16794            .unwrap()
16795            .llm(Arc::new(mock_with_responses(vec![
16796                "Intake response",
16797                "Draft response",
16798                "Review response",
16799            ])))
16800            .build()
16801            .unwrap();
16802
16803        agent
16804            .set_context("route", serde_json::json!("drafting"))
16805            .unwrap();
16806        agent.chat("Start a draft").await.unwrap();
16807        assert_eq!(agent.current_state().as_deref(), Some("drafting"));
16808        assert_eq!(
16809            agent.get_context().get("draft_version"),
16810            Some(&serde_json::json!(1))
16811        );
16812
16813        agent
16814            .set_context("route", serde_json::json!("review"))
16815            .unwrap();
16816        agent.chat("Review this").await.unwrap();
16817        assert_eq!(agent.current_state().as_deref(), Some("review"));
16818        assert_eq!(
16819            agent.get_context().get("review_entry"),
16820            Some(&serde_json::json!("first"))
16821        );
16822
16823        agent
16824            .set_context("route", serde_json::json!("drafting"))
16825            .unwrap();
16826        agent.chat("Revise this").await.unwrap();
16827        assert_eq!(agent.current_state().as_deref(), Some("drafting"));
16828        assert_eq!(
16829            agent.get_context().get("draft_version"),
16830            Some(&serde_json::json!(2))
16831        );
16832    }
16833
16834    #[tokio::test]
16835    async fn test_manual_transition_uses_on_enter_then_on_reenter() {
16836        let yaml = r#"
16837name: ManualLifecycleAgent
16838system_prompt: "You are helpful."
16839states:
16840  initial: intake
16841  states:
16842    intake:
16843      prompt: "Intake"
16844    drafting:
16845      prompt: "Drafting"
16846      on_enter:
16847        - set_context:
16848            draft_version: 1
16849      on_reenter:
16850        - set_context:
16851            draft_version: 2
16852    review:
16853      prompt: "Review"
16854"#;
16855        let agent = AgentBuilder::from_yaml(yaml)
16856            .unwrap()
16857            .llm(Arc::new(mock_with_response("unused")))
16858            .build()
16859            .unwrap();
16860
16861        assert!(!agent.get_context().contains_key("draft_version"));
16862        agent.transition_to("drafting").await.unwrap();
16863        assert_eq!(agent.current_state().as_deref(), Some("drafting"));
16864        assert_eq!(
16865            agent.get_context().get("draft_version"),
16866            Some(&serde_json::json!(1))
16867        );
16868
16869        agent.transition_to("review").await.unwrap();
16870        agent.transition_to("drafting").await.unwrap();
16871        assert_eq!(agent.current_state().as_deref(), Some("drafting"));
16872        assert_eq!(
16873            agent.get_context().get("draft_version"),
16874            Some(&serde_json::json!(2))
16875        );
16876    }
16877
16878    #[tokio::test]
16879    async fn test_timeout_transition_uses_on_enter_then_on_reenter() {
16880        let yaml = r#"
16881name: TimeoutLifecycleAgent
16882system_prompt: "You are helpful."
16883states:
16884  initial: intake
16885  regenerate_on_transition: false
16886  states:
16887    intake:
16888      prompt: "Intake"
16889      max_turns: 1
16890      timeout_to: drafting
16891    drafting:
16892      prompt: "Drafting"
16893      max_turns: 1
16894      timeout_to: review
16895      on_enter:
16896        - set_context:
16897            draft_version: 1
16898      on_reenter:
16899        - set_context:
16900            draft_version: 2
16901    review:
16902      prompt: "Review"
16903      max_turns: 1
16904      timeout_to: drafting
16905      on_enter:
16906        - set_context:
16907            review_entry: first
16908"#;
16909        let agent = AgentBuilder::from_yaml(yaml)
16910            .unwrap()
16911            .llm(Arc::new(mock_with_responses(vec![
16912                "Intake",
16913                "First draft",
16914                "Review",
16915                "Revised draft",
16916            ])))
16917            .build()
16918            .unwrap();
16919
16920        agent.chat("First turn").await.unwrap();
16921        assert_eq!(agent.current_state().as_deref(), Some("intake"));
16922        assert!(!agent.get_context().contains_key("draft_version"));
16923
16924        agent.chat("Second turn").await.unwrap();
16925        assert_eq!(agent.current_state().as_deref(), Some("drafting"));
16926        assert_eq!(
16927            agent.get_context().get("draft_version"),
16928            Some(&serde_json::json!(1))
16929        );
16930
16931        agent.chat("Third turn").await.unwrap();
16932        assert_eq!(agent.current_state().as_deref(), Some("review"));
16933        assert_eq!(
16934            agent.get_context().get("review_entry"),
16935            Some(&serde_json::json!("first"))
16936        );
16937
16938        agent.chat("Fourth turn").await.unwrap();
16939        assert_eq!(agent.current_state().as_deref(), Some("drafting"));
16940        assert_eq!(
16941            agent.get_context().get("draft_version"),
16942            Some(&serde_json::json!(2))
16943        );
16944    }
16945
16946    // Process pipeline transforms input
16947    #[tokio::test]
16948    async fn test_integration_process_normalize() {
16949        let yaml = r#"
16950name: ProcessAgent
16951system_prompt: "You are helpful."
16952process:
16953  input:
16954    - type: normalize
16955      config:
16956        trim: true
16957        collapse_whitespace: true
16958"#;
16959        let mock = mock_with_response("Got your message.");
16960        let builder = AgentBuilder::from_yaml(yaml).unwrap();
16961        let agent = builder.llm(Arc::new(mock.clone())).build().unwrap();
16962
16963        let _ = agent.chat("  hello   world  ").await.unwrap();
16964
16965        // Verify the LLM received the normalized input (trimmed + collapsed whitespace)
16966        let history = mock.call_history();
16967        assert!(!history.is_empty());
16968        // The user message in LLM call should be normalized
16969        let last_call = history.last().unwrap();
16970        let user_msg = last_call
16971            .messages
16972            .iter()
16973            .find(|m| m.role == ai_agents_core::Role::User)
16974            .unwrap();
16975        assert_eq!(user_msg.content, "hello world");
16976    }
16977
16978    // ═══════════════════════════════════════════════════════════
16979    // Integration Test 2.1.7: Memory compression triggers
16980    // ═══════════════════════════════════════════════════════════
16981    #[tokio::test]
16982    async fn test_integration_memory_compression() {
16983        let yaml = r#"
16984name: MemoryAgent
16985system_prompt: "You are helpful."
16986memory:
16987  type: compacting
16988  max_messages: 100
16989  compress_threshold: 5
16990  max_recent_messages: 3
16991  summarize_batch_size: 2
16992"#;
16993        // Provide enough responses for compression to trigger
16994        let responses: Vec<&str> = (0..8).map(|_| "Response from assistant.").collect();
16995        let mock = mock_with_responses(responses);
16996        let builder = AgentBuilder::from_yaml(yaml).unwrap();
16997        let agent = builder.llm(Arc::new(mock)).build().unwrap();
16998
16999        // Send enough messages to trigger compression
17000        for i in 0..6 {
17001            let _ = agent.chat(&format!("Message {}", i)).await.unwrap();
17002        }
17003
17004        // Memory should have compressed - verify it didn't crash
17005        // and that messages are bounded
17006        let messages = agent.memory.get_messages(None).await.unwrap();
17007        // With compress_threshold=5 and max_recent_messages=3,
17008        // after 6 turns (12 messages), compression should have run
17009        assert!(messages.len() <= 12); // At most all messages if no compression, fewer if compressed
17010    }
17011
17012    // YAML with multiple LLMs
17013    #[tokio::test]
17014    async fn test_integration_multi_llm_registry() {
17015        let mut mock_default = MockLLMProvider::new("default");
17016        mock_default.set_response("Default LLM response.");
17017        let mut mock_router = MockLLMProvider::new("router");
17018        mock_router.set_response("Router response.");
17019
17020        let agent = AgentBuilder::new()
17021            .system_prompt("You are helpful.")
17022            .llm_alias("default", Arc::new(mock_default))
17023            .llm_alias("router", Arc::new(mock_router))
17024            .build()
17025            .unwrap();
17026
17027        let response = agent.chat("Hello").await.unwrap();
17028        assert_eq!(response.content, "Default LLM response.");
17029    }
17030
17031    // Agent reset clears state
17032    #[tokio::test]
17033    async fn test_integration_agent_reset() {
17034        let mock = mock_with_responses(vec!["Hello!", "Hello again!"]);
17035        let agent = AgentBuilder::new()
17036            .system_prompt("You are helpful.")
17037            .llm(Arc::new(mock))
17038            .build()
17039            .unwrap();
17040
17041        let _ = agent.chat("Hi").await.unwrap();
17042        let messages = agent.memory.get_messages(None).await.unwrap();
17043        assert_eq!(messages.len(), 2); // user + assistant
17044
17045        agent.reset().await.unwrap();
17046        let messages = agent.memory.get_messages(None).await.unwrap();
17047        assert_eq!(messages.len(), 0);
17048    }
17049
17050    // Process pipeline rejects input
17051    #[tokio::test]
17052    async fn test_integration_process_validate_reject() {
17053        use ai_agents_process::{ProcessConfig, ProcessProcessor};
17054
17055        let validate_config = ai_agents_process::ValidateStage {
17056            id: Some("length_check".to_string()),
17057            condition: None,
17058            config: ai_agents_process::ValidateConfig {
17059                rules: vec![ai_agents_process::ValidationRule::MinLength {
17060                    min_length: 10,
17061                    on_fail: ai_agents_process::ValidationAction {
17062                        action: ai_agents_process::ValidationActionType::Reject,
17063                        message: None,
17064                    },
17065                }],
17066                ..Default::default()
17067            },
17068        };
17069        let process_config = ProcessConfig {
17070            input: vec![ai_agents_process::ProcessStage::Validate(validate_config)],
17071            ..Default::default()
17072        };
17073        let processor = ProcessProcessor::new(process_config);
17074
17075        let mock = mock_with_response("Should not reach here.");
17076        let agent = AgentBuilder::new()
17077            .system_prompt("You are helpful.")
17078            .llm(Arc::new(mock))
17079            .process_processor(processor)
17080            .build()
17081            .unwrap();
17082
17083        let response = agent.chat("Hi").await.unwrap();
17084        // Rejected input should produce a rejection response, not call LLM
17085        assert!(
17086            response.content.contains("rejected")
17087                || response.content.contains("Input rejected")
17088                || response.content.contains("too short")
17089                || response.content.contains("Too short")
17090                || response.content.len() < 50, // rejection message is typically short
17091            "Expected rejection response, got: {}",
17092            response.content
17093        );
17094    }
17095
17096    // LLM fallback: primary fails, fallback LLM responds
17097    #[tokio::test]
17098    async fn test_llm_fallback_on_failure() {
17099        use ai_agents_recovery::{ErrorRecoveryConfig, LLMFailureAction, LLMRecoveryConfig};
17100
17101        let mut primary = MockLLMProvider::new("primary");
17102        primary.set_error("Primary LLM is unavailable");
17103
17104        let mut fallback = MockLLMProvider::new("fallback");
17105        fallback.set_response("Fallback response works!");
17106
17107        let agent = AgentBuilder::new()
17108            .system_prompt("You are helpful.")
17109            .llm_alias("default", Arc::new(primary))
17110            .llm_alias("backup", Arc::new(fallback))
17111            .recovery_manager(RecoveryManager::new(ErrorRecoveryConfig {
17112                llm: LLMRecoveryConfig {
17113                    on_failure: LLMFailureAction::FallbackLlm {
17114                        fallback_llm: "backup".to_string(),
17115                    },
17116                    ..Default::default()
17117                },
17118                ..Default::default()
17119            }))
17120            .build()
17121            .unwrap();
17122
17123        let response = agent.chat("Hello").await.unwrap();
17124        assert!(
17125            response.content.contains("Fallback response"),
17126            "Expected fallback response, got: {}",
17127            response.content
17128        );
17129    }
17130
17131    // LLM fallback: primary fails, static message returned
17132    #[tokio::test]
17133    async fn test_llm_fallback_response_static_message() {
17134        use ai_agents_recovery::{ErrorRecoveryConfig, LLMFailureAction, LLMRecoveryConfig};
17135
17136        let mut primary = MockLLMProvider::new("primary");
17137        primary.set_error("Primary LLM is unavailable");
17138
17139        let agent = AgentBuilder::new()
17140            .system_prompt("You are helpful.")
17141            .llm(Arc::new(primary))
17142            .recovery_manager(RecoveryManager::new(ErrorRecoveryConfig {
17143                llm: LLMRecoveryConfig {
17144                    on_failure: LLMFailureAction::FallbackResponse {
17145                        message: "I am temporarily unavailable. Please try again later."
17146                            .to_string(),
17147                    },
17148                    ..Default::default()
17149                },
17150                ..Default::default()
17151            }))
17152            .build()
17153            .unwrap();
17154
17155        let response = agent.chat("Hello").await.unwrap();
17156        assert!(
17157            response.content.contains("temporarily unavailable"),
17158            "Expected static fallback message, got: {}",
17159            response.content
17160        );
17161    }
17162
17163    // Tool skip: tool fails, on_failure: skip absorbs the error
17164    #[tokio::test]
17165    async fn test_tool_failure_skip() {
17166        use ai_agents_recovery::{
17167            ErrorRecoveryConfig, ToolFailureAction, ToolRecoveryConfig, ToolRetryConfig,
17168        };
17169
17170        // LLM requests a nonexistent tool, then responds after seeing the skip result
17171        let mock = mock_with_responses(vec![
17172            r#"I'll use the nonexistent tool.
17173[TOOL_CALL: {"name": "nonexistent_tool", "arguments": {}}]"#,
17174            "The tool was unavailable, but I can still help you.",
17175        ]);
17176
17177        let agent = AgentBuilder::new()
17178            .system_prompt("You are helpful.")
17179            .llm(Arc::new(mock))
17180            .recovery_manager(RecoveryManager::new(ErrorRecoveryConfig {
17181                tools: ToolRecoveryConfig {
17182                    default: ToolRetryConfig {
17183                        max_retries: 0,
17184                        timeout_ms: None,
17185                        on_failure: ToolFailureAction::Skip,
17186                    },
17187                    ..Default::default()
17188                },
17189                ..Default::default()
17190            }))
17191            .build()
17192            .unwrap();
17193
17194        // The tool will fail (not found), but on_failure: skip absorbs the error
17195        let response = agent.chat("Use the nonexistent tool").await;
17196        assert!(
17197            response.is_ok(),
17198            "Expected Ok with skip policy, got: {:?}",
17199            response
17200        );
17201    }
17202}