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