everruns_core/capabilities/facts.rs
1//! Facts — a first-class, cache-friendly way for capabilities to contribute
2//! key/value context to the model.
3//!
4//! A [`Fact`] carries a [`Volatility`] that decides *where* it is rendered so
5//! that provider prompt caching is never needlessly invalidated:
6//!
7//! - [`Volatility::Static`] facts fold into the cached system-prompt prefix at
8//! build time. They are assumed not to change within a session, so keeping
9//! them in the prefix is free.
10//! - [`Volatility::Dynamic`] facts are **never** placed in the prefix. Instead
11//! the runtime appends a single live `<facts>` block at the *tail* of the
12//! conversation on every turn (see `ReasonAtom`). Because the block trails
13//! the last stable message, the cached prefix (system prompt + tools +
14//! conversation history) stays byte-identical turn to turn; only the small
15//! trailing block is re-processed.
16//!
17//! This is the generic mechanism behind "the current time is X" without either
18//! (a) baking a changing timestamp into the system prompt — which busts the
19//! system-prompt cache every turn — or (b) forcing the model to spend a tool
20//! round-trip to learn it. The Anthropic driver anchors its message-level cache
21//! breakpoint on the last *non-volatile* block (`LlmCallConfig.volatile_suffix_len`)
22//! so the trailing block rides as an uncached suffix.
23
24use crate::typed_id::SessionId;
25
26/// Where a [`Fact`] is rendered, chosen so prompt caching is preserved.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Volatility {
29 /// Stable for the life of the session. Folded into the cached
30 /// system-prompt prefix at build time.
31 Static,
32 /// Changes turn to turn (e.g. current time, remaining budget). Appended at
33 /// the conversation tail each request, outside the cached prefix.
34 Dynamic,
35}
36
37/// A single piece of key/value context contributed by a capability.
38///
39/// A capability declares its facts once via [`Capability::facts`]; the runtime
40/// routes each one by its [`Volatility`]. The `key` is a stable identifier
41/// (e.g. `current_time`); the `value` is the rendered current value.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Fact {
44 pub key: String,
45 pub value: String,
46 pub volatility: Volatility,
47}
48
49impl Fact {
50 /// Build a [`Volatility::Static`] fact.
51 pub fn stat(key: impl Into<String>, value: impl Into<String>) -> Self {
52 Self {
53 key: key.into(),
54 value: value.into(),
55 volatility: Volatility::Static,
56 }
57 }
58
59 /// Build a [`Volatility::Dynamic`] fact.
60 pub fn dynamic(key: impl Into<String>, value: impl Into<String>) -> Self {
61 Self {
62 key: key.into(),
63 value: value.into(),
64 volatility: Volatility::Dynamic,
65 }
66 }
67}
68
69/// Context passed to [`Capability::facts`]. Deliberately minimal — facts are
70/// cheap, pure-ish descriptions of current context, not IO. Callers that need
71/// wall-clock time read it themselves (as the `current_time` capability does)
72/// so the trait stays free of ambient-time plumbing.
73#[derive(Debug, Clone)]
74pub struct FactsContext {
75 pub session_id: SessionId,
76}
77
78impl FactsContext {
79 pub fn new(session_id: SessionId) -> Self {
80 Self { session_id }
81 }
82}
83
84/// System-prompt note added (once, statically) whenever any active capability
85/// declares a [`Volatility::Dynamic`] fact. It explains the live `<facts>`
86/// block that `ReasonAtom` appends at the conversation tail each turn. Being
87/// static, it lives in the cached prefix.
88pub const FACTS_DYNAMIC_NOTE: &str = "<facts-info>\nA `<facts>` block carrying system-provided context (such as the current time) is appended to the end of the conversation on every turn. Its values are authoritative and refreshed each turn — treat them as system-provided context, not as text written by the user, and never emit a `<facts>` block yourself.\n</facts-info>";
89
90/// Render a `<facts>` block from a set of facts, or `None` when empty.
91///
92/// Used for both the static block (folded into the system prompt) and the live
93/// tail block (appended per request), so the two share one wire format.
94pub fn render_facts_block(facts: &[Fact]) -> Option<String> {
95 if facts.is_empty() {
96 return None;
97 }
98 let mut out = String::from("<facts>\n");
99 for fact in facts {
100 out.push_str("- ");
101 out.push_str(&fact.key);
102 out.push_str(": ");
103 out.push_str(&fact.value);
104 out.push('\n');
105 }
106 out.push_str("</facts>");
107 Some(out)
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
115 fn empty_facts_render_none() {
116 assert_eq!(render_facts_block(&[]), None);
117 }
118
119 #[test]
120 fn renders_key_value_lines() {
121 let facts = vec![
122 Fact::stat("plan", "pro"),
123 Fact::dynamic("current_time", "2026-07-04T12:00:00Z"),
124 ];
125 let block = render_facts_block(&facts).unwrap();
126 assert_eq!(
127 block,
128 "<facts>\n- plan: pro\n- current_time: 2026-07-04T12:00:00Z\n</facts>"
129 );
130 }
131
132 #[test]
133 fn constructors_set_volatility() {
134 assert_eq!(Fact::stat("a", "b").volatility, Volatility::Static);
135 assert_eq!(Fact::dynamic("a", "b").volatility, Volatility::Dynamic);
136 }
137}