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}