1use super::Agent;
11use crate::channel::Channel;
12use zeph_llm::provider::{LlmProvider, Message, MessageMetadata, Role};
13
14impl<C: Channel> Agent<C> {
15 async fn call_llm_for_session_summary(
24 &self,
25 chat_messages: &[Message],
26 ) -> Option<zeph_memory::StructuredSummary> {
27 let provider = self.resolve_background_provider(
28 &self.services.memory.compaction.shutdown_summary_provider,
29 );
30 let timeout_dur = std::time::Duration::from_secs(
31 self.services
32 .memory
33 .compaction
34 .shutdown_summary_timeout_secs,
35 );
36 match tokio::time::timeout(
37 timeout_dur,
38 provider.chat_typed_erased::<zeph_memory::StructuredSummary>(chat_messages),
39 )
40 .await
41 {
42 Ok(Ok(s)) => Some(s),
43 Ok(Err(e)) => {
44 tracing::warn!(
45 "shutdown summary: structured LLM call failed, falling back to plain: {e:#}"
46 );
47 self.plain_text_summary_fallback(&provider, chat_messages, timeout_dur)
48 .await
49 }
50 Err(_) => {
51 tracing::warn!(
52 "shutdown summary: structured LLM call timed out after {}s, falling back to plain",
53 self.services
54 .memory
55 .compaction
56 .shutdown_summary_timeout_secs
57 );
58 self.plain_text_summary_fallback(&provider, chat_messages, timeout_dur)
59 .await
60 }
61 }
62 }
63 async fn plain_text_summary_fallback(
64 &self,
65 provider: &zeph_llm::any::AnyProvider,
66 chat_messages: &[Message],
67 timeout_dur: std::time::Duration,
68 ) -> Option<zeph_memory::StructuredSummary> {
69 match tokio::time::timeout(timeout_dur, provider.chat(chat_messages)).await {
70 Ok(Ok(plain)) => Some(zeph_memory::StructuredSummary {
71 summary: plain,
72 key_facts: vec![],
73 entities: vec![],
74 }),
75 Ok(Err(e)) => {
76 tracing::warn!("shutdown summary: plain LLM fallback failed: {e:#}");
77 None
78 }
79 Err(_) => {
80 tracing::warn!("shutdown summary: plain LLM fallback timed out");
81 None
82 }
83 }
84 }
85 pub(super) async fn flush_orphaned_tool_use_on_shutdown(&mut self) {
90 use zeph_llm::provider::{MessagePart, Role};
91
92 let msgs = &self.msg.messages;
96 let Some(asst_idx) = msgs.iter().rposition(|m| m.role == Role::Assistant) else {
98 return;
99 };
100 let asst_msg = &msgs[asst_idx];
101 let tool_use_ids: Vec<(&str, &str, &serde_json::Value)> = asst_msg
102 .parts
103 .iter()
104 .filter_map(|p| {
105 if let MessagePart::ToolUse { id, name, input } = p {
106 Some((id.as_str(), name.as_str(), input))
107 } else {
108 None
109 }
110 })
111 .collect();
112 if tool_use_ids.is_empty() {
113 return;
114 }
115
116 let paired_ids: std::collections::HashSet<&str> = msgs
118 .get(asst_idx + 1..)
119 .into_iter()
120 .flatten()
121 .filter(|m| m.role == Role::User)
122 .flat_map(|m| m.parts.iter())
123 .filter_map(|p| {
124 if let MessagePart::ToolResult { tool_use_id, .. } = p {
125 Some(tool_use_id.as_str())
126 } else {
127 None
128 }
129 })
130 .collect();
131
132 let unpaired: Vec<zeph_llm::provider::ToolUseRequest> = tool_use_ids
133 .iter()
134 .filter(|(id, _, _)| !paired_ids.contains(*id))
135 .map(|(id, name, input)| zeph_llm::provider::ToolUseRequest {
136 id: (*id).to_owned(),
137 name: (*name).to_owned().into(),
138 input: (*input).clone(),
139 })
140 .collect();
141
142 if unpaired.is_empty() {
143 return;
144 }
145
146 tracing::info!(
147 count = unpaired.len(),
148 "shutdown: persisting tombstone ToolResults for unpaired in-flight tool calls"
149 );
150 self.persist_cancelled_tool_results(&unpaired, Some(asst_idx + 1))
155 .await;
156 }
157 pub(super) async fn maybe_store_shutdown_summary(&mut self) {
168 if self.runtime.config.bare {
169 return;
170 }
171 if !self.services.memory.compaction.shutdown_summary {
172 return;
173 }
174 let Some(memory) = self.services.memory.persistence.memory.clone() else {
175 return;
176 };
177 let Some(conversation_id) = self.services.memory.persistence.conversation_id else {
178 return;
179 };
180
181 match memory.has_session_summary(conversation_id).await {
183 Ok(true) => {
184 tracing::debug!("shutdown summary: session already has a summary, skipping");
185 return;
186 }
187 Ok(false) => {}
188 Err(e) => {
189 tracing::warn!("shutdown summary: failed to check existing summary: {e:#}");
190 return;
191 }
192 }
193
194 let user_count = self
196 .msg
197 .messages
198 .iter()
199 .skip(1)
200 .filter(|m| m.role == Role::User)
201 .count();
202 let min_messages = self
203 .services
204 .memory
205 .compaction
206 .shutdown_summary_min_messages;
207 if user_count < min_messages {
208 tracing::debug!(
209 user_count,
210 min = min_messages,
211 "shutdown summary: too few user messages, skipping"
212 );
213 return;
214 }
215
216 self.channel
217 .send_status_best_effort("Saving session summary...")
218 .await;
219
220 let max = self
222 .services
223 .memory
224 .compaction
225 .shutdown_summary_max_messages;
226 if max == 0 {
227 tracing::debug!("shutdown summary: max_messages=0, skipping");
228 return;
229 }
230 let non_system: Vec<_> = self.msg.messages.iter().skip(1).collect();
231 let slice = if non_system.len() > max {
232 &non_system[non_system.len() - max..]
233 } else {
234 &non_system[..]
235 };
236
237 let msgs_for_prompt: Vec<(zeph_memory::MessageId, String, String)> = slice
238 .iter()
239 .map(|m| {
240 let role = match m.role {
241 Role::Assistant => "assistant".to_owned(),
242 Role::System => "system".to_owned(),
243 Role::User | _ => "user".to_owned(),
244 };
245 (zeph_memory::MessageId(0), role, m.content.clone())
246 })
247 .collect();
248
249 let prompt = zeph_memory::build_summarization_prompt(&msgs_for_prompt);
250 let chat_messages = vec![Message {
251 role: Role::User,
252 content: prompt,
253 parts: vec![],
254 metadata: MessageMetadata::default(),
255 }];
256
257 let Some(structured) = self.call_llm_for_session_summary(&chat_messages).await else {
258 self.channel.send_status_best_effort("").await;
259 return;
260 };
261
262 if let Err(e) = memory
263 .store_shutdown_summary(conversation_id, &structured.summary, &structured.key_facts)
264 .await
265 {
266 tracing::warn!("shutdown summary: storage failed: {e:#}");
267 } else {
268 tracing::info!(
269 conversation_id = conversation_id.0,
270 "shutdown summary stored"
271 );
272 }
273
274 self.channel.send_status_best_effort("").await;
275 }
276 #[tracing::instrument(name = "core.agent.shutdown", skip_all, level = "debug")]
292 pub async fn shutdown(&mut self) {
293 self.channel
294 .send_status_best_effort("Shutting down...")
295 .await;
296
297 self.provider.save_router_state().await;
299
300 if let Some(ref advisor) = self.services.orchestration.topology_advisor
302 && let Err(e) = advisor.save().await
303 {
304 tracing::warn!(error = %e, "adaptorch: failed to persist state");
305 }
306
307 if let Some(ref mut mgr) = self.services.orchestration.subagent_manager {
308 mgr.shutdown_all();
309 }
310
311 if let Some(ref manager) = self.services.mcp.manager {
312 manager.shutdown_all_shared().await;
313 }
314
315 if let Some(turns) = self.context_manager.turns_since_last_hard_compaction() {
319 self.update_metrics(|m| {
320 m.compaction_turns_after_hard.push(turns);
321 });
322 self.context_manager
323 .set_turns_since_last_hard_compaction(None);
324 }
325
326 if let Some(ref tx) = self.runtime.metrics.metrics_tx {
327 let m = tx.borrow();
328 if m.filter_applications > 0 {
329 #[allow(clippy::cast_precision_loss)]
330 let pct = if m.filter_raw_tokens > 0 {
331 m.filter_saved_tokens as f64 / m.filter_raw_tokens as f64 * 100.0
332 } else {
333 0.0
334 };
335 tracing::info!(
336 raw_tokens = m.filter_raw_tokens,
337 saved_tokens = m.filter_saved_tokens,
338 applications = m.filter_applications,
339 "tool output filtering saved ~{} tokens ({pct:.0}%)",
340 m.filter_saved_tokens,
341 );
342 }
343 if m.compaction_hard_count > 0 {
344 tracing::info!(
345 hard_compactions = m.compaction_hard_count,
346 turns_after_hard = ?m.compaction_turns_after_hard,
347 "hard compaction trajectory"
348 );
349 }
350 }
351
352 self.flush_orphaned_tool_use_on_shutdown().await;
356
357 if let Some(ref token) = self.services.experiments.cancel {
360 token.cancel();
361 }
362 if let Some(h) = self.services.experiments.handle.take() {
363 h.abort();
364 }
365
366 if let Some(memory) = self.services.memory.persistence.memory.as_ref() {
370 memory.cancel_graph_extraction();
371 }
372
373 self.runtime.lifecycle.supervisor.abort_all();
375
376 if let Some(h) = self.services.compression.pending_task_goal.take() {
379 h.abort();
380 }
381 if let Some(h) = self.services.compression.pending_sidequest_result.take() {
382 h.abort();
383 }
384 if let Some(h) = self.services.compression.pending_subgoal.take() {
385 h.abort();
386 }
387 self.flush_durable_writer().await;
388
389 self.services.learning_engine.learning_tasks.abort_all();
391
392 if let Some(h) = self.services.learning_engine.trace_extraction_handle.take() {
395 let deadline = std::time::Duration::from_mins(2);
396 match tokio::time::timeout(deadline, h.join()).await {
397 Ok(Ok(())) => {}
398 Ok(Err(e)) => tracing::warn!("trace_extraction: task error at shutdown: {e}"),
399 Err(_) => tracing::warn!(
400 "trace_extraction: timed out at shutdown ({}s), aborting",
401 deadline.as_secs()
402 ),
403 }
404 }
405
406 if let Some(h) = self
409 .services
410 .learning_engine
411 .heuristic_promotion_handle
412 .take()
413 {
414 h.abort();
415 }
416
417 if let Some(ref sentinel) = self.services.security.shadow_sentinel {
419 sentinel.drain_pending().await;
420 }
421
422 for _ in 0..4 {
427 tokio::task::yield_now().await;
428 }
429
430 self.maybe_store_shutdown_summary().await;
431 self.maybe_store_session_digest().await;
432
433 tracing::info!("agent shutdown complete");
434 }
435
436 async fn flush_durable_writer(&mut self) {
443 let flush_deadline = std::time::Duration::from_secs(2);
444 if let Some(ref writer) = self.services.orchestration.durable_writer {
445 match tokio::time::timeout(flush_deadline, writer.flush()).await {
446 Ok(Ok(())) => {}
447 Ok(Err(e)) => {
448 tracing::warn!(error = %e, "durable writer: flush on shutdown failed");
449 }
450 Err(_) => tracing::warn!("durable writer: flush timed out on shutdown"),
451 }
452 }
453 if let Some(h) = self.services.orchestration.durable_writer_task.take() {
454 h.abort();
455 }
456 if let Some(ref writer) = self.services.session.durable_writer {
457 match tokio::time::timeout(flush_deadline, writer.flush()).await {
458 Ok(Ok(())) => {}
459 Ok(Err(e)) => {
460 tracing::warn!(error = %e, "durable agent_turns writer: flush on shutdown failed");
461 }
462 Err(_) => tracing::warn!("durable agent_turns writer: flush timed out on shutdown"),
463 }
464 }
465 if let Some(ref ctx) = self.services.session.durable_ctx {
474 match tokio::time::timeout(
475 flush_deadline,
476 ctx.finalize(zeph_durable::ExecutionStatus::Completed),
477 )
478 .await
479 {
480 Ok(Ok(())) => {}
481 Ok(Err(e)) => {
482 tracing::warn!(
483 error = %e,
484 "durable agent_turns: failed to finalize execution on shutdown"
485 );
486 }
487 Err(_) => tracing::warn!("durable agent_turns: finalize timed out on shutdown"),
488 }
489 }
490 if let Some(h) = self.services.session.durable_writer_task.take() {
491 h.abort();
492 }
493 }
494}
495
496#[cfg(test)]
497mod tests {
498 use crate::agent::agent_tests::*;
499
500 fn agent_with_conversation() -> crate::agent::Agent<MockChannel> {
501 let provider = mock_provider(vec!["ok".into()]);
502 let channel = MockChannel::new(vec![]);
503 let registry = create_test_registry();
504 let executor = MockToolExecutor::no_tools();
505 let mut agent = crate::agent::Agent::new(provider, channel, registry, None, 5, executor);
506 agent.services.memory.persistence.conversation_id = Some(zeph_memory::ConversationId(1));
507 agent
508 }
509
510 #[tokio::test]
511 async fn flush_durable_writer_finalizes_the_p1_execution_as_completed() {
512 let dir = tempfile::tempdir().unwrap();
518 let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
519
520 let mut agent = agent_with_conversation();
521 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
522 enabled: true,
523 agent_turns: true,
524 ..zeph_config::DurableConfig::default()
525 });
526 agent.services.session.durable_agent_turns_db_url = Some(db_url.clone());
527
528 agent.ensure_session_durable_ctx().await;
529 let exec_id = agent
530 .services
531 .session
532 .durable_ctx
533 .as_ref()
534 .expect("durable_ctx should be populated")
535 .execution_id();
536
537 agent.flush_durable_writer().await;
538
539 let backend = zeph_durable::LocalBackend::open(&db_url, 1_048_576)
540 .await
541 .unwrap();
542 let summaries = backend.list_executions(None, None, 10).await.unwrap();
543 let row = summaries
544 .iter()
545 .find(|s| s.execution_id == exec_id)
546 .expect("the execution's row must still exist");
547 assert_eq!(
548 row.status,
549 zeph_durable::ExecutionStatus::Completed,
550 "the P1 execution must finalize as Completed on graceful shutdown"
551 );
552 }
553
554 #[tokio::test]
555 async fn flush_durable_writer_finalize_is_bounded_by_the_2s_timeout() {
556 let dir = tempfile::tempdir().unwrap();
564 let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
565
566 let mut agent = agent_with_conversation();
567 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
568 enabled: true,
569 agent_turns: true,
570 ..zeph_config::DurableConfig::default()
571 });
572 agent.services.session.durable_agent_turns_db_url = Some(db_url.clone());
573
574 agent.ensure_session_durable_ctx().await;
575 let exec_id = agent
576 .services
577 .session
578 .durable_ctx
579 .as_ref()
580 .expect("durable_ctx should be populated")
581 .execution_id();
582
583 let lock_holder = zeph_durable::LocalBackend::open(&db_url, 1_048_576)
587 .await
588 .unwrap();
589 let blocking_tx = zeph_db::begin_write(lock_holder.pool()).await.unwrap();
590
591 let start = std::time::Instant::now();
592 agent.flush_durable_writer().await;
593 let elapsed = start.elapsed();
594
595 drop(blocking_tx); assert!(
598 elapsed < std::time::Duration::from_secs(4),
599 "flush_durable_writer must return well within its 2s finalize timeout \
600 (plus the writer.flush() call's own bound), not the 5s sqlite busy_timeout; took \
601 {elapsed:?}"
602 );
603
604 let backend = zeph_durable::LocalBackend::open(&db_url, 1_048_576)
605 .await
606 .unwrap();
607 let summaries = backend.list_executions(None, None, 10).await.unwrap();
608 let row = summaries
609 .iter()
610 .find(|s| s.execution_id == exec_id)
611 .expect("the execution's row must still exist");
612 assert_eq!(
613 row.status,
614 zeph_durable::ExecutionStatus::Running,
615 "finalize must not have committed while the write lock was held elsewhere"
616 );
617
618 agent
623 .services
624 .session
625 .durable_ctx
626 .as_ref()
627 .unwrap()
628 .finalize(zeph_durable::ExecutionStatus::Completed)
629 .await
630 .unwrap();
631 let summaries = backend.list_executions(None, None, 10).await.unwrap();
632 let row = summaries
633 .iter()
634 .find(|s| s.execution_id == exec_id)
635 .expect("the execution's row must still exist");
636 assert_eq!(
637 row.status,
638 zeph_durable::ExecutionStatus::Completed,
639 "finalize succeeds once the write lock is free"
640 );
641 }
642}