Skip to main content

perspt_agent/toolloop/
resident.rs

1//! Live resident-context assembly for the worker (PSP-10 Definition 6,
2//! system 24).
3//!
4//! The conversation projection is the backing store; every message is one
5//! immutable content-addressed page with typed obligation labels. Before
6//! each model call the worker assembles the resident set: the mandatory
7//! closure (instructions, the task, and the unresolved tool-pair tail) is
8//! pinned, optional older pages are chosen by deterministic recency-
9//! weighted greedy coverage, and an infeasible mandatory closure makes no
10//! transport call at all.
11
12use 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/// The worker's per-call context reserves, from `[context]`.
21#[derive(Debug, Clone, Copy)]
22pub struct ResidentReserves {
23    /// Live paging on (default). The evaluation ladder's paging arm turns
24    /// it off to measure the feature's contribution.
25    pub paging_enabled: bool,
26    pub output_reserve_tokens: u64,
27    pub guard_reserve_tokens: u64,
28    /// The synopsis frame bound `q`.
29    pub frame_tokens: u64,
30    /// Messages pinned at the tail of the conversation (the live working
31    /// window), unresolved tool pairs included on top.
32    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
47/// Typed obligation labels for one message — derived from typed fields
48/// (roles, tool names, call ids), never from model prose.
49fn 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
63/// The conversation partitioned into atomic pages: an assistant tool-call
64/// message and **all** of its result messages form one page (spec
65/// :1431-1432); every other message is its own page. A page is the unit of
66/// residency, eviction, and recall — a pair can never be half-evicted.
67pub(super) struct PageGroups {
68    /// Member message indices per page, in first-member order.
69    pub(super) members: Vec<Vec<usize>>,
70    /// Owning page id per message index (parallel to the conversation).
71    pub(super) owners: Vec<String>,
72}
73
74/// Group the conversation's messages into atomic pages. A `ToolResponse`
75/// joins the most recent tool-call message that issued its `call_id`; an
76/// orphan response (none did) is its own page.
77pub(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
111/// The single serialization rule page identity hashes over.
112fn serialize_message(message: &Message) -> String {
113    serde_json::to_string(message).unwrap_or_default()
114}
115
116/// The page kind of one group.
117fn 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
126/// The model-facing content of one page, returned by `context_recall`. A
127/// pair page returns the call JSON plus each result labeled by call id.
128fn 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
147/// The typed keys of one page, derived only from typed fields (tool names,
148/// call ids, path arguments, turn indices) — never from model prose.
149fn 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
167/// The `context_recall` store: every projection page addressable by full
168/// id, 16-hex prefix, or typed key. The projection is the backing store
169/// (Definition 6): eviction removes a page from the transport view, never
170/// from here.
171pub(super) struct RecallIndex {
172    by_page: BTreeMap<String, String>,
173    by_prefix: BTreeMap<String, Vec<String>>,
174    /// Typed key -> page ids, oldest first.
175    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    /// Resolve one `context_recall` call: by `page_id` (full or 16-hex
205    /// prefix, also accepted as `provenance_key` — pages are content
206    /// addresses), by `path`, `diagnostic_id`, `test_id`, or `symbol`.
207    /// Multi-hit typed lookups return the newest three pages. Every miss
208    /// message starts with "miss:" (the recall-event contract).
209    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
275/// The transport-only page index: one line per evicted page (newest ≤32),
276/// so the model can discover what `context_recall` can restore. Skipped
277/// while tool calls are unresolved (a trailing user message would break
278/// pairing on strict providers); never enters the projection.
279pub(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
317/// Tool-call arguments above this size are elided when their page is
318/// evicted (whole-file writes dominate stale context cost).
319const 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
328/// The transport view of the conversation under the assembled resident
329/// set: resident pages verbatim, evicted pages tombstoned **in place** so
330/// message structure and tool pairing survive every provider's validation.
331/// Membership is per owning page, so a tool pair is resident or evicted as
332/// one unit and its tombstones all name the same recallable page id.
333/// Returns the view and how many pages were tombstoned.
334pub(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
354/// The in-place tombstone for one evicted page. Tool-call structure is
355/// preserved; only oversized arguments are elided.
356fn 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
390/// One atomic page per group, plus the mandatory page-id set: the leading
391/// instruction/task pages, every unresolved tool pair, and the pinned
392/// recency tail. A pair page's cost, labels, and freshness are member
393/// aggregates, so residency decisions always account the whole pair.
394///
395/// Recency alone never pins a **stale** page: after an accepted root
396/// change, a tail page born under the old root loses its pin and falls to
397/// the optional pool, where `dependency_holds` excludes it (Gate AF — a
398/// stale root-dependent page must not be admitted). The head pages and
399/// unresolved pairs stay pinned unconditionally: the head is
400/// session-invariant and unresolved calls exist only within the turn that
401/// opened them, before any boundary can move the root.
402pub(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            // The page's birth dependency (of its first member), never the
449            // live root — a state change invalidates, it does not rewrite.
450            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
464/// Recency-weighted coverage: newer turns carry more weight, the task
465/// itself the most.
466fn 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
483/// Conservative reserve for the transport-only page-index note appended
484/// when pages are evicted (C4); charged inside the allowance like the
485/// tombstones, and bounded by the note's own size cap.
486const INDEX_NOTE_RESERVE: u64 = 768;
487
488/// The exact token cost the page contributes to the wire **when evicted**:
489/// every member message rendered in tombstone form. Wire cost of a request
490/// is `Σ_all evicted_cost + Σ_resident (tokens − evicted_cost)`, so passing
491/// the first sum as instruction cost and charging marginals per page makes
492/// Definition 6's allowance bound the real composed request.
493fn 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
508/// Assemble the worker's resident set for one model call, budgeting the
509/// composed request: tombstones for every page plus the index-note reserve
510/// form the base cost, and each page is charged its marginal cost over its
511/// own tombstone (`saturating_sub` keeps the bound conservative).
512pub(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        // Reserves alone exceed the window: the same refusal as an
545        // unfittable mandatory closure — no model call.
546        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
574/// Large-page admission: a page whose marginal cost exceeds the synopsis
575/// frame bound `q` is never an optional candidate for greedy selection, so
576/// a big fresh tool result would otherwise always be evicted. Admit such
577/// pages newest-first while the composed request still fits.
578fn 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}