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 = format_skills_catalog(&catalog_skills);
316 let system_prompt = build_system_prompt(&skills_prompt, None);
317 tracing::debug!(len = system_prompt.len(), "initial system prompt built");
318 tracing::trace!(prompt = %system_prompt, "full system prompt");
319
320 let initial_prompt_tokens = estimate_tokens(&system_prompt) as u64;
321 let token_counter = Arc::new(TokenCounter::new());
322
323 let services = Services {
324 memory: MemoryState::default(),
325 skill: SkillState::new(registry, matcher, max_active_skills, skills_prompt),
326 learning_engine: learning_engine::LearningEngine::new(),
327 feedback: FeedbackState::default(),
328 mcp: McpState::default(),
329 index: IndexState::default(),
330 session: SessionState::new(),
331 security: SecurityState::default(),
332 experiments: ExperimentState::new(),
333 compression: CompressionState::default(),
334 orchestration: OrchestrationState::default(),
335 focus: focus::FocusState::default(),
336 sidequest: sidequest::SidequestState::default(),
337 tool_state: ToolState::default(),
338 goal_accounting: None,
339 quality: None,
340 proactive_explorer: None,
341 promotion_engine: None,
342 taco_compressor: None,
343 speculation_engine: None,
344 autonomous: crate::goal::AutonomousDriver::new(tokio::time::Duration::from_millis(500)),
345 autonomous_registry: crate::goal::AutonomousRegistry::new(),
346 };
347
348 let runtime = AgentRuntime {
349 config: RuntimeConfig::default(),
350 lifecycle: LifecycleState::new(),
351 providers: ProviderState::new(initial_prompt_tokens),
352 metrics: MetricsState::new(token_counter),
353 debug: DebugState::default(),
354 instructions: InstructionState::default(),
355 ephemeral_plugins: Vec::new(),
356 };
357
358 Self {
359 provider,
360 embedding_provider,
361 channel,
362 tool_executor: Arc::new(tool_executor),
363 msg: MessageState {
364 messages: vec![Message {
365 role: Role::System,
366 content: system_prompt,
367 parts: vec![],
368 metadata: MessageMetadata::default(),
369 }],
370 message_queue: VecDeque::new(),
371 pending_image_parts: Vec::new(),
372 last_persisted_message_id: None,
373 deferred_db_hide_ids: Vec::new(),
374 deferred_db_summaries: Vec::new(),
375 deferred_db_trust_levels: Vec::new(),
376 history_preloaded: false,
377 history_cursor: 0,
378 non_system_count: 0,
379 },
380 context_manager: context_manager::ContextManager::new(),
381 tool_orchestrator: tool_orchestrator::ToolOrchestrator::new(),
382 services,
383 runtime,
384 }
385 }
386
387 #[must_use]
400 pub fn into_channel(self) -> C {
401 self.channel
402 }
403
404 #[tracing::instrument(name = "core.agent.run", skip_all, level = "debug", err)]
410 #[allow(clippy::too_many_lines)] pub async fn run(&mut self) -> Result<(), error::AgentError>
412 where
413 C: 'static,
414 {
415 if let Some(mut rx) = self.runtime.lifecycle.warmup_ready.take()
416 && !*rx.borrow()
417 {
418 let _ = rx.changed().await;
419 if !*rx.borrow() {
420 tracing::warn!("model warmup did not complete successfully");
421 }
422 }
423
424 self.restore_channel_provider().await;
426
427 self.load_and_cache_session_digest().await;
429 self.maybe_send_resume_recap().await;
430
431 self.maybe_start_heuristic_promotion();
435
436 loop {
437 self.services.session.is_guest_context = false;
446 state::persistence::DEFAULT_OWNER_KEY.clone_into(&mut self.services.session.owner_key);
447
448 self.apply_provider_override();
449 self.check_tool_refresh().await;
450 self.process_pending_elicitations().await;
451 self.refresh_subagent_metrics();
452 self.notify_completed_subagents().await?;
453 self.drain_channel();
454
455 let (text, image_parts) = if let Some(queued) = self.msg.message_queue.pop_front() {
456 self.notify_queue_count().await;
457 if queued.raw_attachments.is_empty() {
458 (queued.text, queued.image_parts)
459 } else {
460 let msg = crate::channel::ChannelMessage {
461 text: queued.text,
462 attachments: queued.raw_attachments,
463 is_guest_context: false,
464 is_from_bot: false,
465 owner_key: None,
466 };
467 self.resolve_message(msg).await
468 }
469 } else {
470 match self.next_event().await? {
471 None | Some(LoopEvent::Shutdown) => break,
472 Some(LoopEvent::SkillReload) => {
473 self.reload_skills().await;
474 continue;
475 }
476 Some(LoopEvent::InstructionReload) => {
477 self.reload_instructions().await;
478 continue;
479 }
480 Some(LoopEvent::ConfigReload) => {
481 self.reload_config();
482 continue;
483 }
484 Some(LoopEvent::UpdateNotification(msg)) => {
485 if let Err(e) = self.channel.send(&msg).await {
486 tracing::warn!("failed to send update notification: {e}");
487 }
488 continue;
489 }
490 Some(LoopEvent::ExperimentCompleted(msg)) => {
491 self.services.experiments.cancel = None;
492 self.services.experiments.handle = None;
493 if let Err(e) = self.channel.send(&msg).await {
494 tracing::warn!("failed to send experiment completion: {e}");
495 }
496 continue;
497 }
498 Some(LoopEvent::ScheduledTask(prompt)) => {
499 let text = format!("{SCHEDULED_TASK_PREFIX}{prompt}");
500 let msg = crate::channel::ChannelMessage {
501 text,
502 attachments: Vec::new(),
503 is_guest_context: false,
504 is_from_bot: false,
505 owner_key: None,
506 };
507 self.drain_channel();
508 self.resolve_message(msg).await
509 }
510 Some(LoopEvent::TaskInjected(injection)) => {
511 if let Some(ref mut ls) = self.runtime.lifecycle.user_loop {
512 ls.iteration += 1;
513 tracing::info!(iteration = ls.iteration, "loop: tick");
514 }
515 let msg = crate::channel::ChannelMessage {
516 text: injection.prompt,
517 attachments: Vec::new(),
518 is_guest_context: false,
519 is_from_bot: false,
520 owner_key: None,
521 };
522 self.drain_channel();
523 self.resolve_message(msg).await
524 }
525 Some(LoopEvent::FileChanged(event)) => {
526 self.handle_file_changed(event).await;
527 continue;
528 }
529 Some(LoopEvent::AutonomousTick) => {
530 if let Err(e) = self.run_autonomous_turn().await {
531 tracing::warn!(error = %e, "autonomous turn error");
532 }
533 continue;
534 }
535 Some(LoopEvent::BgMetricsTick) => {
536 self.reap_background_tasks_and_update_metrics();
537 continue;
538 }
539 Some(LoopEvent::Message(msg)) => {
540 self.services.session.is_guest_context = msg.is_guest_context;
541 self.services.session.owner_key = msg
542 .owner_key
543 .clone()
544 .unwrap_or_else(|| state::persistence::DEFAULT_OWNER_KEY.to_owned());
545 self.drain_channel();
546 self.resolve_message(msg).await
547 }
548 }
549 };
550
551 let trimmed = text.trim();
552
553 if trimmed.starts_with('/') {
556 let slash_urls = zeph_sanitizer::exfiltration::extract_flagged_urls(trimmed);
557 if !slash_urls.is_empty() {
558 self.services
559 .security
560 .user_provided_urls
561 .write()
562 .extend(slash_urls);
563 }
564 }
565
566 let trusted = self.channel.supports_exit();
589 let session_impl = command_context_impls::SessionAccessImpl {
590 supports_exit: trusted,
591 history_expand_default_lines: self
592 .runtime
593 .config
594 .resume_config
595 .expand_default_lines,
596 };
597 let mut messages_impl = command_context_impls::MessageAccessImpl {
598 msg: &mut self.msg,
599 tool_state: &mut self.services.tool_state,
600 providers: &mut self.runtime.providers,
601 metrics: &self.runtime.metrics,
602 security: &mut self.services.security,
603 tool_orchestrator: &mut self.tool_orchestrator,
604 };
605 let mut sink_adapter = crate::channel::ChannelSinkAdapter(&mut self.channel);
607 let mut null_agent = zeph_commands::NullAgent;
609 let registry_handled = {
610 let reg = slash_commands::build_session_debug_registry();
611
612 let mut ctx = zeph_commands::CommandContext {
613 sink: &mut sink_adapter,
614 debug: &mut self.runtime.debug,
615 messages: &mut messages_impl,
616 session: &session_impl,
617 agent: &mut null_agent,
618 };
619 reg.dispatch(&mut ctx, trimmed, trusted).await
620 };
621 let session_reg_missed = registry_handled.is_none();
622 match self
623 .apply_dispatch_result(registry_handled, trimmed, false)
624 .await
625 {
626 DispatchFlow::Break => break,
627 DispatchFlow::Continue => continue,
628 DispatchFlow::Fallthrough => {
629 }
631 }
632
633 let mut agent_null_debug = command_context_impls::NullDebugAccess;
639 let mut agent_null_messages = command_context_impls::NullMessageAccess;
640 let agent_null_session = command_context_impls::NullSessionAccess;
641 let mut agent_null_sink = zeph_commands::NullSink;
642 let agent_result: Option<
643 Result<zeph_commands::CommandOutput, zeph_commands::CommandError>,
644 > = if session_reg_missed {
645 let agent_reg = slash_commands::build_agent_command_registry();
646
647 let mut ctx = zeph_commands::CommandContext {
648 sink: &mut agent_null_sink,
649 debug: &mut agent_null_debug,
650 messages: &mut agent_null_messages,
651 session: &agent_null_session,
652 agent: self,
653 };
654 agent_reg.dispatch(&mut ctx, trimmed, trusted).await
656 } else {
657 None
658 };
659 if let Some((cancelled_id, new_id)) = self.services.autonomous.flush_pending_start() {
665 if let Some(cid) = cancelled_id {
666 tracing::info!(
667 goal_id = cid,
668 "autonomous: previous session cancelled for new goal"
669 );
670 }
671 self.sync_registry_entry();
672 tracing::info!(goal_id = new_id, "autonomous: session started");
673 }
674
675 match self
678 .apply_dispatch_result(agent_result, trimmed, true)
679 .await
680 {
681 DispatchFlow::Break => break,
682 DispatchFlow::Continue => continue,
683 DispatchFlow::Fallthrough => {
684 }
686 }
687
688 match self.handle_builtin_command(trimmed) {
689 Some(true) => break,
690 Some(false) => continue,
691 None => {}
692 }
693
694 self.process_user_message(text, image_parts).await?;
695 }
696
697 self.maybe_autodream().await;
700
701 self.maybe_extract_skills_from_trace().await;
703
704 if let Some(ref mut tc) = self.runtime.debug.trace_collector
710 && let Some(handle) = tc.finish()
711 && let Err(e) = handle.await
712 {
713 tracing::warn!(error = %e, "trace.json write task did not complete");
714 }
715
716 Ok(())
717 }
718
719 async fn apply_dispatch_result(
725 &mut self,
726 result: Option<Result<zeph_commands::CommandOutput, zeph_commands::CommandError>>,
727 command: &str,
728 with_learning: bool,
729 ) -> DispatchFlow {
730 match result {
731 Some(Ok(zeph_commands::CommandOutput::Exit)) => {
732 let _ = self.channel.flush_chunks().await;
733 DispatchFlow::Break
734 }
735 Some(Ok(zeph_commands::CommandOutput::Message(msg))) => {
736 let _ = self.channel.send(&msg).await;
737 let _ = self.channel.flush_chunks().await;
738 if with_learning {
739 self.maybe_trigger_post_command_learning(command).await;
740 }
741 DispatchFlow::Continue
742 }
743 Some(Ok(_)) => {
744 let _ = self.channel.flush_chunks().await;
745 DispatchFlow::Continue
746 }
747 Some(Err(e)) => {
748 let _ = self.channel.send(&e.to_string()).await;
749 let _ = self.channel.flush_chunks().await;
750 tracing::warn!(command = %command, error = %e.0, "slash command failed");
751 DispatchFlow::Continue
752 }
753 None => DispatchFlow::Fallthrough,
754 }
755 }
756
757 fn apply_provider_override(&mut self) {
759 let taken = self
760 .runtime
761 .providers
762 .provider_override
763 .as_ref()
764 .and_then(|slot| slot.write().take());
765 if let Some(new_provider) = taken {
766 tracing::debug!(provider = new_provider.name(), "ACP model override applied");
767 self.set_provider(new_provider);
768 }
769 }
770
771 fn set_provider(&mut self, provider: AnyProvider) {
788 let provider = match self.services.security.secret_registry.clone() {
789 Some(registry) if !matches!(provider, AnyProvider::Masked(_)) => {
790 provider.masked(registry as Arc<dyn zeph_llm::masking::OutboundMasker>)
791 }
792 _ => provider,
793 };
794 debug_assert!(
795 self.services.security.secret_registry.is_none()
796 || matches!(provider, AnyProvider::Masked(_)),
797 "set_provider invariant violated: secret masking is enabled but the new provider \
798 is not wrapped via AnyProvider::masked — every self.provider reassignment must go \
799 through Agent::set_provider, never assign the field directly"
800 );
801 self.provider = provider;
802 }
803
804 #[tracing::instrument(name = "core.agent.next_event", skip_all, level = "debug", err)]
812 async fn next_event(&mut self) -> Result<Option<LoopEvent>, error::AgentError> {
813 let event = tokio::select! {
814 result = self.channel.recv() => {
815 return Ok(result?.map(LoopEvent::Message));
816 }
817 () = shutdown_signal(&mut self.runtime.lifecycle.shutdown) => {
818 tracing::info!("shutting down");
819 LoopEvent::Shutdown
820 }
821 Some(_) = recv_optional(&mut self.services.skill.skill_reload_rx) => {
822 LoopEvent::SkillReload
823 }
824 Some(_) = recv_optional(&mut self.runtime.instructions.reload_rx) => {
825 LoopEvent::InstructionReload
826 }
827 Some(_) = recv_optional(&mut self.runtime.lifecycle.config_reload_rx) => {
828 LoopEvent::ConfigReload
829 }
830 Some(msg) = recv_optional(&mut self.runtime.lifecycle.update_notify_rx) => {
831 LoopEvent::UpdateNotification(msg)
832 }
833 Some(msg) = recv_optional(&mut self.services.experiments.notify_rx) => {
834 LoopEvent::ExperimentCompleted(msg)
835 }
836 Some(prompt) = recv_optional(&mut self.runtime.lifecycle.custom_task_rx) => {
837 tracing::info!("scheduler: injecting custom task as agent turn");
838 LoopEvent::ScheduledTask(prompt)
839 }
840 () = async {
841 if let Some(ref mut ls) = self.runtime.lifecycle.user_loop {
842 if ls.cancel_tx.is_cancelled() {
843 std::future::pending::<()>().await;
844 } else {
845 ls.interval.tick().await;
846 }
847 } else {
848 std::future::pending::<()>().await;
849 }
850 } => {
851 let Some(ls) = self.runtime.lifecycle.user_loop.as_ref() else {
855 return Ok(None);
856 };
857 if ls.cancel_tx.is_cancelled() {
858 self.runtime.lifecycle.user_loop = None;
859 return Ok(None);
860 }
861 let prompt = ls.prompt.clone();
862 LoopEvent::TaskInjected(task_injection::TaskInjection { prompt })
863 }
864 Some(event) = recv_optional(&mut self.runtime.lifecycle.file_changed_rx) => {
865 LoopEvent::FileChanged(event)
866 }
867 () = self.services.autonomous.next_tick(),
869 if self.services.autonomous.should_tick() => {
870 LoopEvent::AutonomousTick
871 }
872 _ = self
885 .runtime
886 .lifecycle
887 .bg_metrics_tick
888 .get_or_insert_with(|| {
889 let mut iv = tokio::time::interval_at(
890 tokio::time::Instant::now() + state::BG_METRICS_TICK_INTERVAL,
891 state::BG_METRICS_TICK_INTERVAL,
892 );
893 iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
894 iv
895 })
896 .tick() => {
897 LoopEvent::BgMetricsTick
898 }
899 };
900 Ok(Some(event))
901 }
902
903 #[tracing::instrument(name = "core.agent.resolve_message", skip_all, level = "debug")]
904 async fn resolve_message(
905 &self,
906 msg: crate::channel::ChannelMessage,
907 ) -> (String, Vec<zeph_llm::provider::MessagePart>) {
908 use crate::channel::{Attachment, AttachmentKind};
909 use zeph_llm::provider::{ImageData, MessagePart};
910
911 let text_base = msg.text.clone();
912
913 let (audio_attachments, image_attachments): (Vec<Attachment>, Vec<Attachment>) = msg
914 .attachments
915 .into_iter()
916 .partition(|a| a.kind == AttachmentKind::Audio);
917
918 tracing::debug!(
919 audio = audio_attachments.len(),
920 has_stt = self.runtime.providers.stt.is_some(),
921 "resolve_message attachments"
922 );
923
924 let text = if !audio_attachments.is_empty()
925 && let Some(stt) = self.runtime.providers.stt.as_ref()
926 {
927 let mut transcribed_parts = Vec::new();
928 for attachment in &audio_attachments {
929 if attachment.data.len() > MAX_AUDIO_BYTES {
930 tracing::warn!(
931 size = attachment.data.len(),
932 max = MAX_AUDIO_BYTES,
933 "audio attachment exceeds size limit, skipping"
934 );
935 continue;
936 }
937 match stt
938 .transcribe(&attachment.data, attachment.filename.as_deref())
939 .await
940 {
941 Ok(result) => {
942 tracing::info!(
943 len = result.text.len(),
944 language = ?result.language,
945 "audio transcribed"
946 );
947 transcribed_parts.push(result.text);
948 }
949 Err(e) => {
950 tracing::error!(error = %e, "audio transcription failed");
951 }
952 }
953 }
954 if transcribed_parts.is_empty() {
955 text_base
956 } else {
957 let transcribed = transcribed_parts.join("\n");
958 if text_base.is_empty() {
959 transcribed
960 } else {
961 format!("[transcribed audio]\n{transcribed}\n\n{text_base}")
962 }
963 }
964 } else {
965 if !audio_attachments.is_empty() {
966 tracing::warn!(
967 count = audio_attachments.len(),
968 "audio attachments received but no STT provider configured, dropping"
969 );
970 }
971 text_base
972 };
973
974 let mut image_parts = Vec::new();
975 for attachment in image_attachments {
976 if attachment.data.len() > MAX_IMAGE_BYTES {
977 tracing::warn!(
978 size = attachment.data.len(),
979 max = MAX_IMAGE_BYTES,
980 "image attachment exceeds size limit, skipping"
981 );
982 continue;
983 }
984 let mime_type = detect_image_mime(attachment.filename.as_deref()).to_string();
985 image_parts.push(MessagePart::Image(Box::new(ImageData {
986 data: attachment.data,
987 mime_type,
988 })));
989 }
990
991 (text, image_parts)
992 }
993
994 fn begin_turn(&mut self, input: turn::TurnInput) -> turn::Turn {
1001 let id = turn::TurnId(self.runtime.debug.iteration_counter as u64);
1002 self.runtime.debug.iteration_counter += 1;
1003 let cancel_token = CancellationToken::new();
1004 self.runtime.lifecycle.cancel_token = cancel_token.clone();
1006 self.services.security.user_provided_urls.write().clear();
1007 *self.services.security.memory_consent_trust.write() = 0;
1016 self.runtime.lifecycle.turn_llm_requests = 0;
1018 self.runtime.lifecycle.turn_tool_calls = 0;
1020
1021 {
1024 use crate::agent::trajectory::{RiskSignal, VigilRiskLevel};
1025 use zeph_memory::shadow::{AuditSignalType as MageSignal, Severity as MageSev};
1026 let pending: Vec<u8> = {
1027 let mut q = self.services.security.trajectory_signal_queue.lock();
1028 std::mem::take(&mut *q)
1029 };
1030 self.services.security.mage_accumulator.advance_turn();
1031 for code in pending {
1032 let signal = RiskSignal::from_code(code);
1033 self.services.security.trajectory.record(signal);
1034 let mage_signal: Option<(MageSignal, MageSev)> = match signal {
1046 RiskSignal::PolicyDeny => Some((MageSignal::PolicyViolation, MageSev::Medium)),
1047 RiskSignal::ExfiltrationRedaction => {
1048 Some((MageSignal::ToolChainAnomaly, MageSev::Medium))
1049 }
1050 RiskSignal::VigilFlagged(VigilRiskLevel::Medium) => {
1051 Some((MageSignal::PromptInjectionPattern, MageSev::Medium))
1052 }
1053 RiskSignal::VigilFlagged(VigilRiskLevel::High) => {
1054 Some((MageSignal::PromptInjectionPattern, MageSev::High))
1055 }
1056 _ => None,
1057 };
1058 if let Some((sig, sev)) = mage_signal {
1059 self.services.security.mage_accumulator.ingest(sig, sev);
1060 }
1061 }
1062 }
1063 if self.services.security.trajectory.advance_turn()
1066 && let Some(logger) = self.tool_orchestrator.audit_logger.clone()
1067 {
1068 let entry = zeph_tools::AuditEntry {
1069 source_kind: None,
1070 trust_level: None,
1071 timestamp: zeph_tools::chrono_now(),
1072 tool: "<sentinel>".to_owned().into(),
1073 command: String::new(),
1074 result: zeph_tools::AuditResult::Success,
1075 duration_ms: 0,
1076 error_category: Some("trajectory_auto_recover".to_owned()),
1077 error_domain: Some("security".to_owned()),
1078 error_phase: None,
1079 claim_source: None,
1080 mcp_server_id: None,
1081 injection_flagged: false,
1082 embedding_anomalous: false,
1083 cross_boundary_mcp_to_acp: false,
1084 adversarial_policy_decision: None,
1085 exit_code: None,
1086 truncated: false,
1087 caller_id: None,
1088 skill_name: None,
1089 policy_match: None,
1090 correlation_id: None,
1091 vigil_risk: None,
1092 execution_env: None,
1093 resolved_cwd: None,
1094 scope_at_definition: None,
1095 scope_at_dispatch: None,
1096 };
1097 self.runtime.lifecycle.supervisor.spawn(
1098 crate::agent::agent_supervisor::TaskClass::Telemetry,
1099 "trajectory-auto-recover-audit",
1100 async move { logger.log(&entry).await },
1101 );
1102 }
1103 if let Some(ref sentinel) = self.services.security.shadow_sentinel {
1105 sentinel.advance_turn();
1106 }
1107 if let Some(ref acc) = self.services.security.risk_chain_accumulator {
1111 acc.advance_turn();
1112 }
1113 let risk_level = self.services.security.trajectory.current_risk();
1115 *self.services.security.trajectory_risk_slot.write() = u8::from(risk_level);
1116 if let Some(alert) = self.services.security.trajectory.poll_alert() {
1118 let msg = format!(
1119 "[trajectory] Risk level: {:?} (score={:.2})",
1120 alert.level, alert.score
1121 );
1122 tracing::warn!(
1123 level = ?alert.level,
1124 score = alert.score,
1125 "trajectory sentinel alert"
1126 );
1127 if let Some(ref tx) = self.services.session.status_tx {
1128 let _ = tx.send(msg);
1129 }
1130 }
1131
1132 let context = turn::TurnContext::new(id, cancel_token, self.runtime.config.timeouts)
1133 .with_tool_allowlist(self.runtime.config.channel_tool_allowlist.clone());
1134 turn::Turn::new(context, input)
1135 }
1136
1137 fn end_turn(&mut self, turn: turn::Turn) {
1144 self.runtime.metrics.pending_timings = turn.metrics.timings;
1145 self.flush_turn_timings();
1146 self.services.session.current_turn_intent = None;
1148 self.services.session.is_guest_context = false;
1150 state::persistence::DEFAULT_OWNER_KEY.clone_into(&mut self.services.session.owner_key);
1157 if let Some(ref engine) = self.services.speculation_engine {
1159 let metrics = engine.end_turn();
1160 if metrics.committed > 0 || metrics.cancelled > 0 {
1161 tracing::debug!(
1162 committed = metrics.committed,
1163 cancelled = metrics.cancelled,
1164 wasted_ms = metrics.wasted_ms,
1165 "speculation: turn boundary metrics"
1166 );
1167 }
1168 }
1169 }
1170
1171 #[tracing::instrument(
1172 name = "core.agent.process_user_message",
1173 skip_all,
1174 level = "debug",
1175 fields(turn_id),
1176 err
1177 )]
1178 async fn process_user_message(
1179 &mut self,
1180 text: String,
1181 image_parts: Vec<zeph_llm::provider::MessagePart>,
1182 ) -> Result<(), error::AgentError> {
1183 self.apply_provider_override();
1188
1189 let input = turn::TurnInput::new(text, image_parts);
1190 let mut t = self.begin_turn(input);
1191
1192 let turn_idx = usize::try_from(t.id().0).unwrap_or(usize::MAX);
1193 tracing::Span::current().record("turn_id", t.id().0);
1194 self.runtime
1196 .debug
1197 .start_iteration_span(turn_idx, t.input.text.trim());
1198
1199 let result = Box::pin(self.process_user_message_inner(&mut t)).await;
1200
1201 let span_status = if result.is_ok() {
1203 crate::debug_dump::trace::SpanStatus::Ok
1204 } else {
1205 crate::debug_dump::trace::SpanStatus::Error {
1206 message: "iteration failed".to_owned(),
1207 }
1208 };
1209 self.runtime.debug.end_iteration_span(turn_idx, span_status);
1210
1211 self.end_turn(t);
1212 result
1213 }
1214
1215 #[allow(clippy::too_many_lines)] #[tracing::instrument(
1217 name = "core.agent.process_user_message_inner",
1218 skip_all,
1219 level = "debug",
1220 err
1221 )]
1222 async fn process_user_message_inner(
1223 &mut self,
1224 turn: &mut turn::Turn,
1225 ) -> Result<(), error::AgentError> {
1226 self.reap_background_tasks_and_update_metrics();
1227
1228 let tokens_before_turn = self
1229 .runtime
1230 .metrics
1231 .metrics_tx
1232 .as_ref()
1233 .map_or(0, |tx| tx.borrow().total_tokens);
1234
1235 self.drain_background_completions();
1239
1240 self.wire_cancel_bridge(turn.cancel_token());
1241
1242 let text = turn.input.text.clone();
1244 let trimmed_owned = text.trim().to_owned();
1245 let trimmed = trimmed_owned.as_str();
1246
1247 if self.services.security.vigil.is_some() {
1250 let intent_len = trimmed.floor_char_boundary(1024.min(trimmed.len()));
1251 self.services.session.current_turn_intent = Some(trimmed[..intent_len].to_owned());
1252 }
1253
1254 if let Some(result) = self.dispatch_slash_command(trimmed).await {
1255 return result;
1256 }
1257
1258 let text = self.sanitize_channel_text_if_untrusted(text);
1260 let trimmed_owned = text.trim().to_owned();
1261 let trimmed = trimmed_owned.as_str();
1262
1263 self.check_pending_rollbacks().await;
1264
1265 if self.pre_process_security(trimmed).await? {
1266 return Ok(());
1267 }
1268
1269 let t_ctx = std::time::Instant::now();
1270 tracing::debug!("turn timing: prepare_context start");
1271 self.advance_context_lifecycle_guarded(&text, trimmed).await;
1272 turn.metrics_mut().timings.prepare_context_ms =
1273 u64::try_from(t_ctx.elapsed().as_millis()).unwrap_or(u64::MAX);
1274 tracing::debug!(
1275 ms = turn.metrics_snapshot().timings.prepare_context_ms,
1276 "turn timing: prepare_context done"
1277 );
1278 let _ = self
1280 .channel
1281 .send_context_estimate(
1282 usize::try_from(self.runtime.providers.cached_prompt_tokens).unwrap_or(usize::MAX),
1283 )
1284 .await;
1285
1286 let image_parts = std::mem::take(&mut turn.input.image_parts);
1287 let merged_text = self.build_user_message_text_with_bg_completions(&text);
1291 let user_msg = self.build_user_message(&merged_text, image_parts);
1292
1293 let urls = zeph_sanitizer::exfiltration::extract_flagged_urls(trimmed);
1296 if !urls.is_empty() {
1297 self.services
1298 .security
1299 .user_provided_urls
1300 .write()
1301 .extend(urls);
1302 }
1303
1304 self.services.memory.extraction.goal_text = Some(text.clone());
1307
1308 let t_persist = std::time::Instant::now();
1309 tracing::debug!("turn timing: persist_message(user) start");
1310 self.persist_message(Role::User, &text, &[], false).await;
1312 turn.metrics_mut().timings.persist_message_ms =
1313 u64::try_from(t_persist.elapsed().as_millis()).unwrap_or(u64::MAX);
1314 tracing::debug!(
1315 ms = turn.metrics_snapshot().timings.persist_message_ms,
1316 "turn timing: persist_message(user) done"
1317 );
1318 self.push_message(user_msg);
1319
1320 let context_estimate = self.runtime.providers.cached_prompt_tokens;
1322 self.update_metrics(|m| m.context_tokens = context_estimate);
1323
1324 tracing::debug!("turn timing: process_response start");
1327 let turn_had_error = if let Err(e) = self.process_response().await {
1328 self.services.learning_engine.learning_tasks.detach_all();
1330 tracing::error!("Response processing failed: {e:#}");
1331
1332 if e.is_no_providers() {
1335 self.runtime.lifecycle.last_no_providers_at = Some(std::time::Instant::now());
1336 let backoff_secs = self.runtime.config.timeouts.no_providers_backoff_secs;
1337 tracing::warn!(
1338 backoff_secs,
1339 "no providers available; backing off before next turn"
1340 );
1341 tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await;
1342 }
1343
1344 let user_msg = format!("Error: {e:#}");
1345 self.channel.send(&user_msg).await?;
1346 if let Some(popped) = self.msg.messages.pop() {
1347 self.msg.track_single_message(popped.role, false);
1348 }
1349 self.recompute_prompt_tokens();
1350 self.channel.flush_chunks().await?;
1351 true
1352 } else {
1353 self.services.learning_engine.learning_tasks.detach_all();
1356 self.truncate_old_tool_results();
1357 self.maybe_update_magic_docs();
1359 self.maybe_spawn_promotion_scan();
1361 false
1362 };
1363 tracing::debug!("turn timing: process_response done");
1364
1365 if let Some(pipeline) = self.services.quality.clone() {
1367 self.run_self_check_for_turn(pipeline, turn.id().0).await;
1368 }
1369 let _ = self.channel.flush_chunks().await;
1374
1375 self.maybe_fire_completion_notification(turn, turn_had_error);
1376
1377 self.flush_goal_accounting(tokens_before_turn);
1378
1379 turn.metrics_mut().timings.llm_chat_ms = self.runtime.metrics.pending_timings.llm_chat_ms;
1384 turn.metrics_mut().timings.tool_exec_ms = self.runtime.metrics.pending_timings.tool_exec_ms;
1385
1386 Ok(())
1387 }
1388
1389 fn wire_cancel_bridge(&mut self, turn_token: &tokio_util::sync::CancellationToken) {
1395 let signal = Arc::clone(&self.runtime.lifecycle.cancel_signal);
1396 let token = turn_token.clone();
1397 self.runtime.lifecycle.cancel_token = turn_token.clone();
1399 if let Some(prev) = self.runtime.lifecycle.cancel_bridge_handle.take() {
1400 prev.abort();
1401 }
1402 self.runtime.lifecycle.cancel_bridge_handle =
1403 Some(self.runtime.lifecycle.task_supervisor.spawn_oneshot(
1404 std::sync::Arc::from("agent.lifecycle.cancel_bridge"),
1405 move || async move {
1406 signal.notified().await;
1407 token.cancel();
1408 },
1409 ));
1410 }
1411
1412 fn reap_background_tasks_and_update_metrics(&mut self) {
1419 let bg_signal = self.runtime.lifecycle.supervisor.reap();
1420 if bg_signal.did_summarize {
1421 self.services.memory.persistence.unsummarized_count = 0;
1422 tracing::debug!("background summarization completed; unsummarized_count reset");
1423 }
1424 let snap = self.runtime.lifecycle.supervisor.metrics_snapshot();
1425 self.update_metrics(|m| {
1426 m.bg_inflight = snap.inflight as u64;
1427 m.bg_dropped = snap.total_dropped();
1428 m.bg_completed = snap.total_completed();
1429 m.bg_enrichment_inflight = snap.class_inflight[0] as u64;
1430 m.bg_telemetry_inflight = snap.class_inflight[1] as u64;
1431 });
1432
1433 if self.runtime.lifecycle.shell_executor_handle.is_some() {
1435 let shell_rows: Vec<crate::metrics::ShellBackgroundRunRow> = self
1436 .runtime
1437 .lifecycle
1438 .shell_executor_handle
1439 .as_ref()
1440 .map(|e| e.background_runs_snapshot())
1441 .unwrap_or_default()
1442 .into_iter()
1443 .map(|s| crate::metrics::ShellBackgroundRunRow {
1444 run_id: truncate_shell_run_id(&s.run_id),
1445 command: truncate_shell_command(&s.command),
1446 elapsed_secs: s.elapsed_ms / 1000,
1447 })
1448 .collect();
1449 self.update_metrics(|m| {
1450 m.shell_background_runs = shell_rows;
1451 });
1452 }
1453
1454 if self
1457 .runtime
1458 .config
1459 .supervisor_config
1460 .abort_enrichment_on_turn
1461 {
1462 self.runtime
1463 .lifecycle
1464 .supervisor
1465 .abort_class(agent_supervisor::TaskClass::Enrichment);
1466 }
1467 }
1468
1469 fn maybe_fire_completion_notification(&mut self, turn: &turn::Turn, is_error: bool) {
1482 let snap = turn.metrics_snapshot().timings.clone();
1483 let duration_ms = snap
1484 .prepare_context_ms
1485 .saturating_add(snap.llm_chat_ms)
1486 .saturating_add(snap.tool_exec_ms);
1487 let summary = crate::notifications::TurnSummary {
1488 duration_ms,
1489 preview: self.last_assistant_preview(160),
1490 tool_calls: self.runtime.lifecycle.turn_tool_calls,
1491 llm_requests: self.runtime.lifecycle.turn_llm_requests,
1492 exit_status: if is_error {
1493 crate::notifications::TurnExitStatus::Error
1494 } else {
1495 crate::notifications::TurnExitStatus::Success
1496 },
1497 };
1498
1499 let gate_ok = self
1501 .runtime
1502 .lifecycle
1503 .notifier
1504 .as_ref()
1505 .is_none_or(|n| n.should_fire(&summary));
1506
1507 if let Some(ref notifier) = self.runtime.lifecycle.notifier
1509 && gate_ok
1510 {
1511 notifier.fire(&summary, &mut self.runtime.lifecycle.supervisor);
1512 }
1513
1514 let hooks = self.services.session.hooks_config.turn_complete.clone();
1519 if !hooks.is_empty() && gate_ok {
1520 let mut env = build_turn_hook_env(&summary, is_error);
1521 let conv_id_str = self
1522 .services
1523 .memory
1524 .persistence
1525 .conversation_id
1526 .map(|id| id.0.to_string());
1527 crate::agent::hooks_dispatch::insert_main_agent_ctx(&mut env, conv_id_str.as_deref());
1528 let dispatch = self.mcp_dispatch();
1529 let _span = tracing::info_span!("core.agent.turn_hooks").entered();
1530 let _accepted = self.runtime.lifecycle.supervisor.spawn(
1531 agent_supervisor::TaskClass::Telemetry,
1532 "turn-complete-hooks",
1533 async move {
1534 let mcp: Option<&dyn zeph_subagent::McpDispatch> = dispatch
1535 .as_ref()
1536 .map(|d| d as &dyn zeph_subagent::McpDispatch);
1537 if let Err(e) = zeph_subagent::hooks::fire_hooks(&hooks, &env, mcp, None).await
1538 {
1539 tracing::warn!(error = %e, "turn_complete hook failed");
1540 }
1541 },
1542 );
1543 }
1544 }
1545
1546 fn flush_goal_accounting(&mut self, tokens_before: u64) {
1549 let goal_snap = self
1550 .services
1551 .goal_accounting
1552 .as_ref()
1553 .and_then(|a| a.snapshot());
1554 self.update_metrics(|m| m.active_goal = goal_snap);
1555
1556 if let Some(ref accounting) = self.services.goal_accounting {
1557 let tokens_after = self
1558 .runtime
1559 .metrics
1560 .metrics_tx
1561 .as_ref()
1562 .map_or(0, |tx| tx.borrow().total_tokens);
1563 let turn_tokens = tokens_after.saturating_sub(tokens_before);
1564 let mut spawned: Option<
1565 std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'static>>,
1566 > = None;
1567 accounting.on_turn_complete(turn_tokens, |fut| {
1568 spawned = Some(fut);
1569 });
1570 if let Some(fut) = spawned {
1571 let _ = self.runtime.lifecycle.supervisor.spawn(
1572 agent_supervisor::TaskClass::Telemetry,
1573 "goal-accounting",
1574 fut,
1575 );
1576 }
1577 }
1578 }
1579
1580 fn sanitize_channel_text_if_untrusted(&self, text: String) -> String {
1592 if !self.channel.requires_input_sanitization() {
1593 return text;
1594 }
1595 self.services
1596 .security
1597 .sanitizer
1598 .sanitize(
1599 &text,
1600 zeph_sanitizer::ContentSource::new(
1601 zeph_sanitizer::ContentSourceKind::ChannelMessage,
1602 ),
1603 )
1604 .body
1605 }
1606
1607 #[tracing::instrument(
1609 name = "core.agent.pre_process_security",
1610 skip_all,
1611 level = "debug",
1612 err
1613 )]
1614 async fn pre_process_security(&mut self, trimmed: &str) -> Result<bool, error::AgentError> {
1615 if let Some(ref guardrail) = self.services.security.guardrail {
1617 use zeph_sanitizer::guardrail::GuardrailVerdict;
1618 let verdict = guardrail.check(trimmed).await;
1619 match &verdict {
1620 GuardrailVerdict::Flagged { reason, .. } => {
1621 tracing::warn!(
1622 reason = %reason,
1623 should_block = verdict.should_block(),
1624 "guardrail flagged user input"
1625 );
1626 if verdict.should_block() {
1627 let msg = format!("[guardrail] Input blocked: {reason}");
1628 let _ = self.channel.send(&msg).await;
1629 let _ = self.channel.flush_chunks().await;
1630 return Ok(true);
1631 }
1632 let _ = self
1634 .channel
1635 .send(&format!("[guardrail] Warning: {reason}"))
1636 .await;
1637 }
1638 GuardrailVerdict::Error { error } => {
1639 if guardrail.error_should_block() {
1640 tracing::warn!(%error, "guardrail check failed (fail_strategy=closed), blocking input");
1641 let msg = "[guardrail] Input blocked: check failed (see logs for details)";
1642 let _ = self.channel.send(msg).await;
1643 let _ = self.channel.flush_chunks().await;
1644 return Ok(true);
1645 }
1646 tracing::warn!(%error, "guardrail check failed (fail_strategy=open), allowing input");
1647 }
1648 _ => {}
1649 }
1650 }
1651
1652 self.record_nli_verdict(trimmed, "user_input").await;
1655
1656 #[cfg(feature = "classifiers")]
1662 if self.services.security.sanitizer.scan_user_input() {
1663 match self
1664 .services
1665 .security
1666 .sanitizer
1667 .classify_injection(trimmed)
1668 .await
1669 {
1670 zeph_sanitizer::InjectionVerdict::Blocked => {
1671 self.push_classifier_metrics();
1672 let _ = self
1673 .channel
1674 .send("[security] Input blocked: injection detected by classifier.")
1675 .await;
1676 let _ = self.channel.flush_chunks().await;
1677 return Ok(true);
1678 }
1679 zeph_sanitizer::InjectionVerdict::Suspicious => {
1680 tracing::warn!("injection_classifier soft_signal on user input");
1681 }
1682 _ => {}
1683 }
1684 }
1685 #[cfg(feature = "classifiers")]
1686 self.push_classifier_metrics();
1687
1688 Ok(false)
1689 }
1690
1691 async fn advance_context_lifecycle_guarded(&mut self, text: &str, trimmed: &str) {
1698 let backoff_secs = self.runtime.config.timeouts.no_providers_backoff_secs;
1699 let prep_timeout_secs = self.runtime.config.timeouts.context_prep_timeout_secs;
1700
1701 let providers_recently_failed = self
1703 .runtime
1704 .lifecycle
1705 .last_no_providers_at
1706 .is_some_and(|t| t.elapsed().as_secs() < backoff_secs);
1707
1708 if providers_recently_failed {
1709 tracing::warn!(
1710 backoff_secs,
1711 "skipping context preparation: providers were unavailable on last turn"
1712 );
1713 return;
1714 }
1715
1716 let timeout_dur = std::time::Duration::from_secs(prep_timeout_secs);
1717 match tokio::time::timeout(timeout_dur, self.advance_context_lifecycle(text, trimmed)).await
1718 {
1719 Ok(()) => {}
1720 Err(_elapsed) => {
1721 tracing::warn!(
1722 timeout_secs = prep_timeout_secs,
1723 "context preparation timed out; proceeding with degraded context"
1724 );
1725 }
1726 }
1727 }
1728
1729 #[tracing::instrument(
1730 name = "core.agent.advance_context_lifecycle",
1731 skip_all,
1732 level = "debug"
1733 )]
1734 async fn advance_context_lifecycle(&mut self, text: &str, trimmed: &str) {
1735 self.services.mcp.pruning_cache.reset();
1737
1738 let conv_id = self.services.memory.persistence.conversation_id;
1741 self.rebuild_system_prompt(text).await;
1746
1747 self.detect_and_record_corrections(trimmed, conv_id).await;
1748 self.services.learning_engine.tick();
1749 self.analyze_and_learn().await;
1750 self.sync_graph_counts().await;
1751
1752 self.context_manager
1757 .set_compaction_state(self.context_manager.compaction_state().advance_turn());
1758
1759 {
1761 self.services.focus.tick();
1762
1763 let sidequest_should_fire = self.services.sidequest.tick();
1766 if sidequest_should_fire
1767 && !self
1768 .context_manager
1769 .compaction_state()
1770 .is_compacted_this_turn()
1771 {
1772 self.maybe_sidequest_eviction();
1773 }
1774 }
1775
1776 {
1779 let cfg = &self.services.memory.extraction.graph_config.experience;
1780 if cfg.enabled
1781 && cfg.evolution_sweep_enabled
1782 && cfg.evolution_sweep_interval > 0
1783 && self
1784 .services
1785 .sidequest
1786 .turn_counter
1787 .checked_rem(cfg.evolution_sweep_interval as u64)
1788 == Some(0)
1789 && let Some(memory) = self.services.memory.persistence.memory.as_ref()
1790 && let (Some(exp), Some(graph)) =
1791 (memory.experience.as_ref(), memory.graph_store.as_ref())
1792 {
1793 let exp = std::sync::Arc::clone(exp);
1794 let graph = std::sync::Arc::clone(graph);
1795 let threshold = cfg.confidence_prune_threshold;
1796 let turn = self.services.sidequest.turn_counter;
1797 let accepted = self.runtime.lifecycle.supervisor.spawn(
1798 agent_supervisor::TaskClass::Telemetry,
1799 "experience-sweep",
1800 async move {
1801 match exp.evolution_sweep(graph.as_ref(), threshold).await {
1802 Ok(stats) => tracing::info!(
1803 turn,
1804 self_loops = stats.pruned_self_loops,
1805 low_confidence = stats.pruned_low_confidence,
1806 "evolution sweep complete",
1807 ),
1808 Err(e) => tracing::warn!(
1809 turn,
1810 error = %e,
1811 "evolution sweep failed",
1812 ),
1813 }
1814 },
1815 );
1816 if !accepted {
1817 tracing::warn!(
1818 turn = self.services.sidequest.turn_counter,
1819 "experience-sweep dropped (telemetry class at capacity)",
1820 );
1821 }
1822 }
1823 }
1824
1825 if let Some(warning) = self.cache_expiry_warning() {
1827 tracing::info!(warning, "cache expiry warning");
1828 self.channel.send_status_best_effort(&warning).await;
1829 }
1830
1831 self.maybe_time_based_microcompact();
1834
1835 self.maybe_apply_deferred_summaries();
1840 self.flush_deferred_summaries().await;
1841
1842 if let Err(e) = self.maybe_proactive_compress().await {
1844 tracing::warn!("proactive compression failed: {e:#}");
1845 }
1846
1847 if let Err(e) = self.maybe_compact().await {
1848 tracing::warn!("context compaction failed: {e:#}");
1849 }
1850
1851 if let Err(e) = Box::pin(self.prepare_context(trimmed)).await {
1852 tracing::warn!("context preparation failed: {e:#}");
1853 }
1854
1855 self.provider
1857 .set_memory_confidence(self.services.memory.persistence.last_recall_confidence);
1858
1859 self.services.learning_engine.reset_reflection();
1860 }
1861
1862 fn build_user_message(
1863 &mut self,
1864 text: &str,
1865 image_parts: Vec<zeph_llm::provider::MessagePart>,
1866 ) -> Message {
1867 let mut all_image_parts = std::mem::take(&mut self.msg.pending_image_parts);
1868 all_image_parts.extend(image_parts);
1869
1870 if !all_image_parts.is_empty() && self.provider.supports_vision() {
1871 let mut parts = vec![zeph_llm::provider::MessagePart::Text {
1872 text: text.to_owned(),
1873 }];
1874 parts.extend(all_image_parts);
1875 Message::from_parts(Role::User, parts)
1876 } else {
1877 if !all_image_parts.is_empty() {
1878 tracing::warn!(
1879 count = all_image_parts.len(),
1880 "image attachments dropped: provider does not support vision"
1881 );
1882 }
1883 Message {
1884 role: Role::User,
1885 content: text.to_owned(),
1886 parts: vec![],
1887 metadata: MessageMetadata::default(),
1888 }
1889 }
1890 }
1891
1892 fn drain_background_completions(&mut self) {
1896 const BACKGROUND_COMPLETION_BUFFER_CAP: usize = 16;
1897
1898 let Some(ref mut rx) = self.runtime.lifecycle.background_completion_rx else {
1899 return;
1900 };
1901 while let Ok(completion) = rx.try_recv() {
1903 if self.runtime.lifecycle.pending_background_completions.len()
1904 >= BACKGROUND_COMPLETION_BUFFER_CAP
1905 {
1906 tracing::warn!(
1907 run_id = %completion.run_id,
1908 "background completion buffer full; dropping run result"
1909 );
1910 self.runtime
1913 .lifecycle
1914 .pending_background_completions
1915 .pop_front();
1916 self.runtime
1917 .lifecycle
1918 .pending_background_completions
1919 .push_back(zeph_tools::BackgroundCompletion {
1920 run_id: completion.run_id,
1921 exit_code: -1,
1922 success: false,
1923 elapsed_ms: 0,
1924 command: completion.command,
1925 output: format!(
1926 "[background result for run {} dropped: buffer overflow]",
1927 completion.run_id
1928 ),
1929 });
1930 } else {
1931 self.runtime
1932 .lifecycle
1933 .pending_background_completions
1934 .push_back(completion);
1935 }
1936 }
1937 }
1938
1939 fn build_user_message_text_with_bg_completions(&mut self, user_text: &str) -> String {
1943 if self
1944 .runtime
1945 .lifecycle
1946 .pending_background_completions
1947 .is_empty()
1948 {
1949 return user_text.to_owned();
1950 }
1951 let mut parts = String::new();
1952 for completion in self
1953 .runtime
1954 .lifecycle
1955 .pending_background_completions
1956 .drain(..)
1957 {
1958 let _ = write!(
1959 parts,
1960 "[Background task {} completed]\nexit_code: {}\nsuccess: {}\nelapsed_ms: {}\ncommand: {}\n\n{}\n\n",
1961 completion.run_id,
1962 completion.exit_code,
1963 completion.success,
1964 completion.elapsed_ms,
1965 completion.command,
1966 completion.output,
1967 );
1968 }
1969 parts.push_str(user_text);
1970 parts
1971 }
1972
1973 pub(super) fn maybe_spawn_promotion_scan(&mut self) {
1983 let Some(engine) = self.services.promotion_engine.clone() else {
1984 return;
1985 };
1986
1987 let Some(memory) = self.services.memory.persistence.memory.clone() else {
1988 return;
1989 };
1990
1991 let promotion_window = 200usize;
1994
1995 let accepted = self.runtime.lifecycle.supervisor.spawn(
1996 agent_supervisor::TaskClass::Enrichment,
1997 "compression_spectrum.promotion_scan",
1998 async move {
1999 let window = match memory.load_promotion_window(promotion_window).await {
2000 Ok(w) => w,
2001 Err(e) => {
2002 tracing::warn!(error = %e, "promotion scan: failed to load window");
2003 return;
2004 }
2005 };
2006
2007 if window.is_empty() {
2008 return;
2009 }
2010
2011 let candidates = match engine.scan(&window).await {
2012 Ok(c) => c,
2013 Err(e) => {
2014 tracing::warn!(error = %e, "promotion scan: clustering failed");
2015 return;
2016 }
2017 };
2018
2019 for candidate in &candidates {
2020 if let Err(e) = engine.promote(candidate).await {
2021 tracing::warn!(
2022 signature = %candidate.signature,
2023 error = %e,
2024 "promotion scan: promote failed"
2025 );
2026 }
2027 }
2028
2029 tracing::info!(candidates = candidates.len(), "promotion scan: complete");
2030 }
2031 .instrument(tracing::info_span!("memory.compression.promote.background")),
2032 );
2033
2034 if accepted {
2035 tracing::debug!("compression_spectrum: promotion scan task enqueued");
2036 }
2037 }
2038}
2039
2040pub(crate) async fn shutdown_signal(rx: &mut watch::Receiver<bool>) {
2041 while !*rx.borrow_and_update() {
2042 if rx.changed().await.is_err() {
2043 std::future::pending::<()>().await;
2044 }
2045 }
2046}
2047
2048pub(crate) async fn recv_optional<T>(rx: &mut Option<mpsc::Receiver<T>>) -> Option<T> {
2049 match rx {
2050 Some(inner) => {
2051 if let Some(v) = inner.recv().await {
2052 Some(v)
2053 } else {
2054 *rx = None;
2055 std::future::pending().await
2056 }
2057 }
2058 None => std::future::pending().await,
2059 }
2060}
2061
2062fn truncate_shell_command(cmd: &str) -> String {
2064 if cmd.len() <= 80 {
2065 return cmd.to_owned();
2066 }
2067 let end = cmd.floor_char_boundary(79);
2068 format!("{}…", &cmd[..end])
2069}
2070
2071fn truncate_shell_run_id(id: &str) -> String {
2073 id.chars().take(8).collect()
2074}
2075
2076pub enum ContextBudgetSource {
2081 AutoDetected(usize),
2083 Configured,
2085 Fallback,
2087}
2088
2089pub fn resolve_context_budget_tokens(
2096 config: &Config,
2097 provider: &AnyProvider,
2098) -> (usize, ContextBudgetSource) {
2099 if config.memory.auto_budget && config.memory.context_budget_tokens == 0 {
2100 return match provider.context_window() {
2101 Some(ctx_size) if ctx_size > 0 => {
2102 (ctx_size, ContextBudgetSource::AutoDetected(ctx_size))
2103 }
2104 _ => (128_000, ContextBudgetSource::Fallback),
2105 };
2106 }
2107 if config.memory.context_budget_tokens == 0 {
2108 return (128_000, ContextBudgetSource::Fallback);
2109 }
2110 (
2111 config.memory.context_budget_tokens,
2112 ContextBudgetSource::Configured,
2113 )
2114}
2115
2116pub(crate) fn resolve_context_budget(config: &Config, provider: &AnyProvider) -> usize {
2117 let (tokens, source) = resolve_context_budget_tokens(config, provider);
2118 match source {
2119 ContextBudgetSource::AutoDetected(ctx_size) => tracing::info!(
2120 model_context = ctx_size,
2121 "auto-configured context budget on reload"
2122 ),
2123 ContextBudgetSource::Fallback => tracing::warn!(
2124 "context_budget_tokens resolved to 0 on reload — using fallback of 128000 tokens"
2125 ),
2126 ContextBudgetSource::Configured => {}
2127 }
2128 tokens
2129}
2130
2131#[cfg(test)]
2132mod tests;
2133
2134#[cfg(test)]
2135pub(crate) use tests::agent_tests;
2136
2137#[cfg(test)]
2138mod test_stubs {
2139 use std::pin::Pin;
2140
2141 use zeph_commands::{
2142 CommandContext, CommandError, CommandHandler, CommandOutput, SlashCategory,
2143 };
2144
2145 pub(super) struct TestErrorCommand;
2151
2152 impl CommandHandler<CommandContext<'_>> for TestErrorCommand {
2153 fn name(&self) -> &'static str {
2154 "/test-error"
2155 }
2156
2157 fn description(&self) -> &'static str {
2158 "Test stub: always returns CommandError"
2159 }
2160
2161 fn category(&self) -> SlashCategory {
2162 SlashCategory::Session
2163 }
2164
2165 fn requires_auth(&self) -> bool {
2166 true
2167 }
2168
2169 fn handle<'a>(
2170 &'a self,
2171 _ctx: &'a mut CommandContext<'_>,
2172 _args: &'a str,
2173 ) -> Pin<
2174 Box<dyn std::future::Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>,
2175 > {
2176 Box::pin(async { Err(CommandError::new("boom")) })
2177 }
2178 }
2179}