1use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use crate::intent::{AgentId, Authority, Intent, ToolCall};
11use crate::object::ObjectId;
12
13pub const MAX_DESCRIPTION_LEN: usize = 1024;
18
19pub const MAX_REASONING_LEN: usize = 8192;
21
22pub const MAX_AGENT_ID_LEN: usize = 128;
24
25pub 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct Commit {
40 pub id: ObjectId,
42 pub state_root: ObjectId,
44 pub parents: Vec<ObjectId>,
46 pub timestamp: DateTime<Utc>,
48
49 pub agent_id: AgentId,
52
53 pub authority: Authority,
56
57 pub intent: Intent,
60
61 pub reasoning: Option<String>,
64 pub confidence: Option<f64>,
66
67 pub tool_calls: Vec<ToolCall>,
70}
71
72impl Commit {
73 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
108pub 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 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 pub fn parents(mut self, parents: Vec<ObjectId>) -> Self {
142 self.parents = parents;
143 self
144 }
145
146 pub fn parent(mut self, parent: ObjectId) -> Self {
148 self.parents = vec![parent];
149 self
150 }
151
152 pub fn reasoning(mut self, reasoning: impl Into<String>) -> Self {
154 self.reasoning = Some(reasoning.into());
155 self
156 }
157
158 pub fn confidence(mut self, confidence: f64) -> Self {
160 self.confidence = Some(confidence.clamp(0.0, 1.0));
161 self
162 }
163
164 pub fn tool_calls(mut self, calls: Vec<ToolCall>) -> Self {
166 self.tool_calls = calls;
167 self
168 }
169
170 pub fn build(self) -> Commit {
177 let timestamp = Utc::now();
178
179 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 let mut commit = Commit {
193 id: ObjectId::from_bytes([0u8; 32]), 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 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#[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 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 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 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 let big_reasoning = "r".repeat(100_000); 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 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) .build();
454
455 assert_eq!(commit.confidence, Some(1.0));
456 }
457}