Skip to main content

zeph_core/agent/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4mod acp_commands;
5mod agent_access_impl;
6pub(crate) mod agent_supervisor;
7mod autodream;
8mod autonomous_turn;
9mod builder;
10pub use builder::{SecurityWiringSnapshot, SkillConfigParams};
11#[cfg(feature = "cocoon")]
12mod cocoon_cmd;
13mod command_context_impls;
14mod command_macros;
15pub(super) mod compression_feedback;
16mod config_reload;
17mod context;
18mod context_impls;
19pub(crate) mod context_manager;
20mod corrections;
21mod durable_bootstrap;
22pub use durable_bootstrap::DurableKeyMaterial;
23pub mod error;
24mod experiment_cmd;
25pub(crate) mod focus;
26mod graph_commands;
27mod heuristic_promotion;
28mod hooks_dispatch;
29mod index;
30mod learning;
31pub(crate) mod learning_engine;
32mod log_commands;
33mod loop_event;
34mod lsp_commands;
35mod magic_docs;
36mod mcp;
37pub(crate) mod memcot;
38mod memory_commands;
39mod message_queue;
40mod microcompact;
41mod misc_commands;
42mod model_commands;
43mod orchestration_commands;
44mod persistence;
45#[cfg(feature = "scheduler")]
46mod plan;
47mod policy_commands;
48mod provider_cmd;
49mod quality_hook;
50pub(crate) mod rate_limiter;
51mod scheduler_commands;
52#[cfg(feature = "scheduler")]
53mod scheduler_loop;
54mod scope_commands;
55pub mod session_config;
56mod session_digest;
57pub mod shadow_sentinel;
58mod shutdown;
59pub(crate) mod sidequest;
60mod skill_commands;
61mod skill_management;
62mod skill_reload;
63pub mod slash_commands;
64pub mod speculative;
65pub(crate) mod state;
66mod subagent_commands;
67pub(crate) mod task_injection;
68pub(crate) mod tool_execution;
69pub(crate) mod tool_orchestrator;
70mod trace_extraction;
71pub mod trajectory;
72mod trajectory_commands;
73mod trust_commands;
74pub mod turn;
75mod utils;
76pub(crate) mod vigil;
77mod worktree_commands;
78
79use std::collections::VecDeque;
80use std::fmt::Write as _;
81use std::sync::Arc;
82
83use parking_lot::RwLock;
84
85use tokio::sync::{mpsc, watch};
86use tokio_util::sync::CancellationToken;
87use zeph_llm::any::AnyProvider;
88use zeph_llm::provider::{LlmProvider, Message, MessageMetadata, Role};
89use zeph_memory::TokenCounter;
90use zeph_memory::semantic::SemanticMemory;
91use zeph_skills::loader::Skill;
92use zeph_skills::matcher::SkillMatcherBackend;
93use zeph_skills::prompt::format_skills_catalog;
94use zeph_skills::registry::SkillRegistry;
95use zeph_tools::executor::{ErasedToolExecutor, ToolExecutor};
96
97use tracing::Instrument as _;
98
99use crate::channel::Channel;
100use crate::config::Config;
101use crate::context::build_system_prompt;
102use zeph_common::text::estimate_tokens;
103
104use loop_event::LoopEvent;
105use message_queue::{MAX_AUDIO_BYTES, MAX_IMAGE_BYTES, detect_image_mime};
106use state::MessageState;
107
108pub(crate) const DOOM_LOOP_WINDOW: usize = 3;
109/// Circuit breaker for the utility gate's `Retrieve` action (#5774): once this many
110/// "you MUST call it again" mandates have been issued in a single turn, the gate stops
111/// demanding further retrieval detours and lets the requested tool call proceed directly.
112pub(crate) const MAX_RETRIEVE_MANDATES_PER_TURN: usize = 3;
113// CODE_CONTEXT_PREFIX is re-exported from zeph-agent-context::helpers so callers inside
114// zeph-core that build system-prompt injections can use it without depending on zeph-agent-context
115// directly. SESSION_DIGEST_PREFIX was removed when assembly migrated to ContextService.
116pub(crate) use zeph_agent_context::helpers::CODE_CONTEXT_PREFIX;
117pub(crate) const SCHEDULED_TASK_PREFIX: &str = "Execute the following scheduled task now: ";
118pub(crate) const TOOL_OUTPUT_SUFFIX: &str = "\n```";
119
120pub(crate) fn format_tool_output(tool_name: &str, body: &str) -> String {
121    use std::fmt::Write;
122    let capacity = "[tool output: ".len()
123        + tool_name.len()
124        + "]\n```\n".len()
125        + body.len()
126        + TOOL_OUTPUT_SUFFIX.len();
127    let mut buf = String::with_capacity(capacity);
128    let _ = write!(
129        buf,
130        "[tool output: {tool_name}]\n```\n{body}{TOOL_OUTPUT_SUFFIX}"
131    );
132    buf
133}
134
135/// Zeph agent: autonomous AI system with multi-model inference, semantic memory, skills,
136/// tool orchestration, and multi-channel I/O.
137///
138/// The agent maintains conversation history, manages LLM provider state, coordinates tool
139/// execution, and orchestrates memory and skill subsystems. It communicates with the outside
140/// world via the [`Channel`] trait, enabling support for CLI, Telegram, TUI, or custom I/O.
141///
142/// # Architecture
143///
144/// - **Message state**: Conversation history with system prompt, message queue, and metadata
145/// - **Memory state**: `SQLite` + Qdrant vector store for semantic search and compaction
146/// - **Skill state**: Registry, matching engine, and self-learning evolution
147/// - **Context manager**: Token budgeting, context assembly, and summarization
148/// - **Tool orchestrator**: DAG-based multi-tool execution with streaming output
149/// - **MCP client**: Multi-server support for Model Context Protocol
150/// - **Index state**: AST-based code indexing and semantic retrieval
151/// - **Security**: Sanitization, exfiltration detection, adversarial probes
152/// - **Metrics**: Token usage, latency, cost, and anomaly tracking
153///
154/// # Channel Contract
155///
156/// The agent requires a [`Channel`] implementation for user interaction:
157/// - Sends agent responses via `channel.send(message)`
158/// - Receives user input via `channel.recv()` / `channel.recv_internal()`
159/// - Supports structured events: tool invocations, tool output, streaming updates
160///
161/// # Lifecycle
162///
163/// 1. Create with [`Self::new`] or [`Self::new_with_registry_arc`]
164/// 2. Run main loop with [`Self::run`]
165/// 3. Clean up with [`Self::shutdown`] to persist state and close resources
166///
167pub struct Agent<C: Channel> {
168    // --- I/O & primary providers (kept inline) ---
169    provider: AnyProvider,
170    /// Dedicated embedding provider. Resolved once at bootstrap from `[[llm.providers]]`
171    /// (the entry with `embed = true`, or first entry with `embedding_model` set).
172    /// Falls back to `provider.clone()` when no dedicated entry exists.
173    /// **Never replaced** by `/provider switch`.
174    embedding_provider: AnyProvider,
175    channel: C,
176    pub(crate) tool_executor: Arc<dyn ErasedToolExecutor>,
177
178    // --- Conversation core (kept inline) ---
179    pub(super) msg: MessageState,
180    pub(super) context_manager: context_manager::ContextManager,
181    pub(super) tool_orchestrator: tool_orchestrator::ToolOrchestrator,
182
183    // --- Aggregated background services ---
184    pub(super) services: state::Services,
185
186    // --- Aggregated runtime / lifecycle / telemetry ---
187    pub(super) runtime: state::AgentRuntime,
188}
189
190/// Control flow signal returned by [`Agent::apply_dispatch_result`].
191enum DispatchFlow {
192    /// The command requested exit; the agent loop should `break`.
193    Break,
194    /// The command was handled; the agent loop should `continue`.
195    Continue,
196    /// The command was not recognised; the agent loop should fall through.
197    Fallthrough,
198}
199
200/// Build the `turn_complete` hook environment from a turn summary.
201///
202/// Mirrors the fields exposed on [`crate::notifications::TurnSummary`]: duration, status,
203/// redacted preview, LLM round-trip count, and tool-call count. Extracted as a pure function
204/// so the env var set can be asserted without driving a full agent turn.
205fn build_turn_hook_env(
206    summary: &crate::notifications::TurnSummary,
207    is_error: bool,
208) -> std::collections::HashMap<String, String> {
209    let mut env = std::collections::HashMap::new();
210    env.insert(
211        "ZEPH_TURN_DURATION_MS".to_owned(),
212        summary.duration_ms.to_string(),
213    );
214    env.insert(
215        "ZEPH_TURN_STATUS".to_owned(),
216        if is_error { "error" } else { "success" }.to_owned(),
217    );
218    env.insert("ZEPH_TURN_PREVIEW".to_owned(), summary.preview.clone());
219    env.insert(
220        "ZEPH_TURN_LLM_REQUESTS".to_owned(),
221        summary.llm_requests.to_string(),
222    );
223    env.insert(
224        "ZEPH_TURN_TOOL_CALLS".to_owned(),
225        summary.tool_calls.to_string(),
226    );
227    env
228}
229
230impl<C: Channel> Agent<C> {
231    /// Create a new agent instance with the given LLM provider, I/O channel, and subsystems.
232    ///
233    /// # Arguments
234    ///
235    /// * `provider` — Multi-model LLM provider (Claude, `OpenAI`, Ollama, Candle)
236    /// * `channel` — I/O abstraction for user interaction (CLI, Telegram, TUI, etc.)
237    /// * `registry` — Skill registry; moved into an internal `Arc<RwLock<_>>` for sharing
238    /// * `matcher` — Optional semantic skill matcher (e.g., Qdrant, BM25). If `None`,
239    ///   skills are matched by exact name only
240    /// * `max_active_skills` — Max concurrent skills in execution (must be > 0)
241    /// * `tool_executor` — Trait object for executing shell, web, and custom tools
242    ///
243    /// # Initialization
244    ///
245    /// The constructor:
246    /// 1. Wraps the skill registry into `Arc<RwLock<_>>` internally
247    /// 2. Builds the system prompt from registered skills
248    /// 3. Initializes all subsystems (memory, context manager, metrics, security)
249    /// 4. Returns a ready-to-run agent
250    ///
251    /// # Panics
252    ///
253    /// Panics if `max_active_skills` is 0.
254    #[must_use]
255    pub fn new(
256        provider: AnyProvider,
257        channel: C,
258        registry: SkillRegistry,
259        matcher: Option<SkillMatcherBackend>,
260        max_active_skills: usize,
261        tool_executor: impl ToolExecutor + 'static,
262    ) -> Self {
263        let registry = Arc::new(RwLock::new(registry));
264        let embedding_provider = provider.clone();
265        Self::new_with_registry_arc(
266            provider,
267            embedding_provider,
268            channel,
269            registry,
270            matcher,
271            max_active_skills,
272            tool_executor,
273        )
274    }
275
276    /// Create an agent from a pre-wrapped registry Arc, allowing the caller to
277    /// share the same Arc with other components (e.g. [`crate::SkillLoaderExecutor`]).
278    ///
279    /// # Panics
280    ///
281    /// Panics if the registry `RwLock` is poisoned.
282    #[must_use]
283    pub fn new_with_registry_arc(
284        provider: AnyProvider,
285        embedding_provider: AnyProvider,
286        channel: C,
287        registry: Arc<RwLock<SkillRegistry>>,
288        matcher: Option<SkillMatcherBackend>,
289        max_active_skills: usize,
290        tool_executor: impl ToolExecutor + 'static,
291    ) -> Self {
292        use state::{
293            AgentRuntime, CompressionState, DebugState, ExperimentState, FeedbackState, IndexState,
294            InstructionState, LifecycleState, McpState, MemoryState, MetricsState,
295            OrchestrationState, ProviderState, RuntimeConfig, SecurityState, Services,
296            SessionState, SkillState, ToolState,
297        };
298
299        debug_assert!(max_active_skills > 0, "max_active_skills must be > 0");
300        // Catalog-only listing (name + description) — full skill bodies are injected
301        // exclusively by the per-turn `rebuild_system_prompt(query)` matcher on turn 1.
302        // Building the initial prompt from metadata alone avoids a synchronous full-body
303        // disk read of every SKILL.md at construction time (#6413).
304        let catalog_skills: Vec<Skill> = {
305            let reg = registry.read();
306            reg.all_meta()
307                .into_iter()
308                .map(|m| Skill {
309                    meta: m.clone(),
310                    body: String::new(),
311                    resources: zeph_skills::resource::SkillResources::default(),
312                })
313                .collect()
314        };
315        // No trust data has been loaded from the store yet at construction time — every skill
316        // resolves to `SkillTrustLevel::MISSING_ENTRY_FALLBACK` (Trusted), so no catalog entry
317        // gets a trust attribute here; the first turn's `reload_skills`/`apply_skill_trust_and_gating`
318        // refreshes this with real data.
319        let skills_prompt =
320            format_skills_catalog(&catalog_skills, &std::collections::HashMap::new());
321        let system_prompt = build_system_prompt(&skills_prompt, None);
322        tracing::debug!(len = system_prompt.len(), "initial system prompt built");
323        tracing::trace!(prompt = %system_prompt, "full system prompt");
324
325        let initial_prompt_tokens = estimate_tokens(&system_prompt) as u64;
326        let token_counter = Arc::new(TokenCounter::new());
327
328        let services = Services {
329            memory: MemoryState::default(),
330            skill: SkillState::new(registry, matcher, max_active_skills, skills_prompt),
331            learning_engine: learning_engine::LearningEngine::new(),
332            feedback: FeedbackState::default(),
333            mcp: McpState::default(),
334            index: IndexState::default(),
335            session: SessionState::new(),
336            security: SecurityState::default(),
337            experiments: ExperimentState::new(),
338            compression: CompressionState::default(),
339            orchestration: OrchestrationState::default(),
340            focus: focus::FocusState::default(),
341            sidequest: sidequest::SidequestState::default(),
342            tool_state: ToolState::default(),
343            goal_accounting: None,
344            quality: None,
345            proactive_explorer: None,
346            promotion_engine: None,
347            taco_compressor: None,
348            speculation_engine: None,
349            autonomous: crate::goal::AutonomousDriver::new(tokio::time::Duration::from_millis(500)),
350            autonomous_registry: crate::goal::AutonomousRegistry::new(),
351        };
352
353        let runtime = AgentRuntime {
354            config: RuntimeConfig::default(),
355            lifecycle: LifecycleState::new(),
356            providers: ProviderState::new(initial_prompt_tokens),
357            metrics: MetricsState::new(token_counter),
358            debug: DebugState::default(),
359            instructions: InstructionState::default(),
360            ephemeral_plugins: Vec::new(),
361        };
362
363        Self {
364            provider,
365            embedding_provider,
366            channel,
367            tool_executor: Arc::new(tool_executor),
368            msg: MessageState {
369                messages: vec![Message {
370                    role: Role::System,
371                    content: system_prompt,
372                    parts: vec![],
373                    metadata: MessageMetadata::default(),
374                }],
375                message_queue: VecDeque::new(),
376                pending_image_parts: Vec::new(),
377                last_persisted_message_id: None,
378                deferred_db_hide_ids: Vec::new(),
379                deferred_db_summaries: Vec::new(),
380                deferred_db_trust_levels: Vec::new(),
381                history_preloaded: false,
382                history_cursor: 0,
383                non_system_count: 0,
384            },
385            context_manager: context_manager::ContextManager::new(),
386            tool_orchestrator: tool_orchestrator::ToolOrchestrator::new(),
387            services,
388            runtime,
389        }
390    }
391
392    /// Consume the agent and return the inner channel.
393    ///
394    /// Call this after [`run`][Agent::run] completes to retrieve the I/O channel (e.g., to
395    /// read captured responses from a headless channel such as `BenchmarkChannel`).
396    ///
397    /// # Examples
398    ///
399    /// ```no_run
400    /// # use zeph_core::agent::Agent;
401    /// // After agent.run().await completes, consume the agent to retrieve the channel.
402    /// // let channel: MyChannel = agent.into_channel();
403    /// ```
404    #[must_use]
405    pub fn into_channel(self) -> C {
406        self.channel
407    }
408
409    /// Run the agent main loop.
410    ///
411    /// # Errors
412    ///
413    /// Returns an error if the channel, LLM provider, or tool execution encounters a fatal error.
414    #[tracing::instrument(name = "core.agent.run", skip_all, level = "debug", err)]
415    #[allow(clippy::too_many_lines)] // run loop is inherently large; each branch is independent
416    pub async fn run(&mut self) -> Result<(), error::AgentError>
417    where
418        C: 'static,
419    {
420        if let Some(mut rx) = self.runtime.lifecycle.warmup_ready.take()
421            && !*rx.borrow()
422        {
423            let _ = rx.changed().await;
424            if !*rx.borrow() {
425                tracing::warn!("model warmup did not complete successfully");
426            }
427        }
428
429        // Restore the last-used provider preference before any user interaction (#3308).
430        self.restore_channel_provider().await;
431
432        // Load the session digest once at session start for context injection.
433        self.load_and_cache_session_digest().await;
434        self.maybe_send_resume_recap().await;
435
436        // Emit the initial skill catalog once at startup (spec 084 §6, issue #6648) so a
437        // TUI mention picker opened before the first hot-reload still sees real skill
438        // names/descriptions instead of an indefinite "loading skills…" placeholder.
439        let initial_skill_catalog = self.skill_catalog_items().await;
440        if let Err(e) = self
441            .channel
442            .send_skill_catalog(&initial_skill_catalog)
443            .await
444        {
445            tracing::warn!("failed to emit initial skill catalog: {e}");
446        }
447
448        // AutoSkill A6: start periodic heuristic promotion task at session startup so it runs
449        // even when the main loop exits early due to an error (spec 061). The function guards
450        // against double-spawn via a heuristic_promotion_handle.is_some() check.
451        self.maybe_start_heuristic_promotion();
452
453        loop {
454            // #6418: reset per-turn cross-thread identity state unconditionally at the top of
455            // every iteration, rather than relying solely on `Agent::end_turn` (which only runs
456            // after `process_user_message` completes). A fast-path slash-command dispatch, or any
457            // non-`Message` `LoopEvent`, can `continue`/`break` out of this loop before ever
458            // reaching `end_turn` — without this reset that would leave the previous iteration's
459            // `owner_key`/`is_guest_context` stale for whatever runs next (a different sender's
460            // message, a scheduled task, an autonomous tick, ...). The `LoopEvent::Message` arm
461            // below overwrites both with the real per-message values for this iteration only.
462            self.services.session.is_guest_context = false;
463            state::persistence::DEFAULT_OWNER_KEY.clone_into(&mut self.services.session.owner_key);
464
465            self.apply_provider_override();
466            self.check_tool_refresh().await;
467            self.process_pending_elicitations().await;
468            self.refresh_subagent_metrics();
469            self.notify_completed_subagents().await?;
470            self.drain_channel();
471
472            let (text, image_parts) = if let Some(queued) = self.msg.message_queue.pop_front() {
473                self.notify_queue_count().await;
474                if queued.raw_attachments.is_empty() {
475                    (queued.text, queued.image_parts)
476                } else {
477                    let msg = crate::channel::ChannelMessage {
478                        text: queued.text,
479                        attachments: queued.raw_attachments,
480                        is_guest_context: false,
481                        is_from_bot: false,
482                        owner_key: None,
483                    };
484                    self.resolve_message(msg).await
485                }
486            } else {
487                match self.next_event().await? {
488                    None | Some(LoopEvent::Shutdown) => break,
489                    Some(LoopEvent::SkillReload) => {
490                        self.reload_skills().await;
491                        continue;
492                    }
493                    Some(LoopEvent::InstructionReload) => {
494                        self.reload_instructions().await;
495                        continue;
496                    }
497                    Some(LoopEvent::ConfigReload) => {
498                        self.reload_config();
499                        continue;
500                    }
501                    Some(LoopEvent::UpdateNotification(msg)) => {
502                        if let Err(e) = self.channel.send(&msg).await {
503                            tracing::warn!("failed to send update notification: {e}");
504                        }
505                        continue;
506                    }
507                    Some(LoopEvent::ExperimentCompleted(msg)) => {
508                        self.services.experiments.cancel = None;
509                        self.services.experiments.handle = None;
510                        if let Err(e) = self.channel.send(&msg).await {
511                            tracing::warn!("failed to send experiment completion: {e}");
512                        }
513                        continue;
514                    }
515                    Some(LoopEvent::ScheduledTask(prompt)) => {
516                        let text = format!("{SCHEDULED_TASK_PREFIX}{prompt}");
517                        let msg = crate::channel::ChannelMessage {
518                            text,
519                            attachments: Vec::new(),
520                            is_guest_context: false,
521                            is_from_bot: false,
522                            owner_key: None,
523                        };
524                        self.drain_channel();
525                        self.resolve_message(msg).await
526                    }
527                    Some(LoopEvent::TaskInjected(injection)) => {
528                        if let Some(ref mut ls) = self.runtime.lifecycle.user_loop {
529                            ls.iteration += 1;
530                            tracing::info!(iteration = ls.iteration, "loop: tick");
531                        }
532                        let msg = crate::channel::ChannelMessage {
533                            text: injection.prompt,
534                            attachments: Vec::new(),
535                            is_guest_context: false,
536                            is_from_bot: false,
537                            owner_key: None,
538                        };
539                        self.drain_channel();
540                        self.resolve_message(msg).await
541                    }
542                    Some(LoopEvent::FileChanged(event)) => {
543                        self.handle_file_changed(event).await;
544                        continue;
545                    }
546                    Some(LoopEvent::AutonomousTick) => {
547                        if let Err(e) = self.run_autonomous_turn().await {
548                            tracing::warn!(error = %e, "autonomous turn error");
549                        }
550                        continue;
551                    }
552                    Some(LoopEvent::BgMetricsTick) => {
553                        self.reap_background_tasks_and_update_metrics();
554                        continue;
555                    }
556                    Some(LoopEvent::Message(msg)) => {
557                        self.services.session.is_guest_context = msg.is_guest_context;
558                        self.services.session.owner_key = msg
559                            .owner_key
560                            .clone()
561                            .unwrap_or_else(|| state::persistence::DEFAULT_OWNER_KEY.to_owned());
562                        self.drain_channel();
563                        self.resolve_message(msg).await
564                    }
565                }
566            };
567
568            let trimmed = text.trim();
569
570            // M3: extract flagged URLs from all slash commands before any registry dispatch,
571            // so `/skill install <url>` and similar commands populate user_provided_urls.
572            if trimmed.starts_with('/') {
573                let slash_urls = zeph_sanitizer::exfiltration::extract_flagged_urls(trimmed);
574                if !slash_urls.is_empty() {
575                    self.services
576                        .security
577                        .user_provided_urls
578                        .write()
579                        .extend(slash_urls);
580                }
581            }
582
583            // Registry dispatch: two-phase command dispatch.
584            //
585            // Phase 1 (session/debug): handlers that need sink + debug + messages but NOT agent.
586            // Phase 2 (agent): handlers that need &mut Agent directly; use null sentinels for
587            // the other CommandContext fields to satisfy the type but avoid borrow conflicts.
588            //
589            // STRUCTURAL NOTE (C4 — borrow-checker constraint, not deferred by oversight):
590            // A `TurnState<'a, C>` struct grouping disjoint `&mut Agent<C>` sub-fields would
591            // eliminate the LIFO-sentinel ordering below. The obstacle: `AgentAccess` is
592            // implemented on `Agent<C>` itself (see `agent_access_impl.rs`), which accesses
593            // fields like `memory_state`, `providers`, `mcp`, and `skill_state`. Those fields
594            // overlap with what a `TurnState` would need to borrow, so `AgentBackend::Real`
595            // cannot simultaneously hold `&mut Agent` while `TurnState` holds `&mut Agent.providers`.
596            // The fix requires splitting `Agent<C>` fields into two disjoint sub-structs and moving
597            // `AgentAccess` to the sub-struct that is disjoint from `TurnState`'s borrow set.
598            // That restructuring touches `agent_access_impl.rs`, `state.rs`, `builder.rs`, all
599            // command handlers, and the binary crate — estimated > 300 lines across > 5 files.
600            // Track as a multi-PR refactor; the current sentinel pattern is correct and safe.
601            //
602            // Drop-order rules enforced here:
603            //   - `sink_adapter` / `null_agent` declared before the registry block → dropped after.
604            //   - Phase-2 sentinels declared before `ctx` → dropped after `ctx`.
605            let trusted = self.channel.supports_exit();
606            let session_impl = command_context_impls::SessionAccessImpl {
607                supports_exit: trusted,
608                history_expand_default_lines: self
609                    .runtime
610                    .config
611                    .resume_config
612                    .expand_default_lines,
613            };
614            let mut messages_impl = command_context_impls::MessageAccessImpl {
615                msg: &mut self.msg,
616                tool_state: &mut self.services.tool_state,
617                providers: &mut self.runtime.providers,
618                metrics: &self.runtime.metrics,
619                security: &mut self.services.security,
620                tool_orchestrator: &mut self.tool_orchestrator,
621            };
622            // sink_adapter declared before reg so it is dropped after reg (LIFO).
623            let mut sink_adapter = crate::channel::ChannelSinkAdapter(&mut self.channel);
624            // null_agent must be declared before reg so it lives longer (LIFO drop order).
625            let mut null_agent = zeph_commands::NullAgent;
626            let registry_handled = {
627                let reg = slash_commands::build_session_debug_registry();
628
629                let mut ctx = zeph_commands::CommandContext {
630                    sink: &mut sink_adapter,
631                    debug: &mut self.runtime.debug,
632                    messages: &mut messages_impl,
633                    session: &session_impl,
634                    agent: &mut null_agent,
635                };
636                reg.dispatch(&mut ctx, trimmed, trusted).await
637            };
638            let session_reg_missed = registry_handled.is_none();
639            match self
640                .apply_dispatch_result(registry_handled, trimmed, false)
641                .await
642            {
643                DispatchFlow::Break => break,
644                DispatchFlow::Continue => continue,
645                DispatchFlow::Fallthrough => {
646                    // Not handled by the session/debug registry; try agent-command registry.
647                }
648            }
649
650            // Agent-command registry: handlers access Agent<C> directly.
651            // Null sentinels declared here so they outlive ctx regardless of whether the `if`
652            // block is entered. `ctx` borrows both `self` and the sentinels; it must drop before
653            // any subsequent `self.channel.*` calls. Because Rust drops in LIFO order, the
654            // sentinels here will outlive ctx (ctx is declared later, inside the block).
655            let mut agent_null_debug = command_context_impls::NullDebugAccess;
656            let mut agent_null_messages = command_context_impls::NullMessageAccess;
657            let agent_null_session = command_context_impls::NullSessionAccess;
658            let mut agent_null_sink = zeph_commands::NullSink;
659            let agent_result: Option<
660                Result<zeph_commands::CommandOutput, zeph_commands::CommandError>,
661            > = if session_reg_missed {
662                let agent_reg = slash_commands::build_agent_command_registry();
663
664                let mut ctx = zeph_commands::CommandContext {
665                    sink: &mut agent_null_sink,
666                    debug: &mut agent_null_debug,
667                    messages: &mut agent_null_messages,
668                    session: &agent_null_session,
669                    agent: self,
670                };
671                // self is reborrowed; ctx drops at end of this block.
672                agent_reg.dispatch(&mut ctx, trimmed, trusted).await
673            } else {
674                None
675            };
676            // self.channel is available again here (ctx borrow dropped above).
677
678            // S1 fix: drain any pending autonomous session start queued by handle_goal.
679            // handle_goal runs inside Box::pin(async move) and cannot borrow &mut self directly,
680            // so it writes to pending_start_arc. We consume it here where &mut self is free.
681            if let Some((cancelled_id, new_id)) = self.services.autonomous.flush_pending_start() {
682                if let Some(cid) = cancelled_id {
683                    tracing::info!(
684                        goal_id = cid,
685                        "autonomous: previous session cancelled for new goal"
686                    );
687                }
688                self.sync_registry_entry();
689                tracing::info!(goal_id = new_id, "autonomous: session started");
690            }
691
692            // Post-dispatch learning hook for `/skill reject` / `/feedback` is triggered
693            // inside apply_dispatch_result when with_learning = true.
694            match self
695                .apply_dispatch_result(agent_result, trimmed, true)
696                .await
697            {
698                DispatchFlow::Break => break,
699                DispatchFlow::Continue => continue,
700                DispatchFlow::Fallthrough => {
701                    // Not handled by agent registry; fall through to existing dispatch.
702                }
703            }
704
705            match self.handle_builtin_command(trimmed) {
706                Some(true) => break,
707                Some(false) => continue,
708                None => {}
709            }
710
711            self.process_user_message(text, image_parts).await?;
712        }
713
714        // autoDream: run background memory consolidation if conditions are met (#2697).
715        // Runs with a timeout — partial state is acceptable for MVP.
716        self.maybe_autodream().await;
717
718        // AutoSkill A1: extract skill candidates from the completed session trace (spec 056).
719        self.maybe_extract_skills_from_trace().await;
720
721        // Flush trace collector on normal exit (C-04: Drop handles error/panic paths). This is
722        // the last write of the session's trace.json — nothing runs after it, so unlike the
723        // mid-session format-switch site (`state/mod.rs`) there's no latency benefit to firing
724        // it and forgetting; await the handle so the write is guaranteed to land instead of
725        // racing process/runtime teardown (#6107 critic finding S1).
726        if let Some(ref mut tc) = self.runtime.debug.trace_collector
727            && let Some(handle) = tc.finish()
728            && let Err(e) = handle.await
729        {
730            tracing::warn!(error = %e, "trace.json write task did not complete");
731        }
732
733        Ok(())
734    }
735
736    /// Dispatch a slash-command registry result and flush the channel.
737    ///
738    /// Returns [`DispatchFlow::Break`] on exit, [`DispatchFlow::Continue`] when handled, or
739    /// [`DispatchFlow::Fallthrough`] when `result` is `None`.
740    /// When `with_learning` is `true`, triggers the post-command learning hook for `Message` output.
741    async fn apply_dispatch_result(
742        &mut self,
743        result: Option<Result<zeph_commands::CommandOutput, zeph_commands::CommandError>>,
744        command: &str,
745        with_learning: bool,
746    ) -> DispatchFlow {
747        match result {
748            Some(Ok(zeph_commands::CommandOutput::Exit)) => {
749                let _ = self.channel.flush_chunks().await;
750                DispatchFlow::Break
751            }
752            Some(Ok(zeph_commands::CommandOutput::Message(msg))) => {
753                let _ = self.channel.send(&msg).await;
754                let _ = self.channel.flush_chunks().await;
755                if with_learning {
756                    self.maybe_trigger_post_command_learning(command).await;
757                }
758                DispatchFlow::Continue
759            }
760            Some(Ok(_)) => {
761                let _ = self.channel.flush_chunks().await;
762                DispatchFlow::Continue
763            }
764            Some(Err(e)) => {
765                let _ = self.channel.send(&e.to_string()).await;
766                let _ = self.channel.flush_chunks().await;
767                tracing::warn!(command = %command, error = %e.0, "slash command failed");
768                DispatchFlow::Continue
769            }
770            None => DispatchFlow::Fallthrough,
771        }
772    }
773
774    /// Apply any pending LLM provider override from ACP `set_session_config_option`.
775    fn apply_provider_override(&mut self) {
776        let taken = self
777            .runtime
778            .providers
779            .provider_override
780            .as_ref()
781            .and_then(|slot| slot.write().take());
782        if let Some(new_provider) = taken {
783            tracing::debug!(provider = new_provider.name(), "ACP model override applied");
784            self.set_provider(new_provider);
785        }
786    }
787
788    /// The single guarded path for reassigning `self.provider` after construction (#5437,
789    /// recurrence guard — S1/M1 of the round-3 critique).
790    ///
791    /// Every runtime provider swap (`/provider` switch, ACP `set_session_config_option` via
792    /// [`Agent::apply_provider_override`], and any future one) **must** go through this method
793    /// instead of assigning `self.provider` directly. `Agent::with_secret_registry` masks
794    /// `self.provider` once at construction time, but that one-time wrap cannot cover providers
795    /// swapped in later — this method re-applies masking on every swap if it's missing, so a
796    /// new call site literally cannot ship an unmasked provider by forgetting a step: it would
797    /// have to bypass this method and assign the field directly, which is what the `debug_assert`
798    /// below catches in every debug/test build.
799    ///
800    /// A caller that already resolved `provider` through a registry-aware path (e.g.
801    /// `build_provider_for_switch` with the registry threaded in) passes an already-`Masked`
802    /// value here; wrapping is skipped in that case (`AnyProvider::masked` nesting would be
803    /// harmless but wasteful).
804    fn set_provider(&mut self, provider: AnyProvider) {
805        let provider = match self.services.security.secret_registry.clone() {
806            Some(registry) if !matches!(provider, AnyProvider::Masked(_)) => {
807                provider.masked(registry as Arc<dyn zeph_llm::masking::OutboundMasker>)
808            }
809            _ => provider,
810        };
811        debug_assert!(
812            self.services.security.secret_registry.is_none()
813                || matches!(provider, AnyProvider::Masked(_)),
814            "set_provider invariant violated: secret masking is enabled but the new provider \
815             is not wrapped via AnyProvider::masked — every self.provider reassignment must go \
816             through Agent::set_provider, never assign the field directly"
817        );
818        self.provider = provider;
819    }
820
821    /// Poll all event sources and return the next [`LoopEvent`].
822    ///
823    /// Returns `None` when the inbound channel closes (graceful shutdown).
824    ///
825    /// # Errors
826    ///
827    /// Propagates channel receive errors.
828    #[tracing::instrument(name = "core.agent.next_event", skip_all, level = "debug", err)]
829    async fn next_event(&mut self) -> Result<Option<LoopEvent>, error::AgentError> {
830        let event = tokio::select! {
831            result = self.channel.recv() => {
832                return Ok(result?.map(LoopEvent::Message));
833            }
834            () = shutdown_signal(&mut self.runtime.lifecycle.shutdown) => {
835                tracing::info!("shutting down");
836                LoopEvent::Shutdown
837            }
838            Some(_) = recv_optional(&mut self.services.skill.skill_reload_rx) => {
839                LoopEvent::SkillReload
840            }
841            Some(_) = recv_optional(&mut self.runtime.instructions.reload_rx) => {
842                LoopEvent::InstructionReload
843            }
844            Some(_) = recv_optional(&mut self.runtime.lifecycle.config_reload_rx) => {
845                LoopEvent::ConfigReload
846            }
847            Some(msg) = recv_optional(&mut self.runtime.lifecycle.update_notify_rx) => {
848                LoopEvent::UpdateNotification(msg)
849            }
850            Some(msg) = recv_optional(&mut self.services.experiments.notify_rx) => {
851                LoopEvent::ExperimentCompleted(msg)
852            }
853            Some(prompt) = recv_optional(&mut self.runtime.lifecycle.custom_task_rx) => {
854                tracing::info!("scheduler: injecting custom task as agent turn");
855                LoopEvent::ScheduledTask(prompt)
856            }
857            () = async {
858                if let Some(ref mut ls) = self.runtime.lifecycle.user_loop {
859                    if ls.cancel_tx.is_cancelled() {
860                        std::future::pending::<()>().await;
861                    } else {
862                        ls.interval.tick().await;
863                    }
864                } else {
865                    std::future::pending::<()>().await;
866                }
867            } => {
868                // Re-check user_loop after the tick — /loop stop may have fired between the
869                // interval firing and this arm executing. Returning Ok(None) causes the caller
870                // to `continue` without injecting a stale or empty prompt.
871                let Some(ls) = self.runtime.lifecycle.user_loop.as_ref() else {
872                    return Ok(None);
873                };
874                if ls.cancel_tx.is_cancelled() {
875                    self.runtime.lifecycle.user_loop = None;
876                    return Ok(None);
877                }
878                let prompt = ls.prompt.clone();
879                LoopEvent::TaskInjected(task_injection::TaskInjection { prompt })
880            }
881            Some(event) = recv_optional(&mut self.runtime.lifecycle.file_changed_rx) => {
882                LoopEvent::FileChanged(event)
883            }
884            // Autonomous goal tick: fires when a running session is active.
885            () = self.services.autonomous.next_tick(),
886                if self.services.autonomous.should_tick() => {
887                LoopEvent::AutonomousTick
888            }
889            // Periodic background-metrics refresh: keeps the TUI's bg status segment live
890            // during idle time between turns (#6279). Lazily constructed here (not in
891            // `LifecycleState::new()`) because `tokio::time::interval` requires an active Tokio
892            // runtime, which plain `#[test]`-constructed agents do not have.
893            //
894            // `interval_at(now + INTERVAL, ...)` defers the *first* tick by a full interval.
895            // Plain `tokio::time::interval()` fires its first tick immediately on construction,
896            // which — since this is lazily built on the very first `next_event()` poll — raced
897            // the pre-existing `self.channel.recv()`/shutdown branches on every agent startup:
898            // both were simultaneously ready and `tokio::select!` (unbiased here) could pick
899            // `BgMetricsTick`, forcing one spurious extra loop iteration before an
900            // already-closed/closing channel was observed (tester-found race).
901            _ = self
902                .runtime
903                .lifecycle
904                .bg_metrics_tick
905                .get_or_insert_with(|| {
906                    let mut iv = tokio::time::interval_at(
907                        tokio::time::Instant::now() + state::BG_METRICS_TICK_INTERVAL,
908                        state::BG_METRICS_TICK_INTERVAL,
909                    );
910                    iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
911                    iv
912                })
913                .tick() => {
914                LoopEvent::BgMetricsTick
915            }
916        };
917        Ok(Some(event))
918    }
919
920    #[tracing::instrument(name = "core.agent.resolve_message", skip_all, level = "debug")]
921    async fn resolve_message(
922        &self,
923        msg: crate::channel::ChannelMessage,
924    ) -> (String, Vec<zeph_llm::provider::MessagePart>) {
925        use crate::channel::{Attachment, AttachmentKind};
926        use zeph_llm::provider::{ImageData, MessagePart};
927
928        let text_base = msg.text.clone();
929
930        let (audio_attachments, image_attachments): (Vec<Attachment>, Vec<Attachment>) = msg
931            .attachments
932            .into_iter()
933            .partition(|a| a.kind == AttachmentKind::Audio);
934
935        tracing::debug!(
936            audio = audio_attachments.len(),
937            has_stt = self.runtime.providers.stt.is_some(),
938            "resolve_message attachments"
939        );
940
941        let text = if !audio_attachments.is_empty()
942            && let Some(stt) = self.runtime.providers.stt.as_ref()
943        {
944            let mut transcribed_parts = Vec::new();
945            for attachment in &audio_attachments {
946                if attachment.data.len() > MAX_AUDIO_BYTES {
947                    tracing::warn!(
948                        size = attachment.data.len(),
949                        max = MAX_AUDIO_BYTES,
950                        "audio attachment exceeds size limit, skipping"
951                    );
952                    continue;
953                }
954                match stt
955                    .transcribe(&attachment.data, attachment.filename.as_deref())
956                    .await
957                {
958                    Ok(result) => {
959                        tracing::info!(
960                            len = result.text.len(),
961                            language = ?result.language,
962                            "audio transcribed"
963                        );
964                        transcribed_parts.push(result.text);
965                    }
966                    Err(e) => {
967                        tracing::error!(error = %e, "audio transcription failed");
968                    }
969                }
970            }
971            if transcribed_parts.is_empty() {
972                text_base
973            } else {
974                let transcribed = transcribed_parts.join("\n");
975                if text_base.is_empty() {
976                    transcribed
977                } else {
978                    format!("[transcribed audio]\n{transcribed}\n\n{text_base}")
979                }
980            }
981        } else {
982            if !audio_attachments.is_empty() {
983                tracing::warn!(
984                    count = audio_attachments.len(),
985                    "audio attachments received but no STT provider configured, dropping"
986                );
987            }
988            text_base
989        };
990
991        let mut image_parts = Vec::new();
992        for attachment in image_attachments {
993            if attachment.data.len() > MAX_IMAGE_BYTES {
994                tracing::warn!(
995                    size = attachment.data.len(),
996                    max = MAX_IMAGE_BYTES,
997                    "image attachment exceeds size limit, skipping"
998                );
999                continue;
1000            }
1001            let mime_type = detect_image_mime(attachment.filename.as_deref()).to_string();
1002            image_parts.push(MessagePart::Image(Box::new(ImageData {
1003                data: attachment.data,
1004                mime_type,
1005            })));
1006        }
1007
1008        (text, image_parts)
1009    }
1010
1011    /// Create a new [`Turn`] for the given input and advance the turn counter.
1012    ///
1013    /// Clears per-turn state that must not carry over between turns:
1014    /// - per-turn `CancellationToken` (new token for each turn)
1015    /// - per-turn URL set in `SecurityState` (cleared here; re-populated in
1016    ///   `process_user_message_inner` after security checks)
1017    fn begin_turn(&mut self, input: turn::TurnInput) -> turn::Turn {
1018        let id = turn::TurnId(self.runtime.debug.iteration_counter as u64);
1019        self.runtime.debug.iteration_counter += 1;
1020        let cancel_token = CancellationToken::new();
1021        // keep agent-wide token in sync with per-turn token — TODO(#3498): consolidate in Phase 2
1022        self.runtime.lifecycle.cancel_token = cancel_token.clone();
1023        self.services.security.user_provided_urls.write().clear();
1024        // Issue #6490 (MemGhost): reset the memory-consent trust tracker to a floor of 0 here.
1025        // This reset ALONE is not sufficient for correctness (see #6558) — if untrusted tool
1026        // output from a prior turn is still present in `self.msg.messages`, it must still gate
1027        // `memory_save`. The authoritative recompute happens in
1028        // `Agent::ratchet_memory_consent_trust_for_dispatch`, called at the top of
1029        // `handle_native_tool_calls` before any tool dispatch, which rebuilds the slot from
1030        // live context (`context_max_trust_level`) plus this batch's tool-name trust tiers —
1031        // see `MemoryConsentTrustSlot`'s doc comment for the full picture.
1032        *self.services.security.memory_consent_trust.write() = 0;
1033        // Reset per-turn LLM request counter for the notification gate.
1034        self.runtime.lifecycle.turn_llm_requests = 0;
1035        // Reset per-turn tool-call dispatch counter (feeds TurnSummary::tool_calls).
1036        self.runtime.lifecycle.turn_tool_calls = 0;
1037
1038        // Spec 050 §2: drain pending risk signals from executor layers before advancing.
1039        // Also advance MAGE accumulator (spec 004-19 FR-009) and ingest mapped signals.
1040        {
1041            use crate::agent::trajectory::{RiskSignal, VigilRiskLevel};
1042            use zeph_memory::shadow::{AuditSignalType as MageSignal, Severity as MageSev};
1043            let pending: Vec<u8> = {
1044                let mut q = self.services.security.trajectory_signal_queue.lock();
1045                std::mem::take(&mut *q)
1046            };
1047            self.services.security.mage_accumulator.advance_turn();
1048            for code in pending {
1049                let signal = RiskSignal::from_code(code);
1050                self.services.security.trajectory.record(signal);
1051                // Map RiskSignal to MAGE AuditSignalType + Severity (spec 004-19 FR-002, FR-007).
1052                // Matching on the already-decoded `RiskSignal` (rather than the raw `code`)
1053                // keeps this in sync with `RiskSignal::from_code`, the single source of truth
1054                // for the code-to-meaning table. Only the four spec-004-19 signal classes have a
1055                // MAGE equivalent; the remaining RiskSignal variants (OutOfScope, PiiRedaction,
1056                // ToolFailure, HighCallRate, UnusualReadVolume, ToolPairTransition,
1057                // ExfilReadThenSend, CredThenEgress, and VigilFlagged(Low)) are trajectory-only
1058                // and intentionally not surfaced to MAGE (spec 004-19's four classes are a fixed
1059                // set; widening MAGE's mapping is a separate, spec-governed change, not part of
1060                // #6561/F2's scope, which only fixes these two signals' TrajectorySentinel
1061                // weight).
1062                let mage_signal: Option<(MageSignal, MageSev)> = match signal {
1063                    RiskSignal::PolicyDeny => Some((MageSignal::PolicyViolation, MageSev::Medium)),
1064                    RiskSignal::ExfiltrationRedaction => {
1065                        Some((MageSignal::ToolChainAnomaly, MageSev::Medium))
1066                    }
1067                    RiskSignal::VigilFlagged(VigilRiskLevel::Medium) => {
1068                        Some((MageSignal::PromptInjectionPattern, MageSev::Medium))
1069                    }
1070                    RiskSignal::VigilFlagged(VigilRiskLevel::High) => {
1071                        Some((MageSignal::PromptInjectionPattern, MageSev::High))
1072                    }
1073                    _ => None,
1074                };
1075                if let Some((sig, sev)) = mage_signal {
1076                    self.services.security.mage_accumulator.ingest(sig, sev);
1077                }
1078            }
1079        }
1080        // Spec 050 Invariant 2: advance trajectory sentinel BEFORE any gate evaluation.
1081        // F5: write auto-recover audit entry when sentinel hard-resets.
1082        if self.services.security.trajectory.advance_turn()
1083            && let Some(logger) = self.tool_orchestrator.audit_logger.clone()
1084        {
1085            let entry = zeph_tools::AuditEntry {
1086                source_kind: None,
1087                trust_level: None,
1088                timestamp: zeph_tools::chrono_now(),
1089                tool: "<sentinel>".to_owned().into(),
1090                command: String::new(),
1091                result: zeph_tools::AuditResult::Success,
1092                duration_ms: 0,
1093                error_category: Some("trajectory_auto_recover".to_owned()),
1094                error_domain: Some("security".to_owned()),
1095                error_phase: None,
1096                claim_source: None,
1097                mcp_server_id: None,
1098                injection_flagged: false,
1099                embedding_anomalous: false,
1100                cross_boundary_mcp_to_acp: false,
1101                adversarial_policy_decision: None,
1102                exit_code: None,
1103                truncated: false,
1104                caller_id: None,
1105                skill_name: None,
1106                policy_match: None,
1107                correlation_id: None,
1108                vigil_risk: None,
1109                execution_env: None,
1110                resolved_cwd: None,
1111                scope_at_definition: None,
1112                scope_at_dispatch: None,
1113            };
1114            self.runtime.lifecycle.supervisor.spawn(
1115                crate::agent::agent_supervisor::TaskClass::Telemetry,
1116                "trajectory-auto-recover-audit",
1117                async move { logger.log(&entry).await },
1118            );
1119        }
1120        // Spec 050 Phase 2: reset per-turn probe counter before any tool dispatch.
1121        if let Some(ref sentinel) = self.services.security.shadow_sentinel {
1122            sentinel.advance_turn();
1123        }
1124        // #6561: advance (not reset) the risk chain accumulator — it prunes calls older than
1125        // its cross-turn window and recomputes the score, rather than fully clearing state, so
1126        // a chain split across turns is still detected. See `RiskChainAccumulator::advance_turn`.
1127        if let Some(ref acc) = self.services.security.risk_chain_accumulator {
1128            acc.advance_turn();
1129        }
1130        // Publish updated risk level to the shared slot so PolicyGateExecutor can read it.
1131        let risk_level = self.services.security.trajectory.current_risk();
1132        *self.services.security.trajectory_risk_slot.write() = u8::from(risk_level);
1133        // TUI/CLI: emit a status indicator when risk reaches High or Critical (NFR-CG-006).
1134        if let Some(alert) = self.services.security.trajectory.poll_alert() {
1135            let msg = format!(
1136                "[trajectory] Risk level: {:?} (score={:.2})",
1137                alert.level, alert.score
1138            );
1139            tracing::warn!(
1140                level = ?alert.level,
1141                score = alert.score,
1142                "trajectory sentinel alert"
1143            );
1144            if let Some(ref tx) = self.services.session.status_tx {
1145                let _ = tx.send(msg);
1146            }
1147        }
1148
1149        let context = turn::TurnContext::new(id, cancel_token, self.runtime.config.timeouts)
1150            .with_tool_allowlist(self.runtime.config.channel_tool_allowlist.clone());
1151        turn::Turn::new(context, input)
1152    }
1153
1154    /// Finalise a turn: copy accumulated timings into `MetricsState` and flush.
1155    ///
1156    /// Must be called exactly once per turn, after `process_user_message_inner` returns
1157    /// (regardless of success or error). Corresponds to the M2 resolution in the spec:
1158    /// `TurnMetrics.timings` is the single source of truth; `MetricsState.pending_timings`
1159    /// is populated from it here so the rest of the pipeline is unchanged.
1160    fn end_turn(&mut self, turn: turn::Turn) {
1161        self.runtime.metrics.pending_timings = turn.metrics.timings;
1162        self.flush_turn_timings();
1163        // Clear per-turn intent (FR-008): must not persist across turns.
1164        self.services.session.current_turn_intent = None;
1165        // Clear guest context flag: each turn is independently classified.
1166        self.services.session.is_guest_context = false;
1167        // Reset the cross-thread store owner key (#6389) at turn end, same as is_guest_context
1168        // above. Both resets are redundant with the unconditional per-iteration reset at the top
1169        // of the `run()` loop (#6418) — that loop-top reset is what actually closes the
1170        // fast-path-dispatch staleness gap (a slash command or non-Message LoopEvent can
1171        // `continue`/`break` before ever reaching end_turn); these two lines are kept as
1172        // defense-in-depth for the normal end-of-turn path.
1173        state::persistence::DEFAULT_OWNER_KEY.clone_into(&mut self.services.session.owner_key);
1174        // Cancel all in-flight speculative handles at turn boundary.
1175        if let Some(ref engine) = self.services.speculation_engine {
1176            let metrics = engine.end_turn();
1177            if metrics.committed > 0 || metrics.cancelled > 0 {
1178                tracing::debug!(
1179                    committed = metrics.committed,
1180                    cancelled = metrics.cancelled,
1181                    wasted_ms = metrics.wasted_ms,
1182                    "speculation: turn boundary metrics"
1183                );
1184            }
1185        }
1186    }
1187
1188    #[tracing::instrument(
1189        name = "core.agent.process_user_message",
1190        skip_all,
1191        level = "debug",
1192        fields(turn_id),
1193        err
1194    )]
1195    async fn process_user_message(
1196        &mut self,
1197        text: String,
1198        image_parts: Vec<zeph_llm::provider::MessagePart>,
1199    ) -> Result<(), error::AgentError> {
1200        // Re-check for a pending provider override (#5548): the loop-top check in `run()`
1201        // happens before the potentially long block on `next_event()`, so an ACP
1202        // `session/set_config_option` model switch written while this iteration was
1203        // parked would otherwise miss this turn and only apply on the next one.
1204        self.apply_provider_override();
1205
1206        let input = turn::TurnInput::new(text, image_parts);
1207        let mut t = self.begin_turn(input);
1208
1209        let turn_idx = usize::try_from(t.id().0).unwrap_or(usize::MAX);
1210        tracing::Span::current().record("turn_id", t.id().0);
1211        // Record iteration start in trace collector (C-02: owned guard, no borrow held).
1212        self.runtime
1213            .debug
1214            .start_iteration_span(turn_idx, t.input.text.trim());
1215
1216        let result = Box::pin(self.process_user_message_inner(&mut t)).await;
1217
1218        // Close iteration span regardless of outcome (partial trace preserved on error).
1219        let span_status = if result.is_ok() {
1220            crate::debug_dump::trace::SpanStatus::Ok
1221        } else {
1222            crate::debug_dump::trace::SpanStatus::Error {
1223                message: "iteration failed".to_owned(),
1224            }
1225        };
1226        self.runtime.debug.end_iteration_span(turn_idx, span_status);
1227
1228        self.end_turn(t);
1229        result
1230    }
1231
1232    #[allow(clippy::too_many_lines)] // turn pipeline is inherently sequential; each step is a single call
1233    #[tracing::instrument(
1234        name = "core.agent.process_user_message_inner",
1235        skip_all,
1236        level = "debug",
1237        err
1238    )]
1239    async fn process_user_message_inner(
1240        &mut self,
1241        turn: &mut turn::Turn,
1242    ) -> Result<(), error::AgentError> {
1243        self.reap_background_tasks_and_update_metrics();
1244
1245        let tokens_before_turn = self
1246            .runtime
1247            .metrics
1248            .metrics_tx
1249            .as_ref()
1250            .map_or(0, |tx| tx.borrow().total_tokens);
1251
1252        // Drain any background shell completions that arrived since the last turn.
1253        // They are buffered in `pending_background_completions` and merged with the
1254        // real user message into a single user-role block below (N1 invariant).
1255        self.drain_background_completions();
1256
1257        self.wire_cancel_bridge(turn.cancel_token());
1258
1259        // Clone text out of Turn so we can hold both `&str` borrows and mutate turn.metrics.
1260        let text = turn.input.text.clone();
1261        let trimmed_owned = text.trim().to_owned();
1262        let trimmed = trimmed_owned.as_str();
1263
1264        // Capture current-turn intent for VIGIL gate (FR-007). Truncated to 1024 chars.
1265        // Must be set BEFORE any tool call; cleared at end_turn (FR-008).
1266        if self.services.security.vigil.is_some() {
1267            let intent_len = trimmed.floor_char_boundary(1024.min(trimmed.len()));
1268            self.services.session.current_turn_intent = Some(trimmed[..intent_len].to_owned());
1269        }
1270
1271        if let Some(result) = self.dispatch_slash_command(trimmed).await {
1272            return result;
1273        }
1274
1275        // #5460: sanitize only after both dispatch layers ran on unsanitized text.
1276        let text = self.sanitize_channel_text_if_untrusted(text);
1277        let trimmed_owned = text.trim().to_owned();
1278        let trimmed = trimmed_owned.as_str();
1279
1280        self.check_pending_rollbacks().await;
1281
1282        if self.pre_process_security(trimmed).await? {
1283            return Ok(());
1284        }
1285
1286        let t_ctx = std::time::Instant::now();
1287        tracing::debug!("turn timing: prepare_context start");
1288        self.advance_context_lifecycle_guarded(&text, trimmed).await;
1289        turn.metrics_mut().timings.prepare_context_ms =
1290            u64::try_from(t_ctx.elapsed().as_millis()).unwrap_or(u64::MAX);
1291        tracing::debug!(
1292            ms = turn.metrics_snapshot().timings.prepare_context_ms,
1293            "turn timing: prepare_context done"
1294        );
1295        // Emit projected token count so TUI can display it before the LLM call.
1296        let _ = self
1297            .channel
1298            .send_context_estimate(
1299                usize::try_from(self.runtime.providers.cached_prompt_tokens).unwrap_or(usize::MAX),
1300            )
1301            .await;
1302
1303        let image_parts = std::mem::take(&mut turn.input.image_parts);
1304        // Prepend any background completion blocks to the user text. All completions and the
1305        // user message MUST be merged into a single user-role block to satisfy the strict
1306        // user/assistant alternation rule (Anthropic Messages API — N1 invariant).
1307        let merged_text = self.build_user_message_text_with_bg_completions(&text);
1308        let user_msg = self.build_user_message(&merged_text, image_parts);
1309
1310        // Extract URLs from user input and add to user_provided_urls for grounding checks.
1311        // URL set was cleared in begin_turn; re-populate for this turn.
1312        let urls = zeph_sanitizer::exfiltration::extract_flagged_urls(trimmed);
1313        if !urls.is_empty() {
1314            self.services
1315                .security
1316                .user_provided_urls
1317                .write()
1318                .extend(urls);
1319        }
1320
1321        // Capture raw user input as goal text for A-MAC goal-conditioned write gating (#2483).
1322        // Derived from the raw input text before context assembly to avoid timing dependencies.
1323        self.services.memory.extraction.goal_text = Some(text.clone());
1324
1325        let t_persist = std::time::Instant::now();
1326        tracing::debug!("turn timing: persist_message(user) start");
1327        // Image parts intentionally excluded — base64 payloads too large for message history.
1328        self.persist_message(Role::User, &text, &[], false).await;
1329        turn.metrics_mut().timings.persist_message_ms =
1330            u64::try_from(t_persist.elapsed().as_millis()).unwrap_or(u64::MAX);
1331        tracing::debug!(
1332            ms = turn.metrics_snapshot().timings.persist_message_ms,
1333            "turn timing: persist_message(user) done"
1334        );
1335        self.push_message(user_msg);
1336
1337        // Emit pre-LLM context size so the TUI gauge is non-zero before the provider responds.
1338        let context_estimate = self.runtime.providers.cached_prompt_tokens;
1339        self.update_metrics(|m| m.context_tokens = context_estimate);
1340
1341        // llm_chat_ms and tool_exec_ms are accumulated inside call_chat_with_tools and
1342        // handle_native_tool_calls respectively via metrics.pending_timings.
1343        tracing::debug!("turn timing: process_response start");
1344        let turn_had_error = if let Err(e) = self.process_response().await {
1345            // Detach any in-flight learning tasks before mutating message state.
1346            self.services.learning_engine.learning_tasks.detach_all();
1347            tracing::error!("Response processing failed: {e:#}");
1348
1349            // Record provider failure timestamp so the next turn can skip
1350            // expensive context preparation while providers are known-down.
1351            if e.is_no_providers() {
1352                self.runtime.lifecycle.last_no_providers_at = Some(std::time::Instant::now());
1353                let backoff_secs = self.runtime.config.timeouts.no_providers_backoff_secs;
1354                tracing::warn!(
1355                    backoff_secs,
1356                    "no providers available; backing off before next turn"
1357                );
1358                tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await;
1359            }
1360
1361            let user_msg = format!("Error: {e:#}");
1362            self.channel.send(&user_msg).await?;
1363            if let Some(popped) = self.msg.messages.pop() {
1364                self.msg.track_single_message(popped.role, false);
1365            }
1366            self.recompute_prompt_tokens();
1367            self.channel.flush_chunks().await?;
1368            true
1369        } else {
1370            // Detach learning tasks spawned this turn — they are fire-and-forget and must not
1371            // leak into the next turn's context.
1372            self.services.learning_engine.learning_tasks.detach_all();
1373            self.truncate_old_tool_results();
1374            // MagicDocs: spawn background doc updates if any are due (#2702).
1375            self.maybe_update_magic_docs();
1376            // Compression spectrum: fire-and-forget promotion scan (#3305).
1377            self.maybe_spawn_promotion_scan();
1378            false
1379        };
1380        tracing::debug!("turn timing: process_response done");
1381
1382        // MARCH self-check hook: runs after every successful response, including cache-hit path.
1383        if let Some(pipeline) = self.services.quality.clone() {
1384            self.run_self_check_for_turn(pipeline, turn.id().0).await;
1385        }
1386        // Flush pending response chunks and emit ResponseEnd exactly once per turn.
1387        // send() no longer emits ResponseEnd — flush_chunks() is the sole emitter.
1388        // When self-check appends a flag_marker chunk, this single call covers both
1389        // the main response and the marker, preventing the double response_end of #3243.
1390        let _ = self.channel.flush_chunks().await;
1391
1392        self.maybe_fire_completion_notification(turn, turn_had_error);
1393
1394        self.flush_goal_accounting(tokens_before_turn);
1395
1396        // Collect llm_chat_ms and tool_exec_ms from MetricsState.pending_timings (accumulated
1397        // by the tool execution chain) into turn.metrics so end_turn can flush them.
1398        // This is the Phase 1 bridging: existing code writes to pending_timings directly;
1399        // we harvest those values into Turn before end_turn overwrites pending_timings.
1400        turn.metrics_mut().timings.llm_chat_ms = self.runtime.metrics.pending_timings.llm_chat_ms;
1401        turn.metrics_mut().timings.tool_exec_ms = self.runtime.metrics.pending_timings.tool_exec_ms;
1402
1403        Ok(())
1404    }
1405
1406    /// Wire the per-turn cancellation token into the cancel bridge.
1407    ///
1408    /// The bridge translates `cancel_signal` (Notify) into a `CancellationToken` cancel so that
1409    /// channel-level abort requests propagate to the in-flight LLM call. The previous bridge task
1410    /// is aborted before a new one is spawned to prevent unbounded accumulation (#2737).
1411    fn wire_cancel_bridge(&mut self, turn_token: &tokio_util::sync::CancellationToken) {
1412        let signal = Arc::clone(&self.runtime.lifecycle.cancel_signal);
1413        let token = turn_token.clone();
1414        // Keep lifecycle.cancel_token in sync so existing code that reads it still works.
1415        self.runtime.lifecycle.cancel_token = turn_token.clone();
1416        if let Some(prev) = self.runtime.lifecycle.cancel_bridge_handle.take() {
1417            prev.abort();
1418        }
1419        self.runtime.lifecycle.cancel_bridge_handle =
1420            Some(self.runtime.lifecycle.task_supervisor.spawn_oneshot(
1421                std::sync::Arc::from("agent.lifecycle.cancel_bridge"),
1422                move || async move {
1423                    signal.notified().await;
1424                    token.cancel();
1425                },
1426            ));
1427    }
1428
1429    /// Reap completed background tasks, apply summarization signal, and update supervisor metrics.
1430    ///
1431    /// Called at the top of each turn, before any user message processing, and — since #6279 —
1432    /// also on every `LoopEvent::BgMetricsTick` (a periodic idle-time tick), so the TUI's
1433    /// background-work status segment reflects real in-flight enrichment/telemetry tasks
1434    /// continuously, not only at turn boundaries.
1435    fn reap_background_tasks_and_update_metrics(&mut self) {
1436        let bg_signal = self.runtime.lifecycle.supervisor.reap();
1437        if bg_signal.did_summarize {
1438            self.services.memory.persistence.unsummarized_count = 0;
1439            tracing::debug!("background summarization completed; unsummarized_count reset");
1440        }
1441        let snap = self.runtime.lifecycle.supervisor.metrics_snapshot();
1442        self.update_metrics(|m| {
1443            m.bg_inflight = snap.inflight as u64;
1444            m.bg_dropped = snap.total_dropped();
1445            m.bg_completed = snap.total_completed();
1446            m.bg_enrichment_inflight = snap.class_inflight[0] as u64;
1447            m.bg_telemetry_inflight = snap.class_inflight[1] as u64;
1448        });
1449
1450        // Update shell background run rows for TUI panel.
1451        if self.runtime.lifecycle.shell_executor_handle.is_some() {
1452            let shell_rows: Vec<crate::metrics::ShellBackgroundRunRow> = self
1453                .runtime
1454                .lifecycle
1455                .shell_executor_handle
1456                .as_ref()
1457                .map(|e| e.background_runs_snapshot())
1458                .unwrap_or_default()
1459                .into_iter()
1460                .map(|s| crate::metrics::ShellBackgroundRunRow {
1461                    run_id: truncate_shell_run_id(&s.run_id),
1462                    command: truncate_shell_command(&s.command),
1463                    elapsed_secs: s.elapsed_ms / 1000,
1464                })
1465                .collect();
1466            self.update_metrics(|m| {
1467                m.shell_background_runs = shell_rows;
1468            });
1469        }
1470
1471        // Intentional ordering: reap() runs before abort_class() so completed tasks are
1472        // accounted in the snapshot above.
1473        if self
1474            .runtime
1475            .config
1476            .supervisor_config
1477            .abort_enrichment_on_turn
1478        {
1479            self.runtime
1480                .lifecycle
1481                .supervisor
1482                .abort_class(agent_supervisor::TaskClass::Enrichment);
1483        }
1484    }
1485
1486    /// Fire completion notifications and `turn_complete` hooks after each turn.
1487    ///
1488    /// Builds [`crate::notifications::TurnSummary`] once and reuses it for both the
1489    /// [`crate::notifications::Notifier`] and any `[[hooks.turn_complete]]` entries. The
1490    /// `preview` field is already redacted by [`Self::last_assistant_preview`], so hook
1491    /// env vars carry no raw assistant output.
1492    ///
1493    /// Gating:
1494    /// - When a `Notifier` is configured, both the notifier and hooks share its
1495    ///   `should_fire` gate (`min_turn_duration_ms`, `only_on_error`, `enabled`).
1496    /// - When no `Notifier` is configured, hooks fire on every turn completion (the
1497    ///   notifier path is simply skipped).
1498    fn maybe_fire_completion_notification(&mut self, turn: &turn::Turn, is_error: bool) {
1499        let snap = turn.metrics_snapshot().timings.clone();
1500        let duration_ms = snap
1501            .prepare_context_ms
1502            .saturating_add(snap.llm_chat_ms)
1503            .saturating_add(snap.tool_exec_ms);
1504        let summary = crate::notifications::TurnSummary {
1505            duration_ms,
1506            preview: self.last_assistant_preview(160),
1507            tool_calls: self.runtime.lifecycle.turn_tool_calls,
1508            llm_requests: self.runtime.lifecycle.turn_llm_requests,
1509            exit_status: if is_error {
1510                crate::notifications::TurnExitStatus::Error
1511            } else {
1512                crate::notifications::TurnExitStatus::Success
1513            },
1514        };
1515
1516        // Gate evaluation: notifier's should_fire result (or unconditional when absent).
1517        let gate_ok = self
1518            .runtime
1519            .lifecycle
1520            .notifier
1521            .as_ref()
1522            .is_none_or(|n| n.should_fire(&summary));
1523
1524        // 1) Existing notifier path — unchanged semantics.
1525        if let Some(ref notifier) = self.runtime.lifecycle.notifier
1526            && gate_ok
1527        {
1528            notifier.fire(&summary, &mut self.runtime.lifecycle.supervisor);
1529        }
1530
1531        // 2) turn_complete hooks — fire-and-forget via supervisor.
1532        // McpManagerDispatch wraps Arc<McpManager> and is 'static, so it can be moved
1533        // into the async block satisfying tokio::spawn's bound. The &dyn McpDispatch
1534        // borrow is created inside the future from the owned dispatch value.
1535        let hooks = self.services.session.hooks_config.turn_complete.clone();
1536        if !hooks.is_empty() && gate_ok {
1537            let mut env = build_turn_hook_env(&summary, is_error);
1538            let conv_id_str = self
1539                .services
1540                .memory
1541                .persistence
1542                .conversation_id
1543                .map(|id| id.0.to_string());
1544            crate::agent::hooks_dispatch::insert_main_agent_ctx(&mut env, conv_id_str.as_deref());
1545            let dispatch = self.mcp_dispatch();
1546            let _span = tracing::info_span!("core.agent.turn_hooks").entered();
1547            let _accepted = self.runtime.lifecycle.supervisor.spawn(
1548                agent_supervisor::TaskClass::Telemetry,
1549                "turn-complete-hooks",
1550                async move {
1551                    let mcp: Option<&dyn zeph_subagent::McpDispatch> = dispatch
1552                        .as_ref()
1553                        .map(|d| d as &dyn zeph_subagent::McpDispatch);
1554                    if let Err(e) = zeph_subagent::hooks::fire_hooks(&hooks, &env, mcp, None).await
1555                    {
1556                        tracing::warn!(error = %e, "turn_complete hook failed");
1557                    }
1558                },
1559            );
1560        }
1561    }
1562
1563    /// Publish the active goal snapshot to `MetricsSnapshot` and fire `on_turn_complete`
1564    /// accounting as a tracked background task.
1565    fn flush_goal_accounting(&mut self, tokens_before: u64) {
1566        let goal_snap = self
1567            .services
1568            .goal_accounting
1569            .as_ref()
1570            .and_then(|a| a.snapshot());
1571        self.update_metrics(|m| m.active_goal = goal_snap);
1572
1573        if let Some(ref accounting) = self.services.goal_accounting {
1574            let tokens_after = self
1575                .runtime
1576                .metrics
1577                .metrics_tx
1578                .as_ref()
1579                .map_or(0, |tx| tx.borrow().total_tokens);
1580            let turn_tokens = tokens_after.saturating_sub(tokens_before);
1581            let mut spawned: Option<
1582                std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'static>>,
1583            > = None;
1584            accounting.on_turn_complete(turn_tokens, |fut| {
1585                spawned = Some(fut);
1586            });
1587            if let Some(fut) = spawned {
1588                let _ = self.runtime.lifecycle.supervisor.spawn(
1589                    agent_supervisor::TaskClass::Telemetry,
1590                    "goal-accounting",
1591                    fut,
1592                );
1593            }
1594        }
1595    }
1596
1597    /// Sanitize `text` when it originates from an untrusted channel (#5460).
1598    ///
1599    /// Telegram/Discord/Slack users are external and untrusted, but recognized commands must
1600    /// dispatch on raw text — by the time this is called, both dispatch layers
1601    /// (`Agent::run`'s registries and `dispatch_slash_command`) have already run on the
1602    /// unsanitized text and found no match. Only the residual text that actually reaches the
1603    /// LLM context is wrapped here, mirroring how gateway webhooks and A2A messages are already
1604    /// sanitized before they reach this function (`src/gateway_spawn.rs::forward_webhooks`,
1605    /// `src/daemon.rs::AgentTaskProcessor`) — `LoopbackChannel` (which carries both) reports
1606    /// `requires_input_sanitization() == false` so that pre-sanitized content isn't wrapped
1607    /// twice.
1608    fn sanitize_channel_text_if_untrusted(&self, text: String) -> String {
1609        if !self.channel.requires_input_sanitization() {
1610            return text;
1611        }
1612        self.services
1613            .security
1614            .sanitizer
1615            .sanitize(
1616                &text,
1617                zeph_sanitizer::ContentSource::new(
1618                    zeph_sanitizer::ContentSourceKind::ChannelMessage,
1619                ),
1620            )
1621            .body
1622    }
1623
1624    // Returns true if the input was blocked and the caller should return Ok(()) immediately.
1625    #[tracing::instrument(
1626        name = "core.agent.pre_process_security",
1627        skip_all,
1628        level = "debug",
1629        err
1630    )]
1631    async fn pre_process_security(&mut self, trimmed: &str) -> Result<bool, error::AgentError> {
1632        // Guardrail: LLM-based prompt injection pre-screening at the user input boundary.
1633        if let Some(ref guardrail) = self.services.security.guardrail {
1634            use zeph_sanitizer::guardrail::GuardrailVerdict;
1635            let verdict = guardrail.check(trimmed).await;
1636            match &verdict {
1637                GuardrailVerdict::Flagged { reason, .. } => {
1638                    tracing::warn!(
1639                        reason = %reason,
1640                        should_block = verdict.should_block(),
1641                        "guardrail flagged user input"
1642                    );
1643                    if verdict.should_block() {
1644                        let msg = format!("[guardrail] Input blocked: {reason}");
1645                        let _ = self.channel.send(&msg).await;
1646                        let _ = self.channel.flush_chunks().await;
1647                        return Ok(true);
1648                    }
1649                    // Warn mode: notify but continue.
1650                    let _ = self
1651                        .channel
1652                        .send(&format!("[guardrail] Warning: {reason}"))
1653                        .await;
1654                }
1655                GuardrailVerdict::Error { error } => {
1656                    if guardrail.error_should_block() {
1657                        tracing::warn!(%error, "guardrail check failed (fail_strategy=closed), blocking input");
1658                        let msg = "[guardrail] Input blocked: check failed (see logs for details)";
1659                        let _ = self.channel.send(msg).await;
1660                        let _ = self.channel.flush_chunks().await;
1661                        return Ok(true);
1662                    }
1663                    tracing::warn!(%error, "guardrail check failed (fail_strategy=open), allowing input");
1664                }
1665                _ => {}
1666            }
1667        }
1668
1669        // SONAR NLI: probabilistic entailment check at the user input boundary. Observe-only —
1670        // never blocks, mirrors the tool-output check in `sanitize_tool_output`.
1671        self.record_nli_verdict(trimmed, "user_input").await;
1672
1673        // ML classifier: lightweight injection detection on user input boundary.
1674        // Runs after guardrail (LLM-based) to layer defenses. On detection, blocks and returns.
1675        // Falls back to regex on classifier error/timeout — never degrades below regex baseline.
1676        // Gated by `scan_user_input`: DeBERTa is tuned for external/untrusted content, not
1677        // direct user chat. Disabled by default to prevent false positives on benign messages.
1678        #[cfg(feature = "classifiers")]
1679        if self.services.security.sanitizer.scan_user_input() {
1680            match self
1681                .services
1682                .security
1683                .sanitizer
1684                .classify_injection(trimmed)
1685                .await
1686            {
1687                zeph_sanitizer::InjectionVerdict::Blocked => {
1688                    self.push_classifier_metrics();
1689                    let _ = self
1690                        .channel
1691                        .send("[security] Input blocked: injection detected by classifier.")
1692                        .await;
1693                    let _ = self.channel.flush_chunks().await;
1694                    return Ok(true);
1695                }
1696                zeph_sanitizer::InjectionVerdict::Suspicious => {
1697                    tracing::warn!("injection_classifier soft_signal on user input");
1698                }
1699                _ => {}
1700            }
1701        }
1702        #[cfg(feature = "classifiers")]
1703        self.push_classifier_metrics();
1704
1705        Ok(false)
1706    }
1707
1708    /// Run `advance_context_lifecycle` with provider-health gating and a wall-clock timeout.
1709    ///
1710    /// Skips context preparation entirely when providers failed on the previous turn and the
1711    /// `no_providers_backoff_secs` window has not yet elapsed. When providers are available,
1712    /// wraps the call with `context_prep_timeout_secs` to prevent a stall when embed backends
1713    /// are rate-limited or unavailable (#3357).
1714    async fn advance_context_lifecycle_guarded(&mut self, text: &str, trimmed: &str) {
1715        let backoff_secs = self.runtime.config.timeouts.no_providers_backoff_secs;
1716        let prep_timeout_secs = self.runtime.config.timeouts.context_prep_timeout_secs;
1717
1718        // Skip expensive memory recall / embedding when providers are known-down.
1719        let providers_recently_failed = self
1720            .runtime
1721            .lifecycle
1722            .last_no_providers_at
1723            .is_some_and(|t| t.elapsed().as_secs() < backoff_secs);
1724
1725        if providers_recently_failed {
1726            tracing::warn!(
1727                backoff_secs,
1728                "skipping context preparation: providers were unavailable on last turn"
1729            );
1730            return;
1731        }
1732
1733        let timeout_dur = std::time::Duration::from_secs(prep_timeout_secs);
1734        match tokio::time::timeout(timeout_dur, self.advance_context_lifecycle(text, trimmed)).await
1735        {
1736            Ok(()) => {}
1737            Err(_elapsed) => {
1738                tracing::warn!(
1739                    timeout_secs = prep_timeout_secs,
1740                    "context preparation timed out; proceeding with degraded context"
1741                );
1742            }
1743        }
1744    }
1745
1746    #[tracing::instrument(
1747        name = "core.agent.advance_context_lifecycle",
1748        skip_all,
1749        level = "debug"
1750    )]
1751    async fn advance_context_lifecycle(&mut self, text: &str, trimmed: &str) {
1752        // Reset per-message pruning cache at the start of each turn (#2298).
1753        self.services.mcp.pruning_cache.reset();
1754
1755        // Extract before rebuild_system_prompt so the value is not tainted
1756        // by the secrets-bearing system prompt (ConversationId is just an i64).
1757        let conv_id = self.services.memory.persistence.conversation_id;
1758        // ORDERING DEPENDENCY (#6361): rebuild_system_prompt's time-reminder injection reads
1759        // `sidequest.turn_counter` BEFORE it is incremented by `sidequest.tick()` below — this
1760        // is why `0.is_multiple_of(N) == true` fires the reminder on turn 1. Moving
1761        // `sidequest.tick()` before this call would silently break turn-1 firing.
1762        self.rebuild_system_prompt(text).await;
1763
1764        self.detect_and_record_corrections(trimmed, conv_id).await;
1765        self.services.learning_engine.tick();
1766        self.analyze_and_learn().await;
1767        self.sync_graph_counts().await;
1768
1769        // Reset per-turn compaction guard FIRST so SideQuest sees a clean slate (C2 fix).
1770        // complete_focus and maybe_sidequest_eviction set this flag when they run (C1 fix).
1771        // advance_turn() transitions CompactedThisTurn → Cooling/Ready; all other states
1772        // pass through unchanged. See CompactionState::advance_turn for ordering guarantees.
1773        self.context_manager
1774            .set_compaction_state(self.context_manager.compaction_state().advance_turn());
1775
1776        // Tick Focus Agent and SideQuest turn counters (#1850, #1885).
1777        {
1778            self.services.focus.tick();
1779
1780            // SideQuest eviction: runs every N user turns when enabled.
1781            // Skipped when is_compacted_this_turn (focus truncation or prior eviction ran).
1782            let sidequest_should_fire = self.services.sidequest.tick();
1783            if sidequest_should_fire
1784                && !self
1785                    .context_manager
1786                    .compaction_state()
1787                    .is_compacted_this_turn()
1788            {
1789                self.maybe_sidequest_eviction();
1790            }
1791        }
1792
1793        // Experience memory: evolution sweep (fire-and-forget). Runs every N user turns,
1794        // gated on graph + experience config, and only when both stores are attached.
1795        {
1796            let cfg = &self.services.memory.extraction.graph_config.experience;
1797            if cfg.enabled
1798                && cfg.evolution_sweep_enabled
1799                && cfg.evolution_sweep_interval > 0
1800                && self
1801                    .services
1802                    .sidequest
1803                    .turn_counter
1804                    .checked_rem(cfg.evolution_sweep_interval as u64)
1805                    == Some(0)
1806                && let Some(memory) = self.services.memory.persistence.memory.as_ref()
1807                && let (Some(exp), Some(graph)) =
1808                    (memory.experience.as_ref(), memory.graph_store.as_ref())
1809            {
1810                let exp = std::sync::Arc::clone(exp);
1811                let graph = std::sync::Arc::clone(graph);
1812                let threshold = cfg.confidence_prune_threshold;
1813                let turn = self.services.sidequest.turn_counter;
1814                let accepted = self.runtime.lifecycle.supervisor.spawn(
1815                    agent_supervisor::TaskClass::Telemetry,
1816                    "experience-sweep",
1817                    async move {
1818                        match exp.evolution_sweep(graph.as_ref(), threshold).await {
1819                            Ok(stats) => tracing::info!(
1820                                turn,
1821                                self_loops = stats.pruned_self_loops,
1822                                low_confidence = stats.pruned_low_confidence,
1823                                "evolution sweep complete",
1824                            ),
1825                            Err(e) => tracing::warn!(
1826                                turn,
1827                                error = %e,
1828                                "evolution sweep failed",
1829                            ),
1830                        }
1831                    },
1832                );
1833                if !accepted {
1834                    tracing::warn!(
1835                        turn = self.services.sidequest.turn_counter,
1836                        "experience-sweep dropped (telemetry class at capacity)",
1837                    );
1838                }
1839            }
1840        }
1841
1842        // Cache-expiry warning (#2715): notify user when prompt cache has likely expired.
1843        if let Some(warning) = self.cache_expiry_warning() {
1844            tracing::info!(warning, "cache expiry warning");
1845            self.channel.send_status_best_effort(&warning).await;
1846        }
1847
1848        // Time-based microcompact (#2699): strip stale low-value tool outputs before compaction.
1849        // Zero-LLM-cost; runs only when session gap exceeds configured threshold.
1850        self.maybe_time_based_microcompact();
1851
1852        // Tier 0: batch-apply deferred tool summaries when approaching context limit.
1853        // This is a pure in-memory operation (no LLM call) — summaries were pre-computed
1854        // during the tool loop. Intentionally does NOT set compacted_this_turn, so
1855        // proactive/reactive compaction may still fire if tokens remain above their thresholds.
1856        self.maybe_apply_deferred_summaries();
1857        self.flush_deferred_summaries().await;
1858
1859        // Proactive compression fires first (if configured); if it runs, reactive is skipped.
1860        if let Err(e) = self.maybe_proactive_compress().await {
1861            tracing::warn!("proactive compression failed: {e:#}");
1862        }
1863
1864        if let Err(e) = self.maybe_compact().await {
1865            tracing::warn!("context compaction failed: {e:#}");
1866        }
1867
1868        if let Err(e) = Box::pin(self.prepare_context(trimmed)).await {
1869            tracing::warn!("context preparation failed: {e:#}");
1870        }
1871
1872        // MAR: propagate top-1 recall confidence to the router for cost-aware routing.
1873        self.provider
1874            .set_memory_confidence(self.services.memory.persistence.last_recall_confidence);
1875
1876        self.services.learning_engine.reset_reflection();
1877    }
1878
1879    fn build_user_message(
1880        &mut self,
1881        text: &str,
1882        image_parts: Vec<zeph_llm::provider::MessagePart>,
1883    ) -> Message {
1884        let mut all_image_parts = std::mem::take(&mut self.msg.pending_image_parts);
1885        all_image_parts.extend(image_parts);
1886
1887        if !all_image_parts.is_empty() && self.provider.supports_vision() {
1888            let mut parts = vec![zeph_llm::provider::MessagePart::Text {
1889                text: text.to_owned(),
1890            }];
1891            parts.extend(all_image_parts);
1892            Message::from_parts(Role::User, parts)
1893        } else {
1894            if !all_image_parts.is_empty() {
1895                tracing::warn!(
1896                    count = all_image_parts.len(),
1897                    "image attachments dropped: provider does not support vision"
1898                );
1899            }
1900            Message {
1901                role: Role::User,
1902                content: text.to_owned(),
1903                parts: vec![],
1904                metadata: MessageMetadata::default(),
1905            }
1906        }
1907    }
1908
1909    /// Drain any ready [`zeph_tools::BackgroundCompletion`]s from the channel into
1910    /// `pending_background_completions`. Bounded by `BACKGROUND_COMPLETION_BUFFER_CAP`;
1911    /// on overflow the oldest entry is evicted and a placeholder is inserted.
1912    fn drain_background_completions(&mut self) {
1913        const BACKGROUND_COMPLETION_BUFFER_CAP: usize = 16;
1914
1915        let Some(ref mut rx) = self.runtime.lifecycle.background_completion_rx else {
1916            return;
1917        };
1918        // Non-blocking drain: collect all completions that are already ready.
1919        while let Ok(completion) = rx.try_recv() {
1920            if self.runtime.lifecycle.pending_background_completions.len()
1921                >= BACKGROUND_COMPLETION_BUFFER_CAP
1922            {
1923                tracing::warn!(
1924                    run_id = %completion.run_id,
1925                    "background completion buffer full; dropping run result"
1926                );
1927                // Buffer is full: drop the oldest queued completion and push a sentinel
1928                // for the new (incoming) run so the LLM is informed its result was lost.
1929                self.runtime
1930                    .lifecycle
1931                    .pending_background_completions
1932                    .pop_front();
1933                self.runtime
1934                    .lifecycle
1935                    .pending_background_completions
1936                    .push_back(zeph_tools::BackgroundCompletion {
1937                        run_id: completion.run_id,
1938                        exit_code: -1,
1939                        success: false,
1940                        elapsed_ms: 0,
1941                        command: completion.command,
1942                        output: format!(
1943                            "[background result for run {} dropped: buffer overflow]",
1944                            completion.run_id
1945                        ),
1946                    });
1947            } else {
1948                self.runtime
1949                    .lifecycle
1950                    .pending_background_completions
1951                    .push_back(completion);
1952            }
1953        }
1954    }
1955
1956    /// Format and drain `pending_background_completions` into a prefix string, then
1957    /// return the final merged text (prefix + user message). When there are no pending
1958    /// completions the original text is returned unchanged.
1959    fn build_user_message_text_with_bg_completions(&mut self, user_text: &str) -> String {
1960        if self
1961            .runtime
1962            .lifecycle
1963            .pending_background_completions
1964            .is_empty()
1965        {
1966            return user_text.to_owned();
1967        }
1968        let mut parts = String::new();
1969        for completion in self
1970            .runtime
1971            .lifecycle
1972            .pending_background_completions
1973            .drain(..)
1974        {
1975            let _ = write!(
1976                parts,
1977                "[Background task {} completed]\nexit_code: {}\nsuccess: {}\nelapsed_ms: {}\ncommand: {}\n\n{}\n\n",
1978                completion.run_id,
1979                completion.exit_code,
1980                completion.success,
1981                completion.elapsed_ms,
1982                completion.command,
1983                completion.output,
1984            );
1985        }
1986        parts.push_str(user_text);
1987        parts
1988    }
1989
1990    /// If the compression spectrum is enabled and a promotion engine is wired, spawn a
1991    /// background scan task.
1992    ///
1993    /// The task loads the most-recent episodic window from `SemanticMemory`, runs the
1994    /// greedy clustering scan, and calls `promote` for each qualifying candidate.
1995    ///
1996    /// Supervised via [`agent_supervisor::BackgroundSupervisor`] under
1997    /// [`agent_supervisor::TaskClass::Enrichment`] — dropped under high load rather than
1998    /// blocking the turn.
1999    pub(super) fn maybe_spawn_promotion_scan(&mut self) {
2000        let Some(engine) = self.services.promotion_engine.clone() else {
2001            return;
2002        };
2003
2004        let Some(memory) = self.services.memory.persistence.memory.clone() else {
2005            return;
2006        };
2007
2008        // Use a conservative window cap. The engine's own PromotionConfig thresholds
2009        // determine whether a cluster actually qualifies; this is just the DB scan limit.
2010        let promotion_window = 200usize;
2011
2012        let accepted = self.runtime.lifecycle.supervisor.spawn(
2013            agent_supervisor::TaskClass::Enrichment,
2014            "compression_spectrum.promotion_scan",
2015            async move {
2016                let window = match memory.load_promotion_window(promotion_window).await {
2017                    Ok(w) => w,
2018                    Err(e) => {
2019                        tracing::warn!(error = %e, "promotion scan: failed to load window");
2020                        return;
2021                    }
2022                };
2023
2024                if window.is_empty() {
2025                    return;
2026                }
2027
2028                let candidates = match engine.scan(&window).await {
2029                    Ok(c) => c,
2030                    Err(e) => {
2031                        tracing::warn!(error = %e, "promotion scan: clustering failed");
2032                        return;
2033                    }
2034                };
2035
2036                for candidate in &candidates {
2037                    if let Err(e) = engine.promote(candidate).await {
2038                        tracing::warn!(
2039                            signature = %candidate.signature,
2040                            error = %e,
2041                            "promotion scan: promote failed"
2042                        );
2043                    }
2044                }
2045
2046                tracing::info!(candidates = candidates.len(), "promotion scan: complete");
2047            }
2048            .instrument(tracing::info_span!("memory.compression.promote.background")),
2049        );
2050
2051        if accepted {
2052            tracing::debug!("compression_spectrum: promotion scan task enqueued");
2053        }
2054    }
2055}
2056
2057pub(crate) async fn shutdown_signal(rx: &mut watch::Receiver<bool>) {
2058    while !*rx.borrow_and_update() {
2059        if rx.changed().await.is_err() {
2060            std::future::pending::<()>().await;
2061        }
2062    }
2063}
2064
2065pub(crate) async fn recv_optional<T>(rx: &mut Option<mpsc::Receiver<T>>) -> Option<T> {
2066    match rx {
2067        Some(inner) => {
2068            if let Some(v) = inner.recv().await {
2069                Some(v)
2070            } else {
2071                *rx = None;
2072                std::future::pending().await
2073            }
2074        }
2075        None => std::future::pending().await,
2076    }
2077}
2078
2079/// Truncate a background run command to at most 80 characters for TUI display.
2080fn truncate_shell_command(cmd: &str) -> String {
2081    if cmd.len() <= 80 {
2082        return cmd.to_owned();
2083    }
2084    let end = cmd.floor_char_boundary(79);
2085    format!("{}…", &cmd[..end])
2086}
2087
2088/// Take the first 8 characters of a run-id hex string for compact TUI display.
2089fn truncate_shell_run_id(id: &str) -> String {
2090    id.chars().take(8).collect()
2091}
2092
2093/// How the effective context-token budget was determined by [`resolve_context_budget_tokens`].
2094///
2095/// Callers use this to choose a diagnostic log message appropriate to their call site
2096/// (initial startup vs. config hot-reload) while sharing the same resolution algorithm.
2097pub enum ContextBudgetSource {
2098    /// Auto-detected from the provider's advertised context window.
2099    AutoDetected(usize),
2100    /// Explicit `memory.context_budget_tokens` config value, or `auto_budget` disabled.
2101    Configured,
2102    /// Neither the config nor the provider yielded a usable value; the hardcoded fallback was used.
2103    Fallback,
2104}
2105
2106/// Resolve the effective context-token budget, shared by initial startup
2107/// (`AppBuilder::auto_budget_tokens`) and config hot-reload (`resolve_context_budget`).
2108///
2109/// If `auto_budget` is enabled and no explicit budget is configured, uses the provider's
2110/// reported context window. Falls back to a hardcoded 128 000 tokens if the resolved value
2111/// would otherwise be 0, to guarantee that compaction fires rather than being silently skipped.
2112pub fn resolve_context_budget_tokens(
2113    config: &Config,
2114    provider: &AnyProvider,
2115) -> (usize, ContextBudgetSource) {
2116    if config.memory.auto_budget && config.memory.context_budget_tokens == 0 {
2117        return match provider.context_window() {
2118            Some(ctx_size) if ctx_size > 0 => {
2119                (ctx_size, ContextBudgetSource::AutoDetected(ctx_size))
2120            }
2121            _ => (128_000, ContextBudgetSource::Fallback),
2122        };
2123    }
2124    if config.memory.context_budget_tokens == 0 {
2125        return (128_000, ContextBudgetSource::Fallback);
2126    }
2127    (
2128        config.memory.context_budget_tokens,
2129        ContextBudgetSource::Configured,
2130    )
2131}
2132
2133pub(crate) fn resolve_context_budget(config: &Config, provider: &AnyProvider) -> usize {
2134    let (tokens, source) = resolve_context_budget_tokens(config, provider);
2135    match source {
2136        ContextBudgetSource::AutoDetected(ctx_size) => tracing::info!(
2137            model_context = ctx_size,
2138            "auto-configured context budget on reload"
2139        ),
2140        ContextBudgetSource::Fallback => tracing::warn!(
2141            "context_budget_tokens resolved to 0 on reload — using fallback of 128000 tokens"
2142        ),
2143        ContextBudgetSource::Configured => {}
2144    }
2145    tokens
2146}
2147
2148#[cfg(test)]
2149mod tests;
2150
2151#[cfg(test)]
2152pub(crate) use tests::agent_tests;
2153
2154#[cfg(test)]
2155mod test_stubs {
2156    use std::pin::Pin;
2157
2158    use zeph_commands::{
2159        CommandContext, CommandError, CommandHandler, CommandOutput, SlashCategory,
2160    };
2161
2162    /// Stub slash command registered only in `#[cfg(test)]` builds.
2163    ///
2164    /// Triggers the `Some(Err(CommandError))` arm in the session/debug registry
2165    /// dispatch block so the non-fatal error path can be tested without production
2166    /// command validation logic.
2167    pub(super) struct TestErrorCommand;
2168
2169    impl CommandHandler<CommandContext<'_>> for TestErrorCommand {
2170        fn name(&self) -> &'static str {
2171            "/test-error"
2172        }
2173
2174        fn description(&self) -> &'static str {
2175            "Test stub: always returns CommandError"
2176        }
2177
2178        fn category(&self) -> SlashCategory {
2179            SlashCategory::Session
2180        }
2181
2182        fn requires_auth(&self) -> bool {
2183            true
2184        }
2185
2186        fn handle<'a>(
2187            &'a self,
2188            _ctx: &'a mut CommandContext<'_>,
2189            _args: &'a str,
2190        ) -> Pin<
2191            Box<dyn std::future::Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>,
2192        > {
2193            Box::pin(async { Err(CommandError::new("boom")) })
2194        }
2195    }
2196}