1use std::collections::BTreeMap;
13
14use perspt_sdk::prompt::{
15 assemble_resident, ContextBudget, ContextPage, DependencyEnv, ResidentOutcome, StateDependency,
16 TokenAccountantRef,
17};
18use perspt_sdk::{Conversation, Message};
19
20#[derive(Debug, Clone, Copy)]
22pub struct ResidentReserves {
23 pub paging_enabled: bool,
26 pub output_reserve_tokens: u64,
27 pub guard_reserve_tokens: u64,
28 pub frame_tokens: u64,
30 pub pinned_tail: usize,
33}
34
35impl Default for ResidentReserves {
36 fn default() -> Self {
37 Self {
38 paging_enabled: true,
39 output_reserve_tokens: 1_024,
40 guard_reserve_tokens: 256,
41 frame_tokens: 512,
42 pinned_tail: 8,
43 }
44 }
45}
46
47fn labels(index: usize, message: &Message) -> Vec<String> {
50 let mut labels = vec![format!("turn:{index}")];
51 match message {
52 Message::System { .. } => labels.push("instruction".into()),
53 Message::User { .. } => labels.push("task".into()),
54 Message::Assistant { .. } => labels.push("assistant".into()),
55 Message::AssistantToolCalls { calls } => {
56 labels.extend(calls.iter().map(|call| format!("tool:{}", call.name)));
57 }
58 Message::ToolResponse { call_id, .. } => labels.push(format!("result:{call_id}")),
59 }
60 labels
61}
62
63pub(super) struct PageGroups {
68 pub(super) members: Vec<Vec<usize>>,
70 pub(super) owners: Vec<String>,
72}
73
74pub(super) fn page_groups(conversation: &Conversation) -> PageGroups {
78 let messages = conversation.messages();
79 let mut members: Vec<Vec<usize>> = Vec::new();
80 let mut issued: BTreeMap<String, usize> = BTreeMap::new();
81 for (index, message) in messages.iter().enumerate() {
82 match message {
83 Message::AssistantToolCalls { calls } => {
84 let group = members.len();
85 members.push(vec![index]);
86 for call in calls {
87 issued.insert(call.call_id.clone(), group);
88 }
89 }
90 Message::ToolResponse { call_id, .. } => match issued.get(call_id) {
91 Some(&group) => members[group].push(index),
92 None => members.push(vec![index]),
93 },
94 _ => members.push(vec![index]),
95 }
96 }
97 let mut owners = vec![String::new(); messages.len()];
98 for group in &members {
99 let serialized: String = group
100 .iter()
101 .map(|&i| serialize_message(&messages[i]))
102 .collect();
103 let page_id = perspt_sdk::ledger::content_hash(serialized.as_bytes());
104 for &index in group {
105 owners[index] = page_id.clone();
106 }
107 }
108 PageGroups { members, owners }
109}
110
111fn serialize_message(message: &Message) -> String {
113 serde_json::to_string(message).unwrap_or_default()
114}
115
116fn group_kind(messages: &[Message], group: &[usize]) -> &'static str {
118 match &messages[group[0]] {
119 Message::System { .. } => "instruction",
120 Message::User { .. } => "task",
121 Message::Assistant { .. } => "assistant",
122 Message::AssistantToolCalls { .. } | Message::ToolResponse { .. } => "tool_pair",
123 }
124}
125
126fn group_content(messages: &[Message], group: &[usize]) -> String {
129 let mut parts = Vec::new();
130 for &index in group {
131 match &messages[index] {
132 Message::System { content }
133 | Message::User { content }
134 | Message::Assistant { content } => parts.push(content.clone()),
135 Message::AssistantToolCalls { calls } => parts.push(format!(
136 "calls: {}",
137 serde_json::to_string(calls).unwrap_or_default()
138 )),
139 Message::ToolResponse { call_id, content } => {
140 parts.push(format!("result {call_id}:\n{content}"));
141 }
142 }
143 }
144 parts.join("\n")
145}
146
147fn typed_keys(messages: &[Message], group: &[usize]) -> Vec<String> {
150 let mut keys = vec![format!("turn:{}", group[0])];
151 for &index in group {
152 if let Message::AssistantToolCalls { calls } = &messages[index] {
153 for call in calls {
154 keys.push(format!("tool:{}", call.name));
155 keys.push(format!("call:{}", call.call_id));
156 for field in ["path", "file"] {
157 if let Some(path) = call.arguments.get(field).and_then(|v| v.as_str()) {
158 keys.push(format!("path:{path}"));
159 }
160 }
161 }
162 }
163 }
164 keys
165}
166
167pub(super) struct RecallIndex {
172 by_page: BTreeMap<String, String>,
173 by_prefix: BTreeMap<String, Vec<String>>,
174 by_key: BTreeMap<String, Vec<String>>,
176}
177
178pub(super) fn recall_index(conversation: &Conversation) -> RecallIndex {
179 let groups = page_groups(conversation);
180 let messages = conversation.messages();
181 let mut index = RecallIndex {
182 by_page: BTreeMap::new(),
183 by_prefix: BTreeMap::new(),
184 by_key: BTreeMap::new(),
185 };
186 for group in &groups.members {
187 let page_id = groups.owners[group[0]].clone();
188 index
189 .by_prefix
190 .entry(page_id.chars().take(16).collect())
191 .or_default()
192 .push(page_id.clone());
193 for key in typed_keys(messages, group) {
194 index.by_key.entry(key).or_default().push(page_id.clone());
195 }
196 index
197 .by_page
198 .insert(page_id, group_content(messages, group));
199 }
200 index
201}
202
203impl RecallIndex {
204 pub(super) fn lookup(&self, arguments: &serde_json::Value) -> String {
210 let arg = |name: &str| {
211 arguments
212 .get(name)
213 .and_then(serde_json::Value::as_str)
214 .filter(|value| !value.is_empty())
215 };
216 if let Some(id) = arg("page_id").or_else(|| arg("provenance_key")) {
217 return self.by_id(id);
218 }
219 if let Some(path) = arg("path") {
220 return self.by_typed_key(&format!("path:{path}"), "path", path);
221 }
222 for (field, prefix) in [
223 ("diagnostic_id", "diagnostic:"),
224 ("test_id", "test:"),
225 ("symbol", "symbol:"),
226 ] {
227 if let Some(value) = arg(field) {
228 return self.by_typed_key(&format!("{prefix}{value}"), field, value);
229 }
230 }
231 "miss: context_recall needs one of page_id, path, diagnostic_id, \
232 test_id, symbol, or provenance_key"
233 .to_string()
234 }
235
236 fn by_id(&self, id: &str) -> String {
237 if let Some(content) = self.by_page.get(id) {
238 return format!("recalled page {id}\n{content}");
239 }
240 let prefix: String = id.chars().take(16).collect();
241 match self.by_prefix.get(&prefix).map(Vec::as_slice) {
242 Some([full]) => format!(
243 "recalled page {full}\n{}",
244 self.by_page.get(full).map(String::as_str).unwrap_or("")
245 ),
246 Some(many) => format!(
247 "miss: page prefix {prefix} is ambiguous between {} pages",
248 many.len()
249 ),
250 None => format!(
251 "miss: no context page {id} in this session; evicted page ids \
252 appear in the bracketed eviction notes and the page index"
253 ),
254 }
255 }
256
257 fn by_typed_key(&self, key: &str, field: &str, value: &str) -> String {
258 let Some(ids) = self.by_key.get(key) else {
259 return format!("miss: no page indexed under {field} {value:?}");
260 };
261 let newest: Vec<&String> = ids.iter().rev().take(3).collect();
262 newest
263 .into_iter()
264 .map(|id| {
265 format!(
266 "recalled page {id}\n{}",
267 self.by_page.get(id).map(String::as_str).unwrap_or("")
268 )
269 })
270 .collect::<Vec<_>>()
271 .join("\n---\n")
272 }
273}
274
275pub(super) fn evicted_index_note(
280 conversation: &Conversation,
281 resident: &std::collections::BTreeSet<String>,
282) -> Option<String> {
283 if !conversation.unresolved_call_ids().is_empty() {
284 return None;
285 }
286 let groups = page_groups(conversation);
287 let messages = conversation.messages();
288 let mut lines: Vec<String> = Vec::new();
289 for group in groups.members.iter().rev() {
290 let page_id = &groups.owners[group[0]];
291 if resident.contains(page_id) {
292 continue;
293 }
294 let prefix: String = page_id.chars().take(16).collect();
295 let keys = typed_keys(messages, group).join(" ");
296 lines.push(format!("{prefix} {} {keys}", group_kind(messages, group)));
297 if lines.len() >= 32 {
298 break;
299 }
300 }
301 if lines.is_empty() {
302 return None;
303 }
304 let mut note =
305 String::from("[evicted page index — restore any with context_recall(page_id=<prefix>):\n");
306 for line in &lines {
307 if note.len() + line.len() > 2_048 {
308 break;
309 }
310 note.push_str(line);
311 note.push('\n');
312 }
313 note.push(']');
314 Some(note)
315}
316
317const EVICTED_ARGS_BYTES: usize = 256;
320
321fn tombstone(page_id: &str, kind: &str) -> String {
322 format!(
323 "[{kind} page {page_id} evicted from the resident context; call \
324 context_recall with this page_id to restore it]"
325 )
326}
327
328pub(super) fn resident_view(
335 conversation: &Conversation,
336 resident: &std::collections::BTreeSet<String>,
337) -> (Conversation, usize) {
338 let groups = page_groups(conversation);
339 let mut view = Conversation::default();
340 let mut evicted_pages: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
341 for (index, message) in conversation.messages().iter().enumerate() {
342 let page_id = &groups.owners[index];
343 if resident.contains(page_id) {
344 view.push(message.clone());
345 continue;
346 }
347 evicted_pages.insert(page_id.as_str());
348 view.push(evicted_message(message, page_id));
349 }
350 let evicted = evicted_pages.len();
351 (view, evicted)
352}
353
354fn evicted_message(message: &Message, page_id: &str) -> Message {
357 match message {
358 Message::System { .. } => Message::System {
359 content: tombstone(page_id, "instruction"),
360 },
361 Message::User { .. } => Message::User {
362 content: tombstone(page_id, "task"),
363 },
364 Message::Assistant { .. } => Message::Assistant {
365 content: tombstone(page_id, "assistant"),
366 },
367 Message::ToolResponse { call_id, .. } => Message::ToolResponse {
368 call_id: call_id.clone(),
369 content: tombstone(page_id, "tool_pair"),
370 },
371 Message::AssistantToolCalls { calls } => Message::AssistantToolCalls {
372 calls: calls
373 .iter()
374 .map(|call| {
375 if call.arguments.to_string().len() <= EVICTED_ARGS_BYTES {
376 call.clone()
377 } else {
378 perspt_sdk::ProviderToolCall {
379 call_id: call.call_id.clone(),
380 name: call.name.clone(),
381 arguments: serde_json::json!({ "_evicted_page": page_id }),
382 }
383 }
384 })
385 .collect(),
386 },
387 }
388}
389
390pub(super) fn conversation_pages(
403 conversation: &Conversation,
404 birth_deps: &[StateDependency],
405 pinned_tail: usize,
406 env: &DependencyEnv,
407) -> (Vec<ContextPage>, Vec<String>) {
408 let accountant = TokenAccountantRef::approx_bytes_v1();
409 let messages = conversation.messages();
410 let unresolved = conversation.unresolved_call_ids();
411 let total = messages.len();
412 let groups = page_groups(conversation);
413 let mut pages = Vec::with_capacity(groups.members.len());
414 let mut mandatory = Vec::new();
415 for group in &groups.members {
416 let page_id = groups.owners[group[0]].clone();
417 let dep_holds = birth_deps
418 .get(group[0])
419 .map(|dependency| dependency.holds(env))
420 .unwrap_or(true);
421 let mut tokens = 0u64;
422 let mut bytes = 0u64;
423 let mut obligations = Vec::new();
424 let mut pinned = false;
425 for &index in group {
426 let message = &messages[index];
427 let serialized = serialize_message(message);
428 tokens += accountant.count_message(&serialized);
429 bytes += serialized.len() as u64;
430 obligations.extend(labels(index, message));
431 pinned = pinned
432 || index < 2
433 || (dep_holds && index + pinned_tail >= total)
434 || match message {
435 Message::AssistantToolCalls { calls } => {
436 calls.iter().any(|call| unresolved.contains(&call.call_id))
437 }
438 _ => false,
439 };
440 }
441 if pinned {
442 mandatory.push(page_id.clone());
443 }
444 pages.push(ContextPage {
445 page_id,
446 kind: group_kind(messages, group).into(),
447 source_hashes: Vec::new(),
448 dependency: birth_deps
451 .get(group[0])
452 .cloned()
453 .unwrap_or(StateDependency::SessionInvariant),
454 bytes,
455 tokens,
456 obligations,
457 freshness_turn: group.iter().copied().max().unwrap_or(0) as u64,
458 depends_on: Vec::new(),
459 });
460 }
461 (pages, mandatory)
462}
463
464fn weights(pages: &[ContextPage]) -> BTreeMap<String, f64> {
467 let total = pages.len().max(1) as f64;
468 let mut weights = BTreeMap::new();
469 weights.insert("task".to_string(), 4.0);
470 weights.insert("instruction".to_string(), 4.0);
471 for (index, page) in pages.iter().enumerate() {
472 let recency = 1.0 + (index as f64) / total;
473 for label in &page.obligations {
474 let entry = weights.entry(label.clone()).or_insert(0.0);
475 if *entry < recency {
476 *entry = recency;
477 }
478 }
479 }
480 weights
481}
482
483const INDEX_NOTE_RESERVE: u64 = 768;
487
488fn evicted_cost(
494 accountant: &TokenAccountantRef,
495 messages: &[Message],
496 group: &[usize],
497 page_id: &str,
498) -> u64 {
499 group
500 .iter()
501 .map(|&index| {
502 let rendered = evicted_message(&messages[index], page_id);
503 accountant.count_message(&serialize_message(&rendered))
504 })
505 .sum()
506}
507
508pub(super) fn assemble_worker_resident(
513 conversation: &Conversation,
514 birth_deps: &[StateDependency],
515 accepted_root: &str,
516 partial_root: Option<&str>,
517 window_tokens: u64,
518 tool_reserve: u64,
519 reserves: &ResidentReserves,
520) -> perspt_sdk::Result<ResidentOutcome> {
521 let accountant = TokenAccountantRef::approx_bytes_v1();
522 let env = DependencyEnv {
523 accepted_root: accepted_root.into(),
524 partial_root: partial_root.map(Into::into),
525 path_blobs: BTreeMap::new(),
526 };
527 let (mut pages, mandatory) =
528 conversation_pages(conversation, birth_deps, reserves.pinned_tail, &env);
529 let groups = page_groups(conversation);
530 let messages = conversation.messages();
531 let mut tombstone_base = INDEX_NOTE_RESERVE;
532 for (page, group) in pages.iter_mut().zip(&groups.members) {
533 let evicted = evicted_cost(&accountant, messages, group, &page.page_id);
534 tombstone_base += evicted;
535 page.tokens = page.tokens.saturating_sub(evicted);
536 }
537 let budget = ContextBudget {
538 window_tokens: window_tokens.max(1),
539 output_reserve: reserves.output_reserve_tokens,
540 tool_reserve,
541 guard_reserve: reserves.guard_reserve_tokens,
542 };
543 let Ok(allowance) = budget.input_allowance() else {
544 return Ok(ResidentOutcome::Infeasible {
547 required: reserves.output_reserve_tokens + tool_reserve + reserves.guard_reserve_tokens,
548 allowance: 0,
549 });
550 };
551 let weights = weights(&pages);
552 let outcome = assemble_resident(
553 &pages,
554 &mandatory,
555 &env,
556 &budget,
557 tombstone_base,
558 reserves.frame_tokens.max(1),
559 &weights,
560 )?;
561 Ok(match outcome {
562 ResidentOutcome::Assembled(assembled) => ResidentOutcome::Assembled(admit_large_pages(
563 assembled,
564 &pages,
565 &env,
566 allowance,
567 tombstone_base,
568 reserves.frame_tokens.max(1),
569 )),
570 infeasible => infeasible,
571 })
572}
573
574fn admit_large_pages(
579 assembled: perspt_sdk::prompt::ResidentContext,
580 pages: &[ContextPage],
581 env: &DependencyEnv,
582 allowance: u64,
583 tombstone_base: u64,
584 frame_tokens: u64,
585) -> perspt_sdk::prompt::ResidentContext {
586 let mut resident = assembled;
587 let mut used: u64 = tombstone_base + resident.pages.iter().map(|p| p.tokens).sum::<u64>();
588 let resident_ids: std::collections::BTreeSet<&str> = resident
589 .pages
590 .iter()
591 .map(|page| page.page_id.as_str())
592 .collect();
593 let mut large: Vec<&ContextPage> = pages
594 .iter()
595 .filter(|page| {
596 !resident_ids.contains(page.page_id.as_str())
597 && page.tokens > frame_tokens
598 && page.dependency_holds(env)
599 })
600 .collect();
601 large.sort_by(|a, b| {
602 b.freshness_turn
603 .cmp(&a.freshness_turn)
604 .then_with(|| a.page_id.cmp(&b.page_id))
605 });
606 let mut admitted = false;
607 for page in large {
608 if used + page.tokens > allowance {
609 continue;
610 }
611 used += page.tokens;
612 resident.pages.push(page.clone());
613 admitted = true;
614 }
615 if admitted {
616 resident.resident_digest = perspt_sdk::prompt::resident_page_digest(
617 resident.pages.iter().map(|page| page.page_id.as_str()),
618 );
619 }
620 resident
621}