Skip to main content

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}