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