返回 CodeWhale
prompt_zones.rs
根目录 / crates / tui / src / prompt_zones.rs
1 //! Three-zone prompt contract types for prefix-cache stability (#2264).
2 //!
3 //! Divides every request into three rigid zones:
4 //!
5 //! ```text
6 //! ┌─────────────────────────────────────────┐
7 //! │ PinnedPrefix (frozen after construction) │ ← system prompt + tool catalog
8 //! │ combined_sha256 computed at freeze() │ cache hit candidate
9 //! ├─────────────────────────────────────────┤
10 //! │ AppendLog (append-only) │ ← conversation history
11 //! │ push() only, no insert / remove / edit │ preserves prefix of prior turns
12 //! ├─────────────────────────────────────────┤
13 //! │ TurnScratch (ephemeral) │ ← per-turn metadata
14 //! │ cleared at every turn boundary │ the only new content per request
15 //! └─────────────────────────────────────────┘
16 //! ```
17 //!
18 //! ## Status (Phase 1 foundation)
19 //!
20 //! `PinnedPrefix` / `FrozenPrefix` / `PrefixDrift` are ready for use.
21 //! `AppendLog` / `TurnScratch` / `ThreeZoneRequest` are type scaffolding
22 //! for future phases — not yet wired into the request path.
23
24 use crate::models::{Message, SystemPrompt, Tool};
25 // ── helpers ────────────────────────────────────────────────────────────
26
27 #[allow(dead_code)]
28 fn sha256_hex(bytes: &[u8]) -> String {
29 crate::hashing::sha256_hex(bytes)
30 }
31
32 #[allow(dead_code)]
33 fn system_text(system: Option<&SystemPrompt>) -> String {
34 match system {
35 Some(SystemPrompt::Text(text)) => text.clone(),
36 Some(SystemPrompt::Blocks(blocks)) => {
37 let mut text = String::new();
38 for block in blocks {
39 text.push_str(&block.text);
40 text.push('\n');
41 }
42 text
43 }
44 None => String::new(),
45 }
46 }
47
48 /// Serialize tools to a deterministic, sorted JSON string for hashing.
49 #[allow(dead_code)]
50 fn tool_catalog_digest(tools: &[Tool]) -> String {
51 let mut serialized: Vec<String> = tools
52 .iter()
53 .filter_map(|t| serde_json::to_string(t).ok())
54 .collect();
55 serialized.sort();
56 serialized.join("\n")
57 }
58
59 #[allow(dead_code)]
60 fn combined_hash(system_text: &str, tools: &[Tool]) -> String {
61 let system_sha = sha256_hex(system_text.as_bytes());
62 let tools_digest = tool_catalog_digest(tools);
63 let tools_sha = sha256_hex(tools_digest.as_bytes());
64 let combined = format!("{system_sha}:{tools_sha}");
65 sha256_hex(combined.as_bytes())
66 }
67
68 // ── FrozenPrefix ───────────────────────────────────────────────────────
69
70 /// An immutable frozen prefix — system prompt text + tool catalog,
71 /// hashed at freeze time. The hash is stable as long as the system prompt
72 /// text and full tool definitions (name, description, schema) are unchanged.
73 ///
74 /// Use [`PinnedPrefix::freeze`] to produce one.
75 #[derive(Debug, Clone, PartialEq, Eq)]
76 #[allow(dead_code)]
77 pub struct FrozenPrefix {
78 pub(crate) system_text: String,
79 pub(crate) tool_catalog: String,
80 pub(crate) combined_sha256: String,
81 }
82
83 #[allow(dead_code)]
84 impl FrozenPrefix {
85 /// Verify that `current_system_text` and `current_tools` match the frozen
86 /// prefix. Returns `Ok(())` when stable, `Err(PrefixDrift)` on mismatch.
87 ///
88 /// Fast path: compares raw text before falling back to SHA-256.
89 pub fn verify(
90 &self,
91 current_system_text: &str,
92 current_tools: &[Tool],
93 ) -> Result<(), PrefixDrift> {
94 let system_changed = current_system_text != self.system_text;
95 let current_tool_catalog = tool_catalog_digest(current_tools);
96 let tools_changed = current_tool_catalog != self.tool_catalog;
97
98 if !system_changed && !tools_changed {
99 return Ok(());
100 }
101
102 let current_hash = combined_hash(current_system_text, current_tools);
103 Err(PrefixDrift {
104 system_changed,
105 tools_changed,
106 frozen_hash: self.combined_sha256.clone(),
107 current_hash,
108 })
109 }
110
111 /// Returns a short (12-char) human-readable id for display.
112 #[must_use]
113 pub fn short_id(&self) -> &str {
114 if self.combined_sha256.len() >= 12 {
115 &self.combined_sha256[..12]
116 } else {
117 &self.combined_sha256
118 }
119 }
120
121 /// Returns the full combined SHA-256.
122 #[must_use]
123 pub fn hash(&self) -> &str {
124 &self.combined_sha256
125 }
126 }
127
128 // ── PinnedPrefix ───────────────────────────────────────────────────────
129
130 /// A mutable prefix builder. Construct from the system prompt and tool
131 /// catalog, then call [`freeze`](Self::freeze) to produce a [`FrozenPrefix`].
132 #[derive(Debug, Clone)]
133 #[allow(dead_code)]
134 pub struct PinnedPrefix {
135 system_text: String,
136 tools: Vec<Tool>,
137 }
138
139 #[allow(dead_code)]
140 impl PinnedPrefix {
141 #[must_use]
142 pub fn new(system: Option<&SystemPrompt>, tools: Vec<Tool>) -> Self {
143 Self {
144 system_text: system_text(system),
145 tools,
146 }
147 }
148
149 /// Freeze this prefix into an immutable [`FrozenPrefix`].
150 #[must_use]
151 pub fn freeze(&self) -> FrozenPrefix {
152 let tool_catalog = tool_catalog_digest(&self.tools);
153 let combined_sha256 = combined_hash(&self.system_text, &self.tools);
154
155 FrozenPrefix {
156 system_text: self.system_text.clone(),
157 tool_catalog,
158 combined_sha256,
159 }
160 }
161 }
162
163 // ── PrefixDrift ────────────────────────────────────────────────────────
164
165 /// Describes how the current prefix differs from the frozen baseline.
166 #[derive(Debug, Clone, PartialEq, Eq)]
167 #[allow(dead_code)]
168 pub struct PrefixDrift {
169 pub system_changed: bool,
170 pub tools_changed: bool,
171 pub frozen_hash: String,
172 pub current_hash: String,
173 }
174
175 impl std::fmt::Display for PrefixDrift {
176 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 let cause = match (self.system_changed, self.tools_changed) {
178 (true, true) => "system prompt and tool set",
179 (true, false) => "system prompt",
180 (false, true) => "tool set",
181 (false, false) => "unknown component",
182 };
183 write!(
184 f,
185 "prefix drift: {cause} changed (frozen={}, current={})",
186 &self.frozen_hash[..12.min(self.frozen_hash.len())],
187 &self.current_hash[..12.min(self.current_hash.len())]
188 )
189 }
190 }
191
192 // ── AppendLog ──────────────────────────────────────────────────────────
193
194 /// Append-only conversation history. Derefs to `&[Message]` via
195 /// [`Deref`](std::ops::Deref) for transparent read access; mutations go
196 /// through explicit methods (`push`, `truncate_to`, `trim_front`, `clear`)
197 /// whose names make cache impact obvious.
198 ///
199 /// Phase 4: backing store for `Session.messages` (#2264).
200 #[derive(Debug, Clone)]
201 pub struct AppendLog {
202 messages: Vec<Message>,
203 }
204
205 impl AppendLog {
206 pub fn new() -> Self {
207 Self {
208 messages: Vec::new(),
209 }
210 }
211
212 pub fn from_messages(messages: Vec<Message>) -> Self {
213 Self { messages }
214 }
215
216 /// Append a message to the log. A single-message push is the cheapest
217 /// mutation for prefix-cache stability — it extends the byte sequence
218 /// without disturbing earlier turns.
219 pub fn push(&mut self, message: Message) {
220 self.messages.push(message);
221 }
222
223 /// Append multiple messages in one operation (fewer cache-line
224 /// invalidations than repeated `push`).
225 pub fn push_batch(&mut self, batch: Vec<Message>) {
226 self.messages.extend(batch);
227 }
228
229 /// Truncate to keep only the most recent `new_len` messages.
230 /// Discards older messages (and their prefix-cache contribution)
231 /// from the front.
232 pub fn truncate_to(&mut self, new_len: usize) {
233 self.messages.truncate(new_len);
234 }
235
236 /// Remove `count` messages from the front (oldest first).
237 /// Cache-destroying: drops the prefix that earlier turns share.
238 pub fn trim_front(&mut self, count: usize) {
239 if count >= self.messages.len() {
240 self.messages.clear();
241 } else {
242 self.messages.drain(0..count);
243 }
244 }
245
246 /// Remove all messages. Resets cache state completely.
247 pub fn clear(&mut self) {
248 self.messages.clear();
249 }
250
251 /// Return a mutable reference to the last message, if any.
252 /// Prefer this over `last_mut()` on the inner vec — the name signals
253 /// that only the most recent turn's content is being modified.
254 #[must_use]
255 pub fn last_mut(&mut self) -> Option<&mut Message> {
256 self.messages.last_mut()
257 }
258
259 /// Consume and return the inner `Vec<Message>`.
260 #[must_use]
261 pub fn into_inner(self) -> Vec<Message> {
262 self.messages
263 }
264 }
265
266 impl Default for AppendLog {
267 fn default() -> Self {
268 Self::new()
269 }
270 }
271
272 impl From<Vec<Message>> for AppendLog {
273 fn from(messages: Vec<Message>) -> Self {
274 Self { messages }
275 }
276 }
277
278 impl From<AppendLog> for Vec<Message> {
279 fn from(log: AppendLog) -> Self {
280 log.messages
281 }
282 }
283
284 impl std::ops::Deref for AppendLog {
285 type Target = Vec<Message>;
286
287 fn deref(&self) -> &Self::Target {
288 &self.messages
289 }
290 }
291
292 // ── TurnScratch ────────────────────────────────────────────────────────
293
294 /// Per-turn ephemeral data. Cleared at every turn boundary.
295 ///
296 /// **Phase 1 scaffolding** — not yet wired into the engine request path.
297 #[allow(dead_code)]
298 #[derive(Debug, Clone, Default)]
299 pub struct TurnScratch {
300 pub working_set: Vec<String>,
301 pub user_message: Option<Message>,
302 }
303
304 #[allow(dead_code)]
305 impl TurnScratch {
306 pub fn new() -> Self {
307 Self::default()
308 }
309
310 pub fn clear(&mut self) {
311 self.working_set.clear();
312 self.user_message = None;
313 }
314
315 #[must_use]
316 pub fn is_empty(&self) -> bool {
317 self.working_set.is_empty() && self.user_message.is_none()
318 }
319 }
320
321 // ── ThreeZoneRequest ───────────────────────────────────────────────────
322
323 /// A composed three-zone request ready for DeepSeek API serialization.
324 ///
325 /// **Phase 1 scaffolding** — not yet wired into the engine request path.
326 /// Currently the engine continues to use `MessageRequest` directly.
327 #[allow(dead_code)]
328 #[derive(Debug, Clone)]
329 pub struct ThreeZoneRequest<'a> {
330 pub prefix: &'a FrozenPrefix,
331 pub log: &'a AppendLog,
332 pub scratch: TurnScratch,
333 pub model: String,
334 pub max_tokens: u32,
335 pub system: Option<SystemPrompt>,
336 pub tools: Option<Vec<Tool>>,
337 pub tool_choice: Option<serde_json::Value>,
338 pub reasoning_effort: Option<String>,
339 pub thinking: Option<serde_json::Value>,
340 pub stream: Option<bool>,
341 pub temperature: Option<f32>,
342 pub top_p: Option<f32>,
343 pub metadata: Option<serde_json::Value>,
344 }
345
346 #[allow(dead_code)]
347 impl<'a> ThreeZoneRequest<'a> {
348 /// Build the full message list from system prompt, append-log messages,
349 /// and scratch user message. The returned vector is serialized as the
350 /// `messages` field in the DeepSeek chat-completion request.
351 #[must_use]
352 pub fn build_messages(&self) -> Vec<Message> {
353 let mut messages = Vec::with_capacity(self.message_count());
354
355 match self.system.as_ref() {
356 Some(SystemPrompt::Text(text)) => {
357 messages.push(Message {
358 role: "system".to_string(),
359 content: vec![crate::models::ContentBlock::Text {
360 text: text.clone(),
361 cache_control: None,
362 }],
363 });
364 }
365 Some(SystemPrompt::Blocks(blocks)) => {
366 let content: Vec<crate::models::ContentBlock> = blocks
367 .iter()
368 .map(|block| crate::models::ContentBlock::Text {
369 text: block.text.clone(),
370 cache_control: block.cache_control.clone(),
371 })
372 .collect();
373 messages.push(Message {
374 role: "system".to_string(),
375 content,
376 });
377 }
378 None => {}
379 }
380
381 for msg in self.log.iter() {
382 messages.push(msg.clone());
383 }
384
385 if let Some(ref user_msg) = self.scratch.user_message {
386 messages.push(user_msg.clone());
387 }
388
389 messages
390 }
391
392 #[must_use]
393 pub fn message_count(&self) -> usize {
394 let system_count = if self.system.is_some() { 1 } else { 0 };
395 let scratch_count = if self.scratch.user_message.is_some() {
396 1
397 } else {
398 0
399 };
400 system_count + self.log.len() + scratch_count
401 }
402 }
403
404 // ── tests ──────────────────────────────────────────────────────────────
405
406 #[cfg(test)]
407 mod tests {
408 use super::*;
409 use crate::models::ContentBlock;
410
411 fn make_tool(name: &str) -> Tool {
412 Tool {
413 name: name.to_string(),
414 description: String::new(),
415 input_schema: serde_json::Value::Null,
416 tool_type: None,
417 allowed_callers: None,
418 defer_loading: None,
419 input_examples: None,
420 strict: None,
421 cache_control: None,
422 }
423 }
424
425 fn make_message(role: &str, text: &str) -> Message {
426 Message {
427 role: role.to_string(),
428 content: vec![ContentBlock::Text {
429 text: text.to_string(),
430 cache_control: None,
431 }],
432 }
433 }
434
435 // ── FrozenPrefix / PinnedPrefix ────────────────────────────────
436
437 #[test]
438 fn freeze_produces_stable_hash() {
439 let tools = vec![make_tool("read"), make_tool("write")];
440 let sys = SystemPrompt::Text("hello world".to_string());
441
442 let a = PinnedPrefix::new(Some(&sys), tools.clone()).freeze();
443 let b = PinnedPrefix::new(Some(&sys), tools).freeze();
444
445 assert_eq!(a.combined_sha256, b.combined_sha256);
446 assert_eq!(a.hash(), b.hash());
447 assert_eq!(a.short_id(), b.short_id());
448 }
449
450 #[test]
451 fn freeze_tool_order_is_stable() {
452 let sys = SystemPrompt::Text("system".to_string());
453 let tools_a = vec![make_tool("b"), make_tool("a")];
454 let tools_b = vec![make_tool("a"), make_tool("b")];
455
456 let a = PinnedPrefix::new(Some(&sys), tools_a).freeze();
457 let b = PinnedPrefix::new(Some(&sys), tools_b).freeze();
458
459 assert_eq!(a.combined_sha256, b.combined_sha256);
460 }
461
462 #[test]
463 fn freeze_empty_tools() {
464 let sys = SystemPrompt::Text("system".to_string());
465 let frozen = PinnedPrefix::new(Some(&sys), vec![]).freeze();
466 assert!(frozen.tool_catalog.is_empty());
467 assert!(!frozen.combined_sha256.is_empty());
468 assert_eq!(frozen.short_id().len(), 12);
469 }
470
471 #[test]
472 fn freeze_no_system() {
473 let tools = vec![make_tool("t1")];
474 let frozen = PinnedPrefix::new(None, tools).freeze();
475 assert!(frozen.system_text.is_empty());
476 assert!(frozen.tool_catalog.contains("t1"));
477 }
478
479 #[test]
480 fn verify_passes_when_stable() {
481 let sys = SystemPrompt::Text("system".to_string());
482 let tools = vec![make_tool("a")];
483 let frozen = PinnedPrefix::new(Some(&sys), tools.clone()).freeze();
484
485 assert!(frozen.verify("system", &tools).is_ok());
486 }
487
488 #[test]
489 fn verify_detects_system_change() {
490 let sys = SystemPrompt::Text("old".to_string());
491 let tools = vec![make_tool("a")];
492 let frozen = PinnedPrefix::new(Some(&sys), tools.clone()).freeze();
493
494 let drift = frozen.verify("new", &tools).unwrap_err();
495 assert!(drift.system_changed);
496 assert!(!drift.tools_changed);
497 }
498
499 #[test]
500 fn verify_detects_tool_change() {
501 let sys = SystemPrompt::Text("system".to_string());
502 let tools_a = vec![make_tool("a")];
503 let frozen = PinnedPrefix::new(Some(&sys), tools_a).freeze();
504
505 let tools_b = vec![make_tool("b")];
506 let drift = frozen.verify("system", &tools_b).unwrap_err();
507 assert!(!drift.system_changed);
508 assert!(drift.tools_changed);
509 }
510
511 #[test]
512 fn verify_detects_both_changes() {
513 let sys = SystemPrompt::Text("old".to_string());
514 let tools = vec![make_tool("a")];
515 let frozen = PinnedPrefix::new(Some(&sys), tools).freeze();
516
517 let drift = frozen.verify("new", &[make_tool("b")]).unwrap_err();
518 assert!(drift.system_changed);
519 assert!(drift.tools_changed);
520 }
521
522 #[test]
523 fn verify_detects_schema_change() {
524 let sys = SystemPrompt::Text("system".to_string());
525 let tool_a = make_tool("a");
526 let mut tool_a_v2 = make_tool("a");
527 tool_a_v2.description = "updated desc".to_string();
528
529 let frozen = PinnedPrefix::new(Some(&sys), vec![tool_a]).freeze();
530 let drift = frozen.verify("system", &[tool_a_v2]).unwrap_err();
531 // Same name, different schema — should detect the change.
532 assert!(drift.tools_changed);
533 }
534
535 #[test]
536 fn prefix_drift_display_is_readable() {
537 let drift = PrefixDrift {
538 system_changed: true,
539 tools_changed: false,
540 frozen_hash: "a".repeat(64),
541 current_hash: "b".repeat(64),
542 };
543 let display = drift.to_string();
544 assert!(display.contains("system prompt"));
545 assert!(display.contains("aaaaaaaaaaaa"));
546 assert!(display.contains("bbbbbbbbbbbb"));
547 }
548
549 // ── AppendLog ─────────────────────────────────────────────────
550
551 #[test]
552 fn append_log_push_and_iter() {
553 let mut log = AppendLog::new();
554 assert!(log.is_empty());
555
556 log.push(make_message("user", "hello"));
557 log.push(make_message("assistant", "hi"));
558
559 assert_eq!(log.len(), 2);
560 assert!(!log.is_empty());
561
562 let messages: Vec<_> = log.iter().collect();
563 assert_eq!(messages.len(), 2);
564 }
565
566 #[test]
567 fn append_log_from_messages() {
568 let msgs = vec![make_message("user", "a"), make_message("assistant", "b")];
569 let log = AppendLog::from_messages(msgs);
570 assert_eq!(log.len(), 2);
571 assert_eq!(log.as_slice().len(), 2);
572 }
573
574 // ── TurnScratch ───────────────────────────────────────────────
575
576 #[test]
577 fn scratch_clear_empties_all_fields() {
578 let mut scratch = TurnScratch::new();
579 scratch.working_set.push("file.rs".to_string());
580 scratch.user_message = Some(make_message("user", "task"));
581
582 assert!(!scratch.is_empty());
583 scratch.clear();
584 assert!(scratch.is_empty());
585 assert!(scratch.working_set.is_empty());
586 assert!(scratch.user_message.is_none());
587 }
588
589 // ── ThreeZoneRequest ──────────────────────────────────────────
590
591 #[test]
592 fn build_messages_concatenates_zones() {
593 let sys = SystemPrompt::Text("you are helpful".to_string());
594 let tools = vec![make_tool("read")];
595 let prefix = PinnedPrefix::new(Some(&sys), tools).freeze();
596
597 let mut log = AppendLog::new();
598 log.push(make_message("user", "prev question"));
599 log.push(make_message("assistant", "prev answer"));
600
601 let scratch = TurnScratch {
602 working_set: vec!["main.rs".to_string()],
603 user_message: Some(make_message("user", "current task")),
604 };
605
606 let request = ThreeZoneRequest {
607 prefix: &prefix,
608 log: &log,
609 scratch,
610 model: "deepseek-v4-pro".to_string(),
611 max_tokens: 4096,
612 system: Some(sys),
613 tools: None,
614 tool_choice: None,
615 reasoning_effort: None,
616 thinking: None,
617 stream: None,
618 temperature: None,
619 top_p: None,
620 metadata: None,
621 };
622
623 let messages = request.build_messages();
624 assert_eq!(messages.len(), 4);
625 assert_eq!(messages[0].role, "system");
626 assert_eq!(messages[1].role, "user");
627 assert_eq!(messages[2].role, "assistant");
628 assert_eq!(messages[3].role, "user");
629 assert_eq!(request.message_count(), 4);
630 }
631
632 #[test]
633 fn build_messages_no_system_no_scratch() {
634 let prefix = PinnedPrefix::new(None, vec![]).freeze();
635
636 let mut log = AppendLog::new();
637 log.push(make_message("user", "hi"));
638
639 let request = ThreeZoneRequest {
640 prefix: &prefix,
641 log: &log,
642 scratch: TurnScratch::new(),
643 model: "x".to_string(),
644 max_tokens: 1,
645 system: None,
646 tools: None,
647 tool_choice: None,
648 reasoning_effort: None,
649 thinking: None,
650 stream: None,
651 temperature: None,
652 top_p: None,
653 metadata: None,
654 };
655
656 let messages = request.build_messages();
657 assert_eq!(messages.len(), 1);
658 assert_eq!(request.message_count(), 1);
659 }
660
661 #[test]
662 fn blocks_system_prompt_preserves_cache_control() {
663 use crate::models::{CacheControl, SystemBlock};
664 let cc = Some(CacheControl {
665 cache_type: "ephemeral".to_string(),
666 });
667 let blocks = SystemPrompt::Blocks(vec![SystemBlock {
668 block_type: "text".to_string(),
669 text: "hello".to_string(),
670 cache_control: cc.clone(),
671 }]);
672
673 let prefix = PinnedPrefix::new(Some(&blocks), vec![]).freeze();
674 let log = AppendLog::new();
675 let scratch = TurnScratch::new();
676 let request = ThreeZoneRequest {
677 prefix: &prefix,
678 log: &log,
679 scratch,
680 model: "x".to_string(),
681 max_tokens: 1,
682 system: Some(blocks),
683 tools: None,
684 tool_choice: None,
685 reasoning_effort: None,
686 thinking: None,
687 stream: None,
688 temperature: None,
689 top_p: None,
690 metadata: None,
691 };
692
693 let messages = request.build_messages();
694 assert_eq!(messages.len(), 1);
695 assert_eq!(messages[0].role, "system");
696 // cache_control should be preserved on the block.
697 if let ContentBlock::Text {
698 cache_control: actual_cc,
699 ..
700 } = &messages[0].content[0]
701 {
702 assert_eq!(
703 actual_cc.as_ref().map(|c| c.cache_type.as_str()),
704 Some("ephemeral")
705 );
706 } else {
707 panic!("expected Text content block");
708 }
709 }
710 }
711
711 lines RUST