feat: add tool execution idempotency cache

When the LLM re-requests the same idempotent tool with identical args
(common during self-repair recovery, stuck job retries, or chat-mode
retry loops), the cache returns the previous result instantly without
re-executing.

Design improvements over the closed PR #204:
- Single global LRU cache (lru crate) instead of per-job HashMap with
  O(N) manual eviction
- Global cap of 2000 entries prevents memory leaks from chat-path
  ephemeral job IDs that never get invalidated
- TTL expiry (30min) checked on read; LRU eviction handles the rest
- Job invalidation still runs on worker completion for prompt cleanup

Idempotent tools: echo, time, json, memory_read/search/tree,
read_file, list_dir, list_jobs, job_status.

Closes #204

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
2026-03-10 02:01:58 -07:00
co-authored by Claude Opus 4.6
parent 3a2989d009
commit 5fcb4d3c03
17 changed files with 453 additions and 15 deletions
+4
View File
@@ -29,6 +29,7 @@ use crate::llm::LlmProvider;
use crate::safety::SafetyLayer;
use crate::skills::SkillRegistry;
use crate::tools::ToolRegistry;
use crate::tools::idempotency::ToolIdempotencyCache;
use crate::workspace::Workspace;
/// Collapse a tool output string into a single-line preview for display.
@@ -81,6 +82,8 @@ pub struct AgentDeps {
pub transcription: Option<Arc<crate::transcription::TranscriptionMiddleware>>,
/// Document text extraction middleware for PDF, DOCX, PPTX, etc.
pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>,
/// Idempotency cache for tool executions.
pub idempotency_cache: ToolIdempotencyCache,
}
/// The main agent that coordinates all components.
@@ -130,6 +133,7 @@ impl Agent {
deps.tools.clone(),
deps.store.clone(),
deps.hooks.clone(),
deps.idempotency_cache.clone(),
);
if let Some(ref tx) = deps.sse_tx {
scheduler.set_sse_sender(tx.clone());
+59 -8
View File
@@ -15,6 +15,7 @@ use crate::channels::{IncomingMessage, StatusUpdate};
use crate::context::JobContext;
use crate::error::Error;
use crate::llm::{ChatMessage, Reasoning, ReasoningContext, RespondResult};
use crate::tools::idempotency::ToolIdempotencyCache;
use crate::tools::redact_params;
/// Result of the agentic loop execution.
@@ -569,6 +570,7 @@ impl Agent {
let pf_idx = *pf_idx;
let tools = self.tools().clone();
let safety = self.safety().clone();
let idempotency_cache = self.deps.idempotency_cache.clone();
let channels = self.channels.clone();
let job_ctx = job_ctx.clone();
let tc = tc.clone();
@@ -589,6 +591,7 @@ impl Agent {
let result = execute_chat_tool_standalone(
&tools,
&safety,
&idempotency_cache,
&tc.name,
&tc.arguments,
&job_ctx,
@@ -859,7 +862,15 @@ impl Agent {
params: &serde_json::Value,
job_ctx: &JobContext,
) -> Result<String, Error> {
execute_chat_tool_standalone(self.tools(), self.safety(), tool_name, params, job_ctx).await
execute_chat_tool_standalone(
self.tools(),
self.safety(),
&self.deps.idempotency_cache,
tool_name,
params,
job_ctx,
)
.await
}
}
@@ -868,9 +879,11 @@ impl Agent {
/// This standalone function enables parallel invocation from spawned JoinSet
/// tasks, which cannot borrow `&self`. It replicates the logic from
/// `Agent::execute_chat_tool`.
#[allow(clippy::too_many_arguments)]
pub(super) async fn execute_chat_tool_standalone(
tools: &crate::tools::ToolRegistry,
safety: &crate::safety::SafetyLayer,
idempotency_cache: &ToolIdempotencyCache,
tool_name: &str,
params: &serde_json::Value,
job_ctx: &crate::context::JobContext,
@@ -882,6 +895,15 @@ pub(super) async fn execute_chat_tool_standalone(
name: tool_name.to_string(),
})?;
// Check idempotency cache
let job_id_str = job_ctx.job_id.to_string();
if tool.is_idempotent()
&& let Some(cached) = idempotency_cache.get(&job_id_str, tool_name, params).await
{
tracing::debug!(tool = %tool_name, "Idempotency cache hit (chat)");
return Ok(cached);
}
// Validate tool parameters
let validation = safety.validator().validate_tool_params(params);
if !validation.is_valid {
@@ -953,13 +975,25 @@ pub(super) async fn execute_chat_tool_standalone(
reason: e.to_string(),
})?;
serde_json::to_string_pretty(&result.result).map_err(|e| {
crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: format!("Failed to serialize result: {}", e),
}
.into()
})
let result_str: Result<String, Error> =
serde_json::to_string_pretty(&result.result).map_err(|e| {
crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: format!("Failed to serialize result: {}", e),
}
.into()
});
// Cache successful results for idempotent tools
if let Ok(ref output_str) = result_str
&& tool.is_idempotent()
{
idempotency_cache
.put(&job_id_str, tool_name, params, output_str.clone())
.await;
}
result_str
}
/// Parsed auth result fields for emitting StatusUpdate::AuthRequired.
@@ -1187,6 +1221,9 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
Agent::new(
@@ -1520,9 +1557,13 @@ mod tests {
let job_ctx = JobContext::with_user("test", "chat", "test session");
let cache = crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
);
let result = super::execute_chat_tool_standalone(
&registry,
&safety,
&cache,
"echo",
&serde_json::json!({"message": "hello"}),
&job_ctx,
@@ -1548,9 +1589,13 @@ mod tests {
});
let job_ctx = JobContext::with_user("test", "chat", "test session");
let cache = crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
);
let result = super::execute_chat_tool_standalone(
&registry,
&safety,
&cache,
"nonexistent",
&serde_json::json!({}),
&job_ctx,
@@ -2026,6 +2071,9 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
Agent::new(
@@ -2143,6 +2191,9 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
Agent::new(
+7
View File
@@ -18,6 +18,7 @@ use crate::error::{Error, JobError};
use crate::hooks::HookRegistry;
use crate::llm::LlmProvider;
use crate::safety::SafetyLayer;
use crate::tools::idempotency::ToolIdempotencyCache;
use crate::tools::{ApprovalContext, ToolRegistry};
/// Message to send to a worker.
@@ -58,6 +59,8 @@ pub struct Scheduler {
sse_tx: Option<tokio::sync::broadcast::Sender<SseEvent>>,
/// HTTP interceptor for trace recording/replay (propagated to workers).
http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>,
/// Idempotency cache for tool executions (shared across all workers).
idempotency_cache: ToolIdempotencyCache,
/// Running jobs (main LLM-driven jobs).
jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>,
/// Running sub-tasks (tool executions, background tasks).
@@ -66,6 +69,7 @@ pub struct Scheduler {
impl Scheduler {
/// Create a new scheduler.
#[allow(clippy::too_many_arguments)]
pub fn new(
config: AgentConfig,
context_manager: Arc<ContextManager>,
@@ -74,6 +78,7 @@ impl Scheduler {
tools: Arc<ToolRegistry>,
store: Option<Arc<dyn Database>>,
hooks: Arc<HookRegistry>,
idempotency_cache: ToolIdempotencyCache,
) -> Self {
Self {
config,
@@ -85,6 +90,7 @@ impl Scheduler {
hooks,
sse_tx: None,
http_interceptor: None,
idempotency_cache,
jobs: Arc::new(RwLock::new(HashMap::new())),
subtasks: Arc::new(RwLock::new(HashMap::new())),
}
@@ -254,6 +260,7 @@ impl Scheduler {
sse_tx: self.sse_tx.clone(),
approval_context,
http_interceptor: self.http_interceptor.clone(),
idempotency_cache: self.idempotency_cache.clone(),
};
let worker = Worker::new(job_id, deps);
+2
View File
@@ -959,6 +959,7 @@ impl Agent {
for (spawn_idx, tc) in runnable.iter().enumerate() {
let tools = self.tools().clone();
let safety = self.safety().clone();
let idempotency_cache = self.deps.idempotency_cache.clone();
let channels = self.channels.clone();
let job_ctx = job_ctx.clone();
let tc = tc.clone();
@@ -979,6 +980,7 @@ impl Agent {
let result = execute_chat_tool_standalone(
&tools,
&safety,
&idempotency_cache,
&tc.name,
&tc.arguments,
&job_ctx,
+50 -7
View File
@@ -19,6 +19,7 @@ use crate::llm::{
ToolSelection,
};
use crate::safety::SafetyLayer;
use crate::tools::idempotency::ToolIdempotencyCache;
use crate::tools::rate_limiter::RateLimitResult;
use crate::tools::{ApprovalContext, ToolRegistry, redact_params};
@@ -44,6 +45,8 @@ pub struct WorkerDeps {
pub approval_context: Option<ApprovalContext>,
/// HTTP interceptor for trace recording/replay (propagated to JobContext).
pub http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>,
/// Idempotency cache for tool executions.
pub idempotency_cache: ToolIdempotencyCache,
}
/// Worker that executes a single job.
@@ -285,6 +288,12 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
}
}
// Clean up idempotency cache entries for this job
self.deps
.idempotency_cache
.invalidate_job(&self.job_id.to_string())
.await;
Ok(())
}
@@ -737,6 +746,22 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
name: tool_name.to_string(),
})?;
// Check idempotency cache before any expensive work
let job_id_str = job_id.to_string();
if tool.is_idempotent()
&& let Some(cached) = deps
.idempotency_cache
.get(&job_id_str, tool_name, params)
.await
{
tracing::debug!(
tool = %tool_name,
job = %job_id,
"Idempotency cache hit"
);
return Ok(cached);
}
// Check approval: use context-aware check if available, else block all non-Never tools
let requirement = tool.requires_approval(params);
let blocked =
@@ -967,13 +992,25 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
})?;
// Return result as string
serde_json::to_string_pretty(&output.result).map_err(|e| {
crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: format!("Failed to serialize result: {}", e),
}
.into()
})
let result_str: Result<String, Error> = serde_json::to_string_pretty(&output.result)
.map_err(|e| {
crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: format!("Failed to serialize result: {}", e),
}
.into()
});
// Cache successful results for idempotent tools
if let Ok(ref output_str) = result_str
&& tool.is_idempotent()
{
deps.idempotency_cache
.put(&job_id_str, tool_name, &params, output_str.clone())
.await;
}
result_str
}
/// Process a tool execution result and add it to the reasoning context.
@@ -1386,6 +1423,9 @@ mod tests {
sse_tx: None,
approval_context: None,
http_interceptor: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
Worker::new(job_id, deps)
@@ -1648,6 +1688,9 @@ mod tests {
sse_tx: None,
approval_context,
http_interceptor: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
Worker::new(job_id, deps)
+3
View File
@@ -623,6 +623,9 @@ async fn async_main() -> anyhow::Result<()> {
document_extraction: Some(Arc::new(
ironclaw::document_extraction::DocumentExtractionMiddleware::new(),
)),
idempotency_cache: ironclaw::tools::idempotency::ToolIdempotencyCache::new(
ironclaw::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
let mut agent = Agent::new(
+3
View File
@@ -453,6 +453,9 @@ impl TestHarnessBuilder {
http_interceptor: None,
transcription: None,
document_extraction: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
TestHarness {
+4
View File
@@ -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
}
}
+8
View File
@@ -170,6 +170,10 @@ impl Tool for ReadFileTool {
true // File content could contain anything
}
fn is_idempotent(&self) -> bool {
true
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::UnlessAutoApproved
}
@@ -397,6 +401,10 @@ impl Tool for ListDirTool {
false // Directory listings are safe
}
fn is_idempotent(&self) -> bool {
true
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::UnlessAutoApproved
}
+8
View File
@@ -896,6 +896,10 @@ impl Tool for ListJobsTool {
fn requires_sanitization(&self) -> bool {
false
}
fn is_idempotent(&self) -> bool {
true
}
}
/// Tool for checking job status.
@@ -975,6 +979,10 @@ impl Tool for JobStatusTool {
fn requires_sanitization(&self) -> bool {
false
}
fn is_idempotent(&self) -> bool {
true
}
}
/// Tool for canceling a job.
+4
View File
@@ -132,6 +132,10 @@ impl Tool for JsonTool {
fn requires_sanitization(&self) -> bool {
false // Internal tool, no external data
}
fn is_idempotent(&self) -> bool {
true
}
}
fn parse_json_input(data: &serde_json::Value) -> Result<serde_json::Value, ToolError> {
+12
View File
@@ -114,6 +114,10 @@ impl Tool for MemorySearchTool {
fn requires_sanitization(&self) -> bool {
false // Internal memory, trusted content
}
fn is_idempotent(&self) -> bool {
true
}
}
/// Tool for writing to workspace memory.
@@ -377,6 +381,10 @@ impl Tool for MemoryReadTool {
fn requires_sanitization(&self) -> bool {
false // Internal memory
}
fn is_idempotent(&self) -> bool {
true
}
}
/// Tool for viewing workspace structure as a tree.
@@ -496,6 +504,10 @@ impl Tool for MemoryTreeTool {
fn requires_sanitization(&self) -> bool {
false // Internal tool
}
fn is_idempotent(&self) -> bool {
true
}
}
#[cfg(all(test, feature = "postgres"))]
+4
View File
@@ -95,6 +95,10 @@ impl Tool for TimeTool {
fn requires_sanitization(&self) -> bool {
false // Internal tool, no external data
}
fn is_idempotent(&self) -> bool {
true
}
}
fn execute_now(
+270
View File
@@ -0,0 +1,270 @@
//! Tool execution idempotency cache.
//!
//! Caches results of idempotent tool calls (same tool + same args = same result)
//! to avoid redundant re-execution during LLM retry loops, self-repair recovery,
//! and stuck job retries.
use std::sync::Arc;
use std::time::{Duration, Instant};
use lru::LruCache;
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
/// Cached tool result with expiration.
#[derive(Clone, Debug)]
struct CachedResult {
/// The serialized tool output string.
output: String,
/// When this entry was inserted.
inserted_at: Instant,
}
/// Configuration for the idempotency cache.
#[derive(Debug, Clone)]
pub struct IdempotencyCacheConfig {
/// Maximum total entries across all jobs. Default: 2000.
pub max_entries: usize,
/// Time-to-live for cached entries. Default: 30 minutes.
pub ttl: Duration,
}
impl Default for IdempotencyCacheConfig {
fn default() -> Self {
Self {
max_entries: 2000,
ttl: Duration::from_secs(30 * 60),
}
}
}
/// Global idempotency cache for tool executions.
///
/// Uses a single LRU cache with composite keys (job_id + tool_name + args_hash).
/// Entries expire after `ttl` and the total size is bounded by `max_entries`.
#[derive(Clone)]
pub struct ToolIdempotencyCache {
inner: Arc<Mutex<LruCache<String, CachedResult>>>,
config: IdempotencyCacheConfig,
}
impl ToolIdempotencyCache {
/// Create a new cache with the given configuration.
pub fn new(config: IdempotencyCacheConfig) -> Self {
let cap = std::num::NonZeroUsize::new(config.max_entries)
.unwrap_or(std::num::NonZeroUsize::new(1).expect("nonzero"));
Self {
inner: Arc::new(Mutex::new(LruCache::new(cap))),
config,
}
}
/// Look up a cached result. Returns `None` if absent or expired.
pub async fn get(
&self,
job_id: &str,
tool_name: &str,
params: &serde_json::Value,
) -> Option<String> {
let key = Self::cache_key(job_id, tool_name, params);
let mut cache = self.inner.lock().await;
if let Some(entry) = cache.get(&key) {
if entry.inserted_at.elapsed() < self.config.ttl {
return Some(entry.output.clone());
}
// Expired — remove it
cache.pop(&key);
}
None
}
/// Store a successful tool result in the cache.
pub async fn put(
&self,
job_id: &str,
tool_name: &str,
params: &serde_json::Value,
output: String,
) {
let key = Self::cache_key(job_id, tool_name, params);
let entry = CachedResult {
output,
inserted_at: Instant::now(),
};
let mut cache = self.inner.lock().await;
cache.put(key, entry);
}
/// Remove all cached entries for a specific job (call on job completion).
pub async fn invalidate_job(&self, job_id: &str) {
let prefix = format!("{}:", job_id);
let mut cache = self.inner.lock().await;
// Collect keys to remove (can't mutate while iterating)
let keys_to_remove: Vec<String> = cache
.iter()
.filter_map(|(k, _)| {
if k.starts_with(&prefix) {
Some(k.clone())
} else {
None
}
})
.collect();
for key in keys_to_remove {
cache.pop(&key);
}
}
/// Build a deterministic cache key from job_id, tool name, and params.
fn cache_key(job_id: &str, tool_name: &str, params: &serde_json::Value) -> String {
let mut hasher = Sha256::new();
hasher.update(tool_name.as_bytes());
hasher.update(b":");
// Canonical JSON via serde_json::to_string (keys are sorted in serde_json maps)
let params_str = serde_json::to_string(params).unwrap_or_default();
hasher.update(params_str.as_bytes());
let hash = format!("{:x}", hasher.finalize());
format!("{}:{}:{}", job_id, tool_name, hash)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> IdempotencyCacheConfig {
IdempotencyCacheConfig {
max_entries: 10,
ttl: Duration::from_secs(60),
}
}
#[tokio::test]
async fn test_cache_hit() {
let cache = ToolIdempotencyCache::new(config());
let params = serde_json::json!({"path": "/etc/hosts"});
cache
.put("job1", "read_file", &params, "file contents".into())
.await;
let result = cache.get("job1", "read_file", &params).await;
assert_eq!(result, Some("file contents".into()));
}
#[tokio::test]
async fn test_cache_miss() {
let cache = ToolIdempotencyCache::new(config());
let params = serde_json::json!({"path": "/etc/hosts"});
let result = cache.get("job1", "read_file", &params).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn test_different_params_miss() {
let cache = ToolIdempotencyCache::new(config());
let params1 = serde_json::json!({"path": "/etc/hosts"});
let params2 = serde_json::json!({"path": "/etc/passwd"});
cache
.put("job1", "read_file", &params1, "hosts".into())
.await;
let result = cache.get("job1", "read_file", &params2).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn test_different_jobs_isolated() {
let cache = ToolIdempotencyCache::new(config());
let params = serde_json::json!({"path": "/etc/hosts"});
cache
.put("job1", "read_file", &params, "from job1".into())
.await;
let result = cache.get("job2", "read_file", &params).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn test_invalidate_job() {
let cache = ToolIdempotencyCache::new(config());
let params = serde_json::json!({"q": "test"});
cache.put("job1", "echo", &params, "echo1".into()).await;
cache
.put("job1", "time", &serde_json::json!({}), "now".into())
.await;
cache.put("job2", "echo", &params, "echo2".into()).await;
cache.invalidate_job("job1").await;
assert_eq!(cache.get("job1", "echo", &params).await, None);
assert_eq!(
cache.get("job1", "time", &serde_json::json!({})).await,
None
);
// job2 unaffected
assert_eq!(
cache.get("job2", "echo", &params).await,
Some("echo2".into())
);
}
#[tokio::test]
async fn test_ttl_expiry() {
let cache = ToolIdempotencyCache::new(IdempotencyCacheConfig {
max_entries: 10,
ttl: Duration::from_millis(1),
});
let params = serde_json::json!({"x": 1});
cache.put("job1", "echo", &params, "val".into()).await;
tokio::time::sleep(Duration::from_millis(5)).await;
assert_eq!(cache.get("job1", "echo", &params).await, None);
}
#[tokio::test]
async fn test_lru_eviction() {
let cache = ToolIdempotencyCache::new(IdempotencyCacheConfig {
max_entries: 3,
ttl: Duration::from_secs(60),
});
// Fill to capacity
for i in 0..3 {
let params = serde_json::json!({"i": i});
cache.put("job1", "echo", &params, format!("val{i}")).await;
}
// Insert one more, evicting the oldest (i=0)
let params_new = serde_json::json!({"i": 99});
cache.put("job1", "echo", &params_new, "val99".into()).await;
assert_eq!(
cache
.get("job1", "echo", &serde_json::json!({"i": 0}))
.await,
None
);
assert_eq!(
cache
.get("job1", "echo", &serde_json::json!({"i": 2}))
.await,
Some("val2".into())
);
assert_eq!(
cache.get("job1", "echo", &params_new).await,
Some("val99".into())
);
}
#[tokio::test]
async fn test_cache_key_determinism() {
let key1 =
ToolIdempotencyCache::cache_key("j1", "echo", &serde_json::json!({"a": 1, "b": 2}));
let key2 =
ToolIdempotencyCache::cache_key("j1", "echo", &serde_json::json!({"a": 1, "b": 2}));
assert_eq!(key1, key2);
}
#[tokio::test]
async fn test_overwrite_existing_entry() {
let cache = ToolIdempotencyCache::new(config());
let params = serde_json::json!({"x": 1});
cache.put("job1", "echo", &params, "old".into()).await;
cache.put("job1", "echo", &params, "new".into()).await;
assert_eq!(cache.get("job1", "echo", &params).await, Some("new".into()));
}
}
+1
View File
@@ -9,6 +9,7 @@
pub mod builder;
pub mod builtin;
pub mod idempotency;
pub mod mcp;
pub mod rate_limiter;
pub mod schema_validator;
+11
View File
@@ -312,6 +312,17 @@ pub trait Tool: Send + Sync {
&[]
}
/// Whether this tool produces the same output for the same input.
///
/// Idempotent tools (read-only, pure functions) can have their results cached
/// to avoid re-execution when the LLM re-requests the same tool with identical
/// arguments (common during self-repair recovery or retry loops).
///
/// Default: `false`. Override to return `true` for read-only tools.
fn is_idempotent(&self) -> bool {
false
}
/// Per-invocation rate limit for this tool.
///
/// Return `Some(config)` to throttle how often this tool can be called per user.
+3
View File
@@ -624,6 +624,9 @@ impl TestRigBuilder {
},
transcription: None,
document_extraction: None,
idempotency_cache: ironclaw::tools::idempotency::ToolIdempotencyCache::new(
ironclaw::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
// 7. Create TestChannel and ChannelManager.