Skip to main content

brainwires_core/
tool.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6
7/// Specifies which contexts can invoke a tool.
8/// Implements Anthropic's `allowed_callers` pattern for programmatic tool calling.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "snake_case")]
11#[derive(Default)]
12pub enum ToolCaller {
13    /// Tool can be called directly by the AI
14    #[default]
15    Direct,
16    /// Tool can only be called from within code/script execution
17    CodeExecution,
18}
19
20/// A tool that can be used by the AI agent
21#[derive(Debug, Clone, Serialize, Deserialize, Default)]
22pub struct Tool {
23    /// Name of the tool
24    #[serde(default)]
25    pub name: String,
26    /// Description of what the tool does
27    #[serde(default)]
28    pub description: String,
29    /// Input schema (JSON Schema)
30    #[serde(default)]
31    pub input_schema: ToolInputSchema,
32    /// Whether this tool requires user approval before execution
33    #[serde(default)]
34    pub requires_approval: bool,
35    /// Whether this tool should be deferred from initial context loading.
36    #[serde(default)]
37    pub defer_loading: bool,
38    /// Specifies which contexts can call this tool.
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub allowed_callers: Vec<ToolCaller>,
41    /// Example inputs that teach the AI proper parameter usage.
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub input_examples: Vec<Value>,
44    /// When `true`, the agent loop MUST execute this tool sequentially — never
45    /// concurrently with other tools in the same round.
46    ///
47    /// Use for tools that mutate shared state (file writes, git operations,
48    /// registry updates) where concurrent execution could corrupt data or
49    /// interleave side effects. Read-only tools (read_file, search, web_fetch)
50    /// should leave this `false`.
51    #[serde(default, skip_serializing_if = "is_false")]
52    pub serialize: bool,
53}
54
55fn is_false(b: &bool) -> bool {
56    !*b
57}
58
59/// JSON Schema for tool input
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ToolInputSchema {
62    /// Schema type (typically "object").
63    #[serde(rename = "type", default = "default_schema_type")]
64    pub schema_type: String,
65    /// Property definitions mapping name to JSON Schema.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub properties: Option<HashMap<String, Value>>,
68    /// List of required property names.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub required: Option<Vec<String>>,
71}
72
73fn default_schema_type() -> String {
74    "object".to_string()
75}
76
77impl Default for ToolInputSchema {
78    fn default() -> Self {
79        Self {
80            schema_type: "object".to_string(),
81            properties: None,
82            required: None,
83        }
84    }
85}
86
87impl ToolInputSchema {
88    /// Create a new object schema
89    pub fn object(properties: HashMap<String, Value>, required: Vec<String>) -> Self {
90        Self {
91            schema_type: "object".to_string(),
92            properties: Some(properties),
93            required: Some(required),
94        }
95    }
96}
97
98/// A tool use request from the AI
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ToolUse {
101    /// Unique ID for this tool use
102    pub id: String,
103    /// Name of the tool to use
104    pub name: String,
105    /// Input parameters for the tool
106    pub input: Value,
107}
108
109/// Result of a tool execution
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ToolResult {
112    /// ID of the tool use this is a result for
113    pub tool_use_id: String,
114    /// Result content
115    pub content: String,
116    /// Whether this is an error result
117    #[serde(default)]
118    pub is_error: bool,
119}
120
121impl ToolResult {
122    /// Create a successful tool result
123    pub fn success<S: Into<String>>(tool_use_id: S, content: S) -> Self {
124        Self {
125            tool_use_id: tool_use_id.into(),
126            content: content.into(),
127            is_error: false,
128        }
129    }
130
131    /// Create an error tool result
132    pub fn error<S: Into<String>>(tool_use_id: S, error: S) -> Self {
133        Self {
134            tool_use_id: tool_use_id.into(),
135            content: error.into(),
136            is_error: true,
137        }
138    }
139}
140
141// ── Idempotency registry ─────────────────────────────────────────────────────
142
143/// Record of a completed idempotent write operation.
144#[derive(Debug, Clone)]
145pub struct IdempotencyRecord {
146    /// Unix timestamp of first execution.
147    pub executed_at: i64,
148    /// The success message returned on first execution (returned verbatim on retries).
149    pub cached_result: String,
150}
151
152/// Shared registry that deduplicates mutating file-system tool calls within a run.
153///
154/// Create one per agent run and attach it to `ToolContext` via
155/// `ToolContext::with_idempotency_registry`.  All clones of the `ToolContext`
156/// share the same underlying map so that idempotency is enforced across the
157/// entire run regardless of how many times the context is cloned.
158#[derive(Debug, Clone, Default)]
159pub struct IdempotencyRegistry(Arc<Mutex<HashMap<String, IdempotencyRecord>>>);
160
161impl IdempotencyRegistry {
162    /// Create a new, empty registry.
163    pub fn new() -> Self {
164        Self::default()
165    }
166
167    /// Return the cached result for `key`, or `None` if not yet executed.
168    pub fn get(&self, key: &str) -> Option<IdempotencyRecord> {
169        self.0
170            .lock()
171            .expect("idempotency registry lock poisoned")
172            .get(key)
173            .cloned()
174    }
175
176    /// Record that `key` produced `result`.
177    ///
178    /// If `key` was already recorded (concurrent retry), the first result wins.
179    pub fn record(&self, key: String, result: String) {
180        let mut map = self.0.lock().expect("idempotency registry lock poisoned");
181        map.entry(key).or_insert_with(|| {
182            use chrono::Utc;
183            IdempotencyRecord {
184                executed_at: Utc::now().timestamp(),
185                cached_result: result,
186            }
187        });
188    }
189
190    /// Number of recorded operations.
191    pub fn len(&self) -> usize {
192        self.0
193            .lock()
194            .expect("idempotency registry lock poisoned")
195            .len()
196    }
197
198    /// Returns `true` if no operations have been recorded yet.
199    pub fn is_empty(&self) -> bool {
200        self.len() == 0
201    }
202}
203
204// ── Side-effect staging (two-phase commit) ────────────────────────────────────
205
206/// A single write operation that has been staged but not yet committed.
207#[derive(Debug, Clone)]
208pub struct StagedWrite {
209    /// Content-addressed key — used to deduplicate identical staged writes.
210    pub key: String,
211    /// The absolute target path on the filesystem.
212    pub target_path: PathBuf,
213    /// UTF-8 content to write on commit.
214    pub content: String,
215}
216
217/// Result returned by a successful [`StagingBackend::commit`].
218#[derive(Debug, Clone)]
219pub struct CommitResult {
220    /// Number of writes successfully committed to disk.
221    pub committed: usize,
222    /// The target paths that were written.
223    pub paths: Vec<PathBuf>,
224}
225
226/// Trait for staging write operations before committing to the filesystem.
227///
228/// Defined in `brainwires-core` so that [`ToolContext`] can hold an
229/// `Arc<dyn StagingBackend>` without depending on `brainwires-tools`,
230/// which would create a circular crate dependency.
231///
232/// The concrete implementation lives in `brainwires-tools::transaction::TransactionManager`.
233pub trait StagingBackend: std::fmt::Debug + Send + Sync {
234    /// Stage a write operation.
235    ///
236    /// Returns `true` if newly staged, `false` if `key` was already present
237    /// (idempotent — same key staged twice is a no-op).
238    fn stage(&self, write: StagedWrite) -> bool;
239
240    /// Commit all staged writes to the filesystem.
241    ///
242    /// Each staged file is moved (or copied) to its target path atomically.
243    /// On success the staging queue is cleared.
244    fn commit(&self) -> anyhow::Result<CommitResult>;
245
246    /// Discard all staged writes without touching the filesystem.
247    fn rollback(&self);
248
249    /// Return the number of pending staged writes.
250    fn pending_count(&self) -> usize;
251}
252
253// ── Intended-write hash registry ─────────────────────────────────────────────
254
255/// Shared map of `path -> SHA-256 of most recently written content`.
256///
257/// Populated by `write_file` (in `brainwires-tools`) after its post-write
258/// read-back succeeds, and read by the validation loop (in `brainwires-agent`)
259/// to detect *post-validation* clobber by a concurrent writer.
260///
261/// Why this exists (in addition to the tool-level read-back check):
262///   - The read-back check catches interleaved writes within a single
263///     `write_file` call.
264///   - It does NOT catch: agent A writes, agent A's validation passes,
265///     agent B writes, agent A finalises `Success: true` — at which point
266///     the content A claims to have written is no longer on disk.
267///
268/// By recording the intended hash and re-reading at agent-finalisation time,
269/// A sees the mismatch and its retry/failure machinery kicks in — so at most
270/// one of two racing agents can legitimately report success.
271#[derive(Debug, Clone, Default)]
272pub struct IntendedWrites(Arc<Mutex<HashMap<PathBuf, [u8; 32]>>>);
273
274impl IntendedWrites {
275    /// Create a new, empty registry.
276    pub fn new() -> Self {
277        Self::default()
278    }
279
280    /// Record the SHA-256 of content written to `path`.  The most recent
281    /// write wins (overwrite semantics — consistent with filesystem reality).
282    pub fn record(&self, path: PathBuf, hash: [u8; 32]) {
283        let mut map = self.0.lock().expect("intended writes lock poisoned");
284        map.insert(path, hash);
285    }
286
287    /// Return the hash recorded for `path`, or `None` if none.
288    pub fn get(&self, path: &Path) -> Option<[u8; 32]> {
289        let map = self.0.lock().expect("intended writes lock poisoned");
290        map.get(path).copied()
291    }
292
293    /// Snapshot all `(path, hash)` pairs currently recorded.
294    pub fn snapshot(&self) -> Vec<(PathBuf, [u8; 32])> {
295        let map = self.0.lock().expect("intended writes lock poisoned");
296        map.iter().map(|(p, h)| (p.clone(), *h)).collect()
297    }
298
299    /// Number of recorded paths.
300    pub fn len(&self) -> usize {
301        self.0.lock().expect("intended writes lock poisoned").len()
302    }
303
304    /// Returns `true` if no writes have been recorded yet.
305    pub fn is_empty(&self) -> bool {
306        self.len() == 0
307    }
308}
309
310// ── ToolContext ───────────────────────────────────────────────────────────────
311
312/// Execution context for a tool.
313///
314/// Provides the working directory, optional metadata, and permission capabilities
315/// to tool implementations.
316#[derive(Debug, Clone)]
317pub struct ToolContext {
318    /// Current working directory for resolving relative paths
319    pub working_directory: String,
320    /// User ID (if authenticated)
321    pub user_id: Option<String>,
322    /// Additional context data (application-specific key-value pairs)
323    pub metadata: HashMap<String, String>,
324    /// Agent capabilities for permission checks (serialized as JSON value).
325    ///
326    /// Consumers should serialize their concrete capability types into this field
327    /// and deserialize when reading. This keeps the core crate free of capability
328    /// type definitions.
329    pub capabilities: Option<Value>,
330    /// Per-run idempotency registry for mutating file operations.
331    ///
332    /// When `Some`, write/delete/edit operations derive a content-addressed key
333    /// and skip re-execution if the same key has already been processed in this
334    /// run.  `None` disables idempotency tracking (useful for tests or simple
335    /// single-call use cases).
336    pub idempotency_registry: Option<IdempotencyRegistry>,
337    /// Optional two-phase commit staging backend.
338    ///
339    /// When `Some`, mutating file operations (`write_file`, `edit_file`,
340    /// `patch_file`) stage their writes instead of applying them immediately.
341    /// The caller is responsible for calling `backend.commit()` to finalize the
342    /// writes, or `backend.rollback()` to discard them.
343    ///
344    /// Staging is checked *after* the idempotency registry: if the same
345    /// operation key is already cached, the cached result is returned without
346    /// staging again.
347    pub staging_backend: Option<Arc<dyn StagingBackend>>,
348    /// Optional shared registry that tracks `(path -> SHA-256)` for every
349    /// successful `write_file` in this run.
350    ///
351    /// When `Some`, `write_file` records the hash of its content after the
352    /// post-write read-back succeeds.  The agent's validation loop then
353    /// re-reads each tracked file at finalisation and compares to detect
354    /// post-validation clobber by a concurrent writer.
355    ///
356    /// `None` disables hash tracking (CLI-driven tool invocations outside an
357    /// agent).  The cost of tracking is a `HashMap` insert per write.
358    pub intended_writes: Option<IntendedWrites>,
359}
360
361impl ToolContext {
362    /// Attach a fresh idempotency registry to this context (builder pattern).
363    pub fn with_idempotency_registry(mut self) -> Self {
364        self.idempotency_registry = Some(IdempotencyRegistry::new());
365        self
366    }
367
368    /// Attach a staging backend for two-phase commit file writes (builder pattern).
369    pub fn with_staging_backend(mut self, backend: Arc<dyn StagingBackend>) -> Self {
370        self.staging_backend = Some(backend);
371        self
372    }
373
374    /// Attach a fresh intended-writes registry (builder pattern).
375    pub fn with_intended_writes(mut self) -> Self {
376        self.intended_writes = Some(IntendedWrites::new());
377        self
378    }
379
380    /// Attach an existing intended-writes registry — useful when the agent
381    /// owns the registry and wants tool calls to share it (builder pattern).
382    pub fn with_intended_writes_registry(mut self, registry: IntendedWrites) -> Self {
383        self.intended_writes = Some(registry);
384        self
385    }
386
387    /// Record the SHA-256 of content written to `path` in the attached
388    /// intended-writes registry.  No-op when no registry is attached
389    /// (e.g., CLI-driven tool invocations outside an agent).
390    pub fn record_write(&self, path: PathBuf, hash: [u8; 32]) {
391        if let Some(ref reg) = self.intended_writes {
392            reg.record(path, hash);
393        }
394    }
395}
396
397impl Default for ToolContext {
398    fn default() -> Self {
399        Self {
400            working_directory: std::env::current_dir()
401                .ok()
402                .and_then(|p| p.to_str().map(|s| s.to_string()))
403                .unwrap_or_else(|| ".".to_string()),
404            user_id: None,
405            metadata: HashMap::new(),
406            capabilities: None,
407            idempotency_registry: None,
408            staging_backend: None,
409            intended_writes: None,
410        }
411    }
412}
413
414/// Tool selection mode
415#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
416pub enum ToolMode {
417    /// All tools from registry
418    Full,
419    /// User-selected specific tools (stores tool names)
420    Explicit(Vec<String>),
421    /// Smart routing based on query analysis (default)
422    #[default]
423    Smart,
424    /// Core tools only
425    Core,
426    /// No tools enabled
427    None,
428}
429
430impl ToolMode {
431    /// Get a display name for the mode
432    pub fn display_name(&self) -> &'static str {
433        match self {
434            ToolMode::Full => "full",
435            ToolMode::Explicit(_) => "explicit",
436            ToolMode::Smart => "smart",
437            ToolMode::Core => "core",
438            ToolMode::None => "none",
439        }
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use serde_json::json;
447
448    #[test]
449    fn test_tool_result_success() {
450        let result = ToolResult::success("tool-1", "Success!");
451        assert!(!result.is_error);
452    }
453
454    #[test]
455    fn test_tool_result_error() {
456        let result = ToolResult::error("tool-2", "Failed!");
457        assert!(result.is_error);
458    }
459
460    #[test]
461    fn test_tool_input_schema_object() {
462        let mut props = HashMap::new();
463        props.insert("name".to_string(), json!({"type": "string"}));
464        let schema = ToolInputSchema::object(props, vec!["name".to_string()]);
465        assert_eq!(schema.schema_type, "object");
466        assert!(schema.properties.is_some());
467    }
468
469    #[test]
470    fn test_idempotency_registry_basic() {
471        let registry = IdempotencyRegistry::new();
472        assert!(registry.is_empty());
473
474        registry.record("key-1".to_string(), "result-1".to_string());
475        assert_eq!(registry.len(), 1);
476
477        let record = registry.get("key-1").unwrap();
478        assert_eq!(record.cached_result, "result-1");
479        assert!(record.executed_at > 0);
480
481        // Second record call with same key is a no-op (first result wins)
482        registry.record("key-1".to_string(), "result-DIFFERENT".to_string());
483        assert_eq!(registry.get("key-1").unwrap().cached_result, "result-1");
484        assert_eq!(registry.len(), 1);
485    }
486
487    #[test]
488    fn test_idempotency_registry_clone_shares_state() {
489        let registry = IdempotencyRegistry::new();
490        let clone = registry.clone();
491
492        registry.record("k".to_string(), "v".to_string());
493        // Clone sees the same entry because it shares the Arc<Mutex<...>>
494        assert!(clone.get("k").is_some());
495    }
496
497    #[test]
498    fn test_tool_context_default_has_no_registry() {
499        let ctx = ToolContext::default();
500        assert!(ctx.idempotency_registry.is_none());
501    }
502
503    #[test]
504    fn test_tool_context_with_registry() {
505        let ctx = ToolContext::default().with_idempotency_registry();
506        assert!(ctx.idempotency_registry.is_some());
507        assert!(ctx.idempotency_registry.unwrap().is_empty());
508    }
509}