zeph_core/agent/
shutdown.rs1use 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 if user_count
203 < self
204 .services
205 .memory
206 .compaction
207 .shutdown_summary_min_messages
208 {
209 tracing::debug!(
210 user_count,
211 min = self
212 .services
213 .memory
214 .compaction
215 .shutdown_summary_min_messages,
216 "shutdown summary: too few user messages, skipping"
217 );
218 return;
219 }
220
221 let _ = self.channel.send_status("Saving session summary...").await;
223
224 let max = self
226 .services
227 .memory
228 .compaction
229 .shutdown_summary_max_messages;
230 if max == 0 {
231 tracing::debug!("shutdown summary: max_messages=0, skipping");
232 return;
233 }
234 let non_system: Vec<_> = self.msg.messages.iter().skip(1).collect();
235 let slice = if non_system.len() > max {
236 &non_system[non_system.len() - max..]
237 } else {
238 &non_system[..]
239 };
240
241 let msgs_for_prompt: Vec<(zeph_memory::MessageId, String, String)> = slice
242 .iter()
243 .map(|m| {
244 let role = match m.role {
245 Role::Assistant => "assistant".to_owned(),
246 Role::System => "system".to_owned(),
247 Role::User | _ => "user".to_owned(),
248 };
249 (zeph_memory::MessageId(0), role, m.content.clone())
250 })
251 .collect();
252
253 let prompt = zeph_memory::build_summarization_prompt(&msgs_for_prompt);
254 let chat_messages = vec![Message {
255 role: Role::User,
256 content: prompt,
257 parts: vec![],
258 metadata: MessageMetadata::default(),
259 }];
260
261 let Some(structured) = self.call_llm_for_session_summary(&chat_messages).await else {
262 let _ = self.channel.send_status("").await;
263 return;
264 };
265
266 if let Err(e) = memory
267 .store_shutdown_summary(conversation_id, &structured.summary, &structured.key_facts)
268 .await
269 {
270 tracing::warn!("shutdown summary: storage failed: {e:#}");
271 } else {
272 tracing::info!(
273 conversation_id = conversation_id.0,
274 "shutdown summary stored"
275 );
276 }
277
278 let _ = self.channel.send_status("").await;
280 }
281 #[tracing::instrument(name = "core.agent.shutdown", skip_all, level = "debug")]
297 pub async fn shutdown(&mut self) {
298 let _ = self.channel.send_status("Shutting down...").await;
299
300 self.provider.save_router_state().await;
302
303 if let Some(ref advisor) = self.services.orchestration.topology_advisor
305 && let Err(e) = advisor.save().await
306 {
307 tracing::warn!(error = %e, "adaptorch: failed to persist state");
308 }
309
310 if let Some(ref mut mgr) = self.services.orchestration.subagent_manager {
311 mgr.shutdown_all();
312 }
313
314 if let Some(ref manager) = self.services.mcp.manager {
315 manager.shutdown_all_shared().await;
316 }
317
318 if let Some(turns) = self.context_manager.turns_since_last_hard_compaction() {
322 self.update_metrics(|m| {
323 m.compaction_turns_after_hard.push(turns);
324 });
325 self.context_manager
326 .set_turns_since_last_hard_compaction(None);
327 }
328
329 if let Some(ref tx) = self.runtime.metrics.metrics_tx {
330 let m = tx.borrow();
331 if m.filter_applications > 0 {
332 #[allow(clippy::cast_precision_loss)]
333 let pct = if m.filter_raw_tokens > 0 {
334 m.filter_saved_tokens as f64 / m.filter_raw_tokens as f64 * 100.0
335 } else {
336 0.0
337 };
338 tracing::info!(
339 raw_tokens = m.filter_raw_tokens,
340 saved_tokens = m.filter_saved_tokens,
341 applications = m.filter_applications,
342 "tool output filtering saved ~{} tokens ({pct:.0}%)",
343 m.filter_saved_tokens,
344 );
345 }
346 if m.compaction_hard_count > 0 {
347 tracing::info!(
348 hard_compactions = m.compaction_hard_count,
349 turns_after_hard = ?m.compaction_turns_after_hard,
350 "hard compaction trajectory"
351 );
352 }
353 }
354
355 self.flush_orphaned_tool_use_on_shutdown().await;
359
360 if let Some(ref token) = self.services.experiments.cancel {
363 token.cancel();
364 }
365 if let Some(h) = self.services.experiments.handle.take() {
366 h.abort();
367 }
368
369 if let Some(memory) = self.services.memory.persistence.memory.as_ref() {
373 memory.cancel_graph_extraction();
374 }
375
376 self.runtime.lifecycle.supervisor.abort_all();
378
379 if let Some(h) = self.services.compression.pending_task_goal.take() {
382 h.abort();
383 }
384 if let Some(h) = self.services.compression.pending_sidequest_result.take() {
385 h.abort();
386 }
387 if let Some(h) = self.services.compression.pending_subgoal.take() {
388 h.abort();
389 }
390 self.flush_durable_writer().await;
391
392 self.services.learning_engine.learning_tasks.abort_all();
394
395 if let Some(h) = self.services.learning_engine.trace_extraction_handle.take() {
398 let deadline = std::time::Duration::from_mins(2);
399 match tokio::time::timeout(deadline, h.join()).await {
400 Ok(Ok(())) => {}
401 Ok(Err(e)) => tracing::warn!("trace_extraction: task error at shutdown: {e}"),
402 Err(_) => tracing::warn!(
403 "trace_extraction: timed out at shutdown ({}s), aborting",
404 deadline.as_secs()
405 ),
406 }
407 }
408
409 if let Some(h) = self
412 .services
413 .learning_engine
414 .heuristic_promotion_handle
415 .take()
416 {
417 h.abort();
418 }
419
420 if let Some(ref sentinel) = self.services.security.shadow_sentinel {
422 sentinel.drain_pending().await;
423 }
424
425 for _ in 0..4 {
430 tokio::task::yield_now().await;
431 }
432
433 self.maybe_store_shutdown_summary().await;
434 self.maybe_store_session_digest().await;
435
436 tracing::info!("agent shutdown complete");
437 }
438
439 async fn flush_durable_writer(&mut self) {
445 let flush_deadline = std::time::Duration::from_secs(2);
446 if let Some(ref writer) = self.services.orchestration.durable_writer {
447 match tokio::time::timeout(flush_deadline, writer.flush()).await {
448 Ok(Ok(())) => {}
449 Ok(Err(e)) => {
450 tracing::warn!(error = %e, "durable writer: flush on shutdown failed");
451 }
452 Err(_) => tracing::warn!("durable writer: flush timed out on shutdown"),
453 }
454 }
455 if let Some(h) = self.services.orchestration.durable_writer_task.take() {
456 h.abort();
457 }
458 if let Some(ref writer) = self.services.session.durable_writer {
459 match tokio::time::timeout(flush_deadline, writer.flush()).await {
460 Ok(Ok(())) => {}
461 Ok(Err(e)) => {
462 tracing::warn!(error = %e, "durable agent_turns writer: flush on shutdown failed");
463 }
464 Err(_) => tracing::warn!("durable agent_turns writer: flush timed out on shutdown"),
465 }
466 }
467 if let Some(h) = self.services.session.durable_writer_task.take() {
468 h.abort();
469 }
470 }
471}