Skip to main content

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}