返回 DeepSeek-TUI-2026
seam_manager.rs
根目录 / crates / tui / src / seam_manager.rs
1 //! Append-only layered context management with Flash seam manager (issue #159).
2 //!
3 //! ## Why
4 //!
5 //! The current cycle/compaction/capacity mechanisms share a fatal flaw: they
6 //! replace or rewrite messages, which breaks DeepSeek V4's prefix cache
7 //! (SS4.2.1). The prefix cache gives ~90% discount on cached tokens at
8 //! 128-token granularity. Replacing old messages with summaries breaks the
9 //! cache at the replacement point — every token after must be recomputed.
10 //!
11 //! The append-only layered approach keeps all verbatim messages and appends
12 //! `<archived_context>` summary blocks produced by V4 Flash. These blocks
13 //! are *navigational aids* — the model reads them first, then drills into
14 //! verbatim messages when precision is needed. The prefix cache stays hot
15 //! for the entire stable prefix. In v0.7.5 this manager is opt-in while the
16 //! cache/timing policy is audited.
17 //!
18 //! ## Soft seam levels
19 //!
20 //! | Level | Active input trigger | Covers messages | Density |
21 //! |-------|------------------|--------------------|----------------|
22 //! | L1 | 192K | 0–128K | ~2,500 tokens |
23 //! | L2 | 384K | 0–320K | ~1,800 tokens |
24 //! | L3 | 576K | 0–512K | ~1,200 tokens |
25 //! | Cycle | 768K | All -> archive | <=3,000 tokens |
26 //!
27 //! Thresholds derived from V4 paper Figure 9 (MMR): 128K->256K is the real
28 //! cliff at -0.09. L1 triggers at 192K, before the cliff. Hard cycle at
29 //! 768K (~75% of 1M window).
30
31 use std::fmt::Write;
32 use std::path::Path;
33 use std::sync::Arc;
34
35 use anyhow::Result;
36 use chrono::{DateTime, Utc};
37 use tokio::sync::Mutex;
38
39 use crate::client::DeepSeekClient;
40 use crate::compaction::KEEP_RECENT_MESSAGES;
41 use crate::compaction::plan_compaction;
42 use crate::llm_client::LlmClient;
43 use crate::models::{ContentBlock, Message, MessageRequest, SystemBlock, SystemPrompt};
44
45 /// Default seam model — Flash is cheap and fast, ideal for summarization.
46 pub const DEFAULT_SEAM_MODEL: &str = "deepseek-v4-flash";
47
48 /// Default thresholds based on the active request input estimate.
49 pub const DEFAULT_L1_THRESHOLD: usize = 192_000;
50 pub const DEFAULT_L2_THRESHOLD: usize = 384_000;
51 pub const DEFAULT_L3_THRESHOLD: usize = 576_000;
52 pub const DEFAULT_CYCLE_THRESHOLD: usize = 768_000;
53
54 /// Verbatim window: last N turns never summarized.
55 pub const VERBATIM_WINDOW_TURNS: usize = 16;
56
57 /// Approximate token cap for each seam level.
58 const L1_MAX_TOKENS: u32 = 3_200;
59 const L2_MAX_TOKENS: u32 = 2_400;
60 const L3_MAX_TOKENS: u32 = 1_600;
61
62 /// Configuration for the Flash seam manager.
63 #[derive(Debug, Clone, PartialEq, Eq)]
64 pub struct SeamConfig {
65 /// Whether the layered context manager is enabled.
66 pub enabled: bool,
67 /// Verbatim window: last N turns never summarized.
68 pub verbatim_window_turns: usize,
69 /// Soft seam thresholds based on the active request input estimate.
70 pub l1_threshold: usize,
71 pub l2_threshold: usize,
72 pub l3_threshold: usize,
73 /// Hard cycle boundary.
74 pub cycle_threshold: usize,
75 /// Model used for seam/briefing work.
76 pub seam_model: String,
77 }
78
79 impl Default for SeamConfig {
80 fn default() -> Self {
81 Self {
82 enabled: true,
83 verbatim_window_turns: VERBATIM_WINDOW_TURNS,
84 l1_threshold: DEFAULT_L1_THRESHOLD,
85 l2_threshold: DEFAULT_L2_THRESHOLD,
86 l3_threshold: DEFAULT_L3_THRESHOLD,
87 cycle_threshold: DEFAULT_CYCLE_THRESHOLD,
88 seam_model: DEFAULT_SEAM_MODEL.to_string(),
89 }
90 }
91 }
92
93 /// Metadata for a single soft seam block.
94 #[derive(Debug, Clone)]
95 pub struct SeamMetadata {
96 /// Which level (1, 2, or 3).
97 pub level: u8,
98 /// Message range covered (inclusive-exclusive indices).
99 /// Reserved for future diagnostic use.
100 #[allow(dead_code)]
101 pub start_idx: usize,
102 #[allow(dead_code)]
103 pub end_idx: usize,
104 /// Approximate token count of the summary.
105 #[allow(dead_code)]
106 pub token_estimate: usize,
107 /// When the seam was produced.
108 #[allow(dead_code)]
109 pub timestamp: DateTime<Utc>,
110 /// Model that produced it.
111 #[allow(dead_code)]
112 pub model: String,
113 }
114
115 /// The Flash seam manager — produces `<archived_context>` blocks.
116 pub struct SeamManager {
117 /// Flash client for summarization work.
118 flash_client: DeepSeekClient,
119 /// Configuration.
120 config: SeamConfig,
121 /// Currently active seams in order (oldest first).
122 active_seams: Arc<Mutex<Vec<SeamMetadata>>>,
123 }
124
125 impl SeamManager {
126 /// Create a new seam manager with a Flash client.
127 pub fn new(flash_client: DeepSeekClient, config: SeamConfig) -> Self {
128 Self {
129 flash_client,
130 config,
131 active_seams: Arc::new(Mutex::new(Vec::new())),
132 }
133 }
134
135 /// Get the current config.
136 pub fn config(&self) -> &SeamConfig {
137 &self.config
138 }
139
140 /// Current active seam count.
141 pub async fn seam_count(&self) -> usize {
142 self.active_seams.lock().await.len()
143 }
144
145 /// Determine which seam level (if any) should fire for the given
146 /// active request input estimate. Returns `None` when no seam is due.
147 #[must_use]
148 pub fn seam_level_for(
149 &self,
150 active_input_tokens: usize,
151 highest_existing_level: Option<u8>,
152 ) -> Option<u8> {
153 seam_level_for_active_input(&self.config, active_input_tokens, highest_existing_level)
154 }
155
156 /// Check whether the hard cycle boundary is crossed.
157 ///
158 /// Note: not currently called — cycle detection uses an inline check.
159 /// Kept as the canonical boundary definition for future wiring.
160 #[must_use]
161 #[allow(dead_code)]
162 pub fn should_cycle(&self, active_input_tokens: usize) -> bool {
163 self.config.enabled && active_input_tokens >= self.config.cycle_threshold
164 }
165
166 /// Compute the verbatim window: the last N message indices that must
167 /// never be summarized. Returns the start index of the verbatim window.
168 pub fn verbatim_window_start(&self, message_count: usize) -> usize {
169 let turn_count = message_count / 2; // Rough: user+assistant per turn
170 let verbatim_turns = self.config.verbatim_window_turns.min(turn_count);
171 let verbatim_messages = (verbatim_turns * 2).min(message_count);
172 message_count.saturating_sub(verbatim_messages)
173 }
174
175 /// Produce a soft seam for the given message range and level.
176 ///
177 /// Returns the `<archived_context>` XML block as a string, ready to
178 /// be appended as an assistant message.
179 pub async fn produce_soft_seam(
180 &self,
181 messages: &[Message],
182 level: u8,
183 start_idx: usize,
184 end_idx: usize,
185 workspace: Option<&Path>,
186 pinned_indices: &[usize],
187 ) -> Result<String> {
188 if messages.is_empty() || start_idx >= end_idx {
189 return Ok(String::new());
190 }
191
192 let range = &messages[start_idx..end_idx.min(messages.len())];
193 if range.is_empty() {
194 return Ok(String::new());
195 }
196
197 // Use compaction pinning heuristics to identify which messages to
198 // exclude from summarization. Pinned messages stay verbatim; the
199 // seam summary covers everything else.
200 let local_pins = local_pins_for_range(pinned_indices, start_idx, end_idx, messages.len());
201 let plan = plan_compaction(
202 range,
203 workspace,
204 KEEP_RECENT_MESSAGES.min(range.len().saturating_sub(1)),
205 Some(&local_pins),
206 None,
207 );
208
209 // Collect messages to summarize (non-pinned), excluding pinned ones.
210 let to_summarize: Vec<&Message> = range
211 .iter()
212 .enumerate()
213 .filter(|(idx, _msg)| !plan.pinned_indices.contains(idx))
214 .map(|(_idx, msg)| msg)
215 .collect();
216
217 if to_summarize.is_empty() {
218 // Nothing to summarize — all messages are pinned.
219 return Ok(String::new());
220 }
221
222 let summary = self
223 .summarize_messages(&to_summarize, level, start_idx, end_idx)
224 .await?;
225
226 let density_label = match level {
227 1 => "~2,500 tokens",
228 2 => "~1,800 tokens",
229 3 => "~1,200 tokens",
230 _ => "unknown",
231 };
232
233 let timestamp = Utc::now();
234 let token_estimate = summary.len() / 4;
235
236 // Record this seam.
237 {
238 let mut seams = self.active_seams.lock().await;
239 seams.push(SeamMetadata {
240 level,
241 start_idx,
242 end_idx,
243 token_estimate,
244 timestamp,
245 model: self.config.seam_model.clone(),
246 });
247 }
248
249 Ok(format!(
250 "<archived_context level=\"{level}\" range=\"msg {start_idx}-{end_idx}\" \
251 tokens=\"~{token_estimate}\" density=\"{density_label}\" \
252 model=\"{seam_model}\" timestamp=\"{ts}\">\n\
253 {summary}\n\
254 </archived_context>",
255 seam_model = self.config.seam_model,
256 ts = timestamp.to_rfc3339()
257 ))
258 }
259
260 /// Re-compact existing seams into a higher-level block. Consumes prior
261 /// `<archived_context>` content and fuses it with new messages.
262 pub async fn recompact(
263 &self,
264 existing_seams: &[String],
265 new_messages: &[&Message],
266 level: u8,
267 start_idx: usize,
268 end_idx: usize,
269 ) -> Result<String> {
270 let mut input = String::from(
271 "## Prior Context Summaries\n\n\
272 The following <archived_context> blocks were produced earlier. \
273 Merge their key information into a single denser summary.\n\n",
274 );
275
276 for (i, seam) in existing_seams.iter().enumerate() {
277 let _ = write!(input, "### Seam {}\n{seam}\n\n", i + 1);
278 }
279
280 if !new_messages.is_empty() {
281 input.push_str("## Recent Messages\n\n");
282 for msg in new_messages {
283 let role = &msg.role;
284 for block in &msg.content {
285 if let ContentBlock::Text { text, .. } = block {
286 let _ = write!(input, "**{role}:** {text}\n\n");
287 }
288 }
289 }
290 }
291
292 let (max_tokens, word_limit) = match level {
293 2 => (L2_MAX_TOKENS, 700),
294 3 => (L3_MAX_TOKENS, 400),
295 _ => (L3_MAX_TOKENS, 400),
296 };
297
298 let request = MessageRequest {
299 model: self.config.seam_model.clone(),
300 messages: vec![Message {
301 role: "user".to_string(),
302 content: vec![ContentBlock::Text {
303 text: format!(
304 "Synthesize the following context into a single dense summary. \
305 Preserve: decisions made, file paths, error messages, \
306 constraints, hypotheses, open questions, and task state. \
307 Drop: greeting, filler, repeated information. \
308 Keep it under {word_limit} words.\n\n{input}"
309 ),
310 cache_control: None,
311 }],
312 }],
313 max_tokens,
314 system: Some(SystemPrompt::Text(
315 "You are a context compaction specialist. Produce dense, factual summaries that \
316 preserve every decision, path, error, constraint, and open question. Drop \
317 conversational filler and repetition."
318 .to_string(),
319 )),
320 tools: None,
321 tool_choice: None,
322 metadata: None,
323 thinking: None,
324 reasoning_effort: None,
325 stream: Some(false),
326 temperature: Some(0.1),
327 top_p: None,
328 };
329
330 let response = self.flash_client.create_message(request).await?;
331 // Seam recompaction calls are billed; route through the
332 // side-channel (#526) so the footer total matches the
333 // DeepSeek website.
334 crate::cost_status::report(&response.model, &response.usage);
335 let summary = response
336 .content
337 .iter()
338 .filter_map(|block| match block {
339 ContentBlock::Text { text, .. } => Some(text.clone()),
340 _ => None,
341 })
342 .collect::<Vec<_>>()
343 .join("\n");
344
345 let token_estimate = summary.len() / 4;
346 let timestamp = Utc::now();
347
348 // Record this recompacted seam.
349 {
350 let mut seams = self.active_seams.lock().await;
351 seams.push(SeamMetadata {
352 level,
353 start_idx,
354 end_idx,
355 token_estimate,
356 timestamp,
357 model: self.config.seam_model.clone(),
358 });
359 }
360
361 Ok(format!(
362 "<archived_context level=\"{level}\" range=\"msg {start_idx}-{end_idx}\" \
363 tokens=\"~{token_estimate}\" model=\"{model}\" timestamp=\"{ts}\">\n\
364 {summary}\n\
365 </archived_context>",
366 model = self.config.seam_model,
367 ts = timestamp.to_rfc3339()
368 ))
369 }
370
371 /// Produce a cycle briefing using Flash. Unlike the current
372 /// `produce_briefing` in cycle_manager.rs (which uses the main model),
373 /// this consumes existing `<archived_context>` blocks as input rather
374 /// than scanning raw history.
375 pub async fn produce_flash_briefing(
376 &self,
377 existing_seams: &[String],
378 structured_state: Option<&str>,
379 ) -> Result<String> {
380 let mut input = String::from(
381 "## Briefing Request\n\n\
382 Produce a <carry_forward> block summarizing the session state. \
383 Include: decisions made + why, constraints discovered, \
384 hypotheses being tested, approaches that failed, open questions. \
385 Do NOT include tool output bytes, file contents, or step-by-step recaps.\n\n",
386 );
387
388 if let Some(state) = structured_state {
389 let _ = write!(input, "## Structured State\n\n{state}\n\n");
390 }
391
392 if !existing_seams.is_empty() {
393 input.push_str("## Prior Context Summaries\n\n");
394 for (i, seam) in existing_seams.iter().enumerate() {
395 let _ = write!(input, "### Seam {}\n{seam}\n\n", i + 1);
396 }
397 } else {
398 input.push_str(
399 "No prior context summaries available. Produce a brief carry-forward \
400 from the structured state alone.\n",
401 );
402 }
403
404 let request = MessageRequest {
405 model: self.config.seam_model.clone(),
406 messages: vec![Message {
407 role: "user".to_string(),
408 content: vec![ContentBlock::Text {
409 text: input,
410 cache_control: None,
411 }],
412 }],
413 max_tokens: 4_096,
414 system: Some(SystemPrompt::Blocks(vec![SystemBlock {
415 block_type: "text".to_string(),
416 text: crate::cycle_manager::CYCLE_HANDOFF_TEMPLATE.to_string(),
417 cache_control: None,
418 }])),
419 tools: None,
420 tool_choice: None,
421 metadata: None,
422 thinking: None,
423 reasoning_effort: None,
424 stream: Some(false),
425 temperature: Some(0.2),
426 top_p: None,
427 };
428
429 let response = self.flash_client.create_message(request).await?;
430 // Seam recompaction calls are billed; route through the
431 // side-channel (#526) so the footer total matches the
432 // DeepSeek website.
433 crate::cost_status::report(&response.model, &response.usage);
434 let raw = response
435 .content
436 .iter()
437 .filter_map(|block| match block {
438 ContentBlock::Text { text, .. } => Some(text.as_str()),
439 _ => None,
440 })
441 .collect::<Vec<_>>()
442 .join("\n");
443
444 Ok(crate::cycle_manager::extract_carry_forward(&raw))
445 }
446
447 /// Internal: summarize a slice of messages using Flash.
448 async fn summarize_messages(
449 &self,
450 messages: &[&Message],
451 level: u8,
452 start_idx: usize,
453 end_idx: usize,
454 ) -> Result<String> {
455 let mut conversation = String::new();
456
457 for msg in messages {
458 let role = if msg.role == "user" {
459 "User"
460 } else {
461 "Assistant"
462 };
463 for block in &msg.content {
464 match block {
465 ContentBlock::Text { text, .. } => {
466 let snippet = truncate_chars(text, 800);
467 let _ = write!(conversation, "{role}: {snippet}\n\n");
468 }
469 ContentBlock::ToolUse { name, .. } => {
470 let _ = write!(conversation, "{role}: [Used tool: {name}]\n\n");
471 }
472 ContentBlock::ToolResult { content, .. } => {
473 let snippet = truncate_chars(content, 200);
474 let _ = write!(conversation, "Tool result: {snippet}\n\n");
475 }
476 ContentBlock::Thinking { .. } => {
477 // Skip thinking in seam summaries.
478 }
479 ContentBlock::ServerToolUse { .. }
480 | ContentBlock::ToolSearchToolResult { .. }
481 | ContentBlock::CodeExecutionToolResult { .. } => {}
482 }
483 }
484 }
485
486 let (max_tokens, word_limit) = match level {
487 1 => (L1_MAX_TOKENS, 800),
488 2 => (L2_MAX_TOKENS, 600),
489 3 => (L3_MAX_TOKENS, 400),
490 _ => (L3_MAX_TOKENS, 400),
491 };
492
493 let request = MessageRequest {
494 model: self.config.seam_model.clone(),
495 messages: vec![Message {
496 role: "user".to_string(),
497 content: vec![ContentBlock::Text {
498 text: format!(
499 "Summarize the following conversation segment (messages {start_idx}-{end_idx}). \
500 Preserve: key decisions and their rationale, exact file paths, \
501 command invocations, error messages, tool-result facts, constraints \
502 discovered, hypotheses being tested, and open questions. \
503 Drop: greetings, filler, repeated information, and thinking blocks. \
504 Keep it under {word_limit} words.\n\n---\n\n{conversation}"
505 ),
506 cache_control: None,
507 }],
508 }],
509 max_tokens,
510 system: Some(SystemPrompt::Text(
511 "You are a context summarization specialist. Produce dense, factual summaries \
512 that preserve every decision, path, error, constraint, and open question. \
513 Never omit a file path, error message, or decision rationale."
514 .to_string(),
515 )),
516 tools: None,
517 tool_choice: None,
518 metadata: None,
519 thinking: None,
520 reasoning_effort: None,
521 stream: Some(false),
522 temperature: Some(0.1),
523 top_p: None,
524 };
525
526 let response = self.flash_client.create_message(request).await?;
527 // Seam recompaction calls are billed; route through the
528 // side-channel (#526) so the footer total matches the
529 // DeepSeek website.
530 crate::cost_status::report(&response.model, &response.usage);
531 let summary = response
532 .content
533 .iter()
534 .filter_map(|block| match block {
535 ContentBlock::Text { text, .. } => Some(text.clone()),
536 _ => None,
537 })
538 .collect::<Vec<_>>()
539 .join("\n");
540
541 Ok(summary)
542 }
543
544 /// Collect the text content of all active seams (for use as input to
545 /// re-compaction or briefing).
546 pub async fn collect_seam_texts(&self, messages: &[Message]) -> Vec<String> {
547 let _seams = self.active_seams.lock().await;
548 let mut texts = Vec::new();
549
550 // Extract `<archived_context>` blocks from messages.
551 for msg in messages {
552 if msg.role == "assistant" {
553 for block in &msg.content {
554 if let ContentBlock::Text { text, .. } = block
555 && text.contains("<archived_context")
556 {
557 texts.push(text.clone());
558 }
559 }
560 }
561 }
562
563 texts
564 }
565
566 /// Get the highest seam level currently recorded.
567 pub async fn highest_level(&self) -> Option<u8> {
568 let seams = self.active_seams.lock().await;
569 seams.last().map(|s| s.level)
570 }
571
572 /// Clear seam tracking (called on hard cycle reset).
573 pub async fn reset(&self) {
574 self.active_seams.lock().await.clear();
575 }
576 }
577
578 #[must_use]
579 pub fn seam_level_for_active_input(
580 config: &SeamConfig,
581 active_input_tokens: usize,
582 highest_existing_level: Option<u8>,
583 ) -> Option<u8> {
584 if !config.enabled {
585 return None;
586 }
587 let highest = highest_existing_level.unwrap_or(0);
588
589 // Each level fires at most once, and only in order.
590 if highest < 1 && active_input_tokens >= config.l1_threshold {
591 return Some(1);
592 }
593 if highest < 2 && active_input_tokens >= config.l2_threshold {
594 return Some(2);
595 }
596 if highest < 3 && active_input_tokens >= config.l3_threshold {
597 return Some(3);
598 }
599 None
600 }
601
602 /// Truncate a string to max_chars, respecting Unicode boundaries.
603 fn truncate_chars(text: &str, max_chars: usize) -> String {
604 if max_chars == 0 {
605 return String::new();
606 }
607 if text.chars().count() <= max_chars {
608 return text.to_string();
609 }
610 text.chars().take(max_chars).collect()
611 }
612
613 fn local_pins_for_range(
614 pinned_indices: &[usize],
615 start_idx: usize,
616 end_idx: usize,
617 message_count: usize,
618 ) -> Vec<usize> {
619 let end_idx = end_idx.min(message_count);
620 pinned_indices
621 .iter()
622 .copied()
623 .filter(|idx| *idx >= start_idx && *idx < end_idx)
624 .map(|idx| idx - start_idx)
625 .collect()
626 }
627
628 #[cfg(test)]
629 mod tests {
630 use super::*;
631
632 #[test]
633 fn seam_levels_fire_in_order() {
634 // Cannot create DeepSeekClient without API key in test env.
635 // Test the pure logic functions only.
636 let config = SeamConfig::default();
637
638 assert_eq!(seam_level_for_active_input(&config, 100_000, None), None);
639 assert_eq!(seam_level_for_active_input(&config, 192_000, None), Some(1));
640 assert_eq!(
641 seam_level_for_active_input(&config, 384_000, Some(1)),
642 Some(2)
643 );
644 assert_eq!(
645 seam_level_for_active_input(&config, 576_000, Some(2)),
646 Some(3)
647 );
648 }
649
650 #[test]
651 fn seam_trigger_uses_active_request_size_not_lifetime_usage() {
652 let config = SeamConfig::default();
653 let lifetime_prompt_usage = 900_000usize;
654 let active_request_input = 120_000usize;
655
656 assert!(lifetime_prompt_usage >= config.l3_threshold);
657 assert_eq!(
658 seam_level_for_active_input(&config, active_request_input, None),
659 None
660 );
661 }
662
663 #[test]
664 fn cycle_threshold_check() {
665 let config = SeamConfig::default();
666 assert!(768_000 >= config.cycle_threshold);
667 assert!(700_000 < config.cycle_threshold);
668 }
669
670 #[test]
671 fn verbatim_window_calculation() {
672 let config = SeamConfig {
673 verbatim_window_turns: 4,
674 ..Default::default()
675 };
676 // 4 verbatim turns = 8 messages
677 // 20 messages: 20 - (4*2) = 12
678 assert_eq!(20usize.saturating_sub(8), 12);
679 // 8 messages: 8 - 8 = 0
680 assert_eq!(8usize.saturating_sub(8), 0);
681 // 4 messages: 4 - 4 = 0
682 assert_eq!(4usize.saturating_sub(4), 0);
683
684 let _ = config;
685 }
686
687 #[test]
688 fn truncate_chars_handles_unicode() {
689 assert_eq!(truncate_chars("abc😀é", 3), "abc".to_string());
690 assert_eq!(truncate_chars("abc😀é", 4), "abc😀".to_string());
691 assert_eq!(truncate_chars("abc😀é", 10), "abc😀é".to_string());
692 assert_eq!(truncate_chars("", 5), "".to_string());
693 }
694
695 #[test]
696 fn global_pins_are_mapped_to_soft_seam_slice_indices() {
697 let pins = vec![1, 4, 5, 8, 12];
698
699 let local = local_pins_for_range(&pins, 4, 9, 10);
700
701 assert_eq!(local, vec![0, 1, 4]);
702 }
703
704 #[test]
705 fn disabled_config() {
706 let config = SeamConfig {
707 enabled: false,
708 ..Default::default()
709 };
710 assert!(!config.enabled);
711 }
712 }
713
713 lines RUST