Skip to main content

vtcode_core/core/agent/
beliefs.rs

1//! Agent beliefs: a structured store of what the agent "knows" about the user,
2//! system, task, and conversation.
3//!
4//! Following the "state as a first-class citizen" principle (Hitchhiker's Guide
5//! to Agentic AI, Section 18.6.3), beliefs are part of the agent's explicit
6//! state rather than an implicit property of the conversation history.
7//!
8//! Each belief carries a confidence score, source attribution, and reinforcement
9//! count, enabling the agent to distinguish speculative inferences from
10//! confirmed facts.
11
12use serde::{Deserialize, Serialize};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15/// All beliefs the agent currently holds, categorized by domain.
16///
17/// Beliefs are bounded by `max_beliefs` to prevent unbounded growth.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct AgentBeliefs {
20    /// Beliefs about the user (preferences, identity, working style).
21    pub about_user: Vec<Belief>,
22    /// Beliefs about the system (tool capabilities, project structure, limitations).
23    pub about_system: Vec<Belief>,
24    /// Beliefs about the current task (what has been done, what remains).
25    pub about_task: Vec<Belief>,
26    /// Beliefs about the conversation state (context usage, important decisions).
27    pub about_conversation: Vec<Belief>,
28    /// Maximum total beliefs across all categories.
29    #[serde(skip)]
30    pub max_beliefs: usize,
31}
32
33impl Default for AgentBeliefs {
34    fn default() -> Self {
35        Self {
36            about_user: Vec::with_capacity(8),
37            about_system: Vec::with_capacity(8),
38            about_task: Vec::with_capacity(8),
39            about_conversation: Vec::with_capacity(8),
40            max_beliefs: 64,
41        }
42    }
43}
44
45impl AgentBeliefs {
46    /// Add a belief, or reinforce an existing one with the same (or similar) statement.
47    pub fn add_or_reinforce(&mut self, statement: &str, confidence: f64, source: BeliefSource) {
48        let normalized = statement.trim().to_lowercase();
49        let now = unix_timestamp();
50        let category = source.default_category();
51
52        // Check total capacity before taking the mutable borrow
53        let needs_prune = {
54            let total =
55                self.about_user.len() + self.about_system.len() + self.about_task.len() + self.about_conversation.len();
56            total >= self.max_beliefs
57        };
58        if needs_prune {
59            self.prune_low_confidence(0.1);
60        }
61
62        // Select the target category, then try to reinforce or push new
63        match category {
64            BeliefCategory::User => {
65                Self::reinforce_or_push(&mut self.about_user, &normalized, statement, confidence, source, now);
66            }
67            BeliefCategory::System => {
68                Self::reinforce_or_push(&mut self.about_system, &normalized, statement, confidence, source, now);
69            }
70            BeliefCategory::Task => {
71                Self::reinforce_or_push(&mut self.about_task, &normalized, statement, confidence, source, now);
72            }
73            BeliefCategory::Conversation => {
74                Self::reinforce_or_push(&mut self.about_conversation, &normalized, statement, confidence, source, now);
75            }
76        }
77    }
78
79    /// Reinforce an existing matching belief in `list`, or push a new one.
80    fn reinforce_or_push(
81        list: &mut Vec<Belief>,
82        normalized: &str,
83        statement: &str,
84        confidence: f64,
85        source: BeliefSource,
86        now: u64,
87    ) {
88        // Try to find an existing belief with a similar statement
89        if let Some(existing) = list.iter_mut().find(|b| {
90            let b_norm = b.statement.trim().to_lowercase();
91            b_norm == *normalized || b_norm.contains(normalized) || normalized.contains(&b_norm)
92        }) {
93            existing.confidence = existing.confidence.max(confidence);
94            existing.reinforcement_count = existing.reinforcement_count.saturating_add(1);
95            existing.last_reinforced_at = now;
96            return;
97        }
98
99        list.push(Belief {
100            id: format!("belief_{now}_{}", list.len()),
101            statement: statement.trim().to_string(),
102            confidence: confidence.clamp(0.0, 1.0),
103            source,
104            created_at: now,
105            last_reinforced_at: now,
106            reinforcement_count: 1,
107        });
108    }
109
110    /// Return beliefs with confidence at or above the threshold.
111    pub fn above_confidence(&self, threshold: f64) -> Vec<&Belief> {
112        let mut result = Vec::new();
113        for belief in self
114            .about_user
115            .iter()
116            .chain(self.about_system.iter())
117            .chain(self.about_task.iter())
118            .chain(self.about_conversation.iter())
119        {
120            if belief.confidence >= threshold {
121                result.push(belief);
122            }
123        }
124        result
125    }
126
127    /// Remove beliefs below the confidence threshold, unless recently reinforced.
128    pub fn prune_low_confidence(&mut self, threshold: f64) {
129        let now = unix_timestamp();
130        let retain = |belief: &Belief| -> bool {
131            belief.confidence >= threshold
132                || belief.reinforcement_count >= 3
133                || now.saturating_sub(belief.last_reinforced_at) < 3600 // reinforced within 1 hour
134        };
135        self.about_user.retain(retain);
136        self.about_system.retain(retain);
137        self.about_task.retain(retain);
138        self.about_conversation.retain(retain);
139    }
140
141    /// Format all beliefs above a confidence threshold as a markdown string for
142    /// injection into the system prompt.
143    pub fn format_for_prompt(&self, threshold: f64) -> String {
144        let beliefs = self.above_confidence(threshold);
145        if beliefs.is_empty() {
146            return String::new();
147        }
148        let mut lines = vec!["## Agent Beliefs".to_string()];
149        for belief in &beliefs {
150            // Skip tool result beliefs in prompt — they're transient
151            if matches!(&belief.source, BeliefSource::ToolResult { .. }) {
152                continue;
153            }
154            let source_str = match &belief.source {
155                BeliefSource::UserStatement => "user said",
156                BeliefSource::ToolResult { .. } => "tool result",
157                BeliefSource::Inference { .. } => "inferred",
158                BeliefSource::SystemPrompt => "configured",
159                BeliefSource::PersistentMemory => "previous session",
160            };
161            lines.push(format!(
162                "- [{}] {} (confidence: {:.1}, reinforced: {})",
163                source_str, belief.statement, belief.confidence, belief.reinforcement_count
164            ));
165        }
166        if lines.len() == 1 {
167            return String::new();
168        }
169        lines.join("\n")
170    }
171}
172
173/// A single belief held by the agent, with provenance and confidence.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct Belief {
176    /// Unique identifier for this belief instance.
177    pub id: String,
178    /// The belief statement (e.g., "the user prefers Python for scripting").
179    pub statement: String,
180    /// Confidence in [0.0, 1.0]: 0.0 = speculative, 1.0 = confirmed.
181    pub confidence: f64,
182    /// Origin of this belief.
183    pub source: BeliefSource,
184    /// Unix timestamp (seconds) when the belief was first created.
185    pub created_at: u64,
186    /// Unix timestamp (seconds) of the last reinforcement.
187    pub last_reinforced_at: u64,
188    /// How many times this belief has been corroborated.
189    pub reinforcement_count: u64,
190}
191
192/// Origin category for a belief.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(rename_all = "snake_case")]
195pub enum BeliefSource {
196    /// Directly stated by the user.
197    UserStatement,
198    /// Derived from a tool execution result.
199    ToolResult {
200        /// Name of the tool that produced the result.
201        tool: String,
202    },
203    /// Inferred by the agent from evidence.
204    Inference {
205        /// Description of the reasoning that produced this belief.
206        by: String,
207    },
208    /// Established by the system prompt or configuration.
209    SystemPrompt,
210    /// Loaded from persistent memory (cross-session).
211    PersistentMemory,
212}
213
214impl BeliefSource {
215    /// Map a belief source to its default category.
216    pub fn default_category(&self) -> BeliefCategory {
217        match self {
218            BeliefSource::UserStatement => BeliefCategory::User,
219            BeliefSource::ToolResult { .. } => BeliefCategory::System,
220            BeliefSource::Inference { .. } => BeliefCategory::Task,
221            BeliefSource::SystemPrompt => BeliefCategory::System,
222            BeliefSource::PersistentMemory => BeliefCategory::User,
223        }
224    }
225}
226
227/// High-level category for a belief.
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub enum BeliefCategory {
230    /// Beliefs about the user (preferences, identity, working style).
231    User,
232    /// Beliefs about the system (tools, project structure, limitations).
233    System,
234    /// Beliefs about the current task (what has been done, what remains).
235    Task,
236    /// Beliefs about the conversation state (context usage, important decisions).
237    Conversation,
238}
239
240fn unix_timestamp() -> u64 {
241    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn test_add_belief() {
250        let mut beliefs = AgentBeliefs::default();
251        beliefs.add_or_reinforce("user prefers Python", 0.8, BeliefSource::UserStatement);
252        assert_eq!(beliefs.about_user.len(), 1);
253        assert_eq!(beliefs.above_confidence(0.5).len(), 1);
254    }
255
256    #[test]
257    fn test_reinforce_belief() {
258        let mut beliefs = AgentBeliefs::default();
259        beliefs.add_or_reinforce("user prefers Python", 0.6, BeliefSource::UserStatement);
260        beliefs.add_or_reinforce("user prefers Python", 0.9, BeliefSource::UserStatement);
261        assert_eq!(beliefs.about_user.len(), 1);
262        assert!((beliefs.about_user[0].confidence - 0.9).abs() < f64::EPSILON);
263        assert_eq!(beliefs.about_user[0].reinforcement_count, 2);
264    }
265
266    #[test]
267    fn test_prune_low_confidence() {
268        let mut beliefs = AgentBeliefs::default();
269        beliefs.add_or_reinforce("high confidence", 0.9, BeliefSource::UserStatement);
270        // Add a low-confidence belief and manually set it as old so prune removes it
271        beliefs.add_or_reinforce("low confidence", 0.05, BeliefSource::ToolResult { tool: "ls".to_string() });
272        if let Some(low) = beliefs.about_system.last_mut() {
273            low.last_reinforced_at = 1; // Unix epoch — very old
274        }
275        assert_eq!(beliefs.above_confidence(0.0).len(), 2);
276        beliefs.prune_low_confidence(0.5);
277        let remaining = beliefs.above_confidence(0.0);
278        assert_eq!(remaining.len(), 1);
279        assert!(remaining[0].statement.contains("high"));
280    }
281
282    #[test]
283    fn test_format_for_prompt() {
284        let mut beliefs = AgentBeliefs::default();
285        beliefs.add_or_reinforce("user prefers Python", 0.8, BeliefSource::UserStatement);
286        let formatted = beliefs.format_for_prompt(0.5);
287        assert!(formatted.contains("user prefers Python"));
288        assert!(formatted.contains("[user said]"));
289    }
290
291    #[test]
292    fn test_belief_serde_roundtrip() {
293        let belief = Belief {
294            id: "test_1".to_string(),
295            statement: "the answer is 42".to_string(),
296            confidence: 0.95,
297            source: BeliefSource::Inference { by: "test".to_string() },
298            created_at: 1000,
299            last_reinforced_at: 1000,
300            reinforcement_count: 1,
301        };
302        let json = serde_json::to_string(&belief).unwrap();
303        let deserialized: Belief = serde_json::from_str(&json).unwrap();
304        assert_eq!(deserialized.statement, "the answer is 42");
305        assert!((deserialized.confidence - 0.95).abs() < f64::EPSILON);
306    }
307
308    #[test]
309    fn test_belief_source_default_category() {
310        assert_eq!(BeliefSource::UserStatement.default_category(), BeliefCategory::User);
311        assert_eq!(
312            BeliefSource::ToolResult { tool: "read_file".to_string() }.default_category(),
313            BeliefCategory::System
314        );
315        assert_eq!(BeliefSource::SystemPrompt.default_category(), BeliefCategory::System);
316        assert_eq!(BeliefSource::PersistentMemory.default_category(), BeliefCategory::User);
317    }
318}