1mod acp_commands;
5mod agent_access_impl;
6pub(crate) mod agent_supervisor;
7mod autodream;
8mod autonomous_turn;
9mod builder;
10pub use builder::{SecurityWiringSnapshot, SkillConfigParams};
11#[cfg(feature = "cocoon")]
12mod cocoon_cmd;
13mod command_context_impls;
14mod command_macros;
15pub(super) mod compression_feedback;
16mod config_reload;
17mod context;
18mod context_impls;
19pub(crate) mod context_manager;
20mod corrections;
21mod durable_bootstrap;
22pub use durable_bootstrap::DurableKeyMaterial;
23pub mod error;
24mod experiment_cmd;
25pub(crate) mod focus;
26mod graph_commands;
27mod heuristic_promotion;
28mod hooks_dispatch;
29mod index;
30mod learning;
31pub(crate) mod learning_engine;
32mod log_commands;
33mod loop_event;
34mod lsp_commands;
35mod magic_docs;
36mod mcp;
37pub(crate) mod memcot;
38mod memory_commands;
39mod message_queue;
40mod microcompact;
41mod misc_commands;
42mod model_commands;
43mod orchestration_commands;
44mod persistence;
45#[cfg(feature = "scheduler")]
46mod plan;
47mod policy_commands;
48mod provider_cmd;
49mod quality_hook;
50pub(crate) mod rate_limiter;
51mod scheduler_commands;
52#[cfg(feature = "scheduler")]
53mod scheduler_loop;
54mod scope_commands;
55pub mod session_config;
56mod session_digest;
57pub mod shadow_sentinel;
58mod shutdown;
59pub(crate) mod sidequest;
60mod skill_commands;
61mod skill_management;
62mod skill_reload;
63pub mod slash_commands;
64pub mod speculative;
65pub(crate) mod state;
66mod subagent_commands;
67pub(crate) mod task_injection;
68pub(crate) mod tool_execution;
69pub(crate) mod tool_orchestrator;
70mod trace_extraction;
71pub mod trajectory;
72mod trajectory_commands;
73mod trust_commands;
74pub mod turn;
75mod utils;
76pub(crate) mod vigil;
77mod worktree_commands;
78
79use std::collections::VecDeque;
80use std::fmt::Write as _;
81use std::sync::Arc;
82
83use parking_lot::RwLock;
84
85use tokio::sync::{mpsc, watch};
86use tokio_util::sync::CancellationToken;
87use zeph_llm::any::AnyProvider;
88use zeph_llm::provider::{LlmProvider, Message, MessageMetadata, Role};
89use zeph_memory::TokenCounter;
90use zeph_memory::semantic::SemanticMemory;
91use zeph_skills::loader::Skill;
92use zeph_skills::matcher::SkillMatcherBackend;
93use zeph_skills::prompt::format_skills_catalog;
94use zeph_skills::registry::SkillRegistry;
95use zeph_tools::executor::{ErasedToolExecutor, ToolExecutor};
96
97use tracing::Instrument as _;
98
99use crate::channel::Channel;
100use crate::config::Config;
101use crate::context::build_system_prompt;
102use zeph_common::text::estimate_tokens;
103
104use loop_event::LoopEvent;
105use message_queue::{MAX_AUDIO_BYTES, MAX_IMAGE_BYTES, detect_image_mime};
106use state::MessageState;
107
108pub(crate) const DOOM_LOOP_WINDOW: usize = 3;
109pub(crate) const MAX_RETRIEVE_MANDATES_PER_TURN: usize = 3;
113pub(crate) use zeph_agent_context::helpers::CODE_CONTEXT_PREFIX;
117pub(crate) const SCHEDULED_TASK_PREFIX: &str = "Execute the following scheduled task now: ";
118pub(crate) const TOOL_OUTPUT_SUFFIX: &str = "\n```";
119
120pub(crate) fn format_tool_output(tool_name: &str, body: &str) -> String {
121 use std::fmt::Write;
122 let capacity = "[tool output: ".len()
123 + tool_name.len()
124 + "]\n```\n".len()
125 + body.len()
126 + TOOL_OUTPUT_SUFFIX.len();
127 let mut buf = String::with_capacity(capacity);
128 let _ = write!(
129 buf,
130 "[tool output: {tool_name}]\n```\n{body}{TOOL_OUTPUT_SUFFIX}"
131 );
132 buf
133}
134
135pub struct Agent<C: Channel> {
168 provider: AnyProvider,
170 embedding_provider: AnyProvider,
175 channel: C,
176 pub(crate) tool_executor: Arc<dyn ErasedToolExecutor>,
177
178 pub(super) msg: MessageState,
180 pub(super) context_manager: context_manager::ContextManager,
181 pub(super) tool_orchestrator: tool_orchestrator::ToolOrchestrator,
182
183 pub(super) services: state::Services,
185
186 pub(super) runtime: state::AgentRuntime,
188}
189
190enum DispatchFlow {
192 Break,
194 Continue,
196 Fallthrough,
198}
199
200fn build_turn_hook_env(
206 summary: &crate::notifications::TurnSummary,
207 is_error: bool,
208) -> std::collections::HashMap<String, String> {
209 let mut env = std::collections::HashMap::new();
210 env.insert(
211 "ZEPH_TURN_DURATION_MS".to_owned(),
212 summary.duration_ms.to_string(),
213 );
214 env.insert(
215 "ZEPH_TURN_STATUS".to_owned(),
216 if is_error { "error" } else { "success" }.to_owned(),
217 );
218 env.insert("ZEPH_TURN_PREVIEW".to_owned(), summary.preview.clone());
219 env.insert(
220 "ZEPH_TURN_LLM_REQUESTS".to_owned(),
221 summary.llm_requests.to_string(),
222 );
223 env.insert(
224 "ZEPH_TURN_TOOL_CALLS".to_owned(),
225 summary.tool_calls.to_string(),
226 );
227 env
228}
229
230impl<C: Channel> Agent<C> {
231 #[must_use]
255 pub fn new(
256 provider: AnyProvider,
257 channel: C,
258 registry: SkillRegistry,
259 matcher: Option<SkillMatcherBackend>,
260 max_active_skills: usize,
261 tool_executor: impl ToolExecutor + 'static,
262 ) -> Self {
263 let registry = Arc::new(RwLock::new(registry));
264 let embedding_provider = provider.clone();
265 Self::new_with_registry_arc(
266 provider,
267 embedding_provider,
268 channel,
269 registry,
270 matcher,
271 max_active_skills,
272 tool_executor,
273 )
274 }
275
276 #[must_use]
283 pub fn new_with_registry_arc(
284 provider: AnyProvider,
285 embedding_provider: AnyProvider,
286 channel: C,
287 registry: Arc<RwLock<SkillRegistry>>,
288 matcher: Option<SkillMatcherBackend>,
289 max_active_skills: usize,
290 tool_executor: impl ToolExecutor + 'static,
291 ) -> Self {
292 use state::{
293 AgentRuntime, CompressionState, DebugState, ExperimentState, FeedbackState, IndexState,
294 InstructionState, LifecycleState, McpState, MemoryState, MetricsState,
295 OrchestrationState, ProviderState, RuntimeConfig, SecurityState, Services,
296 SessionState, SkillState, ToolState,
297 };
298
299 debug_assert!(max_active_skills > 0, "max_active_skills must be > 0");
300 let catalog_skills: Vec<Skill> = {
305 let reg = registry.read();
306 reg.all_meta()
307 .into_iter()
308 .map(|m| Skill {
309 meta: m.clone(),
310 body: String::new(),
311 resources: zeph_skills::resource::SkillResources::default(),
312 })
313 .collect()
314 };
315 let skills_prompt =
320 format_skills_catalog(&catalog_skills, &std::collections::HashMap::new());
321 let system_prompt = build_system_prompt(&skills_prompt, None);
322 tracing::debug!(len = system_prompt.len(), "initial system prompt built");
323 tracing::trace!(prompt = %system_prompt, "full system prompt");
324
325 let initial_prompt_tokens = estimate_tokens(&system_prompt) as u64;
326 let token_counter = Arc::new(TokenCounter::new());
327
328 let services = Services {
329 memory: MemoryState::default(),
330 skill: SkillState::new(registry, matcher, max_active_skills, skills_prompt),
331 learning_engine: learning_engine::LearningEngine::new(),
332 feedback: FeedbackState::default(),
333 mcp: McpState::default(),
334 index: IndexState::default(),
335 session: SessionState::new(),
336 security: SecurityState::default(),
337 experiments: ExperimentState::new(),
338 compression: CompressionState::default(),
339 orchestration: OrchestrationState::default(),
340 focus: focus::FocusState::default(),
341 sidequest: sidequest::SidequestState::default(),
342 tool_state: ToolState::default(),
343 goal_accounting: None,
344 quality: None,
345 proactive_explorer: None,
346 promotion_engine: None,
347 taco_compressor: None,
348 speculation_engine: None,
349 autonomous: crate::goal::AutonomousDriver::new(tokio::time::Duration::from_millis(500)),
350 autonomous_registry: crate::goal::AutonomousRegistry::new(),
351 };
352
353 let runtime = AgentRuntime {
354 config: RuntimeConfig::default(),
355 lifecycle: LifecycleState::new(),
356 providers: ProviderState::new(initial_prompt_tokens),
357 metrics: MetricsState::new(token_counter),
358 debug: DebugState::default(),
359 instructions: InstructionState::default(),
360 ephemeral_plugins: Vec::new(),
361 };
362
363 Self {
364 provider,
365 embedding_provider,
366 channel,
367 tool_executor: Arc::new(tool_executor),
368 msg: MessageState {
369 messages: vec![Message {
370 role: Role::System,
371 content: system_prompt,
372 parts: vec![],
373 metadata: MessageMetadata::default(),
374 }],
375 message_queue: VecDeque::new(),
376 pending_image_parts: Vec::new(),
377 last_persisted_message_id: None,
378 deferred_db_hide_ids: Vec::new(),
379 deferred_db_summaries: Vec::new(),
380 deferred_db_trust_levels: Vec::new(),
381 history_preloaded: false,
382 history_cursor: 0,
383 non_system_count: 0,
384 },
385 context_manager: context_manager::ContextManager::new(),
386 tool_orchestrator: tool_orchestrator::ToolOrchestrator::new(),
387 services,
388 runtime,
389 }
390 }
391
392 #[must_use]
405 pub fn into_channel(self) -> C {
406 self.channel
407 }
408
409 #[tracing::instrument(name = "core.agent.run", skip_all, level = "debug", err)]
415 #[allow(clippy::too_many_lines)] pub async fn run(&mut self) -> Result<(), error::AgentError>
417 where
418 C: 'static,
419 {
420 if let Some(mut rx) = self.runtime.lifecycle.warmup_ready.take()
421 && !*rx.borrow()
422 {
423 let _ = rx.changed().await;
424 if !*rx.borrow() {
425 tracing::warn!("model warmup did not complete successfully");
426 }
427 }
428
429 self.restore_channel_provider().await;
431
432 self.load_and_cache_session_digest().await;
434 self.maybe_send_resume_recap().await;
435
436 let initial_skill_catalog = self.skill_catalog_items().await;
440 if let Err(e) = self
441 .channel
442 .send_skill_catalog(&initial_skill_catalog)
443 .await
444 {
445 tracing::warn!("failed to emit initial skill catalog: {e}");
446 }
447
448 self.maybe_start_heuristic_promotion();
452
453 loop {
454 self.services.session.is_guest_context = false;
463 state::persistence::DEFAULT_OWNER_KEY.clone_into(&mut self.services.session.owner_key);
464
465 self.apply_provider_override();
466 self.check_tool_refresh().await;
467 self.process_pending_elicitations().await;
468 self.refresh_subagent_metrics();
469 self.notify_completed_subagents().await?;
470 self.drain_channel();
471
472 let (text, image_parts) = if let Some(queued) = self.msg.message_queue.pop_front() {
473 self.notify_queue_count().await;
474 if queued.raw_attachments.is_empty() {
475 (queued.text, queued.image_parts)
476 } else {
477 let msg = crate::channel::ChannelMessage {
478 text: queued.text,
479 attachments: queued.raw_attachments,
480 is_guest_context: false,
481 is_from_bot: false,
482 owner_key: None,
483 };
484 self.resolve_message(msg).await
485 }
486 } else {
487 match self.next_event().await? {
488 None | Some(LoopEvent::Shutdown) => break,
489 Some(LoopEvent::SkillReload) => {
490 self.reload_skills().await;
491 continue;
492 }
493 Some(LoopEvent::InstructionReload) => {
494 self.reload_instructions().await;
495 continue;
496 }
497 Some(LoopEvent::ConfigReload) => {
498 self.reload_config();
499 continue;
500 }
501 Some(LoopEvent::UpdateNotification(msg)) => {
502 if let Err(e) = self.channel.send(&msg).await {
503 tracing::warn!("failed to send update notification: {e}");
504 }
505 continue;
506 }
507 Some(LoopEvent::ExperimentCompleted(msg)) => {
508 self.services.experiments.cancel = None;
509 self.services.experiments.handle = None;
510 if let Err(e) = self.channel.send(&msg).await {
511 tracing::warn!("failed to send experiment completion: {e}");
512 }
513 continue;
514 }
515 Some(LoopEvent::ScheduledTask(prompt)) => {
516 let text = format!("{SCHEDULED_TASK_PREFIX}{prompt}");
517 let msg = crate::channel::ChannelMessage {
518 text,
519 attachments: Vec::new(),
520 is_guest_context: false,
521 is_from_bot: false,
522 owner_key: None,
523 };
524 self.drain_channel();
525 self.resolve_message(msg).await
526 }
527 Some(LoopEvent::TaskInjected(injection)) => {
528 if let Some(ref mut ls) = self.runtime.lifecycle.user_loop {
529 ls.iteration += 1;
530 tracing::info!(iteration = ls.iteration, "loop: tick");
531 }
532 let msg = crate::channel::ChannelMessage {
533 text: injection.prompt,
534 attachments: Vec::new(),
535 is_guest_context: false,
536 is_from_bot: false,
537 owner_key: None,
538 };
539 self.drain_channel();
540 self.resolve_message(msg).await
541 }
542 Some(LoopEvent::FileChanged(event)) => {
543 self.handle_file_changed(event).await;
544 continue;
545 }
546 Some(LoopEvent::AutonomousTick) => {
547 if let Err(e) = self.run_autonomous_turn().await {
548 tracing::warn!(error = %e, "autonomous turn error");
549 }
550 continue;
551 }
552 Some(LoopEvent::BgMetricsTick) => {
553 self.reap_background_tasks_and_update_metrics();
554 continue;
555 }
556 Some(LoopEvent::Message(msg)) => {
557 self.services.session.is_guest_context = msg.is_guest_context;
558 self.services.session.owner_key = msg
559 .owner_key
560 .clone()
561 .unwrap_or_else(|| state::persistence::DEFAULT_OWNER_KEY.to_owned());
562 self.drain_channel();
563 self.resolve_message(msg).await
564 }
565 }
566 };
567
568 let trimmed = text.trim();
569
570 if trimmed.starts_with('/') {
573 let slash_urls = zeph_sanitizer::exfiltration::extract_flagged_urls(trimmed);
574 if !slash_urls.is_empty() {
575 self.services
576 .security
577 .user_provided_urls
578 .write()
579 .extend(slash_urls);
580 }
581 }
582
583 let trusted = self.channel.supports_exit();
606 let session_impl = command_context_impls::SessionAccessImpl {
607 supports_exit: trusted,
608 history_expand_default_lines: self
609 .runtime
610 .config
611 .resume_config
612 .expand_default_lines,
613 };
614 let mut messages_impl = command_context_impls::MessageAccessImpl {
615 msg: &mut self.msg,
616 tool_state: &mut self.services.tool_state,
617 providers: &mut self.runtime.providers,
618 metrics: &self.runtime.metrics,
619 security: &mut self.services.security,
620 tool_orchestrator: &mut self.tool_orchestrator,
621 };
622 let mut sink_adapter = crate::channel::ChannelSinkAdapter(&mut self.channel);
624 let mut null_agent = zeph_commands::NullAgent;
626 let registry_handled = {
627 let reg = slash_commands::build_session_debug_registry();
628
629 let mut ctx = zeph_commands::CommandContext {
630 sink: &mut sink_adapter,
631 debug: &mut self.runtime.debug,
632 messages: &mut messages_impl,
633 session: &session_impl,
634 agent: &mut null_agent,
635 };
636 reg.dispatch(&mut ctx, trimmed, trusted).await
637 };
638 let session_reg_missed = registry_handled.is_none();
639 match self
640 .apply_dispatch_result(registry_handled, trimmed, false)
641 .await
642 {
643 DispatchFlow::Break => break,
644 DispatchFlow::Continue => continue,
645 DispatchFlow::Fallthrough => {
646 }
648 }
649
650 let mut agent_null_debug = command_context_impls::NullDebugAccess;
656 let mut agent_null_messages = command_context_impls::NullMessageAccess;
657 let agent_null_session = command_context_impls::NullSessionAccess;
658 let mut agent_null_sink = zeph_commands::NullSink;
659 let agent_result: Option<
660 Result<zeph_commands::CommandOutput, zeph_commands::CommandError>,
661 > = if session_reg_missed {
662 let agent_reg = slash_commands::build_agent_command_registry();
663
664 let mut ctx = zeph_commands::CommandContext {
665 sink: &mut agent_null_sink,
666 debug: &mut agent_null_debug,
667 messages: &mut agent_null_messages,
668 session: &agent_null_session,
669 agent: self,
670 };
671 agent_reg.dispatch(&mut ctx, trimmed, trusted).await
673 } else {
674 None
675 };
676 if let Some((cancelled_id, new_id)) = self.services.autonomous.flush_pending_start() {
682 if let Some(cid) = cancelled_id {
683 tracing::info!(
684 goal_id = cid,
685 "autonomous: previous session cancelled for new goal"
686 );
687 }
688 self.sync_registry_entry();
689 tracing::info!(goal_id = new_id, "autonomous: session started");
690 }
691
692 match self
695 .apply_dispatch_result(agent_result, trimmed, true)
696 .await
697 {
698 DispatchFlow::Break => break,
699 DispatchFlow::Continue => continue,
700 DispatchFlow::Fallthrough => {
701 }
703 }
704
705 match self.handle_builtin_command(trimmed) {
706 Some(true) => break,
707 Some(false) => continue,
708 None => {}
709 }
710
711 self.process_user_message(text, image_parts).await?;
712 }
713
714 self.maybe_autodream().await;
717
718 self.maybe_extract_skills_from_trace().await;
720
721 if let Some(ref mut tc) = self.runtime.debug.trace_collector
727 && let Some(handle) = tc.finish()
728 && let Err(e) = handle.await
729 {
730 tracing::warn!(error = %e, "trace.json write task did not complete");
731 }
732
733 Ok(())
734 }
735
736 async fn apply_dispatch_result(
742 &mut self,
743 result: Option<Result<zeph_commands::CommandOutput, zeph_commands::CommandError>>,
744 command: &str,
745 with_learning: bool,
746 ) -> DispatchFlow {
747 match result {
748 Some(Ok(zeph_commands::CommandOutput::Exit)) => {
749 let _ = self.channel.flush_chunks().await;
750 DispatchFlow::Break
751 }
752 Some(Ok(zeph_commands::CommandOutput::Message(msg))) => {
753 let _ = self.channel.send(&msg).await;
754 let _ = self.channel.flush_chunks().await;
755 if with_learning {
756 self.maybe_trigger_post_command_learning(command).await;
757 }
758 DispatchFlow::Continue
759 }
760 Some(Ok(_)) => {
761 let _ = self.channel.flush_chunks().await;
762 DispatchFlow::Continue
763 }
764 Some(Err(e)) => {
765 let _ = self.channel.send(&e.to_string()).await;
766 let _ = self.channel.flush_chunks().await;
767 tracing::warn!(command = %command, error = %e.0, "slash command failed");
768 DispatchFlow::Continue
769 }
770 None => DispatchFlow::Fallthrough,
771 }
772 }
773
774 fn apply_provider_override(&mut self) {
776 let taken = self
777 .runtime
778 .providers
779 .provider_override
780 .as_ref()
781 .and_then(|slot| slot.write().take());
782 if let Some(new_provider) = taken {
783 tracing::debug!(provider = new_provider.name(), "ACP model override applied");
784 self.set_provider(new_provider);
785 }
786 }
787
788 fn set_provider(&mut self, provider: AnyProvider) {
805 let provider = match self.services.security.secret_registry.clone() {
806 Some(registry) if !matches!(provider, AnyProvider::Masked(_)) => {
807 provider.masked(registry as Arc<dyn zeph_llm::masking::OutboundMasker>)
808 }
809 _ => provider,
810 };
811 debug_assert!(
812 self.services.security.secret_registry.is_none()
813 || matches!(provider, AnyProvider::Masked(_)),
814 "set_provider invariant violated: secret masking is enabled but the new provider \
815 is not wrapped via AnyProvider::masked — every self.provider reassignment must go \
816 through Agent::set_provider, never assign the field directly"
817 );
818 self.provider = provider;
819 }
820
821 #[tracing::instrument(name = "core.agent.next_event", skip_all, level = "debug", err)]
829 async fn next_event(&mut self) -> Result<Option<LoopEvent>, error::AgentError> {
830 let event = tokio::select! {
831 result = self.channel.recv() => {
832 return Ok(result?.map(LoopEvent::Message));
833 }
834 () = shutdown_signal(&mut self.runtime.lifecycle.shutdown) => {
835 tracing::info!("shutting down");
836 LoopEvent::Shutdown
837 }
838 Some(_) = recv_optional(&mut self.services.skill.skill_reload_rx) => {
839 LoopEvent::SkillReload
840 }
841 Some(_) = recv_optional(&mut self.runtime.instructions.reload_rx) => {
842 LoopEvent::InstructionReload
843 }
844 Some(_) = recv_optional(&mut self.runtime.lifecycle.config_reload_rx) => {
845 LoopEvent::ConfigReload
846 }
847 Some(msg) = recv_optional(&mut self.runtime.lifecycle.update_notify_rx) => {
848 LoopEvent::UpdateNotification(msg)
849 }
850 Some(msg) = recv_optional(&mut self.services.experiments.notify_rx) => {
851 LoopEvent::ExperimentCompleted(msg)
852 }
853 Some(prompt) = recv_optional(&mut self.runtime.lifecycle.custom_task_rx) => {
854 tracing::info!("scheduler: injecting custom task as agent turn");
855 LoopEvent::ScheduledTask(prompt)
856 }
857 () = async {
858 if let Some(ref mut ls) = self.runtime.lifecycle.user_loop {
859 if ls.cancel_tx.is_cancelled() {
860 std::future::pending::<()>().await;
861 } else {
862 ls.interval.tick().await;
863 }
864 } else {
865 std::future::pending::<()>().await;
866 }
867 } => {
868 let Some(ls) = self.runtime.lifecycle.user_loop.as_ref() else {
872 return Ok(None);
873 };
874 if ls.cancel_tx.is_cancelled() {
875 self.runtime.lifecycle.user_loop = None;
876 return Ok(None);
877 }
878 let prompt = ls.prompt.clone();
879 LoopEvent::TaskInjected(task_injection::TaskInjection { prompt })
880 }
881 Some(event) = recv_optional(&mut self.runtime.lifecycle.file_changed_rx) => {
882 LoopEvent::FileChanged(event)
883 }
884 () = self.services.autonomous.next_tick(),
886 if self.services.autonomous.should_tick() => {
887 LoopEvent::AutonomousTick
888 }
889 _ = self
902 .runtime
903 .lifecycle
904 .bg_metrics_tick
905 .get_or_insert_with(|| {
906 let mut iv = tokio::time::interval_at(
907 tokio::time::Instant::now() + state::BG_METRICS_TICK_INTERVAL,
908 state::BG_METRICS_TICK_INTERVAL,
909 );
910 iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
911 iv
912 })
913 .tick() => {
914 LoopEvent::BgMetricsTick
915 }
916 };
917 Ok(Some(event))
918 }
919
920 #[tracing::instrument(name = "core.agent.resolve_message", skip_all, level = "debug")]
921 async fn resolve_message(
922 &self,
923 msg: crate::channel::ChannelMessage,
924 ) -> (String, Vec<zeph_llm::provider::MessagePart>) {
925 use crate::channel::{Attachment, AttachmentKind};
926 use zeph_llm::provider::{ImageData, MessagePart};
927
928 let text_base = msg.text.clone();
929
930 let (audio_attachments, image_attachments): (Vec<Attachment>, Vec<Attachment>) = msg
931 .attachments
932 .into_iter()
933 .partition(|a| a.kind == AttachmentKind::Audio);
934
935 tracing::debug!(
936 audio = audio_attachments.len(),
937 has_stt = self.runtime.providers.stt.is_some(),
938 "resolve_message attachments"
939 );
940
941 let text = if !audio_attachments.is_empty()
942 && let Some(stt) = self.runtime.providers.stt.as_ref()
943 {
944 let mut transcribed_parts = Vec::new();
945 for attachment in &audio_attachments {
946 if attachment.data.len() > MAX_AUDIO_BYTES {
947 tracing::warn!(
948 size = attachment.data.len(),
949 max = MAX_AUDIO_BYTES,
950 "audio attachment exceeds size limit, skipping"
951 );
952 continue;
953 }
954 match stt
955 .transcribe(&attachment.data, attachment.filename.as_deref())
956 .await
957 {
958 Ok(result) => {
959 tracing::info!(
960 len = result.text.len(),
961 language = ?result.language,
962 "audio transcribed"
963 );
964 transcribed_parts.push(result.text);
965 }
966 Err(e) => {
967 tracing::error!(error = %e, "audio transcription failed");
968 }
969 }
970 }
971 if transcribed_parts.is_empty() {
972 text_base
973 } else {
974 let transcribed = transcribed_parts.join("\n");
975 if text_base.is_empty() {
976 transcribed
977 } else {
978 format!("[transcribed audio]\n{transcribed}\n\n{text_base}")
979 }
980 }
981 } else {
982 if !audio_attachments.is_empty() {
983 tracing::warn!(
984 count = audio_attachments.len(),
985 "audio attachments received but no STT provider configured, dropping"
986 );
987 }
988 text_base
989 };
990
991 let mut image_parts = Vec::new();
992 for attachment in image_attachments {
993 if attachment.data.len() > MAX_IMAGE_BYTES {
994 tracing::warn!(
995 size = attachment.data.len(),
996 max = MAX_IMAGE_BYTES,
997 "image attachment exceeds size limit, skipping"
998 );
999 continue;
1000 }
1001 let mime_type = detect_image_mime(attachment.filename.as_deref()).to_string();
1002 image_parts.push(MessagePart::Image(Box::new(ImageData {
1003 data: attachment.data,
1004 mime_type,
1005 })));
1006 }
1007
1008 (text, image_parts)
1009 }
1010
1011 fn begin_turn(&mut self, input: turn::TurnInput) -> turn::Turn {
1018 let id = turn::TurnId(self.runtime.debug.iteration_counter as u64);
1019 self.runtime.debug.iteration_counter += 1;
1020 let cancel_token = CancellationToken::new();
1021 self.runtime.lifecycle.cancel_token = cancel_token.clone();
1023 self.services.security.user_provided_urls.write().clear();
1024 *self.services.security.memory_consent_trust.write() = 0;
1033 self.runtime.lifecycle.turn_llm_requests = 0;
1035 self.runtime.lifecycle.turn_tool_calls = 0;
1037
1038 {
1041 use crate::agent::trajectory::{RiskSignal, VigilRiskLevel};
1042 use zeph_memory::shadow::{AuditSignalType as MageSignal, Severity as MageSev};
1043 let pending: Vec<u8> = {
1044 let mut q = self.services.security.trajectory_signal_queue.lock();
1045 std::mem::take(&mut *q)
1046 };
1047 self.services.security.mage_accumulator.advance_turn();
1048 for code in pending {
1049 let signal = RiskSignal::from_code(code);
1050 self.services.security.trajectory.record(signal);
1051 let mage_signal: Option<(MageSignal, MageSev)> = match signal {
1063 RiskSignal::PolicyDeny => Some((MageSignal::PolicyViolation, MageSev::Medium)),
1064 RiskSignal::ExfiltrationRedaction => {
1065 Some((MageSignal::ToolChainAnomaly, MageSev::Medium))
1066 }
1067 RiskSignal::VigilFlagged(VigilRiskLevel::Medium) => {
1068 Some((MageSignal::PromptInjectionPattern, MageSev::Medium))
1069 }
1070 RiskSignal::VigilFlagged(VigilRiskLevel::High) => {
1071 Some((MageSignal::PromptInjectionPattern, MageSev::High))
1072 }
1073 _ => None,
1074 };
1075 if let Some((sig, sev)) = mage_signal {
1076 self.services.security.mage_accumulator.ingest(sig, sev);
1077 }
1078 }
1079 }
1080 if self.services.security.trajectory.advance_turn()
1083 && let Some(logger) = self.tool_orchestrator.audit_logger.clone()
1084 {
1085 let entry = zeph_tools::AuditEntry {
1086 source_kind: None,
1087 trust_level: None,
1088 timestamp: zeph_tools::chrono_now(),
1089 tool: "<sentinel>".to_owned().into(),
1090 command: String::new(),
1091 result: zeph_tools::AuditResult::Success,
1092 duration_ms: 0,
1093 error_category: Some("trajectory_auto_recover".to_owned()),
1094 error_domain: Some("security".to_owned()),
1095 error_phase: None,
1096 claim_source: None,
1097 mcp_server_id: None,
1098 injection_flagged: false,
1099 embedding_anomalous: false,
1100 cross_boundary_mcp_to_acp: false,
1101 adversarial_policy_decision: None,
1102 exit_code: None,
1103 truncated: false,
1104 caller_id: None,
1105 skill_name: None,
1106 policy_match: None,
1107 correlation_id: None,
1108 vigil_risk: None,
1109 execution_env: None,
1110 resolved_cwd: None,
1111 scope_at_definition: None,
1112 scope_at_dispatch: None,
1113 };
1114 self.runtime.lifecycle.supervisor.spawn(
1115 crate::agent::agent_supervisor::TaskClass::Telemetry,
1116 "trajectory-auto-recover-audit",
1117 async move { logger.log(&entry).await },
1118 );
1119 }
1120 if let Some(ref sentinel) = self.services.security.shadow_sentinel {
1122 sentinel.advance_turn();
1123 }
1124 if let Some(ref acc) = self.services.security.risk_chain_accumulator {
1128 acc.advance_turn();
1129 }
1130 let risk_level = self.services.security.trajectory.current_risk();
1132 *self.services.security.trajectory_risk_slot.write() = u8::from(risk_level);
1133 if let Some(alert) = self.services.security.trajectory.poll_alert() {
1135 let msg = format!(
1136 "[trajectory] Risk level: {:?} (score={:.2})",
1137 alert.level, alert.score
1138 );
1139 tracing::warn!(
1140 level = ?alert.level,
1141 score = alert.score,
1142 "trajectory sentinel alert"
1143 );
1144 if let Some(ref tx) = self.services.session.status_tx {
1145 let _ = tx.send(msg);
1146 }
1147 }
1148
1149 let context = turn::TurnContext::new(id, cancel_token, self.runtime.config.timeouts)
1150 .with_tool_allowlist(self.runtime.config.channel_tool_allowlist.clone());
1151 turn::Turn::new(context, input)
1152 }
1153
1154 fn end_turn(&mut self, turn: turn::Turn) {
1161 self.runtime.metrics.pending_timings = turn.metrics.timings;
1162 self.flush_turn_timings();
1163 self.services.session.current_turn_intent = None;
1165 self.services.session.is_guest_context = false;
1167 state::persistence::DEFAULT_OWNER_KEY.clone_into(&mut self.services.session.owner_key);
1174 if let Some(ref engine) = self.services.speculation_engine {
1176 let metrics = engine.end_turn();
1177 if metrics.committed > 0 || metrics.cancelled > 0 {
1178 tracing::debug!(
1179 committed = metrics.committed,
1180 cancelled = metrics.cancelled,
1181 wasted_ms = metrics.wasted_ms,
1182 "speculation: turn boundary metrics"
1183 );
1184 }
1185 }
1186 }
1187
1188 #[tracing::instrument(
1189 name = "core.agent.process_user_message",
1190 skip_all,
1191 level = "debug",
1192 fields(turn_id),
1193 err
1194 )]
1195 async fn process_user_message(
1196 &mut self,
1197 text: String,
1198 image_parts: Vec<zeph_llm::provider::MessagePart>,
1199 ) -> Result<(), error::AgentError> {
1200 self.apply_provider_override();
1205
1206 let input = turn::TurnInput::new(text, image_parts);
1207 let mut t = self.begin_turn(input);
1208
1209 let turn_idx = usize::try_from(t.id().0).unwrap_or(usize::MAX);
1210 tracing::Span::current().record("turn_id", t.id().0);
1211 self.runtime
1213 .debug
1214 .start_iteration_span(turn_idx, t.input.text.trim());
1215
1216 let result = Box::pin(self.process_user_message_inner(&mut t)).await;
1217
1218 let span_status = if result.is_ok() {
1220 crate::debug_dump::trace::SpanStatus::Ok
1221 } else {
1222 crate::debug_dump::trace::SpanStatus::Error {
1223 message: "iteration failed".to_owned(),
1224 }
1225 };
1226 self.runtime.debug.end_iteration_span(turn_idx, span_status);
1227
1228 self.end_turn(t);
1229 result
1230 }
1231
1232 #[allow(clippy::too_many_lines)] #[tracing::instrument(
1234 name = "core.agent.process_user_message_inner",
1235 skip_all,
1236 level = "debug",
1237 err
1238 )]
1239 async fn process_user_message_inner(
1240 &mut self,
1241 turn: &mut turn::Turn,
1242 ) -> Result<(), error::AgentError> {
1243 self.reap_background_tasks_and_update_metrics();
1244
1245 let tokens_before_turn = self
1246 .runtime
1247 .metrics
1248 .metrics_tx
1249 .as_ref()
1250 .map_or(0, |tx| tx.borrow().total_tokens);
1251
1252 self.drain_background_completions();
1256
1257 self.wire_cancel_bridge(turn.cancel_token());
1258
1259 let text = turn.input.text.clone();
1261 let trimmed_owned = text.trim().to_owned();
1262 let trimmed = trimmed_owned.as_str();
1263
1264 if self.services.security.vigil.is_some() {
1267 let intent_len = trimmed.floor_char_boundary(1024.min(trimmed.len()));
1268 self.services.session.current_turn_intent = Some(trimmed[..intent_len].to_owned());
1269 }
1270
1271 if let Some(result) = self.dispatch_slash_command(trimmed).await {
1272 return result;
1273 }
1274
1275 let text = self.sanitize_channel_text_if_untrusted(text);
1277 let trimmed_owned = text.trim().to_owned();
1278 let trimmed = trimmed_owned.as_str();
1279
1280 self.check_pending_rollbacks().await;
1281
1282 if self.pre_process_security(trimmed).await? {
1283 return Ok(());
1284 }
1285
1286 let t_ctx = std::time::Instant::now();
1287 tracing::debug!("turn timing: prepare_context start");
1288 self.advance_context_lifecycle_guarded(&text, trimmed).await;
1289 turn.metrics_mut().timings.prepare_context_ms =
1290 u64::try_from(t_ctx.elapsed().as_millis()).unwrap_or(u64::MAX);
1291 tracing::debug!(
1292 ms = turn.metrics_snapshot().timings.prepare_context_ms,
1293 "turn timing: prepare_context done"
1294 );
1295 let _ = self
1297 .channel
1298 .send_context_estimate(
1299 usize::try_from(self.runtime.providers.cached_prompt_tokens).unwrap_or(usize::MAX),
1300 )
1301 .await;
1302
1303 let image_parts = std::mem::take(&mut turn.input.image_parts);
1304 let merged_text = self.build_user_message_text_with_bg_completions(&text);
1308 let user_msg = self.build_user_message(&merged_text, image_parts);
1309
1310 let urls = zeph_sanitizer::exfiltration::extract_flagged_urls(trimmed);
1313 if !urls.is_empty() {
1314 self.services
1315 .security
1316 .user_provided_urls
1317 .write()
1318 .extend(urls);
1319 }
1320
1321 self.services.memory.extraction.goal_text = Some(text.clone());
1324
1325 let t_persist = std::time::Instant::now();
1326 tracing::debug!("turn timing: persist_message(user) start");
1327 self.persist_message(Role::User, &text, &[], false).await;
1329 turn.metrics_mut().timings.persist_message_ms =
1330 u64::try_from(t_persist.elapsed().as_millis()).unwrap_or(u64::MAX);
1331 tracing::debug!(
1332 ms = turn.metrics_snapshot().timings.persist_message_ms,
1333 "turn timing: persist_message(user) done"
1334 );
1335 self.push_message(user_msg);
1336
1337 let context_estimate = self.runtime.providers.cached_prompt_tokens;
1339 self.update_metrics(|m| m.context_tokens = context_estimate);
1340
1341 tracing::debug!("turn timing: process_response start");
1344 let turn_had_error = if let Err(e) = self.process_response().await {
1345 self.services.learning_engine.learning_tasks.detach_all();
1347 tracing::error!("Response processing failed: {e:#}");
1348
1349 if e.is_no_providers() {
1352 self.runtime.lifecycle.last_no_providers_at = Some(std::time::Instant::now());
1353 let backoff_secs = self.runtime.config.timeouts.no_providers_backoff_secs;
1354 tracing::warn!(
1355 backoff_secs,
1356 "no providers available; backing off before next turn"
1357 );
1358 tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await;
1359 }
1360
1361 let user_msg = format!("Error: {e:#}");
1362 self.channel.send(&user_msg).await?;
1363 if let Some(popped) = self.msg.messages.pop() {
1364 self.msg.track_single_message(popped.role, false);
1365 }
1366 self.recompute_prompt_tokens();
1367 self.channel.flush_chunks().await?;
1368 true
1369 } else {
1370 self.services.learning_engine.learning_tasks.detach_all();
1373 self.truncate_old_tool_results();
1374 self.maybe_update_magic_docs();
1376 self.maybe_spawn_promotion_scan();
1378 false
1379 };
1380 tracing::debug!("turn timing: process_response done");
1381
1382 if let Some(pipeline) = self.services.quality.clone() {
1384 self.run_self_check_for_turn(pipeline, turn.id().0).await;
1385 }
1386 let _ = self.channel.flush_chunks().await;
1391
1392 self.maybe_fire_completion_notification(turn, turn_had_error);
1393
1394 self.flush_goal_accounting(tokens_before_turn);
1395
1396 turn.metrics_mut().timings.llm_chat_ms = self.runtime.metrics.pending_timings.llm_chat_ms;
1401 turn.metrics_mut().timings.tool_exec_ms = self.runtime.metrics.pending_timings.tool_exec_ms;
1402
1403 Ok(())
1404 }
1405
1406 fn wire_cancel_bridge(&mut self, turn_token: &tokio_util::sync::CancellationToken) {
1412 let signal = Arc::clone(&self.runtime.lifecycle.cancel_signal);
1413 let token = turn_token.clone();
1414 self.runtime.lifecycle.cancel_token = turn_token.clone();
1416 if let Some(prev) = self.runtime.lifecycle.cancel_bridge_handle.take() {
1417 prev.abort();
1418 }
1419 self.runtime.lifecycle.cancel_bridge_handle =
1420 Some(self.runtime.lifecycle.task_supervisor.spawn_oneshot(
1421 std::sync::Arc::from("agent.lifecycle.cancel_bridge"),
1422 move || async move {
1423 signal.notified().await;
1424 token.cancel();
1425 },
1426 ));
1427 }
1428
1429 fn reap_background_tasks_and_update_metrics(&mut self) {
1436 let bg_signal = self.runtime.lifecycle.supervisor.reap();
1437 if bg_signal.did_summarize {
1438 self.services.memory.persistence.unsummarized_count = 0;
1439 tracing::debug!("background summarization completed; unsummarized_count reset");
1440 }
1441 let snap = self.runtime.lifecycle.supervisor.metrics_snapshot();
1442 self.update_metrics(|m| {
1443 m.bg_inflight = snap.inflight as u64;
1444 m.bg_dropped = snap.total_dropped();
1445 m.bg_completed = snap.total_completed();
1446 m.bg_enrichment_inflight = snap.class_inflight[0] as u64;
1447 m.bg_telemetry_inflight = snap.class_inflight[1] as u64;
1448 });
1449
1450 if self.runtime.lifecycle.shell_executor_handle.is_some() {
1452 let shell_rows: Vec<crate::metrics::ShellBackgroundRunRow> = self
1453 .runtime
1454 .lifecycle
1455 .shell_executor_handle
1456 .as_ref()
1457 .map(|e| e.background_runs_snapshot())
1458 .unwrap_or_default()
1459 .into_iter()
1460 .map(|s| crate::metrics::ShellBackgroundRunRow {
1461 run_id: truncate_shell_run_id(&s.run_id),
1462 command: truncate_shell_command(&s.command),
1463 elapsed_secs: s.elapsed_ms / 1000,
1464 })
1465 .collect();
1466 self.update_metrics(|m| {
1467 m.shell_background_runs = shell_rows;
1468 });
1469 }
1470
1471 if self
1474 .runtime
1475 .config
1476 .supervisor_config
1477 .abort_enrichment_on_turn
1478 {
1479 self.runtime
1480 .lifecycle
1481 .supervisor
1482 .abort_class(agent_supervisor::TaskClass::Enrichment);
1483 }
1484 }
1485
1486 fn maybe_fire_completion_notification(&mut self, turn: &turn::Turn, is_error: bool) {
1499 let snap = turn.metrics_snapshot().timings.clone();
1500 let duration_ms = snap
1501 .prepare_context_ms
1502 .saturating_add(snap.llm_chat_ms)
1503 .saturating_add(snap.tool_exec_ms);
1504 let summary = crate::notifications::TurnSummary {
1505 duration_ms,
1506 preview: self.last_assistant_preview(160),
1507 tool_calls: self.runtime.lifecycle.turn_tool_calls,
1508 llm_requests: self.runtime.lifecycle.turn_llm_requests,
1509 exit_status: if is_error {
1510 crate::notifications::TurnExitStatus::Error
1511 } else {
1512 crate::notifications::TurnExitStatus::Success
1513 },
1514 };
1515
1516 let gate_ok = self
1518 .runtime
1519 .lifecycle
1520 .notifier
1521 .as_ref()
1522 .is_none_or(|n| n.should_fire(&summary));
1523
1524 if let Some(ref notifier) = self.runtime.lifecycle.notifier
1526 && gate_ok
1527 {
1528 notifier.fire(&summary, &mut self.runtime.lifecycle.supervisor);
1529 }
1530
1531 let hooks = self.services.session.hooks_config.turn_complete.clone();
1536 if !hooks.is_empty() && gate_ok {
1537 let mut env = build_turn_hook_env(&summary, is_error);
1538 let conv_id_str = self
1539 .services
1540 .memory
1541 .persistence
1542 .conversation_id
1543 .map(|id| id.0.to_string());
1544 crate::agent::hooks_dispatch::insert_main_agent_ctx(&mut env, conv_id_str.as_deref());
1545 let dispatch = self.mcp_dispatch();
1546 let _span = tracing::info_span!("core.agent.turn_hooks").entered();
1547 let _accepted = self.runtime.lifecycle.supervisor.spawn(
1548 agent_supervisor::TaskClass::Telemetry,
1549 "turn-complete-hooks",
1550 async move {
1551 let mcp: Option<&dyn zeph_subagent::McpDispatch> = dispatch
1552 .as_ref()
1553 .map(|d| d as &dyn zeph_subagent::McpDispatch);
1554 if let Err(e) = zeph_subagent::hooks::fire_hooks(&hooks, &env, mcp, None).await
1555 {
1556 tracing::warn!(error = %e, "turn_complete hook failed");
1557 }
1558 },
1559 );
1560 }
1561 }
1562
1563 fn flush_goal_accounting(&mut self, tokens_before: u64) {
1566 let goal_snap = self
1567 .services
1568 .goal_accounting
1569 .as_ref()
1570 .and_then(|a| a.snapshot());
1571 self.update_metrics(|m| m.active_goal = goal_snap);
1572
1573 if let Some(ref accounting) = self.services.goal_accounting {
1574 let tokens_after = self
1575 .runtime
1576 .metrics
1577 .metrics_tx
1578 .as_ref()
1579 .map_or(0, |tx| tx.borrow().total_tokens);
1580 let turn_tokens = tokens_after.saturating_sub(tokens_before);
1581 let mut spawned: Option<
1582 std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'static>>,
1583 > = None;
1584 accounting.on_turn_complete(turn_tokens, |fut| {
1585 spawned = Some(fut);
1586 });
1587 if let Some(fut) = spawned {
1588 let _ = self.runtime.lifecycle.supervisor.spawn(
1589 agent_supervisor::TaskClass::Telemetry,
1590 "goal-accounting",
1591 fut,
1592 );
1593 }
1594 }
1595 }
1596
1597 fn sanitize_channel_text_if_untrusted(&self, text: String) -> String {
1609 if !self.channel.requires_input_sanitization() {
1610 return text;
1611 }
1612 self.services
1613 .security
1614 .sanitizer
1615 .sanitize(
1616 &text,
1617 zeph_sanitizer::ContentSource::new(
1618 zeph_sanitizer::ContentSourceKind::ChannelMessage,
1619 ),
1620 )
1621 .body
1622 }
1623
1624 #[tracing::instrument(
1626 name = "core.agent.pre_process_security",
1627 skip_all,
1628 level = "debug",
1629 err
1630 )]
1631 async fn pre_process_security(&mut self, trimmed: &str) -> Result<bool, error::AgentError> {
1632 if let Some(ref guardrail) = self.services.security.guardrail {
1634 use zeph_sanitizer::guardrail::GuardrailVerdict;
1635 let verdict = guardrail.check(trimmed).await;
1636 match &verdict {
1637 GuardrailVerdict::Flagged { reason, .. } => {
1638 tracing::warn!(
1639 reason = %reason,
1640 should_block = verdict.should_block(),
1641 "guardrail flagged user input"
1642 );
1643 if verdict.should_block() {
1644 let msg = format!("[guardrail] Input blocked: {reason}");
1645 let _ = self.channel.send(&msg).await;
1646 let _ = self.channel.flush_chunks().await;
1647 return Ok(true);
1648 }
1649 let _ = self
1651 .channel
1652 .send(&format!("[guardrail] Warning: {reason}"))
1653 .await;
1654 }
1655 GuardrailVerdict::Error { error } => {
1656 if guardrail.error_should_block() {
1657 tracing::warn!(%error, "guardrail check failed (fail_strategy=closed), blocking input");
1658 let msg = "[guardrail] Input blocked: check failed (see logs for details)";
1659 let _ = self.channel.send(msg).await;
1660 let _ = self.channel.flush_chunks().await;
1661 return Ok(true);
1662 }
1663 tracing::warn!(%error, "guardrail check failed (fail_strategy=open), allowing input");
1664 }
1665 _ => {}
1666 }
1667 }
1668
1669 self.record_nli_verdict(trimmed, "user_input").await;
1672
1673 #[cfg(feature = "classifiers")]
1679 if self.services.security.sanitizer.scan_user_input() {
1680 match self
1681 .services
1682 .security
1683 .sanitizer
1684 .classify_injection(trimmed)
1685 .await
1686 {
1687 zeph_sanitizer::InjectionVerdict::Blocked => {
1688 self.push_classifier_metrics();
1689 let _ = self
1690 .channel
1691 .send("[security] Input blocked: injection detected by classifier.")
1692 .await;
1693 let _ = self.channel.flush_chunks().await;
1694 return Ok(true);
1695 }
1696 zeph_sanitizer::InjectionVerdict::Suspicious => {
1697 tracing::warn!("injection_classifier soft_signal on user input");
1698 }
1699 _ => {}
1700 }
1701 }
1702 #[cfg(feature = "classifiers")]
1703 self.push_classifier_metrics();
1704
1705 Ok(false)
1706 }
1707
1708 async fn advance_context_lifecycle_guarded(&mut self, text: &str, trimmed: &str) {
1715 let backoff_secs = self.runtime.config.timeouts.no_providers_backoff_secs;
1716 let prep_timeout_secs = self.runtime.config.timeouts.context_prep_timeout_secs;
1717
1718 let providers_recently_failed = self
1720 .runtime
1721 .lifecycle
1722 .last_no_providers_at
1723 .is_some_and(|t| t.elapsed().as_secs() < backoff_secs);
1724
1725 if providers_recently_failed {
1726 tracing::warn!(
1727 backoff_secs,
1728 "skipping context preparation: providers were unavailable on last turn"
1729 );
1730 return;
1731 }
1732
1733 let timeout_dur = std::time::Duration::from_secs(prep_timeout_secs);
1734 match tokio::time::timeout(timeout_dur, self.advance_context_lifecycle(text, trimmed)).await
1735 {
1736 Ok(()) => {}
1737 Err(_elapsed) => {
1738 tracing::warn!(
1739 timeout_secs = prep_timeout_secs,
1740 "context preparation timed out; proceeding with degraded context"
1741 );
1742 }
1743 }
1744 }
1745
1746 #[tracing::instrument(
1747 name = "core.agent.advance_context_lifecycle",
1748 skip_all,
1749 level = "debug"
1750 )]
1751 async fn advance_context_lifecycle(&mut self, text: &str, trimmed: &str) {
1752 self.services.mcp.pruning_cache.reset();
1754
1755 let conv_id = self.services.memory.persistence.conversation_id;
1758 self.rebuild_system_prompt(text).await;
1763
1764 self.detect_and_record_corrections(trimmed, conv_id).await;
1765 self.services.learning_engine.tick();
1766 self.analyze_and_learn().await;
1767 self.sync_graph_counts().await;
1768
1769 self.context_manager
1774 .set_compaction_state(self.context_manager.compaction_state().advance_turn());
1775
1776 {
1778 self.services.focus.tick();
1779
1780 let sidequest_should_fire = self.services.sidequest.tick();
1783 if sidequest_should_fire
1784 && !self
1785 .context_manager
1786 .compaction_state()
1787 .is_compacted_this_turn()
1788 {
1789 self.maybe_sidequest_eviction();
1790 }
1791 }
1792
1793 {
1796 let cfg = &self.services.memory.extraction.graph_config.experience;
1797 if cfg.enabled
1798 && cfg.evolution_sweep_enabled
1799 && cfg.evolution_sweep_interval > 0
1800 && self
1801 .services
1802 .sidequest
1803 .turn_counter
1804 .checked_rem(cfg.evolution_sweep_interval as u64)
1805 == Some(0)
1806 && let Some(memory) = self.services.memory.persistence.memory.as_ref()
1807 && let (Some(exp), Some(graph)) =
1808 (memory.experience.as_ref(), memory.graph_store.as_ref())
1809 {
1810 let exp = std::sync::Arc::clone(exp);
1811 let graph = std::sync::Arc::clone(graph);
1812 let threshold = cfg.confidence_prune_threshold;
1813 let turn = self.services.sidequest.turn_counter;
1814 let accepted = self.runtime.lifecycle.supervisor.spawn(
1815 agent_supervisor::TaskClass::Telemetry,
1816 "experience-sweep",
1817 async move {
1818 match exp.evolution_sweep(graph.as_ref(), threshold).await {
1819 Ok(stats) => tracing::info!(
1820 turn,
1821 self_loops = stats.pruned_self_loops,
1822 low_confidence = stats.pruned_low_confidence,
1823 "evolution sweep complete",
1824 ),
1825 Err(e) => tracing::warn!(
1826 turn,
1827 error = %e,
1828 "evolution sweep failed",
1829 ),
1830 }
1831 },
1832 );
1833 if !accepted {
1834 tracing::warn!(
1835 turn = self.services.sidequest.turn_counter,
1836 "experience-sweep dropped (telemetry class at capacity)",
1837 );
1838 }
1839 }
1840 }
1841
1842 if let Some(warning) = self.cache_expiry_warning() {
1844 tracing::info!(warning, "cache expiry warning");
1845 self.channel.send_status_best_effort(&warning).await;
1846 }
1847
1848 self.maybe_time_based_microcompact();
1851
1852 self.maybe_apply_deferred_summaries();
1857 self.flush_deferred_summaries().await;
1858
1859 if let Err(e) = self.maybe_proactive_compress().await {
1861 tracing::warn!("proactive compression failed: {e:#}");
1862 }
1863
1864 if let Err(e) = self.maybe_compact().await {
1865 tracing::warn!("context compaction failed: {e:#}");
1866 }
1867
1868 if let Err(e) = Box::pin(self.prepare_context(trimmed)).await {
1869 tracing::warn!("context preparation failed: {e:#}");
1870 }
1871
1872 self.provider
1874 .set_memory_confidence(self.services.memory.persistence.last_recall_confidence);
1875
1876 self.services.learning_engine.reset_reflection();
1877 }
1878
1879 fn build_user_message(
1880 &mut self,
1881 text: &str,
1882 image_parts: Vec<zeph_llm::provider::MessagePart>,
1883 ) -> Message {
1884 let mut all_image_parts = std::mem::take(&mut self.msg.pending_image_parts);
1885 all_image_parts.extend(image_parts);
1886
1887 if !all_image_parts.is_empty() && self.provider.supports_vision() {
1888 let mut parts = vec![zeph_llm::provider::MessagePart::Text {
1889 text: text.to_owned(),
1890 }];
1891 parts.extend(all_image_parts);
1892 Message::from_parts(Role::User, parts)
1893 } else {
1894 if !all_image_parts.is_empty() {
1895 tracing::warn!(
1896 count = all_image_parts.len(),
1897 "image attachments dropped: provider does not support vision"
1898 );
1899 }
1900 Message {
1901 role: Role::User,
1902 content: text.to_owned(),
1903 parts: vec![],
1904 metadata: MessageMetadata::default(),
1905 }
1906 }
1907 }
1908
1909 fn drain_background_completions(&mut self) {
1913 const BACKGROUND_COMPLETION_BUFFER_CAP: usize = 16;
1914
1915 let Some(ref mut rx) = self.runtime.lifecycle.background_completion_rx else {
1916 return;
1917 };
1918 while let Ok(completion) = rx.try_recv() {
1920 if self.runtime.lifecycle.pending_background_completions.len()
1921 >= BACKGROUND_COMPLETION_BUFFER_CAP
1922 {
1923 tracing::warn!(
1924 run_id = %completion.run_id,
1925 "background completion buffer full; dropping run result"
1926 );
1927 self.runtime
1930 .lifecycle
1931 .pending_background_completions
1932 .pop_front();
1933 self.runtime
1934 .lifecycle
1935 .pending_background_completions
1936 .push_back(zeph_tools::BackgroundCompletion {
1937 run_id: completion.run_id,
1938 exit_code: -1,
1939 success: false,
1940 elapsed_ms: 0,
1941 command: completion.command,
1942 output: format!(
1943 "[background result for run {} dropped: buffer overflow]",
1944 completion.run_id
1945 ),
1946 });
1947 } else {
1948 self.runtime
1949 .lifecycle
1950 .pending_background_completions
1951 .push_back(completion);
1952 }
1953 }
1954 }
1955
1956 fn build_user_message_text_with_bg_completions(&mut self, user_text: &str) -> String {
1960 if self
1961 .runtime
1962 .lifecycle
1963 .pending_background_completions
1964 .is_empty()
1965 {
1966 return user_text.to_owned();
1967 }
1968 let mut parts = String::new();
1969 for completion in self
1970 .runtime
1971 .lifecycle
1972 .pending_background_completions
1973 .drain(..)
1974 {
1975 let _ = write!(
1976 parts,
1977 "[Background task {} completed]\nexit_code: {}\nsuccess: {}\nelapsed_ms: {}\ncommand: {}\n\n{}\n\n",
1978 completion.run_id,
1979 completion.exit_code,
1980 completion.success,
1981 completion.elapsed_ms,
1982 completion.command,
1983 completion.output,
1984 );
1985 }
1986 parts.push_str(user_text);
1987 parts
1988 }
1989
1990 pub(super) fn maybe_spawn_promotion_scan(&mut self) {
2000 let Some(engine) = self.services.promotion_engine.clone() else {
2001 return;
2002 };
2003
2004 let Some(memory) = self.services.memory.persistence.memory.clone() else {
2005 return;
2006 };
2007
2008 let promotion_window = 200usize;
2011
2012 let accepted = self.runtime.lifecycle.supervisor.spawn(
2013 agent_supervisor::TaskClass::Enrichment,
2014 "compression_spectrum.promotion_scan",
2015 async move {
2016 let window = match memory.load_promotion_window(promotion_window).await {
2017 Ok(w) => w,
2018 Err(e) => {
2019 tracing::warn!(error = %e, "promotion scan: failed to load window");
2020 return;
2021 }
2022 };
2023
2024 if window.is_empty() {
2025 return;
2026 }
2027
2028 let candidates = match engine.scan(&window).await {
2029 Ok(c) => c,
2030 Err(e) => {
2031 tracing::warn!(error = %e, "promotion scan: clustering failed");
2032 return;
2033 }
2034 };
2035
2036 for candidate in &candidates {
2037 if let Err(e) = engine.promote(candidate).await {
2038 tracing::warn!(
2039 signature = %candidate.signature,
2040 error = %e,
2041 "promotion scan: promote failed"
2042 );
2043 }
2044 }
2045
2046 tracing::info!(candidates = candidates.len(), "promotion scan: complete");
2047 }
2048 .instrument(tracing::info_span!("memory.compression.promote.background")),
2049 );
2050
2051 if accepted {
2052 tracing::debug!("compression_spectrum: promotion scan task enqueued");
2053 }
2054 }
2055}
2056
2057pub(crate) async fn shutdown_signal(rx: &mut watch::Receiver<bool>) {
2058 while !*rx.borrow_and_update() {
2059 if rx.changed().await.is_err() {
2060 std::future::pending::<()>().await;
2061 }
2062 }
2063}
2064
2065pub(crate) async fn recv_optional<T>(rx: &mut Option<mpsc::Receiver<T>>) -> Option<T> {
2066 match rx {
2067 Some(inner) => {
2068 if let Some(v) = inner.recv().await {
2069 Some(v)
2070 } else {
2071 *rx = None;
2072 std::future::pending().await
2073 }
2074 }
2075 None => std::future::pending().await,
2076 }
2077}
2078
2079fn truncate_shell_command(cmd: &str) -> String {
2081 if cmd.len() <= 80 {
2082 return cmd.to_owned();
2083 }
2084 let end = cmd.floor_char_boundary(79);
2085 format!("{}…", &cmd[..end])
2086}
2087
2088fn truncate_shell_run_id(id: &str) -> String {
2090 id.chars().take(8).collect()
2091}
2092
2093pub enum ContextBudgetSource {
2098 AutoDetected(usize),
2100 Configured,
2102 Fallback,
2104}
2105
2106pub fn resolve_context_budget_tokens(
2113 config: &Config,
2114 provider: &AnyProvider,
2115) -> (usize, ContextBudgetSource) {
2116 if config.memory.auto_budget && config.memory.context_budget_tokens == 0 {
2117 return match provider.context_window() {
2118 Some(ctx_size) if ctx_size > 0 => {
2119 (ctx_size, ContextBudgetSource::AutoDetected(ctx_size))
2120 }
2121 _ => (128_000, ContextBudgetSource::Fallback),
2122 };
2123 }
2124 if config.memory.context_budget_tokens == 0 {
2125 return (128_000, ContextBudgetSource::Fallback);
2126 }
2127 (
2128 config.memory.context_budget_tokens,
2129 ContextBudgetSource::Configured,
2130 )
2131}
2132
2133pub(crate) fn resolve_context_budget(config: &Config, provider: &AnyProvider) -> usize {
2134 let (tokens, source) = resolve_context_budget_tokens(config, provider);
2135 match source {
2136 ContextBudgetSource::AutoDetected(ctx_size) => tracing::info!(
2137 model_context = ctx_size,
2138 "auto-configured context budget on reload"
2139 ),
2140 ContextBudgetSource::Fallback => tracing::warn!(
2141 "context_budget_tokens resolved to 0 on reload — using fallback of 128000 tokens"
2142 ),
2143 ContextBudgetSource::Configured => {}
2144 }
2145 tokens
2146}
2147
2148#[cfg(test)]
2149mod tests;
2150
2151#[cfg(test)]
2152pub(crate) use tests::agent_tests;
2153
2154#[cfg(test)]
2155mod test_stubs {
2156 use std::pin::Pin;
2157
2158 use zeph_commands::{
2159 CommandContext, CommandError, CommandHandler, CommandOutput, SlashCategory,
2160 };
2161
2162 pub(super) struct TestErrorCommand;
2168
2169 impl CommandHandler<CommandContext<'_>> for TestErrorCommand {
2170 fn name(&self) -> &'static str {
2171 "/test-error"
2172 }
2173
2174 fn description(&self) -> &'static str {
2175 "Test stub: always returns CommandError"
2176 }
2177
2178 fn category(&self) -> SlashCategory {
2179 SlashCategory::Session
2180 }
2181
2182 fn requires_auth(&self) -> bool {
2183 true
2184 }
2185
2186 fn handle<'a>(
2187 &'a self,
2188 _ctx: &'a mut CommandContext<'_>,
2189 _args: &'a str,
2190 ) -> Pin<
2191 Box<dyn std::future::Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>,
2192 > {
2193 Box::pin(async { Err(CommandError::new("boom")) })
2194 }
2195 }
2196}