Skip to main content

harn_vm/stdlib/host/
turn_cache.rs

1//! Per-turn memoization of turn-stable host capability reads.
2//!
3//! Context assembly reads `runtime.pipeline_input` many times per agent-loop
4//! iteration — harn#5190 measured ~20 identical round-trips per turn, a cost
5//! that grows as hosts deliver more data through that channel. This module
6//! front-runs the thread-local `HOST_CALL_BRIDGE` with a per-turn memo so those
7//! reads collapse to one host round-trip per turn, leaving every call site
8//! unchanged. The allowlist ([`is_turn_stable`]) is deliberately narrow.
9//!
10//! The memoized value is stable only *within* a turn: the host re-projects
11//! `runtime.pipeline_input` each turn (e.g. so a mid-session model switch is
12//! observed on the next prompt). The memo is therefore cleared at each
13//! agent-loop iteration boundary (`iteration_start`, wired in
14//! `__host_agent_emit_event`) and at run/embedder boundaries via [`reset`].
15
16use std::cell::RefCell;
17use std::collections::HashMap;
18use std::sync::atomic::{AtomicU64, Ordering};
19
20use crate::value::{DictMap, VmError, VmValue};
21
22/// Monotonic turn counter, bumped by [`reset`] at every turn boundary.
23///
24/// Deliberately process-global rather than per-session. A turn boundary in one
25/// session therefore also invalidates a concurrently-running session's memo,
26/// which costs that session one extra round-trip per crossed boundary. That is
27/// the conservative direction: the failure it forecloses is serving *stale*
28/// turn-stable state, and the worst case degrades toward the uncached behaviour
29/// this module replaced rather than toward incorrectness. Keying by session
30/// would recover those hits, but `reset` is driven from an agent-loop event that
31/// carries no session identity here, so it would be inferred rather than known.
32///
33/// The memo below is thread-local, but turn boundaries are not guaranteed to be
34/// observed on the same thread that populated it — `reset` runs where the
35/// agent-loop event is emitted, while a `host_call` may be served from
36/// elsewhere. Storing the epoch alongside each entry makes a stale entry
37/// *unreadable* rather than merely unlikely, so correctness no longer depends on
38/// a reset reaching any particular thread; thread-locality is then a pure
39/// performance choice. Without this, a missed reset would silently serve last
40/// turn's `runtime.pipeline_input` — which is exactly the mid-session `/model`
41/// switch that hosts re-project it per turn to observe.
42static TURN_EPOCH: AtomicU64 = AtomicU64::new(0);
43
44thread_local! {
45    /// Turn-scoped memo keyed by [`cache_key`], each entry tagged with the
46    /// [`TURN_EPOCH`] it was written in. Non-authoritative and always
47    /// resettable, so it is safe to keep thread-private.
48    static TURN_STABLE_HOST_CACHE: RefCell<HashMap<String, (u64, VmValue)>> =
49        RefCell::new(HashMap::new());
50}
51
52fn current_epoch() -> u64 {
53    TURN_EPOCH.load(Ordering::Acquire)
54}
55
56/// True for host capabilities whose result is stable for the duration of a
57/// single agent-loop iteration: pure, side-effect-free reads that project the
58/// current turn's host input.
59///
60/// Membership is an explicit allowlist, and the bar for adding an entry is a
61/// producer-side citation that no host mutates the value *within* a turn — not
62/// merely that it "looks stable." The default is NOT to cache, so a write, an
63/// interactive prompt, or any value a host can change mid-turn is never served
64/// stale.
65///
66/// Only `runtime.pipeline_input` qualifies today: every Burin host recomputes it
67/// per turn from per-turn-stable inputs (model selection, task, dry-run), so a
68/// mid-turn re-read never diverges. Deliberately excluded after auditing the
69/// producers:
70/// - `session.active_roots` — the IDE host serves it live from the mutable
71///   workspace root set (also used live for path validation), so a user adding
72///   a root mid-turn would be served stale within the turn.
73/// - `runtime.task` / `runtime.dry_run` / `runtime.approved_plan` — not served
74///   as standalone host ops by the Burin hosts at all; their values ride inside
75///   `pipeline_input` (already cached here), so caching the standalone op buys
76///   nothing.
77fn is_turn_stable(capability: &str, operation: &str) -> bool {
78    matches!((capability, operation), ("runtime", "pipeline_input"))
79}
80
81/// Cache key for a turn-stable host call. Keyed on capability, operation, and a
82/// canonical fingerprint of the params so a (future) parameterized read caches
83/// per distinct argument set; the current allowlist is all no-arg reads that
84/// take the cheap empty-params path. serde_json's `Map` is key-sorted here (no
85/// `preserve_order` feature), so the fingerprint is stable.
86fn cache_key(capability: &str, operation: &str, params: &DictMap) -> String {
87    if params.is_empty() {
88        return format!("{capability}.{operation}");
89    }
90    let json = crate::llm::helpers::vm_value_to_json(&VmValue::dict(params.clone()));
91    format!(
92        "{capability}.{operation}#{}",
93        serde_json::to_string(&json).unwrap_or_default()
94    )
95}
96
97/// Serve `(capability, operation, params)` from the per-turn memo when it is a
98/// turn-stable read, otherwise run `dispatch` verbatim. A successful
99/// `Ok(Some(value))` from a turn-stable read is memoized for the rest of the
100/// turn; `Ok(None)` (the bridge declined) and errors are never cached.
101pub(crate) async fn cached_or<F, Fut>(
102    capability: &str,
103    operation: &str,
104    params: &DictMap,
105    dispatch: F,
106) -> Result<Option<VmValue>, VmError>
107where
108    F: FnOnce() -> Fut,
109    Fut: std::future::Future<Output = Result<Option<VmValue>, VmError>>,
110{
111    if !is_turn_stable(capability, operation) {
112        return dispatch().await;
113    }
114    if let Some(cached) = lookup(capability, operation, params) {
115        return Ok(Some(cached));
116    }
117    let result = dispatch().await?;
118    if let Some(value) = &result {
119        store(capability, operation, params, value);
120    }
121    Ok(result)
122}
123
124/// Read a turn-stable host fact from the current turn's memo.
125///
126/// Returns `None` for anything not on the [`is_turn_stable`] allowlist, for a
127/// cold memo, and for an entry written in an earlier turn.
128///
129/// Read API for the turn memo. Prefer going through canonical
130/// [`super::dispatch_host_operation`]; this exists for tests that seed or
131/// inspect the memo directly (harn#5190 / harn#5523).
132pub fn lookup(capability: &str, operation: &str, params: &DictMap) -> Option<VmValue> {
133    if !is_turn_stable(capability, operation) {
134        return None;
135    }
136    let key = cache_key(capability, operation, params);
137    let epoch = current_epoch();
138    TURN_STABLE_HOST_CACHE.with(|cache| {
139        cache
140            .borrow()
141            .get(&key)
142            .filter(|(written, _)| *written == epoch)
143            .map(|(_, value)| value.clone())
144    })
145}
146
147/// Memoize a turn-stable host fact for the remainder of the current turn.
148/// Non-allowlisted `(capability, operation)` pairs are ignored, so a caller
149/// cannot widen the allowlist by calling this directly. See [`lookup`].
150pub fn store(capability: &str, operation: &str, params: &DictMap, value: &VmValue) {
151    if !is_turn_stable(capability, operation) {
152        return;
153    }
154    let key = cache_key(capability, operation, params);
155    let epoch = current_epoch();
156    TURN_STABLE_HOST_CACHE.with(|cache| {
157        cache.borrow_mut().insert(key, (epoch, value.clone()));
158    });
159}
160
161/// Split a dotted `capability.operation` host-call name and [`lookup`] it.
162/// Convenience for embedder `host_call` implementations, which receive the
163/// dotted wire name rather than a split pair.
164pub fn lookup_by_name(name: &str, params: &DictMap) -> Option<VmValue> {
165    let (capability, operation) = name.split_once('.')?;
166    lookup(capability, operation, params)
167}
168
169/// Dotted-name counterpart to [`store`]. See [`lookup_by_name`].
170pub fn store_by_name(name: &str, params: &DictMap, value: &VmValue) {
171    if let Some((capability, operation)) = name.split_once('.') {
172        store(capability, operation, params, value);
173    }
174}
175
176/// Open a new turn: every entry written before this call becomes unreadable,
177/// on this thread and every other.
178///
179/// Called at each agent-loop iteration boundary so a turn re-reads turn-stable
180/// host facts exactly once, and at bridge install/teardown so a memo can never
181/// leak across embedders on a reused thread. Bumping a global epoch rather than
182/// clearing the thread-local map means a turn boundary observed on one thread
183/// invalidates entries cached on every thread — see [`TURN_EPOCH`].
184pub(crate) fn reset() {
185    TURN_EPOCH.fetch_add(1, Ordering::AcqRel);
186    reset_local();
187}
188
189/// Drop this thread's entries without opening a new turn.
190///
191/// For `reset_host_state`, reached from `reset_stdlib_state` and in turn from
192/// [`crate::reset_thread_local_state`] — whose contract is to reset *this
193/// thread*, and which runs between VM runs on a reused thread rather than at any
194/// turn boundary. [`TURN_EPOCH`] is process-global, so bumping it from there
195/// reaches past that contract: every VM run that ended anywhere would invalidate
196/// the live memo of every concurrently-running session, costing each an extra
197/// round-trip. A thread-local reset should clear thread-local state only.
198///
199/// This is exactly the pre-epoch behaviour of [`reset`], so the call sites moved
200/// here keep the semantics they already had; only genuine turn boundaries and
201/// bridge swaps gained cross-thread reach.
202pub(crate) fn reset_local() {
203    TURN_STABLE_HOST_CACHE.with(|cache| cache.borrow_mut().clear());
204}
205
206/// Serializes tests that bump [`TURN_EPOCH`] against tests that rely on a memo
207/// entry surviving between a `store` and a `lookup`.
208///
209/// The epoch is process-global, so without this a bridge swap in one test
210/// invalidates another test's entry mid-assertion. Mirrors the
211/// `LONG_RUNNING_TEST_LOCK` convention in `stdlib::fs::tests` for the same
212/// reason: process-global state needs process-global test exclusion.
213#[cfg(test)]
214pub(crate) fn epoch_test_lock() -> &'static std::sync::Mutex<()> {
215    static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
216    LOCK.get_or_init(|| std::sync::Mutex::new(()))
217}
218
219#[cfg(test)]
220mod tests {
221    use std::sync::{Arc, Mutex};
222
223    use super::super::{
224        clear_host_call_bridge, dispatch_host_operation, reset_host_state, set_host_call_bridge,
225        HostCallBridge,
226    };
227    use super::reset;
228    use crate::value::{DictMap, VmValue};
229
230    /// [`TURN_EPOCH`] is process-global, so these tests mutate shared state: a
231    /// `reset` in one invalidates entries another is mid-assertion about. Cargo
232    /// runs them on separate threads by default, which made that a real
233    /// cross-talk failure rather than a theoretical one. Serialize them.
234    /// Bridge that counts dispatches per `(capability, operation)` and answers
235    /// every op, so a test can assert how many times the host was actually hit.
236    struct CountingRuntimeBridge {
237        counts: Arc<Mutex<std::collections::HashMap<(String, String), usize>>>,
238    }
239
240    impl HostCallBridge for CountingRuntimeBridge {
241        fn dispatch<'a>(
242            &'a self,
243            capability: &'a str,
244            operation: &'a str,
245            _params: &'a DictMap,
246        ) -> super::super::HostCallDispatchFuture<'a> {
247            *self
248                .counts
249                .lock()
250                .unwrap()
251                .entry((capability.to_string(), operation.to_string()))
252                .or_insert(0) += 1;
253            super::super::host_call_ready(Ok(Some(VmValue::String(arcstr::ArcStr::from(format!(
254                "{capability}.{operation}"
255            ))))))
256        }
257    }
258
259    fn run_async<F, Fut>(test: F)
260    where
261        F: FnOnce() -> Fut,
262        Fut: std::future::Future<Output = ()>,
263    {
264        let rt = tokio::runtime::Builder::new_current_thread()
265            .enable_all()
266            .build()
267            .expect("runtime");
268        rt.block_on(async {
269            let local = tokio::task::LocalSet::new();
270            local.run_until(test()).await;
271        });
272    }
273
274    #[test]
275    fn turn_stable_host_capability_is_fetched_once_per_turn() {
276        let _guard = super::epoch_test_lock()
277            .lock()
278            .unwrap_or_else(|e| e.into_inner());
279        run_async(|| async {
280            reset_host_state();
281            let counts = Arc::new(Mutex::new(std::collections::HashMap::new()));
282            set_host_call_bridge(Arc::new(CountingRuntimeBridge {
283                counts: counts.clone(),
284            }));
285
286            let count = |cap: &str, op: &str| -> usize {
287                counts
288                    .lock()
289                    .unwrap()
290                    .get(&(cap.to_string(), op.to_string()))
291                    .copied()
292                    .unwrap_or(0)
293            };
294
295            // Many reads within one turn collapse to a single host round-trip.
296            for _ in 0..20 {
297                dispatch_host_operation("runtime", "pipeline_input", &DictMap::new())
298                    .await
299                    .expect("pipeline_input");
300            }
301            assert_eq!(
302                count("runtime", "pipeline_input"),
303                1,
304                "20 same-turn reads must hit the host exactly once"
305            );
306
307            // A non-allowlisted op is never memoized: every call reaches the host.
308            for _ in 0..3 {
309                dispatch_host_operation("runtime", "record_run", &DictMap::new())
310                    .await
311                    .expect("record_run");
312            }
313            assert_eq!(
314                count("runtime", "record_run"),
315                3,
316                "writes/non-stable ops must never be served from the turn memo"
317            );
318
319            // The next turn boundary re-reads the turn-stable fact exactly once,
320            // so a mid-session change (e.g. a model switch) is observed.
321            reset();
322            for _ in 0..20 {
323                dispatch_host_operation("runtime", "pipeline_input", &DictMap::new())
324                    .await
325                    .expect("pipeline_input");
326            }
327            assert_eq!(
328                count("runtime", "pipeline_input"),
329                2,
330                "a new turn must re-fetch once, not serve the prior turn's value"
331            );
332
333            clear_host_call_bridge();
334        });
335    }
336
337    /// A turn boundary observed on a *different* thread must still invalidate
338    /// entries cached here.
339    ///
340    /// This is the property that makes it safe for an embedder to front its own
341    /// `host_call` with [`super::lookup`] / [`super::store`]: `reset` runs where
342    /// the agent-loop event is emitted, which is not guaranteed to be the thread
343    /// that populated the memo. Before epoch tagging, a reset that landed
344    /// elsewhere left this thread serving the previous turn's
345    /// `runtime.pipeline_input` — silently defeating the per-turn re-projection
346    /// hosts rely on to observe a mid-session `/model` switch.
347    #[test]
348    fn turn_boundary_on_another_thread_invalidates_this_thread() {
349        let _guard = super::epoch_test_lock()
350            .lock()
351            .unwrap_or_else(|e| e.into_inner());
352        let params = DictMap::new();
353        let cached = VmValue::String(arcstr::ArcStr::from("turn-1"));
354        super::store("runtime", "pipeline_input", &params, &cached);
355        assert!(
356            super::lookup("runtime", "pipeline_input", &params).is_some(),
357            "same-turn read must hit"
358        );
359
360        std::thread::spawn(reset).join().expect("reset thread");
361
362        assert!(
363            super::lookup("runtime", "pipeline_input", &params).is_none(),
364            "a turn boundary observed on another thread must invalidate this thread's entry"
365        );
366    }
367
368    /// `store` cannot be used to widen the allowlist: a non-turn-stable op is
369    /// dropped rather than memoized, so an embedder wiring these in cannot
370    /// accidentally cache a write or a live read.
371    #[test]
372    fn store_ignores_non_turn_stable_operations() {
373        let _guard = super::epoch_test_lock()
374            .lock()
375            .unwrap_or_else(|e| e.into_inner());
376        let params = DictMap::new();
377        let value = VmValue::String(arcstr::ArcStr::from("live"));
378        super::store("session", "active_roots", &params, &value);
379        assert!(
380            super::lookup("session", "active_roots", &params).is_none(),
381            "non-allowlisted reads must never be served from the memo"
382        );
383    }
384
385    /// The dotted-name helpers an embedder uses must resolve to the same entry
386    /// as the split-pair API, or the two `host_call` routes would keep separate
387    /// memos and the ACP path would still pay every round-trip.
388    #[test]
389    fn dotted_name_helpers_share_the_split_pair_entry() {
390        let _guard = super::epoch_test_lock()
391            .lock()
392            .unwrap_or_else(|e| e.into_inner());
393        reset();
394        let params = DictMap::new();
395        let value = VmValue::String(arcstr::ArcStr::from("shared"));
396        super::store_by_name("runtime.pipeline_input", &params, &value);
397        assert_eq!(
398            super::lookup("runtime", "pipeline_input", &params).map(|v| v.display()),
399            Some("shared".to_string()),
400            "store_by_name must populate the entry lookup() reads"
401        );
402        assert_eq!(
403            super::lookup_by_name("runtime.pipeline_input", &params).map(|v| v.display()),
404            Some("shared".to_string()),
405            "lookup_by_name must read it back"
406        );
407        assert!(
408            super::lookup_by_name("no-separator", &params).is_none(),
409            "a name without a capability separator must not panic or match"
410        );
411    }
412}