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