1mod acp_commands;
5mod agent_access_impl;
6pub(crate) mod agent_supervisor;
7mod autodream;
8mod autonomous_turn;
9mod builder;
10pub use builder::SkillConfigParams;
11#[cfg(feature = "cocoon")]
12mod cocoon_cmd;
13mod command_context_impls;
14pub(super) mod compression_feedback;
15mod config_reload;
16mod context;
17mod context_impls;
18pub(crate) mod context_manager;
19mod corrections;
20mod durable_bootstrap;
21pub mod error;
22mod experiment_cmd;
23pub(crate) mod focus;
24mod heuristic_promotion;
25mod hooks_dispatch;
26mod index;
27mod learning;
28pub(crate) mod learning_engine;
29mod log_commands;
30mod loop_event;
31mod lsp_commands;
32mod magic_docs;
33mod mcp;
34pub(crate) mod memcot;
35mod message_queue;
36mod microcompact;
37mod model_commands;
38mod persistence;
39#[cfg(feature = "scheduler")]
40mod plan;
41mod policy_commands;
42mod provider_cmd;
43mod quality_hook;
44pub(crate) mod rate_limiter;
45#[cfg(feature = "scheduler")]
46mod scheduler_commands;
47#[cfg(feature = "scheduler")]
48mod scheduler_loop;
49mod scope_commands;
50pub mod session_config;
51mod session_digest;
52pub mod shadow_sentinel;
53mod shutdown;
54pub(crate) mod sidequest;
55mod skill_management;
56mod skill_reload;
57pub mod slash_commands;
58pub mod speculative;
59pub(crate) mod state;
60mod subagent_commands;
61pub(crate) mod task_injection;
62pub(crate) mod tool_execution;
63pub(crate) mod tool_orchestrator;
64mod trace_extraction;
65pub mod trajectory;
66mod trajectory_commands;
67mod trust_commands;
68pub mod turn;
69mod utils;
70pub(crate) mod vigil;
71mod worktree_commands;
72
73use std::collections::{HashMap, VecDeque};
74use std::fmt::Write as _;
75use std::sync::Arc;
76
77use parking_lot::RwLock;
78
79use tokio::sync::{mpsc, watch};
80use tokio_util::sync::CancellationToken;
81use zeph_llm::any::AnyProvider;
82use zeph_llm::provider::{LlmProvider, Message, MessageMetadata, Role};
83use zeph_memory::TokenCounter;
84use zeph_memory::semantic::SemanticMemory;
85use zeph_skills::loader::Skill;
86use zeph_skills::matcher::SkillMatcherBackend;
87use zeph_skills::prompt::format_skills_prompt;
88use zeph_skills::registry::SkillRegistry;
89use zeph_tools::executor::{ErasedToolExecutor, ToolExecutor};
90
91use tracing::Instrument as _;
92
93use crate::channel::Channel;
94use crate::config::Config;
95use crate::context::build_system_prompt;
96use zeph_common::text::estimate_tokens;
97
98use loop_event::LoopEvent;
99use message_queue::{MAX_AUDIO_BYTES, MAX_IMAGE_BYTES, detect_image_mime};
100use state::MessageState;
101
102pub(crate) const DOOM_LOOP_WINDOW: usize = 3;
103pub(crate) const MAX_RETRIEVE_MANDATES_PER_TURN: usize = 3;
107pub(crate) use zeph_agent_context::helpers::CODE_CONTEXT_PREFIX;
111pub(crate) const SCHEDULED_TASK_PREFIX: &str = "Execute the following scheduled task now: ";
112pub(crate) const TOOL_OUTPUT_SUFFIX: &str = "\n```";
113
114pub(crate) fn format_tool_output(tool_name: &str, body: &str) -> String {
115 use std::fmt::Write;
116 let capacity = "[tool output: ".len()
117 + tool_name.len()
118 + "]\n```\n".len()
119 + body.len()
120 + TOOL_OUTPUT_SUFFIX.len();
121 let mut buf = String::with_capacity(capacity);
122 let _ = write!(
123 buf,
124 "[tool output: {tool_name}]\n```\n{body}{TOOL_OUTPUT_SUFFIX}"
125 );
126 buf
127}
128
129pub struct Agent<C: Channel> {
162 provider: AnyProvider,
164 embedding_provider: AnyProvider,
169 channel: C,
170 pub(crate) tool_executor: Arc<dyn ErasedToolExecutor>,
171
172 pub(super) msg: MessageState,
174 pub(super) context_manager: context_manager::ContextManager,
175 pub(super) tool_orchestrator: tool_orchestrator::ToolOrchestrator,
176
177 pub(super) services: state::Services,
179
180 pub(super) runtime: state::AgentRuntime,
182}
183
184enum DispatchFlow {
186 Break,
188 Continue,
190 Fallthrough,
192}
193
194impl<C: Channel> Agent<C> {
195 #[must_use]
219 pub fn new(
220 provider: AnyProvider,
221 channel: C,
222 registry: SkillRegistry,
223 matcher: Option<SkillMatcherBackend>,
224 max_active_skills: usize,
225 tool_executor: impl ToolExecutor + 'static,
226 ) -> Self {
227 let registry = Arc::new(RwLock::new(registry));
228 let embedding_provider = provider.clone();
229 Self::new_with_registry_arc(
230 provider,
231 embedding_provider,
232 channel,
233 registry,
234 matcher,
235 max_active_skills,
236 tool_executor,
237 )
238 }
239
240 #[must_use]
247 pub fn new_with_registry_arc(
248 provider: AnyProvider,
249 embedding_provider: AnyProvider,
250 channel: C,
251 registry: Arc<RwLock<SkillRegistry>>,
252 matcher: Option<SkillMatcherBackend>,
253 max_active_skills: usize,
254 tool_executor: impl ToolExecutor + 'static,
255 ) -> Self {
256 use state::{
257 AgentRuntime, CompressionState, DebugState, ExperimentState, FeedbackState, IndexState,
258 InstructionState, LifecycleState, McpState, MemoryState, MetricsState,
259 OrchestrationState, ProviderState, RuntimeConfig, SecurityState, Services,
260 SessionState, SkillState, ToolState,
261 };
262
263 debug_assert!(max_active_skills > 0, "max_active_skills must be > 0");
264 let all_skills: Vec<Skill> = {
265 let reg = registry.read();
266 reg.all_meta()
267 .iter()
268 .filter_map(|m| reg.skill(&m.name).ok())
269 .collect()
270 };
271 let empty_trust = HashMap::new();
272 let empty_health: HashMap<String, (f64, u32)> = HashMap::new();
273 let skills_prompt = format_skills_prompt(&all_skills, &empty_trust, &empty_health);
274 let system_prompt = build_system_prompt(&skills_prompt, None);
275 tracing::debug!(len = system_prompt.len(), "initial system prompt built");
276 tracing::trace!(prompt = %system_prompt, "full system prompt");
277
278 let initial_prompt_tokens = estimate_tokens(&system_prompt) as u64;
279 let token_counter = Arc::new(TokenCounter::new());
280
281 let services = Services {
282 memory: MemoryState::default(),
283 skill: SkillState::new(registry, matcher, max_active_skills, skills_prompt),
284 learning_engine: learning_engine::LearningEngine::new(),
285 feedback: FeedbackState::default(),
286 mcp: McpState::default(),
287 index: IndexState::default(),
288 session: SessionState::new(),
289 security: SecurityState::default(),
290 experiments: ExperimentState::new(),
291 compression: CompressionState::default(),
292 orchestration: OrchestrationState::default(),
293 focus: focus::FocusState::default(),
294 sidequest: sidequest::SidequestState::default(),
295 tool_state: ToolState::default(),
296 goal_accounting: None,
297 quality: None,
298 proactive_explorer: None,
299 promotion_engine: None,
300 taco_compressor: None,
301 speculation_engine: None,
302 autonomous: crate::goal::AutonomousDriver::new(tokio::time::Duration::from_millis(500)),
303 autonomous_registry: crate::goal::AutonomousRegistry::new(),
304 };
305
306 let runtime = AgentRuntime {
307 config: RuntimeConfig::default(),
308 lifecycle: LifecycleState::new(),
309 providers: ProviderState::new(initial_prompt_tokens),
310 metrics: MetricsState::new(token_counter),
311 debug: DebugState::default(),
312 instructions: InstructionState::default(),
313 ephemeral_plugins: Vec::new(),
314 };
315
316 Self {
317 provider,
318 embedding_provider,
319 channel,
320 tool_executor: Arc::new(tool_executor),
321 msg: MessageState {
322 messages: vec![Message {
323 role: Role::System,
324 content: system_prompt,
325 parts: vec![],
326 metadata: MessageMetadata::default(),
327 }],
328 message_queue: VecDeque::new(),
329 pending_image_parts: Vec::new(),
330 last_persisted_message_id: None,
331 deferred_db_hide_ids: Vec::new(),
332 deferred_db_summaries: Vec::new(),
333 history_preloaded: false,
334 },
335 context_manager: context_manager::ContextManager::new(),
336 tool_orchestrator: tool_orchestrator::ToolOrchestrator::new(),
337 services,
338 runtime,
339 }
340 }
341
342 #[must_use]
355 pub fn into_channel(self) -> C {
356 self.channel
357 }
358
359 #[tracing::instrument(name = "core.agent.run", skip_all, level = "debug", err)]
365 #[allow(clippy::too_many_lines)] pub async fn run(&mut self) -> Result<(), error::AgentError>
367 where
368 C: 'static,
369 {
370 if let Some(mut rx) = self.runtime.lifecycle.warmup_ready.take()
371 && !*rx.borrow()
372 {
373 let _ = rx.changed().await;
374 if !*rx.borrow() {
375 tracing::warn!("model warmup did not complete successfully");
376 }
377 }
378
379 self.restore_channel_provider().await;
381
382 self.load_and_cache_session_digest().await;
384 self.maybe_send_resume_recap().await;
385
386 self.maybe_start_heuristic_promotion();
390
391 loop {
392 self.apply_provider_override();
393 self.check_tool_refresh().await;
394 self.process_pending_elicitations().await;
395 self.refresh_subagent_metrics();
396 self.notify_completed_subagents().await?;
397 self.drain_channel();
398
399 let (text, image_parts) = if let Some(queued) = self.msg.message_queue.pop_front() {
400 self.notify_queue_count().await;
401 if queued.raw_attachments.is_empty() {
402 (queued.text, queued.image_parts)
403 } else {
404 let msg = crate::channel::ChannelMessage {
405 text: queued.text,
406 attachments: queued.raw_attachments,
407 is_guest_context: false,
408 is_from_bot: false,
409 };
410 self.resolve_message(msg).await
411 }
412 } else {
413 match self.next_event().await? {
414 None | Some(LoopEvent::Shutdown) => break,
415 Some(LoopEvent::SkillReload) => {
416 self.reload_skills().await;
417 continue;
418 }
419 Some(LoopEvent::InstructionReload) => {
420 self.reload_instructions().await;
421 continue;
422 }
423 Some(LoopEvent::ConfigReload) => {
424 self.reload_config();
425 continue;
426 }
427 Some(LoopEvent::UpdateNotification(msg)) => {
428 if let Err(e) = self.channel.send(&msg).await {
429 tracing::warn!("failed to send update notification: {e}");
430 }
431 continue;
432 }
433 Some(LoopEvent::ExperimentCompleted(msg)) => {
434 self.services.experiments.cancel = None;
435 self.services.experiments.handle = None;
436 if let Err(e) = self.channel.send(&msg).await {
437 tracing::warn!("failed to send experiment completion: {e}");
438 }
439 continue;
440 }
441 Some(LoopEvent::ScheduledTask(prompt)) => {
442 let text = format!("{SCHEDULED_TASK_PREFIX}{prompt}");
443 let msg = crate::channel::ChannelMessage {
444 text,
445 attachments: Vec::new(),
446 is_guest_context: false,
447 is_from_bot: false,
448 };
449 self.drain_channel();
450 self.resolve_message(msg).await
451 }
452 Some(LoopEvent::TaskInjected(injection)) => {
453 if let Some(ref mut ls) = self.runtime.lifecycle.user_loop {
454 ls.iteration += 1;
455 tracing::info!(iteration = ls.iteration, "loop: tick");
456 }
457 let msg = crate::channel::ChannelMessage {
458 text: injection.prompt,
459 attachments: Vec::new(),
460 is_guest_context: false,
461 is_from_bot: false,
462 };
463 self.drain_channel();
464 self.resolve_message(msg).await
465 }
466 Some(LoopEvent::FileChanged(event)) => {
467 self.handle_file_changed(event).await;
468 continue;
469 }
470 Some(LoopEvent::AutonomousTick) => {
471 if let Err(e) = self.run_autonomous_turn().await {
472 tracing::warn!(error = %e, "autonomous turn error");
473 }
474 continue;
475 }
476 Some(LoopEvent::BgMetricsTick) => {
477 self.reap_background_tasks_and_update_metrics();
478 continue;
479 }
480 Some(LoopEvent::Message(msg)) => {
481 self.services.session.is_guest_context = msg.is_guest_context;
482 self.drain_channel();
483 self.resolve_message(msg).await
484 }
485 }
486 };
487
488 let trimmed = text.trim();
489
490 if trimmed.starts_with('/') {
493 let slash_urls = zeph_sanitizer::exfiltration::extract_flagged_urls(trimmed);
494 if !slash_urls.is_empty() {
495 self.services
496 .security
497 .user_provided_urls
498 .write()
499 .extend(slash_urls);
500 }
501 }
502
503 let trusted = self.channel.supports_exit();
526 let session_impl = command_context_impls::SessionAccessImpl {
527 supports_exit: trusted,
528 };
529 let mut messages_impl = command_context_impls::MessageAccessImpl {
530 msg: &mut self.msg,
531 tool_state: &mut self.services.tool_state,
532 providers: &mut self.runtime.providers,
533 metrics: &self.runtime.metrics,
534 security: &mut self.services.security,
535 tool_orchestrator: &mut self.tool_orchestrator,
536 };
537 let mut sink_adapter = crate::channel::ChannelSinkAdapter(&mut self.channel);
539 let mut null_agent = zeph_commands::NullAgent;
541 let registry_handled = {
542 let reg = slash_commands::build_session_debug_registry();
543
544 let mut ctx = zeph_commands::CommandContext {
545 sink: &mut sink_adapter,
546 debug: &mut self.runtime.debug,
547 messages: &mut messages_impl,
548 session: &session_impl,
549 agent: &mut null_agent,
550 };
551 reg.dispatch(&mut ctx, trimmed, trusted).await
552 };
553 let session_reg_missed = registry_handled.is_none();
554 match self
555 .apply_dispatch_result(registry_handled, trimmed, false)
556 .await
557 {
558 DispatchFlow::Break => break,
559 DispatchFlow::Continue => continue,
560 DispatchFlow::Fallthrough => {
561 }
563 }
564
565 let mut agent_null_debug = command_context_impls::NullDebugAccess;
571 let mut agent_null_messages = command_context_impls::NullMessageAccess;
572 let agent_null_session = command_context_impls::NullSessionAccess;
573 let mut agent_null_sink = zeph_commands::NullSink;
574 let agent_result: Option<
575 Result<zeph_commands::CommandOutput, zeph_commands::CommandError>,
576 > = if session_reg_missed {
577 let agent_reg = slash_commands::build_agent_command_registry();
578
579 let mut ctx = zeph_commands::CommandContext {
580 sink: &mut agent_null_sink,
581 debug: &mut agent_null_debug,
582 messages: &mut agent_null_messages,
583 session: &agent_null_session,
584 agent: self,
585 };
586 agent_reg.dispatch(&mut ctx, trimmed, trusted).await
588 } else {
589 None
590 };
591 if let Some((cancelled_id, new_id)) = self.services.autonomous.flush_pending_start() {
597 if let Some(cid) = cancelled_id {
598 tracing::info!(
599 goal_id = cid,
600 "autonomous: previous session cancelled for new goal"
601 );
602 }
603 self.sync_registry_entry();
604 tracing::info!(goal_id = new_id, "autonomous: session started");
605 }
606
607 match self
610 .apply_dispatch_result(agent_result, trimmed, true)
611 .await
612 {
613 DispatchFlow::Break => break,
614 DispatchFlow::Continue => continue,
615 DispatchFlow::Fallthrough => {
616 }
618 }
619
620 match self.handle_builtin_command(trimmed) {
621 Some(true) => break,
622 Some(false) => continue,
623 None => {}
624 }
625
626 self.process_user_message(text, image_parts).await?;
627 }
628
629 self.maybe_autodream().await;
632
633 self.maybe_extract_skills_from_trace().await;
635
636 if let Some(ref mut tc) = self.runtime.debug.trace_collector
642 && let Some(handle) = tc.finish()
643 && let Err(e) = handle.await
644 {
645 tracing::warn!(error = %e, "trace.json write task did not complete");
646 }
647
648 Ok(())
649 }
650
651 async fn apply_dispatch_result(
657 &mut self,
658 result: Option<Result<zeph_commands::CommandOutput, zeph_commands::CommandError>>,
659 command: &str,
660 with_learning: bool,
661 ) -> DispatchFlow {
662 match result {
663 Some(Ok(zeph_commands::CommandOutput::Exit)) => {
664 let _ = self.channel.flush_chunks().await;
665 DispatchFlow::Break
666 }
667 Some(Ok(zeph_commands::CommandOutput::Message(msg))) => {
668 let _ = self.channel.send(&msg).await;
669 let _ = self.channel.flush_chunks().await;
670 if with_learning {
671 self.maybe_trigger_post_command_learning(command).await;
672 }
673 DispatchFlow::Continue
674 }
675 Some(Ok(_)) => {
676 let _ = self.channel.flush_chunks().await;
677 DispatchFlow::Continue
678 }
679 Some(Err(e)) => {
680 let _ = self.channel.send(&e.to_string()).await;
681 let _ = self.channel.flush_chunks().await;
682 tracing::warn!(command = %command, error = %e.0, "slash command failed");
683 DispatchFlow::Continue
684 }
685 None => DispatchFlow::Fallthrough,
686 }
687 }
688
689 fn apply_provider_override(&mut self) {
691 let taken = self
692 .runtime
693 .providers
694 .provider_override
695 .as_ref()
696 .and_then(|slot| slot.write().take());
697 if let Some(new_provider) = taken {
698 tracing::debug!(provider = new_provider.name(), "ACP model override applied");
699 self.set_provider(new_provider);
700 }
701 }
702
703 fn set_provider(&mut self, provider: AnyProvider) {
720 let provider = match self.services.security.secret_registry.clone() {
721 Some(registry) if !matches!(provider, AnyProvider::Masked(_)) => {
722 provider.masked(registry as Arc<dyn zeph_llm::masking::OutboundMasker>)
723 }
724 _ => provider,
725 };
726 debug_assert!(
727 self.services.security.secret_registry.is_none()
728 || matches!(provider, AnyProvider::Masked(_)),
729 "set_provider invariant violated: secret masking is enabled but the new provider \
730 is not wrapped via AnyProvider::masked — every self.provider reassignment must go \
731 through Agent::set_provider, never assign the field directly"
732 );
733 self.provider = provider;
734 }
735
736 #[tracing::instrument(name = "core.agent.next_event", skip_all, level = "debug", err)]
744 async fn next_event(&mut self) -> Result<Option<LoopEvent>, error::AgentError> {
745 let event = tokio::select! {
746 result = self.channel.recv() => {
747 return Ok(result?.map(LoopEvent::Message));
748 }
749 () = shutdown_signal(&mut self.runtime.lifecycle.shutdown) => {
750 tracing::info!("shutting down");
751 LoopEvent::Shutdown
752 }
753 Some(_) = recv_optional(&mut self.services.skill.skill_reload_rx) => {
754 LoopEvent::SkillReload
755 }
756 Some(_) = recv_optional(&mut self.runtime.instructions.reload_rx) => {
757 LoopEvent::InstructionReload
758 }
759 Some(_) = recv_optional(&mut self.runtime.lifecycle.config_reload_rx) => {
760 LoopEvent::ConfigReload
761 }
762 Some(msg) = recv_optional(&mut self.runtime.lifecycle.update_notify_rx) => {
763 LoopEvent::UpdateNotification(msg)
764 }
765 Some(msg) = recv_optional(&mut self.services.experiments.notify_rx) => {
766 LoopEvent::ExperimentCompleted(msg)
767 }
768 Some(prompt) = recv_optional(&mut self.runtime.lifecycle.custom_task_rx) => {
769 tracing::info!("scheduler: injecting custom task as agent turn");
770 LoopEvent::ScheduledTask(prompt)
771 }
772 () = async {
773 if let Some(ref mut ls) = self.runtime.lifecycle.user_loop {
774 if ls.cancel_tx.is_cancelled() {
775 std::future::pending::<()>().await;
776 } else {
777 ls.interval.tick().await;
778 }
779 } else {
780 std::future::pending::<()>().await;
781 }
782 } => {
783 let Some(ls) = self.runtime.lifecycle.user_loop.as_ref() else {
787 return Ok(None);
788 };
789 if ls.cancel_tx.is_cancelled() {
790 self.runtime.lifecycle.user_loop = None;
791 return Ok(None);
792 }
793 let prompt = ls.prompt.clone();
794 LoopEvent::TaskInjected(task_injection::TaskInjection { prompt })
795 }
796 Some(event) = recv_optional(&mut self.runtime.lifecycle.file_changed_rx) => {
797 LoopEvent::FileChanged(event)
798 }
799 () = self.services.autonomous.next_tick(),
801 if self.services.autonomous.should_tick() => {
802 LoopEvent::AutonomousTick
803 }
804 _ = self
817 .runtime
818 .lifecycle
819 .bg_metrics_tick
820 .get_or_insert_with(|| {
821 let mut iv = tokio::time::interval_at(
822 tokio::time::Instant::now() + state::BG_METRICS_TICK_INTERVAL,
823 state::BG_METRICS_TICK_INTERVAL,
824 );
825 iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
826 iv
827 })
828 .tick() => {
829 LoopEvent::BgMetricsTick
830 }
831 };
832 Ok(Some(event))
833 }
834
835 #[tracing::instrument(name = "core.agent.resolve_message", skip_all, level = "debug")]
836 async fn resolve_message(
837 &self,
838 msg: crate::channel::ChannelMessage,
839 ) -> (String, Vec<zeph_llm::provider::MessagePart>) {
840 use crate::channel::{Attachment, AttachmentKind};
841 use zeph_llm::provider::{ImageData, MessagePart};
842
843 let text_base = msg.text.clone();
844
845 let (audio_attachments, image_attachments): (Vec<Attachment>, Vec<Attachment>) = msg
846 .attachments
847 .into_iter()
848 .partition(|a| a.kind == AttachmentKind::Audio);
849
850 tracing::debug!(
851 audio = audio_attachments.len(),
852 has_stt = self.runtime.providers.stt.is_some(),
853 "resolve_message attachments"
854 );
855
856 let text = if !audio_attachments.is_empty()
857 && let Some(stt) = self.runtime.providers.stt.as_ref()
858 {
859 let mut transcribed_parts = Vec::new();
860 for attachment in &audio_attachments {
861 if attachment.data.len() > MAX_AUDIO_BYTES {
862 tracing::warn!(
863 size = attachment.data.len(),
864 max = MAX_AUDIO_BYTES,
865 "audio attachment exceeds size limit, skipping"
866 );
867 continue;
868 }
869 match stt
870 .transcribe(&attachment.data, attachment.filename.as_deref())
871 .await
872 {
873 Ok(result) => {
874 tracing::info!(
875 len = result.text.len(),
876 language = ?result.language,
877 "audio transcribed"
878 );
879 transcribed_parts.push(result.text);
880 }
881 Err(e) => {
882 tracing::error!(error = %e, "audio transcription failed");
883 }
884 }
885 }
886 if transcribed_parts.is_empty() {
887 text_base
888 } else {
889 let transcribed = transcribed_parts.join("\n");
890 if text_base.is_empty() {
891 transcribed
892 } else {
893 format!("[transcribed audio]\n{transcribed}\n\n{text_base}")
894 }
895 }
896 } else {
897 if !audio_attachments.is_empty() {
898 tracing::warn!(
899 count = audio_attachments.len(),
900 "audio attachments received but no STT provider configured, dropping"
901 );
902 }
903 text_base
904 };
905
906 let mut image_parts = Vec::new();
907 for attachment in image_attachments {
908 if attachment.data.len() > MAX_IMAGE_BYTES {
909 tracing::warn!(
910 size = attachment.data.len(),
911 max = MAX_IMAGE_BYTES,
912 "image attachment exceeds size limit, skipping"
913 );
914 continue;
915 }
916 let mime_type = detect_image_mime(attachment.filename.as_deref()).to_string();
917 image_parts.push(MessagePart::Image(Box::new(ImageData {
918 data: attachment.data,
919 mime_type,
920 })));
921 }
922
923 (text, image_parts)
924 }
925
926 fn begin_turn(&mut self, input: turn::TurnInput) -> turn::Turn {
933 let id = turn::TurnId(self.runtime.debug.iteration_counter as u64);
934 self.runtime.debug.iteration_counter += 1;
935 let cancel_token = CancellationToken::new();
936 self.runtime.lifecycle.cancel_token = cancel_token.clone();
938 self.services.security.user_provided_urls.write().clear();
939 self.runtime.lifecycle.turn_llm_requests = 0;
941
942 {
945 use crate::agent::trajectory::{RiskSignal, VigilRiskLevel};
946 use zeph_memory::shadow::{AuditSignalType as MageSignal, Severity as MageSev};
947 let pending: Vec<u8> = {
948 let mut q = self.services.security.trajectory_signal_queue.lock();
949 std::mem::take(&mut *q)
950 };
951 self.services.security.mage_accumulator.advance_turn();
952 for code in pending {
953 let signal = RiskSignal::from_code(code);
954 self.services.security.trajectory.record(signal);
955 let mage_signal: Option<(MageSignal, MageSev)> = match signal {
963 RiskSignal::PolicyDeny => Some((MageSignal::PolicyViolation, MageSev::Medium)),
964 RiskSignal::ExfiltrationRedaction => {
965 Some((MageSignal::ToolChainAnomaly, MageSev::Medium))
966 }
967 RiskSignal::VigilFlagged(VigilRiskLevel::Medium) => {
968 Some((MageSignal::PromptInjectionPattern, MageSev::Medium))
969 }
970 RiskSignal::VigilFlagged(VigilRiskLevel::High) => {
971 Some((MageSignal::PromptInjectionPattern, MageSev::High))
972 }
973 _ => None,
974 };
975 if let Some((sig, sev)) = mage_signal {
976 self.services.security.mage_accumulator.ingest(sig, sev);
977 }
978 }
979 }
980 if self.services.security.trajectory.advance_turn()
983 && let Some(logger) = self.tool_orchestrator.audit_logger.clone()
984 {
985 let entry = zeph_tools::AuditEntry {
986 timestamp: zeph_tools::chrono_now(),
987 tool: "<sentinel>".to_owned().into(),
988 command: String::new(),
989 result: zeph_tools::AuditResult::Success,
990 duration_ms: 0,
991 error_category: Some("trajectory_auto_recover".to_owned()),
992 error_domain: Some("security".to_owned()),
993 error_phase: None,
994 claim_source: None,
995 mcp_server_id: None,
996 injection_flagged: false,
997 embedding_anomalous: false,
998 cross_boundary_mcp_to_acp: false,
999 adversarial_policy_decision: None,
1000 exit_code: None,
1001 truncated: false,
1002 caller_id: None,
1003 skill_name: None,
1004 policy_match: None,
1005 correlation_id: None,
1006 vigil_risk: None,
1007 execution_env: None,
1008 resolved_cwd: None,
1009 scope_at_definition: None,
1010 scope_at_dispatch: None,
1011 };
1012 self.runtime.lifecycle.supervisor.spawn(
1013 crate::agent::agent_supervisor::TaskClass::Telemetry,
1014 "trajectory-auto-recover-audit",
1015 async move { logger.log(&entry).await },
1016 );
1017 }
1018 if let Some(ref sentinel) = self.services.security.shadow_sentinel {
1020 sentinel.advance_turn();
1021 }
1022 if let Some(ref acc) = self.services.security.risk_chain_accumulator {
1024 acc.reset();
1025 }
1026 let risk_level = self.services.security.trajectory.current_risk();
1028 *self.services.security.trajectory_risk_slot.write() = u8::from(risk_level);
1029 if let Some(alert) = self.services.security.trajectory.poll_alert() {
1031 let msg = format!(
1032 "[trajectory] Risk level: {:?} (score={:.2})",
1033 alert.level, alert.score
1034 );
1035 tracing::warn!(
1036 level = ?alert.level,
1037 score = alert.score,
1038 "trajectory sentinel alert"
1039 );
1040 if let Some(ref tx) = self.services.session.status_tx {
1041 let _ = tx.send(msg);
1042 }
1043 }
1044
1045 let context = turn::TurnContext::new(id, cancel_token, self.runtime.config.timeouts)
1046 .with_tool_allowlist(self.runtime.config.channel_tool_allowlist.clone());
1047 turn::Turn::new(context, input)
1048 }
1049
1050 fn end_turn(&mut self, turn: turn::Turn) {
1057 self.runtime.metrics.pending_timings = turn.metrics.timings;
1058 self.flush_turn_timings();
1059 self.services.session.current_turn_intent = None;
1061 self.services.session.is_guest_context = false;
1063 if let Some(ref engine) = self.services.speculation_engine {
1065 let metrics = engine.end_turn();
1066 if metrics.committed > 0 || metrics.cancelled > 0 {
1067 tracing::debug!(
1068 committed = metrics.committed,
1069 cancelled = metrics.cancelled,
1070 wasted_ms = metrics.wasted_ms,
1071 "speculation: turn boundary metrics"
1072 );
1073 }
1074 }
1075 }
1076
1077 #[tracing::instrument(
1078 name = "core.agent.process_user_message",
1079 skip_all,
1080 level = "debug",
1081 fields(turn_id),
1082 err
1083 )]
1084 async fn process_user_message(
1085 &mut self,
1086 text: String,
1087 image_parts: Vec<zeph_llm::provider::MessagePart>,
1088 ) -> Result<(), error::AgentError> {
1089 self.apply_provider_override();
1094
1095 let input = turn::TurnInput::new(text, image_parts);
1096 let mut t = self.begin_turn(input);
1097
1098 let turn_idx = usize::try_from(t.id().0).unwrap_or(usize::MAX);
1099 tracing::Span::current().record("turn_id", t.id().0);
1100 self.runtime
1102 .debug
1103 .start_iteration_span(turn_idx, t.input.text.trim());
1104
1105 let result = Box::pin(self.process_user_message_inner(&mut t)).await;
1106
1107 let span_status = if result.is_ok() {
1109 crate::debug_dump::trace::SpanStatus::Ok
1110 } else {
1111 crate::debug_dump::trace::SpanStatus::Error {
1112 message: "iteration failed".to_owned(),
1113 }
1114 };
1115 self.runtime.debug.end_iteration_span(turn_idx, span_status);
1116
1117 self.end_turn(t);
1118 result
1119 }
1120
1121 #[allow(clippy::too_many_lines)] #[tracing::instrument(
1123 name = "core.agent.process_user_message_inner",
1124 skip_all,
1125 level = "debug",
1126 err
1127 )]
1128 async fn process_user_message_inner(
1129 &mut self,
1130 turn: &mut turn::Turn,
1131 ) -> Result<(), error::AgentError> {
1132 self.reap_background_tasks_and_update_metrics();
1133
1134 let tokens_before_turn = self
1135 .runtime
1136 .metrics
1137 .metrics_tx
1138 .as_ref()
1139 .map_or(0, |tx| tx.borrow().total_tokens);
1140
1141 self.drain_background_completions();
1145
1146 self.wire_cancel_bridge(turn.cancel_token());
1147
1148 let text = turn.input.text.clone();
1150 let trimmed_owned = text.trim().to_owned();
1151 let trimmed = trimmed_owned.as_str();
1152
1153 if self.services.security.vigil.is_some() {
1156 let intent_len = trimmed.floor_char_boundary(1024.min(trimmed.len()));
1157 self.services.session.current_turn_intent = Some(trimmed[..intent_len].to_owned());
1158 }
1159
1160 if let Some(result) = self.dispatch_slash_command(trimmed).await {
1161 return result;
1162 }
1163
1164 let text = self.sanitize_channel_text_if_untrusted(text);
1166 let trimmed_owned = text.trim().to_owned();
1167 let trimmed = trimmed_owned.as_str();
1168
1169 self.check_pending_rollbacks().await;
1170
1171 if self.pre_process_security(trimmed).await? {
1172 return Ok(());
1173 }
1174
1175 let t_ctx = std::time::Instant::now();
1176 tracing::debug!("turn timing: prepare_context start");
1177 self.advance_context_lifecycle_guarded(&text, trimmed).await;
1178 turn.metrics_mut().timings.prepare_context_ms =
1179 u64::try_from(t_ctx.elapsed().as_millis()).unwrap_or(u64::MAX);
1180 tracing::debug!(
1181 ms = turn.metrics_snapshot().timings.prepare_context_ms,
1182 "turn timing: prepare_context done"
1183 );
1184 let _ = self
1186 .channel
1187 .send_context_estimate(
1188 usize::try_from(self.runtime.providers.cached_prompt_tokens).unwrap_or(usize::MAX),
1189 )
1190 .await;
1191
1192 let image_parts = std::mem::take(&mut turn.input.image_parts);
1193 let merged_text = self.build_user_message_text_with_bg_completions(&text);
1197 let user_msg = self.build_user_message(&merged_text, image_parts);
1198
1199 let urls = zeph_sanitizer::exfiltration::extract_flagged_urls(trimmed);
1202 if !urls.is_empty() {
1203 self.services
1204 .security
1205 .user_provided_urls
1206 .write()
1207 .extend(urls);
1208 }
1209
1210 self.services.memory.extraction.goal_text = Some(text.clone());
1213
1214 let t_persist = std::time::Instant::now();
1215 tracing::debug!("turn timing: persist_message(user) start");
1216 self.persist_message(Role::User, &text, &[], false).await;
1218 turn.metrics_mut().timings.persist_message_ms =
1219 u64::try_from(t_persist.elapsed().as_millis()).unwrap_or(u64::MAX);
1220 tracing::debug!(
1221 ms = turn.metrics_snapshot().timings.persist_message_ms,
1222 "turn timing: persist_message(user) done"
1223 );
1224 self.push_message(user_msg);
1225
1226 let context_estimate = self.runtime.providers.cached_prompt_tokens;
1228 self.update_metrics(|m| m.context_tokens = context_estimate);
1229
1230 tracing::debug!("turn timing: process_response start");
1233 let turn_had_error = if let Err(e) = self.process_response().await {
1234 self.services.learning_engine.learning_tasks.detach_all();
1236 tracing::error!("Response processing failed: {e:#}");
1237
1238 if e.is_no_providers() {
1241 self.runtime.lifecycle.last_no_providers_at = Some(std::time::Instant::now());
1242 let backoff_secs = self.runtime.config.timeouts.no_providers_backoff_secs;
1243 tracing::warn!(
1244 backoff_secs,
1245 "no providers available; backing off before next turn"
1246 );
1247 tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await;
1248 }
1249
1250 let user_msg = format!("Error: {e:#}");
1251 self.channel.send(&user_msg).await?;
1252 self.msg.messages.pop();
1253 self.recompute_prompt_tokens();
1254 self.channel.flush_chunks().await?;
1255 true
1256 } else {
1257 self.services.learning_engine.learning_tasks.detach_all();
1260 self.truncate_old_tool_results();
1261 self.maybe_update_magic_docs();
1263 self.maybe_spawn_promotion_scan();
1265 false
1266 };
1267 tracing::debug!("turn timing: process_response done");
1268
1269 if let Some(pipeline) = self.services.quality.clone() {
1271 self.run_self_check_for_turn(pipeline, turn.id().0).await;
1272 }
1273 let _ = self.channel.flush_chunks().await;
1278
1279 self.maybe_fire_completion_notification(turn, turn_had_error);
1280
1281 self.flush_goal_accounting(tokens_before_turn);
1282
1283 turn.metrics_mut().timings.llm_chat_ms = self.runtime.metrics.pending_timings.llm_chat_ms;
1288 turn.metrics_mut().timings.tool_exec_ms = self.runtime.metrics.pending_timings.tool_exec_ms;
1289
1290 Ok(())
1291 }
1292
1293 fn wire_cancel_bridge(&mut self, turn_token: &tokio_util::sync::CancellationToken) {
1299 let signal = Arc::clone(&self.runtime.lifecycle.cancel_signal);
1300 let token = turn_token.clone();
1301 self.runtime.lifecycle.cancel_token = turn_token.clone();
1303 if let Some(prev) = self.runtime.lifecycle.cancel_bridge_handle.take() {
1304 prev.abort();
1305 }
1306 self.runtime.lifecycle.cancel_bridge_handle =
1307 Some(self.runtime.lifecycle.task_supervisor.spawn_oneshot(
1308 std::sync::Arc::from("agent.lifecycle.cancel_bridge"),
1309 move || async move {
1310 signal.notified().await;
1311 token.cancel();
1312 },
1313 ));
1314 }
1315
1316 fn reap_background_tasks_and_update_metrics(&mut self) {
1323 let bg_signal = self.runtime.lifecycle.supervisor.reap();
1324 if bg_signal.did_summarize {
1325 self.services.memory.persistence.unsummarized_count = 0;
1326 tracing::debug!("background summarization completed; unsummarized_count reset");
1327 }
1328 let snap = self.runtime.lifecycle.supervisor.metrics_snapshot();
1329 self.update_metrics(|m| {
1330 m.bg_inflight = snap.inflight as u64;
1331 m.bg_dropped = snap.total_dropped();
1332 m.bg_completed = snap.total_completed();
1333 m.bg_enrichment_inflight = snap.class_inflight[0] as u64;
1334 m.bg_telemetry_inflight = snap.class_inflight[1] as u64;
1335 });
1336
1337 if self.runtime.lifecycle.shell_executor_handle.is_some() {
1339 let shell_rows: Vec<crate::metrics::ShellBackgroundRunRow> = self
1340 .runtime
1341 .lifecycle
1342 .shell_executor_handle
1343 .as_ref()
1344 .map(|e| e.background_runs_snapshot())
1345 .unwrap_or_default()
1346 .into_iter()
1347 .map(|s| crate::metrics::ShellBackgroundRunRow {
1348 run_id: truncate_shell_run_id(&s.run_id),
1349 command: truncate_shell_command(&s.command),
1350 elapsed_secs: s.elapsed_ms / 1000,
1351 })
1352 .collect();
1353 self.update_metrics(|m| {
1354 m.shell_background_runs = shell_rows;
1355 });
1356 }
1357
1358 if self
1361 .runtime
1362 .config
1363 .supervisor_config
1364 .abort_enrichment_on_turn
1365 {
1366 self.runtime
1367 .lifecycle
1368 .supervisor
1369 .abort_class(agent_supervisor::TaskClass::Enrichment);
1370 }
1371 }
1372
1373 fn maybe_fire_completion_notification(&mut self, turn: &turn::Turn, is_error: bool) {
1386 let snap = turn.metrics_snapshot().timings.clone();
1387 let duration_ms = snap
1388 .prepare_context_ms
1389 .saturating_add(snap.llm_chat_ms)
1390 .saturating_add(snap.tool_exec_ms);
1391 let summary = crate::notifications::TurnSummary {
1392 duration_ms,
1393 preview: self.last_assistant_preview(160),
1394 tool_calls: 0,
1396 llm_requests: self.runtime.lifecycle.turn_llm_requests,
1397 exit_status: if is_error {
1398 crate::notifications::TurnExitStatus::Error
1399 } else {
1400 crate::notifications::TurnExitStatus::Success
1401 },
1402 };
1403
1404 let gate_ok = self
1406 .runtime
1407 .lifecycle
1408 .notifier
1409 .as_ref()
1410 .is_none_or(|n| n.should_fire(&summary));
1411
1412 if let Some(ref notifier) = self.runtime.lifecycle.notifier
1414 && gate_ok
1415 {
1416 notifier.fire(&summary, &mut self.runtime.lifecycle.supervisor);
1417 }
1418
1419 let hooks = self.services.session.hooks_config.turn_complete.clone();
1424 if !hooks.is_empty() && gate_ok {
1425 let mut env = std::collections::HashMap::new();
1426 env.insert(
1427 "ZEPH_TURN_DURATION_MS".to_owned(),
1428 summary.duration_ms.to_string(),
1429 );
1430 env.insert(
1431 "ZEPH_TURN_STATUS".to_owned(),
1432 if is_error { "error" } else { "success" }.to_owned(),
1433 );
1434 env.insert("ZEPH_TURN_PREVIEW".to_owned(), summary.preview.clone());
1435 env.insert(
1436 "ZEPH_TURN_LLM_REQUESTS".to_owned(),
1437 summary.llm_requests.to_string(),
1438 );
1439 let conv_id_str = self
1440 .services
1441 .memory
1442 .persistence
1443 .conversation_id
1444 .map(|id| id.0.to_string());
1445 crate::agent::hooks_dispatch::insert_main_agent_ctx(&mut env, conv_id_str.as_deref());
1446 let dispatch = self.mcp_dispatch();
1447 let _span = tracing::info_span!("core.agent.turn_hooks").entered();
1448 let _accepted = self.runtime.lifecycle.supervisor.spawn(
1449 agent_supervisor::TaskClass::Telemetry,
1450 "turn-complete-hooks",
1451 async move {
1452 let mcp: Option<&dyn zeph_subagent::McpDispatch> = dispatch
1453 .as_ref()
1454 .map(|d| d as &dyn zeph_subagent::McpDispatch);
1455 if let Err(e) = zeph_subagent::hooks::fire_hooks(&hooks, &env, mcp, None).await
1456 {
1457 tracing::warn!(error = %e, "turn_complete hook failed");
1458 }
1459 },
1460 );
1461 }
1462 }
1463
1464 fn flush_goal_accounting(&mut self, tokens_before: u64) {
1467 let goal_snap = self
1468 .services
1469 .goal_accounting
1470 .as_ref()
1471 .and_then(|a| a.snapshot());
1472 self.update_metrics(|m| m.active_goal = goal_snap);
1473
1474 if let Some(ref accounting) = self.services.goal_accounting {
1475 let tokens_after = self
1476 .runtime
1477 .metrics
1478 .metrics_tx
1479 .as_ref()
1480 .map_or(0, |tx| tx.borrow().total_tokens);
1481 let turn_tokens = tokens_after.saturating_sub(tokens_before);
1482 let mut spawned: Option<
1483 std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'static>>,
1484 > = None;
1485 accounting.on_turn_complete(turn_tokens, |fut| {
1486 spawned = Some(fut);
1487 });
1488 if let Some(fut) = spawned {
1489 let _ = self.runtime.lifecycle.supervisor.spawn(
1490 agent_supervisor::TaskClass::Telemetry,
1491 "goal-accounting",
1492 fut,
1493 );
1494 }
1495 }
1496 }
1497
1498 fn sanitize_channel_text_if_untrusted(&self, text: String) -> String {
1510 if !self.channel.requires_input_sanitization() {
1511 return text;
1512 }
1513 self.services
1514 .security
1515 .sanitizer
1516 .sanitize(
1517 &text,
1518 zeph_sanitizer::ContentSource::new(
1519 zeph_sanitizer::ContentSourceKind::ChannelMessage,
1520 ),
1521 )
1522 .body
1523 }
1524
1525 #[tracing::instrument(
1527 name = "core.agent.pre_process_security",
1528 skip_all,
1529 level = "debug",
1530 err
1531 )]
1532 async fn pre_process_security(&mut self, trimmed: &str) -> Result<bool, error::AgentError> {
1533 if let Some(ref guardrail) = self.services.security.guardrail {
1535 use zeph_sanitizer::guardrail::GuardrailVerdict;
1536 let verdict = guardrail.check(trimmed).await;
1537 match &verdict {
1538 GuardrailVerdict::Flagged { reason, .. } => {
1539 tracing::warn!(
1540 reason = %reason,
1541 should_block = verdict.should_block(),
1542 "guardrail flagged user input"
1543 );
1544 if verdict.should_block() {
1545 let msg = format!("[guardrail] Input blocked: {reason}");
1546 let _ = self.channel.send(&msg).await;
1547 let _ = self.channel.flush_chunks().await;
1548 return Ok(true);
1549 }
1550 let _ = self
1552 .channel
1553 .send(&format!("[guardrail] Warning: {reason}"))
1554 .await;
1555 }
1556 GuardrailVerdict::Error { error } => {
1557 if guardrail.error_should_block() {
1558 tracing::warn!(%error, "guardrail check failed (fail_strategy=closed), blocking input");
1559 let msg = "[guardrail] Input blocked: check failed (see logs for details)";
1560 let _ = self.channel.send(msg).await;
1561 let _ = self.channel.flush_chunks().await;
1562 return Ok(true);
1563 }
1564 tracing::warn!(%error, "guardrail check failed (fail_strategy=open), allowing input");
1565 }
1566 _ => {}
1567 }
1568 }
1569
1570 self.record_nli_verdict(trimmed, "user_input").await;
1573
1574 #[cfg(feature = "classifiers")]
1580 if self.services.security.sanitizer.scan_user_input() {
1581 match self
1582 .services
1583 .security
1584 .sanitizer
1585 .classify_injection(trimmed)
1586 .await
1587 {
1588 zeph_sanitizer::InjectionVerdict::Blocked => {
1589 self.push_classifier_metrics();
1590 let _ = self
1591 .channel
1592 .send("[security] Input blocked: injection detected by classifier.")
1593 .await;
1594 let _ = self.channel.flush_chunks().await;
1595 return Ok(true);
1596 }
1597 zeph_sanitizer::InjectionVerdict::Suspicious => {
1598 tracing::warn!("injection_classifier soft_signal on user input");
1599 }
1600 _ => {}
1601 }
1602 }
1603 #[cfg(feature = "classifiers")]
1604 self.push_classifier_metrics();
1605
1606 Ok(false)
1607 }
1608
1609 async fn advance_context_lifecycle_guarded(&mut self, text: &str, trimmed: &str) {
1616 let backoff_secs = self.runtime.config.timeouts.no_providers_backoff_secs;
1617 let prep_timeout_secs = self.runtime.config.timeouts.context_prep_timeout_secs;
1618
1619 let providers_recently_failed = self
1621 .runtime
1622 .lifecycle
1623 .last_no_providers_at
1624 .is_some_and(|t| t.elapsed().as_secs() < backoff_secs);
1625
1626 if providers_recently_failed {
1627 tracing::warn!(
1628 backoff_secs,
1629 "skipping context preparation: providers were unavailable on last turn"
1630 );
1631 return;
1632 }
1633
1634 let timeout_dur = std::time::Duration::from_secs(prep_timeout_secs);
1635 match tokio::time::timeout(timeout_dur, self.advance_context_lifecycle(text, trimmed)).await
1636 {
1637 Ok(()) => {}
1638 Err(_elapsed) => {
1639 tracing::warn!(
1640 timeout_secs = prep_timeout_secs,
1641 "context preparation timed out; proceeding with degraded context"
1642 );
1643 }
1644 }
1645 }
1646
1647 #[tracing::instrument(
1648 name = "core.agent.advance_context_lifecycle",
1649 skip_all,
1650 level = "debug"
1651 )]
1652 async fn advance_context_lifecycle(&mut self, text: &str, trimmed: &str) {
1653 self.services.mcp.pruning_cache.reset();
1655
1656 let conv_id = self.services.memory.persistence.conversation_id;
1659 self.rebuild_system_prompt(text).await;
1660
1661 self.detect_and_record_corrections(trimmed, conv_id).await;
1662 self.services.learning_engine.tick();
1663 self.analyze_and_learn().await;
1664 self.sync_graph_counts().await;
1665
1666 self.context_manager
1671 .set_compaction_state(self.context_manager.compaction_state().advance_turn());
1672
1673 {
1675 self.services.focus.tick();
1676
1677 let sidequest_should_fire = self.services.sidequest.tick();
1680 if sidequest_should_fire
1681 && !self
1682 .context_manager
1683 .compaction_state()
1684 .is_compacted_this_turn()
1685 {
1686 self.maybe_sidequest_eviction();
1687 }
1688 }
1689
1690 {
1693 let cfg = &self.services.memory.extraction.graph_config.experience;
1694 if cfg.enabled
1695 && cfg.evolution_sweep_enabled
1696 && cfg.evolution_sweep_interval > 0
1697 && self
1698 .services
1699 .sidequest
1700 .turn_counter
1701 .checked_rem(cfg.evolution_sweep_interval as u64)
1702 == Some(0)
1703 && let Some(memory) = self.services.memory.persistence.memory.as_ref()
1704 && let (Some(exp), Some(graph)) =
1705 (memory.experience.as_ref(), memory.graph_store.as_ref())
1706 {
1707 let exp = std::sync::Arc::clone(exp);
1708 let graph = std::sync::Arc::clone(graph);
1709 let threshold = cfg.confidence_prune_threshold;
1710 let turn = self.services.sidequest.turn_counter;
1711 let accepted = self.runtime.lifecycle.supervisor.spawn(
1712 agent_supervisor::TaskClass::Telemetry,
1713 "experience-sweep",
1714 async move {
1715 match exp.evolution_sweep(graph.as_ref(), threshold).await {
1716 Ok(stats) => tracing::info!(
1717 turn,
1718 self_loops = stats.pruned_self_loops,
1719 low_confidence = stats.pruned_low_confidence,
1720 "evolution sweep complete",
1721 ),
1722 Err(e) => tracing::warn!(
1723 turn,
1724 error = %e,
1725 "evolution sweep failed",
1726 ),
1727 }
1728 },
1729 );
1730 if !accepted {
1731 tracing::warn!(
1732 turn = self.services.sidequest.turn_counter,
1733 "experience-sweep dropped (telemetry class at capacity)",
1734 );
1735 }
1736 }
1737 }
1738
1739 if let Some(warning) = self.cache_expiry_warning() {
1741 tracing::info!(warning, "cache expiry warning");
1742 self.channel.send_status_best_effort(&warning).await;
1743 }
1744
1745 self.maybe_time_based_microcompact();
1748
1749 self.maybe_apply_deferred_summaries();
1754 self.flush_deferred_summaries().await;
1755
1756 if let Err(e) = self.maybe_proactive_compress().await {
1758 tracing::warn!("proactive compression failed: {e:#}");
1759 }
1760
1761 if let Err(e) = self.maybe_compact().await {
1762 tracing::warn!("context compaction failed: {e:#}");
1763 }
1764
1765 if let Err(e) = Box::pin(self.prepare_context(trimmed)).await {
1766 tracing::warn!("context preparation failed: {e:#}");
1767 }
1768
1769 self.provider
1771 .set_memory_confidence(self.services.memory.persistence.last_recall_confidence);
1772
1773 self.services.learning_engine.reset_reflection();
1774 }
1775
1776 fn build_user_message(
1777 &mut self,
1778 text: &str,
1779 image_parts: Vec<zeph_llm::provider::MessagePart>,
1780 ) -> Message {
1781 let mut all_image_parts = std::mem::take(&mut self.msg.pending_image_parts);
1782 all_image_parts.extend(image_parts);
1783
1784 if !all_image_parts.is_empty() && self.provider.supports_vision() {
1785 let mut parts = vec![zeph_llm::provider::MessagePart::Text {
1786 text: text.to_owned(),
1787 }];
1788 parts.extend(all_image_parts);
1789 Message::from_parts(Role::User, parts)
1790 } else {
1791 if !all_image_parts.is_empty() {
1792 tracing::warn!(
1793 count = all_image_parts.len(),
1794 "image attachments dropped: provider does not support vision"
1795 );
1796 }
1797 Message {
1798 role: Role::User,
1799 content: text.to_owned(),
1800 parts: vec![],
1801 metadata: MessageMetadata::default(),
1802 }
1803 }
1804 }
1805
1806 fn drain_background_completions(&mut self) {
1810 const BACKGROUND_COMPLETION_BUFFER_CAP: usize = 16;
1811
1812 let Some(ref mut rx) = self.runtime.lifecycle.background_completion_rx else {
1813 return;
1814 };
1815 while let Ok(completion) = rx.try_recv() {
1817 if self.runtime.lifecycle.pending_background_completions.len()
1818 >= BACKGROUND_COMPLETION_BUFFER_CAP
1819 {
1820 tracing::warn!(
1821 run_id = %completion.run_id,
1822 "background completion buffer full; dropping run result"
1823 );
1824 self.runtime
1827 .lifecycle
1828 .pending_background_completions
1829 .pop_front();
1830 self.runtime
1831 .lifecycle
1832 .pending_background_completions
1833 .push_back(zeph_tools::BackgroundCompletion {
1834 run_id: completion.run_id,
1835 exit_code: -1,
1836 success: false,
1837 elapsed_ms: 0,
1838 command: completion.command,
1839 output: format!(
1840 "[background result for run {} dropped: buffer overflow]",
1841 completion.run_id
1842 ),
1843 });
1844 } else {
1845 self.runtime
1846 .lifecycle
1847 .pending_background_completions
1848 .push_back(completion);
1849 }
1850 }
1851 }
1852
1853 fn build_user_message_text_with_bg_completions(&mut self, user_text: &str) -> String {
1857 if self
1858 .runtime
1859 .lifecycle
1860 .pending_background_completions
1861 .is_empty()
1862 {
1863 return user_text.to_owned();
1864 }
1865 let mut parts = String::new();
1866 for completion in self
1867 .runtime
1868 .lifecycle
1869 .pending_background_completions
1870 .drain(..)
1871 {
1872 let _ = write!(
1873 parts,
1874 "[Background task {} completed]\nexit_code: {}\nsuccess: {}\nelapsed_ms: {}\ncommand: {}\n\n{}\n\n",
1875 completion.run_id,
1876 completion.exit_code,
1877 completion.success,
1878 completion.elapsed_ms,
1879 completion.command,
1880 completion.output,
1881 );
1882 }
1883 parts.push_str(user_text);
1884 parts
1885 }
1886
1887 pub(super) fn maybe_spawn_promotion_scan(&mut self) {
1897 let Some(engine) = self.services.promotion_engine.clone() else {
1898 return;
1899 };
1900
1901 let Some(memory) = self.services.memory.persistence.memory.clone() else {
1902 return;
1903 };
1904
1905 let promotion_window = 200usize;
1908
1909 let accepted = self.runtime.lifecycle.supervisor.spawn(
1910 agent_supervisor::TaskClass::Enrichment,
1911 "compression_spectrum.promotion_scan",
1912 async move {
1913 let window = match memory.load_promotion_window(promotion_window).await {
1914 Ok(w) => w,
1915 Err(e) => {
1916 tracing::warn!(error = %e, "promotion scan: failed to load window");
1917 return;
1918 }
1919 };
1920
1921 if window.is_empty() {
1922 return;
1923 }
1924
1925 let candidates = match engine.scan(&window).await {
1926 Ok(c) => c,
1927 Err(e) => {
1928 tracing::warn!(error = %e, "promotion scan: clustering failed");
1929 return;
1930 }
1931 };
1932
1933 for candidate in &candidates {
1934 if let Err(e) = engine.promote(candidate).await {
1935 tracing::warn!(
1936 signature = %candidate.signature,
1937 error = %e,
1938 "promotion scan: promote failed"
1939 );
1940 }
1941 }
1942
1943 tracing::info!(candidates = candidates.len(), "promotion scan: complete");
1944 }
1945 .instrument(tracing::info_span!("memory.compression.promote.background")),
1946 );
1947
1948 if accepted {
1949 tracing::debug!("compression_spectrum: promotion scan task enqueued");
1950 }
1951 }
1952}
1953
1954pub(crate) async fn shutdown_signal(rx: &mut watch::Receiver<bool>) {
1955 while !*rx.borrow_and_update() {
1956 if rx.changed().await.is_err() {
1957 std::future::pending::<()>().await;
1958 }
1959 }
1960}
1961
1962pub(crate) async fn recv_optional<T>(rx: &mut Option<mpsc::Receiver<T>>) -> Option<T> {
1963 match rx {
1964 Some(inner) => {
1965 if let Some(v) = inner.recv().await {
1966 Some(v)
1967 } else {
1968 *rx = None;
1969 std::future::pending().await
1970 }
1971 }
1972 None => std::future::pending().await,
1973 }
1974}
1975
1976fn truncate_shell_command(cmd: &str) -> String {
1978 if cmd.len() <= 80 {
1979 return cmd.to_owned();
1980 }
1981 let end = cmd.floor_char_boundary(79);
1982 format!("{}…", &cmd[..end])
1983}
1984
1985fn truncate_shell_run_id(id: &str) -> String {
1987 id.chars().take(8).collect()
1988}
1989
1990pub enum ContextBudgetSource {
1995 AutoDetected(usize),
1997 Configured,
1999 Fallback,
2001}
2002
2003pub fn resolve_context_budget_tokens(
2010 config: &Config,
2011 provider: &AnyProvider,
2012) -> (usize, ContextBudgetSource) {
2013 if config.memory.auto_budget && config.memory.context_budget_tokens == 0 {
2014 return match provider.context_window() {
2015 Some(ctx_size) if ctx_size > 0 => {
2016 (ctx_size, ContextBudgetSource::AutoDetected(ctx_size))
2017 }
2018 _ => (128_000, ContextBudgetSource::Fallback),
2019 };
2020 }
2021 if config.memory.context_budget_tokens == 0 {
2022 return (128_000, ContextBudgetSource::Fallback);
2023 }
2024 (
2025 config.memory.context_budget_tokens,
2026 ContextBudgetSource::Configured,
2027 )
2028}
2029
2030pub(crate) fn resolve_context_budget(config: &Config, provider: &AnyProvider) -> usize {
2031 let (tokens, source) = resolve_context_budget_tokens(config, provider);
2032 match source {
2033 ContextBudgetSource::AutoDetected(ctx_size) => tracing::info!(
2034 model_context = ctx_size,
2035 "auto-configured context budget on reload"
2036 ),
2037 ContextBudgetSource::Fallback => tracing::warn!(
2038 "context_budget_tokens resolved to 0 on reload — using fallback of 128000 tokens"
2039 ),
2040 ContextBudgetSource::Configured => {}
2041 }
2042 tokens
2043}
2044
2045#[cfg(test)]
2046mod tests;
2047
2048#[cfg(test)]
2049pub(crate) use tests::agent_tests;
2050
2051#[cfg(test)]
2052mod test_stubs {
2053 use std::pin::Pin;
2054
2055 use zeph_commands::{
2056 CommandContext, CommandError, CommandHandler, CommandOutput, SlashCategory,
2057 };
2058
2059 pub(super) struct TestErrorCommand;
2065
2066 impl CommandHandler<CommandContext<'_>> for TestErrorCommand {
2067 fn name(&self) -> &'static str {
2068 "/test-error"
2069 }
2070
2071 fn description(&self) -> &'static str {
2072 "Test stub: always returns CommandError"
2073 }
2074
2075 fn category(&self) -> SlashCategory {
2076 SlashCategory::Session
2077 }
2078
2079 fn requires_auth(&self) -> bool {
2080 true
2081 }
2082
2083 fn handle<'a>(
2084 &'a self,
2085 _ctx: &'a mut CommandContext<'_>,
2086 _args: &'a str,
2087 ) -> Pin<
2088 Box<dyn std::future::Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>,
2089 > {
2090 Box::pin(async { Err(CommandError::new("boom")) })
2091 }
2092 }
2093}