bob_core/journal.rs
1//! # Tool Call Journal
2//!
3//! Append-only journal for recording tool call inputs and outputs, enabling
4//! idempotent replay across retries and restarts.
5//!
6//! ## Design
7//!
8//! Inspired by Restate's journal-based durable execution, the journal
9//! records every tool call with its result. On retry (e.g. after a crash
10//! or restart), the scheduler can look up previously completed tool calls
11//! and replay their results instead of re-executing them.
12//!
13//! ## Journal Entry
14//!
15//! Each entry captures:
16//! - `session_id` — which session this belongs to
17//! - `call_fingerprint` — deterministic hash of (tool_name + arguments)
18//! - `tool_name` — the tool that was called
19//! - `arguments` — the input arguments
20//! - `result` — the recorded output
21//! - `is_error` — whether the original call resulted in an error
22//! - `timestamp_ms` — when the entry was recorded
23//!
24//! ## Replay Semantics
25//!
26//! The scheduler calls `lookup` before executing a tool call. If a matching
27//! entry exists (same session + fingerprint), the cached result is returned
28//! immediately, skipping the actual tool execution.
29
30use serde::{Deserialize, Serialize};
31
32use crate::{error::StoreError, types::SessionId};
33
34/// A recorded tool call with its result.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct JournalEntry {
37 /// Session this entry belongs to.
38 pub session_id: SessionId,
39 /// Deterministic fingerprint of (tool_name + arguments_json).
40 pub call_fingerprint: String,
41 /// Name of the tool that was called.
42 pub tool_name: String,
43 /// Arguments passed to the tool.
44 pub arguments: serde_json::Value,
45 /// Recorded result output.
46 pub result: serde_json::Value,
47 /// Whether the original call was an error.
48 pub is_error: bool,
49 /// Unix epoch milliseconds.
50 pub timestamp_ms: u64,
51}
52
53impl JournalEntry {
54 /// Compute a deterministic fingerprint for a tool call.
55 ///
56 /// The fingerprint is stable across restarts and is used for
57 /// deduplication and idempotent replay.
58 #[must_use]
59 pub fn fingerprint(tool_name: &str, arguments: &serde_json::Value) -> String {
60 let args_canonical = serde_json::to_string(arguments).unwrap_or_default();
61 format!("{tool_name}:{args_canonical}")
62 }
63}
64
65/// Port for tool call journal persistence.
66///
67/// The journal is append-only: entries are never modified or deleted.
68#[async_trait::async_trait]
69pub trait ToolJournalPort: Send + Sync {
70 /// Record a completed tool call in the journal.
71 async fn append(&self, entry: JournalEntry) -> Result<(), StoreError>;
72
73 /// Look up a previously recorded result for the same tool call.
74 ///
75 /// Returns `Some(entry)` if a matching entry exists for the given
76 /// session and fingerprint, or `None` if this is a new call.
77 async fn lookup(
78 &self,
79 session_id: &SessionId,
80 fingerprint: &str,
81 ) -> Result<Option<JournalEntry>, StoreError>;
82
83 /// Return all journal entries for a session (for diagnostics).
84 async fn entries(&self, session_id: &SessionId) -> Result<Vec<JournalEntry>, StoreError>;
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90
91 #[test]
92 fn fingerprint_is_deterministic() {
93 let args = serde_json::json!({"path": "/tmp/test.txt", "mode": "read"});
94 let fp1 = JournalEntry::fingerprint("read_file", &args);
95 let fp2 = JournalEntry::fingerprint("read_file", &args);
96 assert_eq!(fp1, fp2, "same inputs should produce same fingerprint");
97 }
98
99 #[test]
100 fn fingerprint_differs_for_different_tools() {
101 let args = serde_json::json!({"x": 1});
102 let fp1 = JournalEntry::fingerprint("tool_a", &args);
103 let fp2 = JournalEntry::fingerprint("tool_b", &args);
104 assert_ne!(fp1, fp2, "different tools should produce different fingerprints");
105 }
106
107 #[test]
108 fn fingerprint_differs_for_different_args() {
109 let args1 = serde_json::json!({"x": 1});
110 let args2 = serde_json::json!({"x": 2});
111 let fp1 = JournalEntry::fingerprint("tool_a", &args1);
112 let fp2 = JournalEntry::fingerprint("tool_a", &args2);
113 assert_ne!(fp1, fp2, "different args should produce different fingerprints");
114 }
115}