diff --git a/benchmarks/src/runner.rs b/benchmarks/src/runner.rs index d924582e..00cfda04 100644 --- a/benchmarks/src/runner.rs +++ b/benchmarks/src/runner.rs @@ -385,6 +385,9 @@ async fn run_task_isolated(params: TaskRunParams<'_>) -> TaskResult { ironclaw::agent::cost_guard::CostGuardConfig::default(), )); + let idempotency_cache = Arc::new(ironclaw::tools::ToolIdempotencyCache::new( + ironclaw::tools::IdempotencyCacheConfig::default(), + )); let deps = AgentDeps { store: None, llm: instrumented.clone() as Arc, @@ -397,6 +400,7 @@ async fn run_task_isolated(params: TaskRunParams<'_>) -> TaskResult { skills_config: ironclaw::config::SkillsConfig::default(), hooks: Arc::new(ironclaw::hooks::HookRegistry::new()), cost_guard, + idempotency_cache, }; let mut channels = ChannelManager::new(); diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 45152cd3..d45187a4 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -28,7 +28,7 @@ use crate::hooks::HookRegistry; use crate::llm::LlmProvider; use crate::safety::SafetyLayer; use crate::skills::SkillRegistry; -use crate::tools::ToolRegistry; +use crate::tools::{ToolIdempotencyCache, ToolRegistry}; use crate::workspace::Workspace; /// Collapse a tool output string into a single-line preview for display. @@ -72,6 +72,8 @@ pub struct AgentDeps { pub hooks: Arc, /// Cost enforcement guardrails (daily budget, hourly rate limits). pub cost_guard: Arc, + /// Idempotency cache for tool executions. + pub idempotency_cache: Arc, } /// The main agent that coordinates all components. @@ -115,6 +117,7 @@ impl Agent { deps.tools.clone(), deps.store.clone(), deps.hooks.clone(), + deps.idempotency_cache.clone(), )); Self { diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index da9ce416..d774437d 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -478,6 +478,24 @@ impl Agent { .into()); } + // Check idempotency cache before executing. + // Chat tools use the job_ctx.job_id (an ephemeral UUID per chat turn). + if tool.is_idempotent() + && let Some(cached) = self + .deps + .idempotency_cache + .get(job_ctx.job_id, tool_name, params) + .await + { + return serde_json::to_string_pretty(&cached.result).map_err(|e| { + crate::error::ToolError::ExecutionFailed { + name: tool_name.to_string(), + reason: format!("Failed to serialize cached result: {}", e), + } + .into() + }); + } + tracing::debug!( tool = %tool_name, params = %params, @@ -495,6 +513,14 @@ impl Agent { match &result { Ok(Ok(output)) => { + // Cache successful results for idempotent tools + if tool.is_idempotent() { + self.deps + .idempotency_cache + .put(job_ctx.job_id, tool_name, params, output.clone()) + .await; + } + let result_str = serde_json::to_string(&output.result) .unwrap_or_else(|_| "".to_string()); tracing::debug!( diff --git a/src/agent/scheduler.rs b/src/agent/scheduler.rs index 23b9ea7c..713bbd25 100644 --- a/src/agent/scheduler.rs +++ b/src/agent/scheduler.rs @@ -17,7 +17,7 @@ use crate::error::{Error, JobError}; use crate::hooks::HookRegistry; use crate::llm::LlmProvider; use crate::safety::SafetyLayer; -use crate::tools::ToolRegistry; +use crate::tools::{ToolIdempotencyCache, ToolRegistry}; /// Message to send to a worker. #[derive(Debug)] @@ -51,6 +51,7 @@ pub struct Scheduler { tools: Arc, store: Option>, hooks: Arc, + idempotency_cache: Arc, /// Running jobs (main LLM-driven jobs). jobs: Arc>>, /// Running sub-tasks (tool executions, background tasks). @@ -59,6 +60,7 @@ pub struct Scheduler { impl Scheduler { /// Create a new scheduler. + #[allow(clippy::too_many_arguments)] pub fn new( config: AgentConfig, context_manager: Arc, @@ -67,6 +69,7 @@ impl Scheduler { tools: Arc, store: Option>, hooks: Arc, + idempotency_cache: Arc, ) -> Self { Self { config, @@ -76,6 +79,7 @@ impl Scheduler { tools, store, hooks, + idempotency_cache, jobs: Arc::new(RwLock::new(HashMap::new())), subtasks: Arc::new(RwLock::new(HashMap::new())), } @@ -123,6 +127,7 @@ impl Scheduler { tools: self.tools.clone(), store: self.store.clone(), hooks: self.hooks.clone(), + idempotency_cache: self.idempotency_cache.clone(), timeout: self.config.job_timeout, use_planning: self.config.use_planning, }; diff --git a/src/agent/worker.rs b/src/agent/worker.rs index 3ec88586..50122e0f 100644 --- a/src/agent/worker.rs +++ b/src/agent/worker.rs @@ -17,7 +17,7 @@ use crate::llm::{ ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection, }; use crate::safety::SafetyLayer; -use crate::tools::ToolRegistry; +use crate::tools::{ToolIdempotencyCache, ToolRegistry}; /// Shared dependencies for worker execution. /// @@ -31,6 +31,7 @@ pub struct WorkerDeps { pub tools: Arc, pub store: Option>, pub hooks: Arc, + pub idempotency_cache: Arc, pub timeout: Duration, pub use_planning: bool, } @@ -154,6 +155,12 @@ Report when the job is complete or if you encounter issues you cannot resolve."# } } + // Free cached tool results for this job + self.deps + .idempotency_cache + .invalidate_job(self.job_id) + .await; + Ok(()) } @@ -454,6 +461,32 @@ Report when the job is complete or if you encounter issues you cannot resolve."# .into()); } + // Check idempotency cache before executing + if tool.is_idempotent() + && let Some(cached) = deps.idempotency_cache.get(job_id, tool_name, ¶ms).await + { + // Record the cache hit in memory (fire-and-forget) + let _ = deps + .context_manager + .update_memory(job_id, |mem| { + let rec = mem.create_action(tool_name, params.clone()).succeed( + Some("[idempotency cache hit]".to_string()), + cached.result.clone(), + cached.duration, + ); + mem.record_action(rec); + }) + .await; + + return serde_json::to_string_pretty(&cached.result).map_err(|e| { + crate::error::ToolError::ExecutionFailed { + name: tool_name.to_string(), + reason: format!("Failed to serialize cached result: {}", e), + } + .into() + }); + } + tracing::debug!( tool = %tool_name, params = %params, @@ -499,6 +532,15 @@ Report when the job is complete or if you encounter issues you cannot resolve."# } } + // Cache successful results for idempotent tools + if let Ok(Ok(output)) = &result + && tool.is_idempotent() + { + deps.idempotency_cache + .put(job_id, tool_name, ¶ms, output.clone()) + .await; + } + // Record action in memory and get the ActionRecord for persistence let action = match &result { Ok(Ok(output)) => { diff --git a/src/main.rs b/src/main.rs index a7b3e2af..a301b9c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1372,6 +1372,9 @@ async fn main() -> anyhow::Result<()> { max_actions_per_hour: config.agent.max_actions_per_hour, }, )); + let idempotency_cache = std::sync::Arc::new(ironclaw::tools::ToolIdempotencyCache::new( + ironclaw::tools::IdempotencyCacheConfig::default(), + )); let deps = AgentDeps { store: db, llm, @@ -1384,6 +1387,7 @@ async fn main() -> anyhow::Result<()> { skills_config: config.skills.clone(), hooks, cost_guard, + idempotency_cache, }; let agent = Agent::new( config.agent.clone(), diff --git a/src/testing.rs b/src/testing.rs index 2bdc74ad..9eb4296f 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -282,6 +282,9 @@ impl TestHarnessBuilder { max_actions_per_hour: None, })); + let idempotency_cache = Arc::new(crate::tools::ToolIdempotencyCache::new( + crate::tools::IdempotencyCacheConfig::default(), + )); let deps = AgentDeps { store: Some(Arc::clone(&db)), llm, @@ -294,6 +297,7 @@ impl TestHarnessBuilder { skills_config: SkillsConfig::default(), hooks, cost_guard, + idempotency_cache, }; TestHarness { diff --git a/src/tools/builtin/echo.rs b/src/tools/builtin/echo.rs index faf31982..c3185ad6 100644 --- a/src/tools/builtin/echo.rs +++ b/src/tools/builtin/echo.rs @@ -46,4 +46,8 @@ impl Tool for EchoTool { fn requires_sanitization(&self) -> bool { false // Internal tool, no external data } + + fn is_idempotent(&self) -> bool { + true // Pure function: same input always produces same output + } } diff --git a/src/tools/builtin/file.rs b/src/tools/builtin/file.rs index 9e7ffbcb..2da44232 100644 --- a/src/tools/builtin/file.rs +++ b/src/tools/builtin/file.rs @@ -269,6 +269,10 @@ impl Tool for ReadFileTool { true // Reading local files should require approval } + fn is_idempotent(&self) -> bool { + true // Read-only file access, safe to cache within TTL + } + fn domain(&self) -> ToolDomain { ToolDomain::Container } @@ -492,6 +496,10 @@ impl Tool for ListDirTool { true // Directory listings can leak filesystem structure } + fn is_idempotent(&self) -> bool { + true // Read-only directory listing, safe to cache within TTL + } + fn domain(&self) -> ToolDomain { ToolDomain::Container } diff --git a/src/tools/builtin/job.rs b/src/tools/builtin/job.rs index 1748a2cb..9b633aaf 100644 --- a/src/tools/builtin/job.rs +++ b/src/tools/builtin/job.rs @@ -845,6 +845,10 @@ impl Tool for ListJobsTool { fn requires_sanitization(&self) -> bool { false } + + fn is_idempotent(&self) -> bool { + true // Read-only job listing, safe to cache within TTL + } } /// Tool for checking job status. @@ -924,6 +928,10 @@ impl Tool for JobStatusTool { fn requires_sanitization(&self) -> bool { false } + + fn is_idempotent(&self) -> bool { + true // Read-only status check, safe to cache within TTL + } } /// Tool for canceling a job. diff --git a/src/tools/builtin/json.rs b/src/tools/builtin/json.rs index f6d91a0d..3e2c83fc 100644 --- a/src/tools/builtin/json.rs +++ b/src/tools/builtin/json.rs @@ -102,6 +102,10 @@ impl Tool for JsonTool { fn requires_sanitization(&self) -> bool { false // Internal tool, no external data } + + fn is_idempotent(&self) -> bool { + true // Pure transform: same JSON in, same result out + } } fn parse_json_input(data: &serde_json::Value) -> Result { diff --git a/src/tools/builtin/memory.rs b/src/tools/builtin/memory.rs index cdcf504d..31349b14 100644 --- a/src/tools/builtin/memory.rs +++ b/src/tools/builtin/memory.rs @@ -112,6 +112,10 @@ impl Tool for MemorySearchTool { fn requires_sanitization(&self) -> bool { false // Internal memory, trusted content } + + fn is_idempotent(&self) -> bool { + true // Read-only search, safe to cache + } } /// Tool for writing to workspace memory. @@ -350,6 +354,10 @@ impl Tool for MemoryReadTool { fn requires_sanitization(&self) -> bool { false // Internal memory } + + fn is_idempotent(&self) -> bool { + true // Read-only file access, safe to cache + } } /// Tool for viewing workspace structure as a tree. @@ -469,6 +477,10 @@ impl Tool for MemoryTreeTool { fn requires_sanitization(&self) -> bool { false // Internal tool } + + fn is_idempotent(&self) -> bool { + true // Read-only tree listing, safe to cache + } } #[cfg(all(test, feature = "postgres"))] diff --git a/src/tools/builtin/time.rs b/src/tools/builtin/time.rs index 9388f8c7..ece0bca8 100644 --- a/src/tools/builtin/time.rs +++ b/src/tools/builtin/time.rs @@ -111,4 +111,8 @@ impl Tool for TimeTool { fn requires_sanitization(&self) -> bool { false // Internal tool, no external data } + + fn is_idempotent(&self) -> bool { + true // TTL handles staleness for time-dependent results + } } diff --git a/src/tools/idempotency.rs b/src/tools/idempotency.rs new file mode 100644 index 00000000..5e9a32b3 --- /dev/null +++ b/src/tools/idempotency.rs @@ -0,0 +1,367 @@ +//! In-memory idempotency cache for tool executions. +//! +//! For tools that declare `is_idempotent() == true`, successful results are +//! cached per-job so repeated identical calls (common during self-repair +//! recovery, stuck job retries, or chat-mode retry loops) return instantly +//! without re-executing. +//! +//! ```text +//! ┌──────────────────────────────────────────────────────────┐ +//! │ ToolIdempotencyCache │ +//! │ │ +//! │ get(job_id, tool_name, args) -> Option │ +//! │ put(job_id, tool_name, args, output) │ +//! │ invalidate_job(job_id) // cleanup on job completion │ +//! │ │ +//! │ Internal: Mutex> │ +//! │ Key: sha256(tool_name | canonical_json(args)) │ +//! │ Scoped by job_id │ +//! │ TTL + max entries per job with LRU eviction │ +//! └──────────────────────────────────────────────────────────┘ +//! ``` + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use sha2::{Digest, Sha256}; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::tools::ToolOutput; + +/// Configuration for the idempotency cache. +#[derive(Debug, Clone)] +pub struct IdempotencyCacheConfig { + /// Time-to-live for cache entries. + pub ttl: Duration, + /// Maximum number of cached entries per job before LRU eviction. + pub max_entries_per_job: usize, +} + +impl Default for IdempotencyCacheConfig { + fn default() -> Self { + Self { + ttl: Duration::from_secs(30 * 60), // 30 minutes + max_entries_per_job: 500, + } + } +} + +/// SHA-256 hex digest used as cache key. +type CacheKey = String; + +struct CacheEntry { + output: ToolOutput, + created_at: Instant, + last_accessed: Instant, +} + +/// Per-job idempotency cache for tool results. +/// +/// Only caches `Ok(ToolOutput)` results. Errors are never cached so retries +/// after transient failures get a fresh execution. +pub struct ToolIdempotencyCache { + /// Map from (job_id, cache_key) -> cached result. + entries: Mutex>, + config: IdempotencyCacheConfig, +} + +impl ToolIdempotencyCache { + /// Create a new cache with the given configuration. + pub fn new(config: IdempotencyCacheConfig) -> Self { + Self { + entries: Mutex::new(HashMap::new()), + config, + } + } + + /// Build a deterministic cache key from a tool name and its arguments. + /// + /// `sha256(tool_name | canonical_json(args))`. serde_json produces + /// stable output for the same input structure. + fn cache_key(tool_name: &str, args: &serde_json::Value) -> CacheKey { + let mut hasher = Sha256::new(); + hasher.update(tool_name.as_bytes()); + hasher.update(b"|"); + if let Ok(json) = serde_json::to_string(args) { + hasher.update(json.as_bytes()); + } + format!("{:x}", hasher.finalize()) + } + + /// Look up a cached result for a tool invocation within a job. + /// + /// Returns `Some(ToolOutput)` on a cache hit (within TTL), `None` on miss. + pub async fn get( + &self, + job_id: Uuid, + tool_name: &str, + args: &serde_json::Value, + ) -> Option { + let key = Self::cache_key(tool_name, args); + let now = Instant::now(); + + let mut guard = self.entries.lock().await; + let compound_key = (job_id, key); + + if let Some(entry) = guard.get_mut(&compound_key) { + if now.duration_since(entry.created_at) < self.config.ttl { + entry.last_accessed = now; + tracing::debug!( + tool = %tool_name, + job = %job_id, + "idempotency cache hit" + ); + return Some(entry.output.clone()); + } + // Expired + guard.remove(&compound_key); + } + + tracing::trace!( + tool = %tool_name, + job = %job_id, + "idempotency cache miss" + ); + None + } + + /// Store a successful tool result in the cache. + /// + /// Evicts expired entries and applies LRU eviction if the per-job limit + /// is exceeded. + pub async fn put( + &self, + job_id: Uuid, + tool_name: &str, + args: &serde_json::Value, + output: ToolOutput, + ) { + let key = Self::cache_key(tool_name, args); + let now = Instant::now(); + + let mut guard = self.entries.lock().await; + + // Evict expired entries for this job + guard.retain(|(jid, _), entry| { + *jid != job_id || now.duration_since(entry.created_at) < self.config.ttl + }); + + // Count entries for this job and evict LRU if over capacity + let job_count = guard.keys().filter(|(jid, _)| *jid == job_id).count(); + if job_count >= self.config.max_entries_per_job { + // Find the LRU entry for this job + let oldest_key = guard + .iter() + .filter(|((jid, _), _)| *jid == job_id) + .min_by_key(|(_, entry)| entry.last_accessed) + .map(|(k, _)| k.clone()); + + if let Some(k) = oldest_key { + guard.remove(&k); + } + } + + tracing::trace!( + tool = %tool_name, + job = %job_id, + "idempotency cache store" + ); + + guard.insert( + (job_id, key), + CacheEntry { + output, + created_at: now, + last_accessed: now, + }, + ); + } + + /// Remove all cached entries for a job. + /// + /// Call this when a job completes or fails to free memory. + pub async fn invalidate_job(&self, job_id: Uuid) { + let mut guard = self.entries.lock().await; + let before = guard.len(); + guard.retain(|(jid, _), _| *jid != job_id); + let removed = before - guard.len(); + if removed > 0 { + tracing::debug!( + job = %job_id, + removed, + "idempotency cache invalidated job" + ); + } + } + + /// Total number of entries across all jobs. + #[cfg(test)] + async fn len(&self) -> usize { + self.entries.lock().await.len() + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use uuid::Uuid; + + use crate::tools::ToolOutput; + use crate::tools::idempotency::{IdempotencyCacheConfig, ToolIdempotencyCache}; + + fn make_cache(ttl_ms: u64, max_per_job: usize) -> ToolIdempotencyCache { + ToolIdempotencyCache::new(IdempotencyCacheConfig { + ttl: Duration::from_millis(ttl_ms), + max_entries_per_job: max_per_job, + }) + } + + fn sample_output(text: &str) -> ToolOutput { + ToolOutput::text(text, Duration::from_millis(1)) + } + + #[test] + fn cache_key_deterministic() { + let args = serde_json::json!({"query": "hello", "limit": 5}); + let k1 = ToolIdempotencyCache::cache_key("memory_search", &args); + let k2 = ToolIdempotencyCache::cache_key("memory_search", &args); + assert_eq!(k1, k2); + assert_eq!(k1.len(), 64); // SHA-256 hex + } + + #[test] + fn cache_key_varies_by_tool_name() { + let args = serde_json::json!({"query": "hello"}); + let k1 = ToolIdempotencyCache::cache_key("memory_search", &args); + let k2 = ToolIdempotencyCache::cache_key("memory_read", &args); + assert_ne!(k1, k2); + } + + #[test] + fn cache_key_varies_by_args() { + let k1 = ToolIdempotencyCache::cache_key("echo", &serde_json::json!({"message": "hello"})); + let k2 = ToolIdempotencyCache::cache_key("echo", &serde_json::json!({"message": "world"})); + assert_ne!(k1, k2); + } + + #[tokio::test] + async fn cache_hit_returns_stored_output() { + let cache = make_cache(60_000, 100); + let job = Uuid::new_v4(); + let args = serde_json::json!({"message": "hi"}); + + // Miss + assert!(cache.get(job, "echo", &args).await.is_none()); + + // Store + cache.put(job, "echo", &args, sample_output("hi")).await; + + // Hit + let hit = cache.get(job, "echo", &args).await; + assert!(hit.is_some()); + assert_eq!(hit.unwrap().result, serde_json::json!("hi")); + } + + #[tokio::test] + async fn cache_miss_for_different_job() { + let cache = make_cache(60_000, 100); + let job_a = Uuid::new_v4(); + let job_b = Uuid::new_v4(); + let args = serde_json::json!({"message": "hi"}); + + cache.put(job_a, "echo", &args, sample_output("hi")).await; + + // Different job should miss + assert!(cache.get(job_b, "echo", &args).await.is_none()); + // Same job should hit + assert!(cache.get(job_a, "echo", &args).await.is_some()); + } + + #[tokio::test] + async fn ttl_expiry() { + let cache = make_cache(1, 100); // 1ms TTL + let job = Uuid::new_v4(); + let args = serde_json::json!({"message": "hi"}); + + cache.put(job, "echo", &args, sample_output("hi")).await; + + // Wait for TTL to expire + tokio::time::sleep(Duration::from_millis(10)).await; + + assert!(cache.get(job, "echo", &args).await.is_none()); + } + + #[tokio::test] + async fn lru_eviction() { + let cache = make_cache(60_000, 2); // max 2 per job + let job = Uuid::new_v4(); + + // Fill with 2 entries + let args_a = serde_json::json!({"n": 1}); + let args_b = serde_json::json!({"n": 2}); + cache.put(job, "echo", &args_a, sample_output("a")).await; + cache.put(job, "echo", &args_b, sample_output("b")).await; + assert_eq!(cache.len().await, 2); + + // Access args_a so args_b becomes the LRU + cache.get(job, "echo", &args_a).await; + + // Add a third: should evict args_b (oldest accessed) + let args_c = serde_json::json!({"n": 3}); + cache.put(job, "echo", &args_c, sample_output("c")).await; + assert_eq!(cache.len().await, 2); + + // args_b should be gone, args_a and args_c should remain + assert!(cache.get(job, "echo", &args_b).await.is_none()); + assert!(cache.get(job, "echo", &args_a).await.is_some()); + assert!(cache.get(job, "echo", &args_c).await.is_some()); + } + + #[tokio::test] + async fn invalidate_job_clears_entries() { + let cache = make_cache(60_000, 100); + let job_a = Uuid::new_v4(); + let job_b = Uuid::new_v4(); + let args = serde_json::json!({"message": "hi"}); + + cache.put(job_a, "echo", &args, sample_output("a")).await; + cache.put(job_b, "echo", &args, sample_output("b")).await; + assert_eq!(cache.len().await, 2); + + cache.invalidate_job(job_a).await; + + assert_eq!(cache.len().await, 1); + assert!(cache.get(job_a, "echo", &args).await.is_none()); + assert!(cache.get(job_b, "echo", &args).await.is_some()); + } + + #[tokio::test] + async fn lru_eviction_does_not_affect_other_jobs() { + let cache = make_cache(60_000, 1); // max 1 per job + let job_a = Uuid::new_v4(); + let job_b = Uuid::new_v4(); + + let args_1 = serde_json::json!({"n": 1}); + let args_2 = serde_json::json!({"n": 2}); + + cache.put(job_a, "echo", &args_1, sample_output("a1")).await; + cache.put(job_b, "echo", &args_1, sample_output("b1")).await; + + // Adding a second entry for job_a should evict job_a's first entry, + // but not touch job_b's entry + cache.put(job_a, "echo", &args_2, sample_output("a2")).await; + + assert!(cache.get(job_a, "echo", &args_1).await.is_none()); + assert!(cache.get(job_a, "echo", &args_2).await.is_some()); + assert!(cache.get(job_b, "echo", &args_1).await.is_some()); + } + + #[test] + fn default_config_is_reasonable() { + let cfg = IdempotencyCacheConfig::default(); + assert_eq!(cfg.ttl, Duration::from_secs(30 * 60)); + assert_eq!(cfg.max_entries_per_job, 500); + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index a731d46a..4ea8dd57 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -9,6 +9,7 @@ pub mod builder; pub mod builtin; +pub mod idempotency; pub mod mcp; pub mod wasm; @@ -20,5 +21,6 @@ pub use builder::{ LlmSoftwareBuilder, SoftwareBuilder, SoftwareType, Template, TemplateEngine, TemplateType, TestCase, TestHarness, TestResult, TestSuite, ValidationError, ValidationResult, WasmValidator, }; +pub use idempotency::{IdempotencyCacheConfig, ToolIdempotencyCache}; pub use registry::ToolRegistry; pub use tool::{Tool, ToolDomain, ToolError, ToolOutput}; diff --git a/src/tools/tool.rs b/src/tools/tool.rs index e0e4f4c4..2ed85b39 100644 --- a/src/tools/tool.rs +++ b/src/tools/tool.rs @@ -194,6 +194,15 @@ pub trait Tool: Send + Sync { Duration::from_secs(60) } + /// Whether this tool is idempotent (same args always produce the same result). + /// + /// When true, successful results are cached per-job so repeated identical + /// calls return the cached result without re-executing. Tools that have + /// side effects (shell, file write, HTTP POST) should return false (the default). + fn is_idempotent(&self) -> bool { + false + } + /// Where this tool should execute. /// /// `Orchestrator` tools run in the main agent process (safe, no FS access).