Skip to main content

agentstategraph_core/
commit.rs

1//! Commit — an immutable record linking a state tree to its history and metadata.
2//!
3//! This is where AgentStateGraph diverges most from git. Beyond the standard state-root
4//! and parents, a commit carries: agent identity, authority, structured intent,
5//! reasoning, confidence, and tool call provenance.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use crate::intent::{AgentId, Authority, Intent, ToolCall};
11use crate::object::ObjectId;
12
13/// Maximum length (in `char`s) of `Intent::description` stored on a commit.
14/// Overlong values are silently truncated on `CommitBuilder::build` — this is
15/// advisory provenance, not identity, so rejecting would force the blame/log
16/// path into a failure mode callers don't expect.
17pub const MAX_DESCRIPTION_LEN: usize = 1024;
18
19/// Maximum length (in `char`s) of `Commit::reasoning`. Truncated silently.
20pub const MAX_REASONING_LEN: usize = 8192;
21
22/// Maximum length (in `char`s) of `Commit::agent_id`. Truncated silently.
23pub const MAX_AGENT_ID_LEN: usize = 128;
24
25/// Maximum number of tool calls recorded on a single commit. Excess tail is
26/// silently dropped on `CommitBuilder::build`.
27pub const MAX_TOOL_CALLS: usize = 256;
28
29fn truncate_chars(s: &str, max: usize) -> String {
30    if s.chars().count() <= max {
31        s.to_string()
32    } else {
33        s.chars().take(max).collect()
34    }
35}
36
37/// An immutable record that links a state tree to its history and provenance metadata.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct Commit {
40    /// Content-address of this commit (BLAKE3 hash of all other fields).
41    pub id: ObjectId,
42    /// Root of the state tree at this commit.
43    pub state_root: ObjectId,
44    /// Parent commit(s). Empty for initial commit, one for normal, two+ for merge.
45    pub parents: Vec<ObjectId>,
46    /// When this commit was created.
47    pub timestamp: DateTime<Utc>,
48
49    // -- Identity: who performed this action --
50    /// The agent or human who made this state change.
51    pub agent_id: AgentId,
52
53    // -- Authority: who authorized this action --
54    /// The authorization chain for this action.
55    pub authority: Authority,
56
57    // -- Intent: why this action was taken --
58    /// Structured intent metadata.
59    pub intent: Intent,
60
61    // -- Reasoning: how the agent decided on this approach --
62    /// Agent's chain-of-thought or explanation.
63    pub reasoning: Option<String>,
64    /// Agent's self-assessed confidence (0.0 to 1.0).
65    pub confidence: Option<f64>,
66
67    // -- Provenance: what tool calls produced this state change --
68    /// Tool calls that contributed to this state change.
69    pub tool_calls: Vec<ToolCall>,
70}
71
72impl Commit {
73    /// Apply the same size caps `CommitBuilder::build` uses to a commit
74    /// loaded from storage. Idempotent — commits already within caps are
75    /// returned unchanged.
76    ///
77    /// `CommitBuilder::build` truncates on construction, but a `.db` file
78    /// seeded out-of-band (older ASG versions without caps, or a malicious
79    /// operator) may contain unbounded `description` / `reasoning` /
80    /// `agent_id` / `tool_calls`. Blame/log would otherwise replay them
81    /// verbatim to untrusted readers. Higher-level APIs that surface
82    /// commits to callers invoke this implicitly.
83    /// (security threat model v2, F3)
84    ///
85    /// Note: this mutates the commit in place and does NOT recompute the
86    /// content-addressed `id` — a commit's `id` is a historical identifier
87    /// of what was stored, and re-hashing on read would break log/blame
88    /// identity. The caps here exist purely to bound what's shown to
89    /// readers, not to rewrite history.
90    pub fn enforce_caps(&mut self) {
91        if self.agent_id.chars().count() > MAX_AGENT_ID_LEN {
92            self.agent_id = truncate_chars(&self.agent_id, MAX_AGENT_ID_LEN);
93        }
94        if self.intent.description.chars().count() > MAX_DESCRIPTION_LEN {
95            self.intent.description = truncate_chars(&self.intent.description, MAX_DESCRIPTION_LEN);
96        }
97        if let Some(reasoning) = self.reasoning.as_mut()
98            && reasoning.chars().count() > MAX_REASONING_LEN
99        {
100            *reasoning = truncate_chars(reasoning, MAX_REASONING_LEN);
101        }
102        if self.tool_calls.len() > MAX_TOOL_CALLS {
103            self.tool_calls.truncate(MAX_TOOL_CALLS);
104        }
105    }
106}
107
108/// Builder for creating commits. The commit ID is computed on build.
109pub struct CommitBuilder {
110    state_root: ObjectId,
111    parents: Vec<ObjectId>,
112    agent_id: AgentId,
113    authority: Authority,
114    intent: Intent,
115    reasoning: Option<String>,
116    confidence: Option<f64>,
117    tool_calls: Vec<ToolCall>,
118}
119
120impl CommitBuilder {
121    /// Start building a commit with the required fields.
122    pub fn new(
123        state_root: ObjectId,
124        agent_id: impl Into<AgentId>,
125        authority: Authority,
126        intent: Intent,
127    ) -> Self {
128        Self {
129            state_root,
130            parents: Vec::new(),
131            agent_id: agent_id.into(),
132            authority,
133            intent,
134            reasoning: None,
135            confidence: None,
136            tool_calls: Vec::new(),
137        }
138    }
139
140    /// Set parent commits.
141    pub fn parents(mut self, parents: Vec<ObjectId>) -> Self {
142        self.parents = parents;
143        self
144    }
145
146    /// Set a single parent commit (most common case).
147    pub fn parent(mut self, parent: ObjectId) -> Self {
148        self.parents = vec![parent];
149        self
150    }
151
152    /// Set the reasoning trace.
153    pub fn reasoning(mut self, reasoning: impl Into<String>) -> Self {
154        self.reasoning = Some(reasoning.into());
155        self
156    }
157
158    /// Set the confidence score.
159    pub fn confidence(mut self, confidence: f64) -> Self {
160        self.confidence = Some(confidence.clamp(0.0, 1.0));
161        self
162    }
163
164    /// Add tool calls.
165    pub fn tool_calls(mut self, calls: Vec<ToolCall>) -> Self {
166        self.tool_calls = calls;
167        self
168    }
169
170    /// Build the commit, computing its content-addressed ID.
171    ///
172    /// Silently truncates overlong provenance fields to their caps —
173    /// `MAX_DESCRIPTION_LEN`, `MAX_REASONING_LEN`, `MAX_AGENT_ID_LEN`,
174    /// `MAX_TOOL_CALLS`. Truncation uses `chars()` so UTF-8 codepoints
175    /// are never split.
176    pub fn build(self) -> Commit {
177        let timestamp = Utc::now();
178
179        // Enforce length caps up front — see the module-level MAX_* constants.
180        let agent_id = truncate_chars(&self.agent_id, MAX_AGENT_ID_LEN);
181        let mut intent = self.intent;
182        intent.description = truncate_chars(&intent.description, MAX_DESCRIPTION_LEN);
183        let reasoning = self
184            .reasoning
185            .map(|r| truncate_chars(&r, MAX_REASONING_LEN));
186        let mut tool_calls = self.tool_calls;
187        if tool_calls.len() > MAX_TOOL_CALLS {
188            tool_calls.truncate(MAX_TOOL_CALLS);
189        }
190
191        // Create the commit without the ID first
192        let mut commit = Commit {
193            id: ObjectId::from_bytes([0u8; 32]), // placeholder
194            state_root: self.state_root,
195            parents: self.parents,
196            timestamp,
197            agent_id,
198            authority: self.authority,
199            intent,
200            reasoning,
201            confidence: self.confidence,
202            tool_calls,
203        };
204
205        // Compute the content-addressed ID from all fields
206        let bytes = serde_json::to_vec(&CommitHashInput {
207            state_root: &commit.state_root,
208            parents: &commit.parents,
209            timestamp: &commit.timestamp,
210            agent_id: &commit.agent_id,
211            authority: &commit.authority,
212            intent: &commit.intent,
213            reasoning: &commit.reasoning,
214            confidence: &commit.confidence,
215            tool_calls: &commit.tool_calls,
216        })
217        .expect("commit serialization should never fail");
218
219        commit.id = ObjectId::hash(&bytes);
220        commit
221    }
222}
223
224/// Internal struct used for computing the commit hash.
225/// Excludes the `id` field (which is what we're computing).
226#[derive(Serialize)]
227struct CommitHashInput<'a> {
228    state_root: &'a ObjectId,
229    parents: &'a Vec<ObjectId>,
230    timestamp: &'a DateTime<Utc>,
231    agent_id: &'a AgentId,
232    authority: &'a Authority,
233    intent: &'a Intent,
234    reasoning: &'a Option<String>,
235    confidence: &'a Option<f64>,
236    tool_calls: &'a Vec<ToolCall>,
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::intent::IntentCategory;
243
244    fn test_intent() -> Intent {
245        Intent::new(IntentCategory::Checkpoint, "test commit")
246    }
247
248    fn test_authority() -> Authority {
249        Authority::simple("test-agent")
250    }
251
252    #[test]
253    fn test_commit_builder() {
254        let state_root = ObjectId::hash(b"test-state");
255        let commit = CommitBuilder::new(state_root, "agent/test", test_authority(), test_intent())
256            .reasoning("this is a test")
257            .confidence(0.95)
258            .build();
259
260        assert_eq!(commit.state_root, state_root);
261        assert_eq!(commit.agent_id, "agent/test");
262        assert_eq!(commit.reasoning, Some("this is a test".to_string()));
263        assert_eq!(commit.confidence, Some(0.95));
264        assert!(commit.parents.is_empty());
265    }
266
267    #[test]
268    fn test_commit_id_is_content_addressed() {
269        let state_root = ObjectId::hash(b"test-state");
270
271        // Two commits with different intents should have different IDs
272        let commit1 = CommitBuilder::new(
273            state_root,
274            "agent/test",
275            test_authority(),
276            Intent::new(IntentCategory::Explore, "exploring option A"),
277        )
278        .build();
279
280        let commit2 = CommitBuilder::new(
281            state_root,
282            "agent/test",
283            test_authority(),
284            Intent::new(IntentCategory::Fix, "fixing a bug"),
285        )
286        .build();
287
288        assert_ne!(
289            commit1.id, commit2.id,
290            "different commits should have different IDs"
291        );
292    }
293
294    #[test]
295    fn test_commit_with_parent() {
296        let parent_id = ObjectId::hash(b"parent-commit");
297        let state_root = ObjectId::hash(b"child-state");
298
299        let commit = CommitBuilder::new(state_root, "agent/test", test_authority(), test_intent())
300            .parent(parent_id)
301            .build();
302
303        assert_eq!(commit.parents, vec![parent_id]);
304    }
305
306    #[test]
307    fn test_description_truncated() {
308        let state_root = ObjectId::hash(b"test");
309        let long = "x".repeat(MAX_DESCRIPTION_LEN + 500);
310        let commit = CommitBuilder::new(
311            state_root,
312            "agent/test",
313            test_authority(),
314            Intent::new(IntentCategory::Checkpoint, long),
315        )
316        .build();
317        assert_eq!(
318            commit.intent.description.chars().count(),
319            MAX_DESCRIPTION_LEN
320        );
321    }
322
323    #[test]
324    fn test_reasoning_truncated() {
325        let state_root = ObjectId::hash(b"test");
326        let long = "r".repeat(MAX_REASONING_LEN + 10);
327        let commit = CommitBuilder::new(state_root, "agent/test", test_authority(), test_intent())
328            .reasoning(long)
329            .build();
330        assert_eq!(
331            commit.reasoning.as_ref().unwrap().chars().count(),
332            MAX_REASONING_LEN
333        );
334    }
335
336    #[test]
337    fn test_agent_id_truncated() {
338        let state_root = ObjectId::hash(b"test");
339        let long = "a".repeat(MAX_AGENT_ID_LEN + 50);
340        let commit = CommitBuilder::new(state_root, long, test_authority(), test_intent()).build();
341        assert_eq!(commit.agent_id.chars().count(), MAX_AGENT_ID_LEN);
342    }
343
344    #[test]
345    fn test_tool_calls_truncated() {
346        let state_root = ObjectId::hash(b"test");
347        let calls: Vec<ToolCall> = (0..(MAX_TOOL_CALLS + 20))
348            .map(|i| ToolCall {
349                tool_name: format!("tool_{}", i),
350                arguments: serde_json::json!({}),
351                result: None,
352                timestamp: Utc::now(),
353            })
354            .collect();
355        let commit = CommitBuilder::new(state_root, "agent/test", test_authority(), test_intent())
356            .tool_calls(calls)
357            .build();
358        assert_eq!(commit.tool_calls.len(), MAX_TOOL_CALLS);
359    }
360
361    #[test]
362    fn test_truncation_preserves_utf8_boundaries() {
363        let state_root = ObjectId::hash(b"test");
364        // 4-byte UTF-8 emoji; chars().take() must not split them.
365        let emoji_flood = "🎉".repeat(MAX_DESCRIPTION_LEN + 10);
366        let commit = CommitBuilder::new(
367            state_root,
368            "agent/test",
369            test_authority(),
370            Intent::new(IntentCategory::Checkpoint, emoji_flood),
371        )
372        .build();
373        assert_eq!(
374            commit.intent.description.chars().count(),
375            MAX_DESCRIPTION_LEN
376        );
377        // If we'd split a codepoint, the String would not round-trip through char count equal to byte/4.
378        assert!(
379            commit
380                .intent
381                .description
382                .is_char_boundary(commit.intent.description.len())
383        );
384    }
385
386    #[test]
387    fn test_enforce_caps_truncates_bypassed_builder() {
388        // Simulate a commit loaded from a `.db` written out-of-band
389        // (direct struct construction bypasses `CommitBuilder::build`).
390        let big_reasoning = "r".repeat(100_000); // ~100 KB
391        let big_desc = "d".repeat(MAX_DESCRIPTION_LEN * 4);
392        let big_agent = "a".repeat(MAX_AGENT_ID_LEN * 10);
393        let big_tools: Vec<ToolCall> = (0..(MAX_TOOL_CALLS * 2))
394            .map(|i| ToolCall {
395                tool_name: format!("t{}", i),
396                arguments: serde_json::json!({}),
397                result: None,
398                timestamp: Utc::now(),
399            })
400            .collect();
401
402        let mut commit = Commit {
403            id: ObjectId::hash(b"test"),
404            state_root: ObjectId::hash(b"state"),
405            parents: vec![],
406            timestamp: Utc::now(),
407            agent_id: big_agent,
408            authority: test_authority(),
409            intent: Intent::new(IntentCategory::Checkpoint, big_desc),
410            reasoning: Some(big_reasoning),
411            confidence: Some(0.5),
412            tool_calls: big_tools,
413        };
414
415        commit.enforce_caps();
416
417        assert_eq!(commit.agent_id.chars().count(), MAX_AGENT_ID_LEN);
418        assert_eq!(
419            commit.intent.description.chars().count(),
420            MAX_DESCRIPTION_LEN
421        );
422        assert_eq!(
423            commit.reasoning.as_ref().unwrap().chars().count(),
424            MAX_REASONING_LEN
425        );
426        assert_eq!(commit.tool_calls.len(), MAX_TOOL_CALLS);
427    }
428
429    #[test]
430    fn test_enforce_caps_is_idempotent() {
431        // A commit already within caps must be unchanged.
432        let commit_orig = CommitBuilder::new(
433            ObjectId::hash(b"s"),
434            "agent/test",
435            test_authority(),
436            test_intent(),
437        )
438        .reasoning("short")
439        .build();
440        let mut commit = commit_orig.clone();
441        commit.enforce_caps();
442        assert_eq!(commit.agent_id, commit_orig.agent_id);
443        assert_eq!(commit.intent.description, commit_orig.intent.description);
444        assert_eq!(commit.reasoning, commit_orig.reasoning);
445        assert_eq!(commit.tool_calls.len(), commit_orig.tool_calls.len());
446    }
447
448    #[test]
449    fn test_confidence_clamped() {
450        let state_root = ObjectId::hash(b"test");
451        let commit = CommitBuilder::new(state_root, "agent/test", test_authority(), test_intent())
452            .confidence(1.5) // over 1.0
453            .build();
454
455        assert_eq!(commit.confidence, Some(1.0));
456    }
457}