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", ¶ms, &cached);
355 assert!(
356 super::lookup("runtime", "pipeline_input", ¶ms).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", ¶ms).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", ¶ms, &value);
379 assert!(
380 super::lookup("session", "active_roots", ¶ms).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", ¶ms, &value);
397 assert_eq!(
398 super::lookup("runtime", "pipeline_input", ¶ms).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", ¶ms).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", ¶ms).is_none(),
409 "a name without a capability separator must not panic or match"
410 );
411 }
412}