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) fn cached_or<F>(
102    capability: &str,
103    operation: &str,
104    params: &DictMap,
105    dispatch: F,
106) -> Result<Option<VmValue>, VmError>
107where
108    F: FnOnce() -> Result<Option<VmValue>, VmError>,
109{
110    if !is_turn_stable(capability, operation) {
111        return dispatch();
112    }
113    if let Some(cached) = lookup(capability, operation, params) {
114        return Ok(Some(cached));
115    }
116    let result = dispatch()?;
117    if let Some(value) = &result {
118        store(capability, operation, params, value);
119    }
120    Ok(result)
121}
122
123/// Read a turn-stable host fact from the current turn's memo.
124///
125/// Returns `None` for anything not on the [`is_turn_stable`] allowlist, for a
126/// cold memo, and for an entry written in an earlier turn.
127///
128/// Exposed because the stdlib `host_call` builtin is **not** the only
129/// implementation of that builtin: an embedder can replace it wholesale (the ACP
130/// adapter in `harn-serve` does, forwarding to the editor over JSON-RPC), and
131/// such a replacement never reaches [`cached_or`] in the dispatch path. Those
132/// implementations must front their own dispatch with this pair so every
133/// `host_call` route shares one memo and one allowlist. harn#5190.
134pub fn lookup(capability: &str, operation: &str, params: &DictMap) -> Option<VmValue> {
135    if !is_turn_stable(capability, operation) {
136        return None;
137    }
138    let key = cache_key(capability, operation, params);
139    let epoch = current_epoch();
140    TURN_STABLE_HOST_CACHE.with(|cache| {
141        cache
142            .borrow()
143            .get(&key)
144            .filter(|(written, _)| *written == epoch)
145            .map(|(_, value)| value.clone())
146    })
147}
148
149/// Memoize a turn-stable host fact for the remainder of the current turn.
150/// Non-allowlisted `(capability, operation)` pairs are ignored, so a caller
151/// cannot widen the allowlist by calling this directly. See [`lookup`].
152pub fn store(capability: &str, operation: &str, params: &DictMap, value: &VmValue) {
153    if !is_turn_stable(capability, operation) {
154        return;
155    }
156    let key = cache_key(capability, operation, params);
157    let epoch = current_epoch();
158    TURN_STABLE_HOST_CACHE.with(|cache| {
159        cache.borrow_mut().insert(key, (epoch, value.clone()));
160    });
161}
162
163/// Split a dotted `capability.operation` host-call name and [`lookup`] it.
164/// Convenience for embedder `host_call` implementations, which receive the
165/// dotted wire name rather than a split pair.
166pub fn lookup_by_name(name: &str, params: &DictMap) -> Option<VmValue> {
167    let (capability, operation) = name.split_once('.')?;
168    lookup(capability, operation, params)
169}
170
171/// Dotted-name counterpart to [`store`]. See [`lookup_by_name`].
172pub fn store_by_name(name: &str, params: &DictMap, value: &VmValue) {
173    if let Some((capability, operation)) = name.split_once('.') {
174        store(capability, operation, params, value);
175    }
176}
177
178/// Open a new turn: every entry written before this call becomes unreadable,
179/// on this thread and every other.
180///
181/// Called at each agent-loop iteration boundary so a turn re-reads turn-stable
182/// host facts exactly once, and at bridge install/teardown so a memo can never
183/// leak across embedders on a reused thread. Bumping a global epoch rather than
184/// clearing the thread-local map means a turn boundary observed on one thread
185/// invalidates entries cached on every thread — see [`TURN_EPOCH`].
186pub(crate) fn reset() {
187    TURN_EPOCH.fetch_add(1, Ordering::AcqRel);
188    reset_local();
189}
190
191/// Drop this thread's entries without opening a new turn.
192///
193/// For `reset_host_state`, reached from `reset_stdlib_state` and in turn from
194/// [`crate::reset_thread_local_state`] — whose contract is to reset *this
195/// thread*, and which runs between VM runs on a reused thread rather than at any
196/// turn boundary. [`TURN_EPOCH`] is process-global, so bumping it from there
197/// reaches past that contract: every VM run that ended anywhere would invalidate
198/// the live memo of every concurrently-running session, costing each an extra
199/// round-trip. A thread-local reset should clear thread-local state only.
200///
201/// This is exactly the pre-epoch behaviour of [`reset`], so the call sites moved
202/// here keep the semantics they already had; only genuine turn boundaries and
203/// bridge swaps gained cross-thread reach.
204pub(crate) fn reset_local() {
205    TURN_STABLE_HOST_CACHE.with(|cache| cache.borrow_mut().clear());
206}
207
208/// Serializes tests that bump [`TURN_EPOCH`] against tests that rely on a memo
209/// entry surviving between a `store` and a `lookup`.
210///
211/// The epoch is process-global, so without this a bridge swap in one test
212/// invalidates another test's entry mid-assertion. Mirrors the
213/// `LONG_RUNNING_TEST_LOCK` convention in `stdlib::fs::tests` for the same
214/// reason: process-global state needs process-global test exclusion.
215#[cfg(test)]
216pub(crate) fn epoch_test_lock() -> &'static std::sync::Mutex<()> {
217    static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
218    LOCK.get_or_init(|| std::sync::Mutex::new(()))
219}
220
221#[cfg(test)]
222mod tests {
223    use std::sync::{Arc, Mutex};
224
225    use super::super::{
226        clear_host_call_bridge, dispatch_host_operation, reset_host_state, set_host_call_bridge,
227        HostCallBridge,
228    };
229    use super::reset;
230    use crate::value::{DictMap, VmError, VmValue};
231
232    /// [`TURN_EPOCH`] is process-global, so these tests mutate shared state: a
233    /// `reset` in one invalidates entries another is mid-assertion about. Cargo
234    /// runs them on separate threads by default, which made that a real
235    /// cross-talk failure rather than a theoretical one. Serialize them.
236    /// Bridge that counts dispatches per `(capability, operation)` and answers
237    /// every op, so a test can assert how many times the host was actually hit.
238    struct CountingRuntimeBridge {
239        counts: Arc<Mutex<std::collections::HashMap<(String, String), usize>>>,
240    }
241
242    impl HostCallBridge for CountingRuntimeBridge {
243        fn dispatch(
244            &self,
245            capability: &str,
246            operation: &str,
247            _params: &DictMap,
248        ) -> Result<Option<VmValue>, VmError> {
249            *self
250                .counts
251                .lock()
252                .unwrap()
253                .entry((capability.to_string(), operation.to_string()))
254                .or_insert(0) += 1;
255            Ok(Some(VmValue::String(arcstr::ArcStr::from(format!(
256                "{capability}.{operation}"
257            )))))
258        }
259    }
260
261    fn run_async<F, Fut>(test: F)
262    where
263        F: FnOnce() -> Fut,
264        Fut: std::future::Future<Output = ()>,
265    {
266        let rt = tokio::runtime::Builder::new_current_thread()
267            .enable_all()
268            .build()
269            .expect("runtime");
270        rt.block_on(async {
271            let local = tokio::task::LocalSet::new();
272            local.run_until(test()).await;
273        });
274    }
275
276    #[test]
277    fn turn_stable_host_capability_is_fetched_once_per_turn() {
278        let _guard = super::epoch_test_lock()
279            .lock()
280            .unwrap_or_else(|e| e.into_inner());
281        run_async(|| async {
282            reset_host_state();
283            let counts = Arc::new(Mutex::new(std::collections::HashMap::new()));
284            set_host_call_bridge(Arc::new(CountingRuntimeBridge {
285                counts: counts.clone(),
286            }));
287
288            let count = |cap: &str, op: &str| -> usize {
289                counts
290                    .lock()
291                    .unwrap()
292                    .get(&(cap.to_string(), op.to_string()))
293                    .copied()
294                    .unwrap_or(0)
295            };
296
297            // Many reads within one turn collapse to a single host round-trip.
298            for _ in 0..20 {
299                dispatch_host_operation("runtime", "pipeline_input", &DictMap::new())
300                    .await
301                    .expect("pipeline_input");
302            }
303            assert_eq!(
304                count("runtime", "pipeline_input"),
305                1,
306                "20 same-turn reads must hit the host exactly once"
307            );
308
309            // A non-allowlisted op is never memoized: every call reaches the host.
310            for _ in 0..3 {
311                dispatch_host_operation("runtime", "record_run", &DictMap::new())
312                    .await
313                    .expect("record_run");
314            }
315            assert_eq!(
316                count("runtime", "record_run"),
317                3,
318                "writes/non-stable ops must never be served from the turn memo"
319            );
320
321            // The next turn boundary re-reads the turn-stable fact exactly once,
322            // so a mid-session change (e.g. a model switch) is observed.
323            reset();
324            for _ in 0..20 {
325                dispatch_host_operation("runtime", "pipeline_input", &DictMap::new())
326                    .await
327                    .expect("pipeline_input");
328            }
329            assert_eq!(
330                count("runtime", "pipeline_input"),
331                2,
332                "a new turn must re-fetch once, not serve the prior turn's value"
333            );
334
335            clear_host_call_bridge();
336        });
337    }
338
339    /// A turn boundary observed on a *different* thread must still invalidate
340    /// entries cached here.
341    ///
342    /// This is the property that makes it safe for an embedder to front its own
343    /// `host_call` with [`super::lookup`] / [`super::store`]: `reset` runs where
344    /// the agent-loop event is emitted, which is not guaranteed to be the thread
345    /// that populated the memo. Before epoch tagging, a reset that landed
346    /// elsewhere left this thread serving the previous turn's
347    /// `runtime.pipeline_input` — silently defeating the per-turn re-projection
348    /// hosts rely on to observe a mid-session `/model` switch.
349    #[test]
350    fn turn_boundary_on_another_thread_invalidates_this_thread() {
351        let _guard = super::epoch_test_lock()
352            .lock()
353            .unwrap_or_else(|e| e.into_inner());
354        let params = DictMap::new();
355        let cached = VmValue::String(arcstr::ArcStr::from("turn-1"));
356        super::store("runtime", "pipeline_input", &params, &cached);
357        assert!(
358            super::lookup("runtime", "pipeline_input", &params).is_some(),
359            "same-turn read must hit"
360        );
361
362        std::thread::spawn(reset).join().expect("reset thread");
363
364        assert!(
365            super::lookup("runtime", "pipeline_input", &params).is_none(),
366            "a turn boundary observed on another thread must invalidate this thread's entry"
367        );
368    }
369
370    /// `store` cannot be used to widen the allowlist: a non-turn-stable op is
371    /// dropped rather than memoized, so an embedder wiring these in cannot
372    /// accidentally cache a write or a live read.
373    #[test]
374    fn store_ignores_non_turn_stable_operations() {
375        let _guard = super::epoch_test_lock()
376            .lock()
377            .unwrap_or_else(|e| e.into_inner());
378        let params = DictMap::new();
379        let value = VmValue::String(arcstr::ArcStr::from("live"));
380        super::store("session", "active_roots", &params, &value);
381        assert!(
382            super::lookup("session", "active_roots", &params).is_none(),
383            "non-allowlisted reads must never be served from the memo"
384        );
385    }
386
387    /// The dotted-name helpers an embedder uses must resolve to the same entry
388    /// as the split-pair API, or the two `host_call` routes would keep separate
389    /// memos and the ACP path would still pay every round-trip.
390    #[test]
391    fn dotted_name_helpers_share_the_split_pair_entry() {
392        let _guard = super::epoch_test_lock()
393            .lock()
394            .unwrap_or_else(|e| e.into_inner());
395        reset();
396        let params = DictMap::new();
397        let value = VmValue::String(arcstr::ArcStr::from("shared"));
398        super::store_by_name("runtime.pipeline_input", &params, &value);
399        assert_eq!(
400            super::lookup("runtime", "pipeline_input", &params).map(|v| v.display()),
401            Some("shared".to_string()),
402            "store_by_name must populate the entry lookup() reads"
403        );
404        assert_eq!(
405            super::lookup_by_name("runtime.pipeline_input", &params).map(|v| v.display()),
406            Some("shared".to_string()),
407            "lookup_by_name must read it back"
408        );
409        assert!(
410            super::lookup_by_name("no-separator", &params).is_none(),
411            "a name without a capability separator must not panic or match"
412        );
413    }
414}