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", ¶ms, &cached);
357 assert!(
358 super::lookup("runtime", "pipeline_input", ¶ms).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", ¶ms).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", ¶ms, &value);
381 assert!(
382 super::lookup("session", "active_roots", ¶ms).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", ¶ms, &value);
399 assert_eq!(
400 super::lookup("runtime", "pipeline_input", ¶ms).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", ¶ms).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", ¶ms).is_none(),
411 "a name without a capability separator must not panic or match"
412 );
413 }
414}