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::VecDeque;
74use std::fmt::Write as _;
75use std::sync::Arc;
76
77use parking_lot::RwLock;
78
79use tokio::sync::{mpsc, watch};
80use tokio_util::sync::CancellationToken;
81use zeph_llm::any::AnyProvider;
82use zeph_llm::provider::{LlmProvider, Message, MessageMetadata, Role};
83use zeph_memory::TokenCounter;
84use zeph_memory::semantic::SemanticMemory;
85use zeph_skills::loader::Skill;
86use zeph_skills::matcher::SkillMatcherBackend;
87use zeph_skills::prompt::format_skills_catalog;
88use zeph_skills::registry::SkillRegistry;
89use zeph_tools::executor::{ErasedToolExecutor, ToolExecutor};
90
91use tracing::Instrument as _;
92
93use crate::channel::Channel;
94use crate::config::Config;
95use crate::context::build_system_prompt;
96use zeph_common::text::estimate_tokens;
97
98use loop_event::LoopEvent;
99use message_queue::{MAX_AUDIO_BYTES, MAX_IMAGE_BYTES, detect_image_mime};
100use state::MessageState;
101
102pub(crate) const DOOM_LOOP_WINDOW: usize = 3;
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
194fn build_turn_hook_env(
200 summary: &crate::notifications::TurnSummary,
201 is_error: bool,
202) -> std::collections::HashMap<String, String> {
203 let mut env = std::collections::HashMap::new();
204 env.insert(
205 "ZEPH_TURN_DURATION_MS".to_owned(),
206 summary.duration_ms.to_string(),
207 );
208 env.insert(
209 "ZEPH_TURN_STATUS".to_owned(),
210 if is_error { "error" } else { "success" }.to_owned(),
211 );
212 env.insert("ZEPH_TURN_PREVIEW".to_owned(), summary.preview.clone());
213 env.insert(
214 "ZEPH_TURN_LLM_REQUESTS".to_owned(),
215 summary.llm_requests.to_string(),
216 );
217 env.insert(
218 "ZEPH_TURN_TOOL_CALLS".to_owned(),
219 summary.tool_calls.to_string(),
220 );
221 env
222}
223
224impl<C: Channel> Agent<C> {
225 #[must_use]
249 pub fn new(
250 provider: AnyProvider,
251 channel: C,
252 registry: SkillRegistry,
253 matcher: Option<SkillMatcherBackend>,
254 max_active_skills: usize,
255 tool_executor: impl ToolExecutor + 'static,
256 ) -> Self {
257 let registry = Arc::new(RwLock::new(registry));
258 let embedding_provider = provider.clone();
259 Self::new_with_registry_arc(
260 provider,
261 embedding_provider,
262 channel,
263 registry,
264 matcher,
265 max_active_skills,
266 tool_executor,
267 )
268 }
269
270 #[must_use]
277 pub fn new_with_registry_arc(
278 provider: AnyProvider,
279 embedding_provider: AnyProvider,
280 channel: C,
281 registry: Arc<RwLock<SkillRegistry>>,
282 matcher: Option<SkillMatcherBackend>,
283 max_active_skills: usize,
284 tool_executor: impl ToolExecutor + 'static,
285 ) -> Self {
286 use state::{
287 AgentRuntime, CompressionState, DebugState, ExperimentState, FeedbackState, IndexState,
288 InstructionState, LifecycleState, McpState, MemoryState, MetricsState,
289 OrchestrationState, ProviderState, RuntimeConfig, SecurityState, Services,
290 SessionState, SkillState, ToolState,
291 };
292
293 debug_assert!(max_active_skills > 0, "max_active_skills must be > 0");
294 let catalog_skills: Vec<Skill> = {
299 let reg = registry.read();
300 reg.all_meta()
301 .into_iter()
302 .map(|m| Skill {
303 meta: m.clone(),
304 body: String::new(),
305 resources: zeph_skills::resource::SkillResources::default(),
306 })
307 .collect()
308 };
309 let skills_prompt = format_skills_catalog(&catalog_skills);
310 let system_prompt = build_system_prompt(&skills_prompt, None);
311 tracing::debug!(len = system_prompt.len(), "initial system prompt built");
312 tracing::trace!(prompt = %system_prompt, "full system prompt");
313
314 let initial_prompt_tokens = estimate_tokens(&system_prompt) as u64;
315 let token_counter = Arc::new(TokenCounter::new());
316
317 let services = Services {
318 memory: MemoryState::default(),
319 skill: SkillState::new(registry, matcher, max_active_skills, skills_prompt),
320 learning_engine: learning_engine::LearningEngine::new(),
321 feedback: FeedbackState::default(),
322 mcp: McpState::default(),
323 index: IndexState::default(),
324 session: SessionState::new(),
325 security: SecurityState::default(),
326 experiments: ExperimentState::new(),
327 compression: CompressionState::default(),
328 orchestration: OrchestrationState::default(),
329 focus: focus::FocusState::default(),
330 sidequest: sidequest::SidequestState::default(),
331 tool_state: ToolState::default(),
332 goal_accounting: None,
333 quality: None,
334 proactive_explorer: None,
335 promotion_engine: None,
336 taco_compressor: None,
337 speculation_engine: None,
338 autonomous: crate::goal::AutonomousDriver::new(tokio::time::Duration::from_millis(500)),
339 autonomous_registry: crate::goal::AutonomousRegistry::new(),
340 };
341
342 let runtime = AgentRuntime {
343 config: RuntimeConfig::default(),
344 lifecycle: LifecycleState::new(),
345 providers: ProviderState::new(initial_prompt_tokens),
346 metrics: MetricsState::new(token_counter),
347 debug: DebugState::default(),
348 instructions: InstructionState::default(),
349 ephemeral_plugins: Vec::new(),
350 };
351
352 Self {
353 provider,
354 embedding_provider,
355 channel,
356 tool_executor: Arc::new(tool_executor),
357 msg: MessageState {
358 messages: vec![Message {
359 role: Role::System,
360 content: system_prompt,
361 parts: vec![],
362 metadata: MessageMetadata::default(),
363 }],
364 message_queue: VecDeque::new(),
365 pending_image_parts: Vec::new(),
366 last_persisted_message_id: None,
367 deferred_db_hide_ids: Vec::new(),
368 deferred_db_summaries: Vec::new(),
369 history_preloaded: false,
370 history_cursor: 0,
371 non_system_count: 0,
372 },
373 context_manager: context_manager::ContextManager::new(),
374 tool_orchestrator: tool_orchestrator::ToolOrchestrator::new(),
375 services,
376 runtime,
377 }
378 }
379
380 #[must_use]
393 pub fn into_channel(self) -> C {
394 self.channel
395 }
396
397 #[tracing::instrument(name = "core.agent.run", skip_all, level = "debug", err)]
403 #[allow(clippy::too_many_lines)] pub async fn run(&mut self) -> Result<(), error::AgentError>
405 where
406 C: 'static,
407 {
408 if let Some(mut rx) = self.runtime.lifecycle.warmup_ready.take()
409 && !*rx.borrow()
410 {
411 let _ = rx.changed().await;
412 if !*rx.borrow() {
413 tracing::warn!("model warmup did not complete successfully");
414 }
415 }
416
417 self.restore_channel_provider().await;
419
420 self.load_and_cache_session_digest().await;
422 self.maybe_send_resume_recap().await;
423
424 self.maybe_start_heuristic_promotion();
428
429 loop {
430 self.services.session.is_guest_context = false;
439 state::persistence::DEFAULT_OWNER_KEY.clone_into(&mut self.services.session.owner_key);
440
441 self.apply_provider_override();
442 self.check_tool_refresh().await;
443 self.process_pending_elicitations().await;
444 self.refresh_subagent_metrics();
445 self.notify_completed_subagents().await?;
446 self.drain_channel();
447
448 let (text, image_parts) = if let Some(queued) = self.msg.message_queue.pop_front() {
449 self.notify_queue_count().await;
450 if queued.raw_attachments.is_empty() {
451 (queued.text, queued.image_parts)
452 } else {
453 let msg = crate::channel::ChannelMessage {
454 text: queued.text,
455 attachments: queued.raw_attachments,
456 is_guest_context: false,
457 is_from_bot: false,
458 owner_key: None,
459 };
460 self.resolve_message(msg).await
461 }
462 } else {
463 match self.next_event().await? {
464 None | Some(LoopEvent::Shutdown) => break,
465 Some(LoopEvent::SkillReload) => {
466 self.reload_skills().await;
467 continue;
468 }
469 Some(LoopEvent::InstructionReload) => {
470 self.reload_instructions().await;
471 continue;
472 }
473 Some(LoopEvent::ConfigReload) => {
474 self.reload_config();
475 continue;
476 }
477 Some(LoopEvent::UpdateNotification(msg)) => {
478 if let Err(e) = self.channel.send(&msg).await {
479 tracing::warn!("failed to send update notification: {e}");
480 }
481 continue;
482 }
483 Some(LoopEvent::ExperimentCompleted(msg)) => {
484 self.services.experiments.cancel = None;
485 self.services.experiments.handle = None;
486 if let Err(e) = self.channel.send(&msg).await {
487 tracing::warn!("failed to send experiment completion: {e}");
488 }
489 continue;
490 }
491 Some(LoopEvent::ScheduledTask(prompt)) => {
492 let text = format!("{SCHEDULED_TASK_PREFIX}{prompt}");
493 let msg = crate::channel::ChannelMessage {
494 text,
495 attachments: Vec::new(),
496 is_guest_context: false,
497 is_from_bot: false,
498 owner_key: None,
499 };
500 self.drain_channel();
501 self.resolve_message(msg).await
502 }
503 Some(LoopEvent::TaskInjected(injection)) => {
504 if let Some(ref mut ls) = self.runtime.lifecycle.user_loop {
505 ls.iteration += 1;
506 tracing::info!(iteration = ls.iteration, "loop: tick");
507 }
508 let msg = crate::channel::ChannelMessage {
509 text: injection.prompt,
510 attachments: Vec::new(),
511 is_guest_context: false,
512 is_from_bot: false,
513 owner_key: None,
514 };
515 self.drain_channel();
516 self.resolve_message(msg).await
517 }
518 Some(LoopEvent::FileChanged(event)) => {
519 self.handle_file_changed(event).await;
520 continue;
521 }
522 Some(LoopEvent::AutonomousTick) => {
523 if let Err(e) = self.run_autonomous_turn().await {
524 tracing::warn!(error = %e, "autonomous turn error");
525 }
526 continue;
527 }
528 Some(LoopEvent::BgMetricsTick) => {
529 self.reap_background_tasks_and_update_metrics();
530 continue;
531 }
532 Some(LoopEvent::Message(msg)) => {
533 self.services.session.is_guest_context = msg.is_guest_context;
534 self.services.session.owner_key = msg
535 .owner_key
536 .clone()
537 .unwrap_or_else(|| state::persistence::DEFAULT_OWNER_KEY.to_owned());
538 self.drain_channel();
539 self.resolve_message(msg).await
540 }
541 }
542 };
543
544 let trimmed = text.trim();
545
546 if trimmed.starts_with('/') {
549 let slash_urls = zeph_sanitizer::exfiltration::extract_flagged_urls(trimmed);
550 if !slash_urls.is_empty() {
551 self.services
552 .security
553 .user_provided_urls
554 .write()
555 .extend(slash_urls);
556 }
557 }
558
559 let trusted = self.channel.supports_exit();
582 let session_impl = command_context_impls::SessionAccessImpl {
583 supports_exit: trusted,
584 history_expand_default_lines: self
585 .runtime
586 .config
587 .resume_config
588 .expand_default_lines,
589 };
590 let mut messages_impl = command_context_impls::MessageAccessImpl {
591 msg: &mut self.msg,
592 tool_state: &mut self.services.tool_state,
593 providers: &mut self.runtime.providers,
594 metrics: &self.runtime.metrics,
595 security: &mut self.services.security,
596 tool_orchestrator: &mut self.tool_orchestrator,
597 };
598 let mut sink_adapter = crate::channel::ChannelSinkAdapter(&mut self.channel);
600 let mut null_agent = zeph_commands::NullAgent;
602 let registry_handled = {
603 let reg = slash_commands::build_session_debug_registry();
604
605 let mut ctx = zeph_commands::CommandContext {
606 sink: &mut sink_adapter,
607 debug: &mut self.runtime.debug,
608 messages: &mut messages_impl,
609 session: &session_impl,
610 agent: &mut null_agent,
611 };
612 reg.dispatch(&mut ctx, trimmed, trusted).await
613 };
614 let session_reg_missed = registry_handled.is_none();
615 match self
616 .apply_dispatch_result(registry_handled, trimmed, false)
617 .await
618 {
619 DispatchFlow::Break => break,
620 DispatchFlow::Continue => continue,
621 DispatchFlow::Fallthrough => {
622 }
624 }
625
626 let mut agent_null_debug = command_context_impls::NullDebugAccess;
632 let mut agent_null_messages = command_context_impls::NullMessageAccess;
633 let agent_null_session = command_context_impls::NullSessionAccess;
634 let mut agent_null_sink = zeph_commands::NullSink;
635 let agent_result: Option<
636 Result<zeph_commands::CommandOutput, zeph_commands::CommandError>,
637 > = if session_reg_missed {
638 let agent_reg = slash_commands::build_agent_command_registry();
639
640 let mut ctx = zeph_commands::CommandContext {
641 sink: &mut agent_null_sink,
642 debug: &mut agent_null_debug,
643 messages: &mut agent_null_messages,
644 session: &agent_null_session,
645 agent: self,
646 };
647 agent_reg.dispatch(&mut ctx, trimmed, trusted).await
649 } else {
650 None
651 };
652 if let Some((cancelled_id, new_id)) = self.services.autonomous.flush_pending_start() {
658 if let Some(cid) = cancelled_id {
659 tracing::info!(
660 goal_id = cid,
661 "autonomous: previous session cancelled for new goal"
662 );
663 }
664 self.sync_registry_entry();
665 tracing::info!(goal_id = new_id, "autonomous: session started");
666 }
667
668 match self
671 .apply_dispatch_result(agent_result, trimmed, true)
672 .await
673 {
674 DispatchFlow::Break => break,
675 DispatchFlow::Continue => continue,
676 DispatchFlow::Fallthrough => {
677 }
679 }
680
681 match self.handle_builtin_command(trimmed) {
682 Some(true) => break,
683 Some(false) => continue,
684 None => {}
685 }
686
687 self.process_user_message(text, image_parts).await?;
688 }
689
690 self.maybe_autodream().await;
693
694 self.maybe_extract_skills_from_trace().await;
696
697 if let Some(ref mut tc) = self.runtime.debug.trace_collector
703 && let Some(handle) = tc.finish()
704 && let Err(e) = handle.await
705 {
706 tracing::warn!(error = %e, "trace.json write task did not complete");
707 }
708
709 Ok(())
710 }
711
712 async fn apply_dispatch_result(
718 &mut self,
719 result: Option<Result<zeph_commands::CommandOutput, zeph_commands::CommandError>>,
720 command: &str,
721 with_learning: bool,
722 ) -> DispatchFlow {
723 match result {
724 Some(Ok(zeph_commands::CommandOutput::Exit)) => {
725 let _ = self.channel.flush_chunks().await;
726 DispatchFlow::Break
727 }
728 Some(Ok(zeph_commands::CommandOutput::Message(msg))) => {
729 let _ = self.channel.send(&msg).await;
730 let _ = self.channel.flush_chunks().await;
731 if with_learning {
732 self.maybe_trigger_post_command_learning(command).await;
733 }
734 DispatchFlow::Continue
735 }
736 Some(Ok(_)) => {
737 let _ = self.channel.flush_chunks().await;
738 DispatchFlow::Continue
739 }
740 Some(Err(e)) => {
741 let _ = self.channel.send(&e.to_string()).await;
742 let _ = self.channel.flush_chunks().await;
743 tracing::warn!(command = %command, error = %e.0, "slash command failed");
744 DispatchFlow::Continue
745 }
746 None => DispatchFlow::Fallthrough,
747 }
748 }
749
750 fn apply_provider_override(&mut self) {
752 let taken = self
753 .runtime
754 .providers
755 .provider_override
756 .as_ref()
757 .and_then(|slot| slot.write().take());
758 if let Some(new_provider) = taken {
759 tracing::debug!(provider = new_provider.name(), "ACP model override applied");
760 self.set_provider(new_provider);
761 }
762 }
763
764 fn set_provider(&mut self, provider: AnyProvider) {
781 let provider = match self.services.security.secret_registry.clone() {
782 Some(registry) if !matches!(provider, AnyProvider::Masked(_)) => {
783 provider.masked(registry as Arc<dyn zeph_llm::masking::OutboundMasker>)
784 }
785 _ => provider,
786 };
787 debug_assert!(
788 self.services.security.secret_registry.is_none()
789 || matches!(provider, AnyProvider::Masked(_)),
790 "set_provider invariant violated: secret masking is enabled but the new provider \
791 is not wrapped via AnyProvider::masked — every self.provider reassignment must go \
792 through Agent::set_provider, never assign the field directly"
793 );
794 self.provider = provider;
795 }
796
797 #[tracing::instrument(name = "core.agent.next_event", skip_all, level = "debug", err)]
805 async fn next_event(&mut self) -> Result<Option<LoopEvent>, error::AgentError> {
806 let event = tokio::select! {
807 result = self.channel.recv() => {
808 return Ok(result?.map(LoopEvent::Message));
809 }
810 () = shutdown_signal(&mut self.runtime.lifecycle.shutdown) => {
811 tracing::info!("shutting down");
812 LoopEvent::Shutdown
813 }
814 Some(_) = recv_optional(&mut self.services.skill.skill_reload_rx) => {
815 LoopEvent::SkillReload
816 }
817 Some(_) = recv_optional(&mut self.runtime.instructions.reload_rx) => {
818 LoopEvent::InstructionReload
819 }
820 Some(_) = recv_optional(&mut self.runtime.lifecycle.config_reload_rx) => {
821 LoopEvent::ConfigReload
822 }
823 Some(msg) = recv_optional(&mut self.runtime.lifecycle.update_notify_rx) => {
824 LoopEvent::UpdateNotification(msg)
825 }
826 Some(msg) = recv_optional(&mut self.services.experiments.notify_rx) => {
827 LoopEvent::ExperimentCompleted(msg)
828 }
829 Some(prompt) = recv_optional(&mut self.runtime.lifecycle.custom_task_rx) => {
830 tracing::info!("scheduler: injecting custom task as agent turn");
831 LoopEvent::ScheduledTask(prompt)
832 }
833 () = async {
834 if let Some(ref mut ls) = self.runtime.lifecycle.user_loop {
835 if ls.cancel_tx.is_cancelled() {
836 std::future::pending::<()>().await;
837 } else {
838 ls.interval.tick().await;
839 }
840 } else {
841 std::future::pending::<()>().await;
842 }
843 } => {
844 let Some(ls) = self.runtime.lifecycle.user_loop.as_ref() else {
848 return Ok(None);
849 };
850 if ls.cancel_tx.is_cancelled() {
851 self.runtime.lifecycle.user_loop = None;
852 return Ok(None);
853 }
854 let prompt = ls.prompt.clone();
855 LoopEvent::TaskInjected(task_injection::TaskInjection { prompt })
856 }
857 Some(event) = recv_optional(&mut self.runtime.lifecycle.file_changed_rx) => {
858 LoopEvent::FileChanged(event)
859 }
860 () = self.services.autonomous.next_tick(),
862 if self.services.autonomous.should_tick() => {
863 LoopEvent::AutonomousTick
864 }
865 _ = self
878 .runtime
879 .lifecycle
880 .bg_metrics_tick
881 .get_or_insert_with(|| {
882 let mut iv = tokio::time::interval_at(
883 tokio::time::Instant::now() + state::BG_METRICS_TICK_INTERVAL,
884 state::BG_METRICS_TICK_INTERVAL,
885 );
886 iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
887 iv
888 })
889 .tick() => {
890 LoopEvent::BgMetricsTick
891 }
892 };
893 Ok(Some(event))
894 }
895
896 #[tracing::instrument(name = "core.agent.resolve_message", skip_all, level = "debug")]
897 async fn resolve_message(
898 &self,
899 msg: crate::channel::ChannelMessage,
900 ) -> (String, Vec<zeph_llm::provider::MessagePart>) {
901 use crate::channel::{Attachment, AttachmentKind};
902 use zeph_llm::provider::{ImageData, MessagePart};
903
904 let text_base = msg.text.clone();
905
906 let (audio_attachments, image_attachments): (Vec<Attachment>, Vec<Attachment>) = msg
907 .attachments
908 .into_iter()
909 .partition(|a| a.kind == AttachmentKind::Audio);
910
911 tracing::debug!(
912 audio = audio_attachments.len(),
913 has_stt = self.runtime.providers.stt.is_some(),
914 "resolve_message attachments"
915 );
916
917 let text = if !audio_attachments.is_empty()
918 && let Some(stt) = self.runtime.providers.stt.as_ref()
919 {
920 let mut transcribed_parts = Vec::new();
921 for attachment in &audio_attachments {
922 if attachment.data.len() > MAX_AUDIO_BYTES {
923 tracing::warn!(
924 size = attachment.data.len(),
925 max = MAX_AUDIO_BYTES,
926 "audio attachment exceeds size limit, skipping"
927 );
928 continue;
929 }
930 match stt
931 .transcribe(&attachment.data, attachment.filename.as_deref())
932 .await
933 {
934 Ok(result) => {
935 tracing::info!(
936 len = result.text.len(),
937 language = ?result.language,
938 "audio transcribed"
939 );
940 transcribed_parts.push(result.text);
941 }
942 Err(e) => {
943 tracing::error!(error = %e, "audio transcription failed");
944 }
945 }
946 }
947 if transcribed_parts.is_empty() {
948 text_base
949 } else {
950 let transcribed = transcribed_parts.join("\n");
951 if text_base.is_empty() {
952 transcribed
953 } else {
954 format!("[transcribed audio]\n{transcribed}\n\n{text_base}")
955 }
956 }
957 } else {
958 if !audio_attachments.is_empty() {
959 tracing::warn!(
960 count = audio_attachments.len(),
961 "audio attachments received but no STT provider configured, dropping"
962 );
963 }
964 text_base
965 };
966
967 let mut image_parts = Vec::new();
968 for attachment in image_attachments {
969 if attachment.data.len() > MAX_IMAGE_BYTES {
970 tracing::warn!(
971 size = attachment.data.len(),
972 max = MAX_IMAGE_BYTES,
973 "image attachment exceeds size limit, skipping"
974 );
975 continue;
976 }
977 let mime_type = detect_image_mime(attachment.filename.as_deref()).to_string();
978 image_parts.push(MessagePart::Image(Box::new(ImageData {
979 data: attachment.data,
980 mime_type,
981 })));
982 }
983
984 (text, image_parts)
985 }
986
987 fn begin_turn(&mut self, input: turn::TurnInput) -> turn::Turn {
994 let id = turn::TurnId(self.runtime.debug.iteration_counter as u64);
995 self.runtime.debug.iteration_counter += 1;
996 let cancel_token = CancellationToken::new();
997 self.runtime.lifecycle.cancel_token = cancel_token.clone();
999 self.services.security.user_provided_urls.write().clear();
1000 self.runtime.lifecycle.turn_llm_requests = 0;
1002 self.runtime.lifecycle.turn_tool_calls = 0;
1004
1005 {
1008 use crate::agent::trajectory::{RiskSignal, VigilRiskLevel};
1009 use zeph_memory::shadow::{AuditSignalType as MageSignal, Severity as MageSev};
1010 let pending: Vec<u8> = {
1011 let mut q = self.services.security.trajectory_signal_queue.lock();
1012 std::mem::take(&mut *q)
1013 };
1014 self.services.security.mage_accumulator.advance_turn();
1015 for code in pending {
1016 let signal = RiskSignal::from_code(code);
1017 self.services.security.trajectory.record(signal);
1018 let mage_signal: Option<(MageSignal, MageSev)> = match signal {
1026 RiskSignal::PolicyDeny => Some((MageSignal::PolicyViolation, MageSev::Medium)),
1027 RiskSignal::ExfiltrationRedaction => {
1028 Some((MageSignal::ToolChainAnomaly, MageSev::Medium))
1029 }
1030 RiskSignal::VigilFlagged(VigilRiskLevel::Medium) => {
1031 Some((MageSignal::PromptInjectionPattern, MageSev::Medium))
1032 }
1033 RiskSignal::VigilFlagged(VigilRiskLevel::High) => {
1034 Some((MageSignal::PromptInjectionPattern, MageSev::High))
1035 }
1036 _ => None,
1037 };
1038 if let Some((sig, sev)) = mage_signal {
1039 self.services.security.mage_accumulator.ingest(sig, sev);
1040 }
1041 }
1042 }
1043 if self.services.security.trajectory.advance_turn()
1046 && let Some(logger) = self.tool_orchestrator.audit_logger.clone()
1047 {
1048 let entry = zeph_tools::AuditEntry {
1049 timestamp: zeph_tools::chrono_now(),
1050 tool: "<sentinel>".to_owned().into(),
1051 command: String::new(),
1052 result: zeph_tools::AuditResult::Success,
1053 duration_ms: 0,
1054 error_category: Some("trajectory_auto_recover".to_owned()),
1055 error_domain: Some("security".to_owned()),
1056 error_phase: None,
1057 claim_source: None,
1058 mcp_server_id: None,
1059 injection_flagged: false,
1060 embedding_anomalous: false,
1061 cross_boundary_mcp_to_acp: false,
1062 adversarial_policy_decision: None,
1063 exit_code: None,
1064 truncated: false,
1065 caller_id: None,
1066 skill_name: None,
1067 policy_match: None,
1068 correlation_id: None,
1069 vigil_risk: None,
1070 execution_env: None,
1071 resolved_cwd: None,
1072 scope_at_definition: None,
1073 scope_at_dispatch: None,
1074 };
1075 self.runtime.lifecycle.supervisor.spawn(
1076 crate::agent::agent_supervisor::TaskClass::Telemetry,
1077 "trajectory-auto-recover-audit",
1078 async move { logger.log(&entry).await },
1079 );
1080 }
1081 if let Some(ref sentinel) = self.services.security.shadow_sentinel {
1083 sentinel.advance_turn();
1084 }
1085 if let Some(ref acc) = self.services.security.risk_chain_accumulator {
1087 acc.reset();
1088 }
1089 let risk_level = self.services.security.trajectory.current_risk();
1091 *self.services.security.trajectory_risk_slot.write() = u8::from(risk_level);
1092 if let Some(alert) = self.services.security.trajectory.poll_alert() {
1094 let msg = format!(
1095 "[trajectory] Risk level: {:?} (score={:.2})",
1096 alert.level, alert.score
1097 );
1098 tracing::warn!(
1099 level = ?alert.level,
1100 score = alert.score,
1101 "trajectory sentinel alert"
1102 );
1103 if let Some(ref tx) = self.services.session.status_tx {
1104 let _ = tx.send(msg);
1105 }
1106 }
1107
1108 let context = turn::TurnContext::new(id, cancel_token, self.runtime.config.timeouts)
1109 .with_tool_allowlist(self.runtime.config.channel_tool_allowlist.clone());
1110 turn::Turn::new(context, input)
1111 }
1112
1113 fn end_turn(&mut self, turn: turn::Turn) {
1120 self.runtime.metrics.pending_timings = turn.metrics.timings;
1121 self.flush_turn_timings();
1122 self.services.session.current_turn_intent = None;
1124 self.services.session.is_guest_context = false;
1126 state::persistence::DEFAULT_OWNER_KEY.clone_into(&mut self.services.session.owner_key);
1133 if let Some(ref engine) = self.services.speculation_engine {
1135 let metrics = engine.end_turn();
1136 if metrics.committed > 0 || metrics.cancelled > 0 {
1137 tracing::debug!(
1138 committed = metrics.committed,
1139 cancelled = metrics.cancelled,
1140 wasted_ms = metrics.wasted_ms,
1141 "speculation: turn boundary metrics"
1142 );
1143 }
1144 }
1145 }
1146
1147 #[tracing::instrument(
1148 name = "core.agent.process_user_message",
1149 skip_all,
1150 level = "debug",
1151 fields(turn_id),
1152 err
1153 )]
1154 async fn process_user_message(
1155 &mut self,
1156 text: String,
1157 image_parts: Vec<zeph_llm::provider::MessagePart>,
1158 ) -> Result<(), error::AgentError> {
1159 self.apply_provider_override();
1164
1165 let input = turn::TurnInput::new(text, image_parts);
1166 let mut t = self.begin_turn(input);
1167
1168 let turn_idx = usize::try_from(t.id().0).unwrap_or(usize::MAX);
1169 tracing::Span::current().record("turn_id", t.id().0);
1170 self.runtime
1172 .debug
1173 .start_iteration_span(turn_idx, t.input.text.trim());
1174
1175 let result = Box::pin(self.process_user_message_inner(&mut t)).await;
1176
1177 let span_status = if result.is_ok() {
1179 crate::debug_dump::trace::SpanStatus::Ok
1180 } else {
1181 crate::debug_dump::trace::SpanStatus::Error {
1182 message: "iteration failed".to_owned(),
1183 }
1184 };
1185 self.runtime.debug.end_iteration_span(turn_idx, span_status);
1186
1187 self.end_turn(t);
1188 result
1189 }
1190
1191 #[allow(clippy::too_many_lines)] #[tracing::instrument(
1193 name = "core.agent.process_user_message_inner",
1194 skip_all,
1195 level = "debug",
1196 err
1197 )]
1198 async fn process_user_message_inner(
1199 &mut self,
1200 turn: &mut turn::Turn,
1201 ) -> Result<(), error::AgentError> {
1202 self.reap_background_tasks_and_update_metrics();
1203
1204 let tokens_before_turn = self
1205 .runtime
1206 .metrics
1207 .metrics_tx
1208 .as_ref()
1209 .map_or(0, |tx| tx.borrow().total_tokens);
1210
1211 self.drain_background_completions();
1215
1216 self.wire_cancel_bridge(turn.cancel_token());
1217
1218 let text = turn.input.text.clone();
1220 let trimmed_owned = text.trim().to_owned();
1221 let trimmed = trimmed_owned.as_str();
1222
1223 if self.services.security.vigil.is_some() {
1226 let intent_len = trimmed.floor_char_boundary(1024.min(trimmed.len()));
1227 self.services.session.current_turn_intent = Some(trimmed[..intent_len].to_owned());
1228 }
1229
1230 if let Some(result) = self.dispatch_slash_command(trimmed).await {
1231 return result;
1232 }
1233
1234 let text = self.sanitize_channel_text_if_untrusted(text);
1236 let trimmed_owned = text.trim().to_owned();
1237 let trimmed = trimmed_owned.as_str();
1238
1239 self.check_pending_rollbacks().await;
1240
1241 if self.pre_process_security(trimmed).await? {
1242 return Ok(());
1243 }
1244
1245 let t_ctx = std::time::Instant::now();
1246 tracing::debug!("turn timing: prepare_context start");
1247 self.advance_context_lifecycle_guarded(&text, trimmed).await;
1248 turn.metrics_mut().timings.prepare_context_ms =
1249 u64::try_from(t_ctx.elapsed().as_millis()).unwrap_or(u64::MAX);
1250 tracing::debug!(
1251 ms = turn.metrics_snapshot().timings.prepare_context_ms,
1252 "turn timing: prepare_context done"
1253 );
1254 let _ = self
1256 .channel
1257 .send_context_estimate(
1258 usize::try_from(self.runtime.providers.cached_prompt_tokens).unwrap_or(usize::MAX),
1259 )
1260 .await;
1261
1262 let image_parts = std::mem::take(&mut turn.input.image_parts);
1263 let merged_text = self.build_user_message_text_with_bg_completions(&text);
1267 let user_msg = self.build_user_message(&merged_text, image_parts);
1268
1269 let urls = zeph_sanitizer::exfiltration::extract_flagged_urls(trimmed);
1272 if !urls.is_empty() {
1273 self.services
1274 .security
1275 .user_provided_urls
1276 .write()
1277 .extend(urls);
1278 }
1279
1280 self.services.memory.extraction.goal_text = Some(text.clone());
1283
1284 let t_persist = std::time::Instant::now();
1285 tracing::debug!("turn timing: persist_message(user) start");
1286 self.persist_message(Role::User, &text, &[], false).await;
1288 turn.metrics_mut().timings.persist_message_ms =
1289 u64::try_from(t_persist.elapsed().as_millis()).unwrap_or(u64::MAX);
1290 tracing::debug!(
1291 ms = turn.metrics_snapshot().timings.persist_message_ms,
1292 "turn timing: persist_message(user) done"
1293 );
1294 self.push_message(user_msg);
1295
1296 let context_estimate = self.runtime.providers.cached_prompt_tokens;
1298 self.update_metrics(|m| m.context_tokens = context_estimate);
1299
1300 tracing::debug!("turn timing: process_response start");
1303 let turn_had_error = if let Err(e) = self.process_response().await {
1304 self.services.learning_engine.learning_tasks.detach_all();
1306 tracing::error!("Response processing failed: {e:#}");
1307
1308 if e.is_no_providers() {
1311 self.runtime.lifecycle.last_no_providers_at = Some(std::time::Instant::now());
1312 let backoff_secs = self.runtime.config.timeouts.no_providers_backoff_secs;
1313 tracing::warn!(
1314 backoff_secs,
1315 "no providers available; backing off before next turn"
1316 );
1317 tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await;
1318 }
1319
1320 let user_msg = format!("Error: {e:#}");
1321 self.channel.send(&user_msg).await?;
1322 if let Some(popped) = self.msg.messages.pop() {
1323 self.msg.track_single_message(popped.role, false);
1324 }
1325 self.recompute_prompt_tokens();
1326 self.channel.flush_chunks().await?;
1327 true
1328 } else {
1329 self.services.learning_engine.learning_tasks.detach_all();
1332 self.truncate_old_tool_results();
1333 self.maybe_update_magic_docs();
1335 self.maybe_spawn_promotion_scan();
1337 false
1338 };
1339 tracing::debug!("turn timing: process_response done");
1340
1341 if let Some(pipeline) = self.services.quality.clone() {
1343 self.run_self_check_for_turn(pipeline, turn.id().0).await;
1344 }
1345 let _ = self.channel.flush_chunks().await;
1350
1351 self.maybe_fire_completion_notification(turn, turn_had_error);
1352
1353 self.flush_goal_accounting(tokens_before_turn);
1354
1355 turn.metrics_mut().timings.llm_chat_ms = self.runtime.metrics.pending_timings.llm_chat_ms;
1360 turn.metrics_mut().timings.tool_exec_ms = self.runtime.metrics.pending_timings.tool_exec_ms;
1361
1362 Ok(())
1363 }
1364
1365 fn wire_cancel_bridge(&mut self, turn_token: &tokio_util::sync::CancellationToken) {
1371 let signal = Arc::clone(&self.runtime.lifecycle.cancel_signal);
1372 let token = turn_token.clone();
1373 self.runtime.lifecycle.cancel_token = turn_token.clone();
1375 if let Some(prev) = self.runtime.lifecycle.cancel_bridge_handle.take() {
1376 prev.abort();
1377 }
1378 self.runtime.lifecycle.cancel_bridge_handle =
1379 Some(self.runtime.lifecycle.task_supervisor.spawn_oneshot(
1380 std::sync::Arc::from("agent.lifecycle.cancel_bridge"),
1381 move || async move {
1382 signal.notified().await;
1383 token.cancel();
1384 },
1385 ));
1386 }
1387
1388 fn reap_background_tasks_and_update_metrics(&mut self) {
1395 let bg_signal = self.runtime.lifecycle.supervisor.reap();
1396 if bg_signal.did_summarize {
1397 self.services.memory.persistence.unsummarized_count = 0;
1398 tracing::debug!("background summarization completed; unsummarized_count reset");
1399 }
1400 let snap = self.runtime.lifecycle.supervisor.metrics_snapshot();
1401 self.update_metrics(|m| {
1402 m.bg_inflight = snap.inflight as u64;
1403 m.bg_dropped = snap.total_dropped();
1404 m.bg_completed = snap.total_completed();
1405 m.bg_enrichment_inflight = snap.class_inflight[0] as u64;
1406 m.bg_telemetry_inflight = snap.class_inflight[1] as u64;
1407 });
1408
1409 if self.runtime.lifecycle.shell_executor_handle.is_some() {
1411 let shell_rows: Vec<crate::metrics::ShellBackgroundRunRow> = self
1412 .runtime
1413 .lifecycle
1414 .shell_executor_handle
1415 .as_ref()
1416 .map(|e| e.background_runs_snapshot())
1417 .unwrap_or_default()
1418 .into_iter()
1419 .map(|s| crate::metrics::ShellBackgroundRunRow {
1420 run_id: truncate_shell_run_id(&s.run_id),
1421 command: truncate_shell_command(&s.command),
1422 elapsed_secs: s.elapsed_ms / 1000,
1423 })
1424 .collect();
1425 self.update_metrics(|m| {
1426 m.shell_background_runs = shell_rows;
1427 });
1428 }
1429
1430 if self
1433 .runtime
1434 .config
1435 .supervisor_config
1436 .abort_enrichment_on_turn
1437 {
1438 self.runtime
1439 .lifecycle
1440 .supervisor
1441 .abort_class(agent_supervisor::TaskClass::Enrichment);
1442 }
1443 }
1444
1445 fn maybe_fire_completion_notification(&mut self, turn: &turn::Turn, is_error: bool) {
1458 let snap = turn.metrics_snapshot().timings.clone();
1459 let duration_ms = snap
1460 .prepare_context_ms
1461 .saturating_add(snap.llm_chat_ms)
1462 .saturating_add(snap.tool_exec_ms);
1463 let summary = crate::notifications::TurnSummary {
1464 duration_ms,
1465 preview: self.last_assistant_preview(160),
1466 tool_calls: self.runtime.lifecycle.turn_tool_calls,
1467 llm_requests: self.runtime.lifecycle.turn_llm_requests,
1468 exit_status: if is_error {
1469 crate::notifications::TurnExitStatus::Error
1470 } else {
1471 crate::notifications::TurnExitStatus::Success
1472 },
1473 };
1474
1475 let gate_ok = self
1477 .runtime
1478 .lifecycle
1479 .notifier
1480 .as_ref()
1481 .is_none_or(|n| n.should_fire(&summary));
1482
1483 if let Some(ref notifier) = self.runtime.lifecycle.notifier
1485 && gate_ok
1486 {
1487 notifier.fire(&summary, &mut self.runtime.lifecycle.supervisor);
1488 }
1489
1490 let hooks = self.services.session.hooks_config.turn_complete.clone();
1495 if !hooks.is_empty() && gate_ok {
1496 let mut env = build_turn_hook_env(&summary, is_error);
1497 let conv_id_str = self
1498 .services
1499 .memory
1500 .persistence
1501 .conversation_id
1502 .map(|id| id.0.to_string());
1503 crate::agent::hooks_dispatch::insert_main_agent_ctx(&mut env, conv_id_str.as_deref());
1504 let dispatch = self.mcp_dispatch();
1505 let _span = tracing::info_span!("core.agent.turn_hooks").entered();
1506 let _accepted = self.runtime.lifecycle.supervisor.spawn(
1507 agent_supervisor::TaskClass::Telemetry,
1508 "turn-complete-hooks",
1509 async move {
1510 let mcp: Option<&dyn zeph_subagent::McpDispatch> = dispatch
1511 .as_ref()
1512 .map(|d| d as &dyn zeph_subagent::McpDispatch);
1513 if let Err(e) = zeph_subagent::hooks::fire_hooks(&hooks, &env, mcp, None).await
1514 {
1515 tracing::warn!(error = %e, "turn_complete hook failed");
1516 }
1517 },
1518 );
1519 }
1520 }
1521
1522 fn flush_goal_accounting(&mut self, tokens_before: u64) {
1525 let goal_snap = self
1526 .services
1527 .goal_accounting
1528 .as_ref()
1529 .and_then(|a| a.snapshot());
1530 self.update_metrics(|m| m.active_goal = goal_snap);
1531
1532 if let Some(ref accounting) = self.services.goal_accounting {
1533 let tokens_after = self
1534 .runtime
1535 .metrics
1536 .metrics_tx
1537 .as_ref()
1538 .map_or(0, |tx| tx.borrow().total_tokens);
1539 let turn_tokens = tokens_after.saturating_sub(tokens_before);
1540 let mut spawned: Option<
1541 std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'static>>,
1542 > = None;
1543 accounting.on_turn_complete(turn_tokens, |fut| {
1544 spawned = Some(fut);
1545 });
1546 if let Some(fut) = spawned {
1547 let _ = self.runtime.lifecycle.supervisor.spawn(
1548 agent_supervisor::TaskClass::Telemetry,
1549 "goal-accounting",
1550 fut,
1551 );
1552 }
1553 }
1554 }
1555
1556 fn sanitize_channel_text_if_untrusted(&self, text: String) -> String {
1568 if !self.channel.requires_input_sanitization() {
1569 return text;
1570 }
1571 self.services
1572 .security
1573 .sanitizer
1574 .sanitize(
1575 &text,
1576 zeph_sanitizer::ContentSource::new(
1577 zeph_sanitizer::ContentSourceKind::ChannelMessage,
1578 ),
1579 )
1580 .body
1581 }
1582
1583 #[tracing::instrument(
1585 name = "core.agent.pre_process_security",
1586 skip_all,
1587 level = "debug",
1588 err
1589 )]
1590 async fn pre_process_security(&mut self, trimmed: &str) -> Result<bool, error::AgentError> {
1591 if let Some(ref guardrail) = self.services.security.guardrail {
1593 use zeph_sanitizer::guardrail::GuardrailVerdict;
1594 let verdict = guardrail.check(trimmed).await;
1595 match &verdict {
1596 GuardrailVerdict::Flagged { reason, .. } => {
1597 tracing::warn!(
1598 reason = %reason,
1599 should_block = verdict.should_block(),
1600 "guardrail flagged user input"
1601 );
1602 if verdict.should_block() {
1603 let msg = format!("[guardrail] Input blocked: {reason}");
1604 let _ = self.channel.send(&msg).await;
1605 let _ = self.channel.flush_chunks().await;
1606 return Ok(true);
1607 }
1608 let _ = self
1610 .channel
1611 .send(&format!("[guardrail] Warning: {reason}"))
1612 .await;
1613 }
1614 GuardrailVerdict::Error { error } => {
1615 if guardrail.error_should_block() {
1616 tracing::warn!(%error, "guardrail check failed (fail_strategy=closed), blocking input");
1617 let msg = "[guardrail] Input blocked: check failed (see logs for details)";
1618 let _ = self.channel.send(msg).await;
1619 let _ = self.channel.flush_chunks().await;
1620 return Ok(true);
1621 }
1622 tracing::warn!(%error, "guardrail check failed (fail_strategy=open), allowing input");
1623 }
1624 _ => {}
1625 }
1626 }
1627
1628 self.record_nli_verdict(trimmed, "user_input").await;
1631
1632 #[cfg(feature = "classifiers")]
1638 if self.services.security.sanitizer.scan_user_input() {
1639 match self
1640 .services
1641 .security
1642 .sanitizer
1643 .classify_injection(trimmed)
1644 .await
1645 {
1646 zeph_sanitizer::InjectionVerdict::Blocked => {
1647 self.push_classifier_metrics();
1648 let _ = self
1649 .channel
1650 .send("[security] Input blocked: injection detected by classifier.")
1651 .await;
1652 let _ = self.channel.flush_chunks().await;
1653 return Ok(true);
1654 }
1655 zeph_sanitizer::InjectionVerdict::Suspicious => {
1656 tracing::warn!("injection_classifier soft_signal on user input");
1657 }
1658 _ => {}
1659 }
1660 }
1661 #[cfg(feature = "classifiers")]
1662 self.push_classifier_metrics();
1663
1664 Ok(false)
1665 }
1666
1667 async fn advance_context_lifecycle_guarded(&mut self, text: &str, trimmed: &str) {
1674 let backoff_secs = self.runtime.config.timeouts.no_providers_backoff_secs;
1675 let prep_timeout_secs = self.runtime.config.timeouts.context_prep_timeout_secs;
1676
1677 let providers_recently_failed = self
1679 .runtime
1680 .lifecycle
1681 .last_no_providers_at
1682 .is_some_and(|t| t.elapsed().as_secs() < backoff_secs);
1683
1684 if providers_recently_failed {
1685 tracing::warn!(
1686 backoff_secs,
1687 "skipping context preparation: providers were unavailable on last turn"
1688 );
1689 return;
1690 }
1691
1692 let timeout_dur = std::time::Duration::from_secs(prep_timeout_secs);
1693 match tokio::time::timeout(timeout_dur, self.advance_context_lifecycle(text, trimmed)).await
1694 {
1695 Ok(()) => {}
1696 Err(_elapsed) => {
1697 tracing::warn!(
1698 timeout_secs = prep_timeout_secs,
1699 "context preparation timed out; proceeding with degraded context"
1700 );
1701 }
1702 }
1703 }
1704
1705 #[tracing::instrument(
1706 name = "core.agent.advance_context_lifecycle",
1707 skip_all,
1708 level = "debug"
1709 )]
1710 async fn advance_context_lifecycle(&mut self, text: &str, trimmed: &str) {
1711 self.services.mcp.pruning_cache.reset();
1713
1714 let conv_id = self.services.memory.persistence.conversation_id;
1717 self.rebuild_system_prompt(text).await;
1722
1723 self.detect_and_record_corrections(trimmed, conv_id).await;
1724 self.services.learning_engine.tick();
1725 self.analyze_and_learn().await;
1726 self.sync_graph_counts().await;
1727
1728 self.context_manager
1733 .set_compaction_state(self.context_manager.compaction_state().advance_turn());
1734
1735 {
1737 self.services.focus.tick();
1738
1739 let sidequest_should_fire = self.services.sidequest.tick();
1742 if sidequest_should_fire
1743 && !self
1744 .context_manager
1745 .compaction_state()
1746 .is_compacted_this_turn()
1747 {
1748 self.maybe_sidequest_eviction();
1749 }
1750 }
1751
1752 {
1755 let cfg = &self.services.memory.extraction.graph_config.experience;
1756 if cfg.enabled
1757 && cfg.evolution_sweep_enabled
1758 && cfg.evolution_sweep_interval > 0
1759 && self
1760 .services
1761 .sidequest
1762 .turn_counter
1763 .checked_rem(cfg.evolution_sweep_interval as u64)
1764 == Some(0)
1765 && let Some(memory) = self.services.memory.persistence.memory.as_ref()
1766 && let (Some(exp), Some(graph)) =
1767 (memory.experience.as_ref(), memory.graph_store.as_ref())
1768 {
1769 let exp = std::sync::Arc::clone(exp);
1770 let graph = std::sync::Arc::clone(graph);
1771 let threshold = cfg.confidence_prune_threshold;
1772 let turn = self.services.sidequest.turn_counter;
1773 let accepted = self.runtime.lifecycle.supervisor.spawn(
1774 agent_supervisor::TaskClass::Telemetry,
1775 "experience-sweep",
1776 async move {
1777 match exp.evolution_sweep(graph.as_ref(), threshold).await {
1778 Ok(stats) => tracing::info!(
1779 turn,
1780 self_loops = stats.pruned_self_loops,
1781 low_confidence = stats.pruned_low_confidence,
1782 "evolution sweep complete",
1783 ),
1784 Err(e) => tracing::warn!(
1785 turn,
1786 error = %e,
1787 "evolution sweep failed",
1788 ),
1789 }
1790 },
1791 );
1792 if !accepted {
1793 tracing::warn!(
1794 turn = self.services.sidequest.turn_counter,
1795 "experience-sweep dropped (telemetry class at capacity)",
1796 );
1797 }
1798 }
1799 }
1800
1801 if let Some(warning) = self.cache_expiry_warning() {
1803 tracing::info!(warning, "cache expiry warning");
1804 self.channel.send_status_best_effort(&warning).await;
1805 }
1806
1807 self.maybe_time_based_microcompact();
1810
1811 self.maybe_apply_deferred_summaries();
1816 self.flush_deferred_summaries().await;
1817
1818 if let Err(e) = self.maybe_proactive_compress().await {
1820 tracing::warn!("proactive compression failed: {e:#}");
1821 }
1822
1823 if let Err(e) = self.maybe_compact().await {
1824 tracing::warn!("context compaction failed: {e:#}");
1825 }
1826
1827 if let Err(e) = Box::pin(self.prepare_context(trimmed)).await {
1828 tracing::warn!("context preparation failed: {e:#}");
1829 }
1830
1831 self.provider
1833 .set_memory_confidence(self.services.memory.persistence.last_recall_confidence);
1834
1835 self.services.learning_engine.reset_reflection();
1836 }
1837
1838 fn build_user_message(
1839 &mut self,
1840 text: &str,
1841 image_parts: Vec<zeph_llm::provider::MessagePart>,
1842 ) -> Message {
1843 let mut all_image_parts = std::mem::take(&mut self.msg.pending_image_parts);
1844 all_image_parts.extend(image_parts);
1845
1846 if !all_image_parts.is_empty() && self.provider.supports_vision() {
1847 let mut parts = vec![zeph_llm::provider::MessagePart::Text {
1848 text: text.to_owned(),
1849 }];
1850 parts.extend(all_image_parts);
1851 Message::from_parts(Role::User, parts)
1852 } else {
1853 if !all_image_parts.is_empty() {
1854 tracing::warn!(
1855 count = all_image_parts.len(),
1856 "image attachments dropped: provider does not support vision"
1857 );
1858 }
1859 Message {
1860 role: Role::User,
1861 content: text.to_owned(),
1862 parts: vec![],
1863 metadata: MessageMetadata::default(),
1864 }
1865 }
1866 }
1867
1868 fn drain_background_completions(&mut self) {
1872 const BACKGROUND_COMPLETION_BUFFER_CAP: usize = 16;
1873
1874 let Some(ref mut rx) = self.runtime.lifecycle.background_completion_rx else {
1875 return;
1876 };
1877 while let Ok(completion) = rx.try_recv() {
1879 if self.runtime.lifecycle.pending_background_completions.len()
1880 >= BACKGROUND_COMPLETION_BUFFER_CAP
1881 {
1882 tracing::warn!(
1883 run_id = %completion.run_id,
1884 "background completion buffer full; dropping run result"
1885 );
1886 self.runtime
1889 .lifecycle
1890 .pending_background_completions
1891 .pop_front();
1892 self.runtime
1893 .lifecycle
1894 .pending_background_completions
1895 .push_back(zeph_tools::BackgroundCompletion {
1896 run_id: completion.run_id,
1897 exit_code: -1,
1898 success: false,
1899 elapsed_ms: 0,
1900 command: completion.command,
1901 output: format!(
1902 "[background result for run {} dropped: buffer overflow]",
1903 completion.run_id
1904 ),
1905 });
1906 } else {
1907 self.runtime
1908 .lifecycle
1909 .pending_background_completions
1910 .push_back(completion);
1911 }
1912 }
1913 }
1914
1915 fn build_user_message_text_with_bg_completions(&mut self, user_text: &str) -> String {
1919 if self
1920 .runtime
1921 .lifecycle
1922 .pending_background_completions
1923 .is_empty()
1924 {
1925 return user_text.to_owned();
1926 }
1927 let mut parts = String::new();
1928 for completion in self
1929 .runtime
1930 .lifecycle
1931 .pending_background_completions
1932 .drain(..)
1933 {
1934 let _ = write!(
1935 parts,
1936 "[Background task {} completed]\nexit_code: {}\nsuccess: {}\nelapsed_ms: {}\ncommand: {}\n\n{}\n\n",
1937 completion.run_id,
1938 completion.exit_code,
1939 completion.success,
1940 completion.elapsed_ms,
1941 completion.command,
1942 completion.output,
1943 );
1944 }
1945 parts.push_str(user_text);
1946 parts
1947 }
1948
1949 pub(super) fn maybe_spawn_promotion_scan(&mut self) {
1959 let Some(engine) = self.services.promotion_engine.clone() else {
1960 return;
1961 };
1962
1963 let Some(memory) = self.services.memory.persistence.memory.clone() else {
1964 return;
1965 };
1966
1967 let promotion_window = 200usize;
1970
1971 let accepted = self.runtime.lifecycle.supervisor.spawn(
1972 agent_supervisor::TaskClass::Enrichment,
1973 "compression_spectrum.promotion_scan",
1974 async move {
1975 let window = match memory.load_promotion_window(promotion_window).await {
1976 Ok(w) => w,
1977 Err(e) => {
1978 tracing::warn!(error = %e, "promotion scan: failed to load window");
1979 return;
1980 }
1981 };
1982
1983 if window.is_empty() {
1984 return;
1985 }
1986
1987 let candidates = match engine.scan(&window).await {
1988 Ok(c) => c,
1989 Err(e) => {
1990 tracing::warn!(error = %e, "promotion scan: clustering failed");
1991 return;
1992 }
1993 };
1994
1995 for candidate in &candidates {
1996 if let Err(e) = engine.promote(candidate).await {
1997 tracing::warn!(
1998 signature = %candidate.signature,
1999 error = %e,
2000 "promotion scan: promote failed"
2001 );
2002 }
2003 }
2004
2005 tracing::info!(candidates = candidates.len(), "promotion scan: complete");
2006 }
2007 .instrument(tracing::info_span!("memory.compression.promote.background")),
2008 );
2009
2010 if accepted {
2011 tracing::debug!("compression_spectrum: promotion scan task enqueued");
2012 }
2013 }
2014}
2015
2016pub(crate) async fn shutdown_signal(rx: &mut watch::Receiver<bool>) {
2017 while !*rx.borrow_and_update() {
2018 if rx.changed().await.is_err() {
2019 std::future::pending::<()>().await;
2020 }
2021 }
2022}
2023
2024pub(crate) async fn recv_optional<T>(rx: &mut Option<mpsc::Receiver<T>>) -> Option<T> {
2025 match rx {
2026 Some(inner) => {
2027 if let Some(v) = inner.recv().await {
2028 Some(v)
2029 } else {
2030 *rx = None;
2031 std::future::pending().await
2032 }
2033 }
2034 None => std::future::pending().await,
2035 }
2036}
2037
2038fn truncate_shell_command(cmd: &str) -> String {
2040 if cmd.len() <= 80 {
2041 return cmd.to_owned();
2042 }
2043 let end = cmd.floor_char_boundary(79);
2044 format!("{}…", &cmd[..end])
2045}
2046
2047fn truncate_shell_run_id(id: &str) -> String {
2049 id.chars().take(8).collect()
2050}
2051
2052pub enum ContextBudgetSource {
2057 AutoDetected(usize),
2059 Configured,
2061 Fallback,
2063}
2064
2065pub fn resolve_context_budget_tokens(
2072 config: &Config,
2073 provider: &AnyProvider,
2074) -> (usize, ContextBudgetSource) {
2075 if config.memory.auto_budget && config.memory.context_budget_tokens == 0 {
2076 return match provider.context_window() {
2077 Some(ctx_size) if ctx_size > 0 => {
2078 (ctx_size, ContextBudgetSource::AutoDetected(ctx_size))
2079 }
2080 _ => (128_000, ContextBudgetSource::Fallback),
2081 };
2082 }
2083 if config.memory.context_budget_tokens == 0 {
2084 return (128_000, ContextBudgetSource::Fallback);
2085 }
2086 (
2087 config.memory.context_budget_tokens,
2088 ContextBudgetSource::Configured,
2089 )
2090}
2091
2092pub(crate) fn resolve_context_budget(config: &Config, provider: &AnyProvider) -> usize {
2093 let (tokens, source) = resolve_context_budget_tokens(config, provider);
2094 match source {
2095 ContextBudgetSource::AutoDetected(ctx_size) => tracing::info!(
2096 model_context = ctx_size,
2097 "auto-configured context budget on reload"
2098 ),
2099 ContextBudgetSource::Fallback => tracing::warn!(
2100 "context_budget_tokens resolved to 0 on reload — using fallback of 128000 tokens"
2101 ),
2102 ContextBudgetSource::Configured => {}
2103 }
2104 tokens
2105}
2106
2107#[cfg(test)]
2108mod tests;
2109
2110#[cfg(test)]
2111pub(crate) use tests::agent_tests;
2112
2113#[cfg(test)]
2114mod test_stubs {
2115 use std::pin::Pin;
2116
2117 use zeph_commands::{
2118 CommandContext, CommandError, CommandHandler, CommandOutput, SlashCategory,
2119 };
2120
2121 pub(super) struct TestErrorCommand;
2127
2128 impl CommandHandler<CommandContext<'_>> for TestErrorCommand {
2129 fn name(&self) -> &'static str {
2130 "/test-error"
2131 }
2132
2133 fn description(&self) -> &'static str {
2134 "Test stub: always returns CommandError"
2135 }
2136
2137 fn category(&self) -> SlashCategory {
2138 SlashCategory::Session
2139 }
2140
2141 fn requires_auth(&self) -> bool {
2142 true
2143 }
2144
2145 fn handle<'a>(
2146 &'a self,
2147 _ctx: &'a mut CommandContext<'_>,
2148 _args: &'a str,
2149 ) -> Pin<
2150 Box<dyn std::future::Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>,
2151 > {
2152 Box::pin(async { Err(CommandError::new("boom")) })
2153 }
2154 }
2155}