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