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