use crate::typed_id::SessionId;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Volatility {
Static,
Dynamic,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Fact {
pub key: String,
pub value: String,
pub volatility: Volatility,
}
impl Fact {
pub fn stat(key: impl Into<String>, value: impl Into<String>) -> Self {
Self {
key: key.into(),
value: value.into(),
volatility: Volatility::Static,
}
}
pub fn dynamic(key: impl Into<String>, value: impl Into<String>) -> Self {
Self {
key: key.into(),
value: value.into(),
volatility: Volatility::Dynamic,
}
}
}
#[derive(Debug, Clone)]
pub struct FactsContext {
pub session_id: SessionId,
}
impl FactsContext {
pub fn new(session_id: SessionId) -> Self {
Self { session_id }
}
}
pub 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>";
pub fn render_facts_block(facts: &[Fact]) -> Option<String> {
if facts.is_empty() {
return None;
}
let mut out = String::from("<facts>\n");
for fact in facts {
out.push_str("- ");
out.push_str(&fact.key);
out.push_str(": ");
out.push_str(&fact.value);
out.push('\n');
}
out.push_str("</facts>");
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_facts_render_none() {
assert_eq!(render_facts_block(&[]), None);
}
#[test]
fn renders_key_value_lines() {
let facts = vec![
Fact::stat("plan", "pro"),
Fact::dynamic("current_time", "2026-07-04T12:00:00Z"),
];
let block = render_facts_block(&facts).unwrap();
assert_eq!(
block,
"<facts>\n- plan: pro\n- current_time: 2026-07-04T12:00:00Z\n</facts>"
);
}
#[test]
fn constructors_set_volatility() {
assert_eq!(Fact::stat("a", "b").volatility, Volatility::Static);
assert_eq!(Fact::dynamic("a", "b").volatility, Volatility::Dynamic);
}
}