Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ _dirge_ is one of the smallest and most performant coding agents on the market.
- Binary size: 12MB
- RAM footprint: ~8MB on an empty session, ~12MB when working (vs ~300MB for opencode or other JS-based coding agents)

### Tool result caching

Read-only tool calls (`read`, `grep`, `find_files`, `list_dir`) are cached per agent turn. Repeated calls with identical arguments within the same turn return cached results, avoiding redundant filesystem I/O. The cache clears automatically before each new prompt.

## Installation

```bash
Expand Down
39 changes: 29 additions & 10 deletions src/agent/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use std::sync::Arc;

use crate::agent::prompt::{SYSTEM_PROMPT, TODO_TOOLS_PROMPT};
use crate::agent::tools;
use crate::agent::tools::ToolCache;
use crate::cli::Cli;
use crate::config::Config;
use crate::context::ContextFiles;
Expand Down Expand Up @@ -34,7 +35,7 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
parent_model: Option<AnyModel>,
#[cfg(feature = "mcp")] mcp_manager: Option<&McpClientManager>,
#[cfg(feature = "semantic")] semantic_manager: Option<&SemanticManager>,
) -> Agent<M> {
) -> (Agent<M>, ToolCache) {
let cwd = std::env::current_dir().unwrap_or_else(|_| ".".into());
let skills: Arc<[Skill]> = Arc::from(
tokio::task::spawn_blocking(move || skill::discover_skills(&cwd))
Expand Down Expand Up @@ -84,31 +85,49 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
}

if cli.resolve_no_tools(cfg) {
builder.build()
(builder.build(), ToolCache::new())
} else {
let cache = ToolCache::new();

let base_tools: Vec<Box<dyn rig::tool::ToolDyn>> = vec![
Box::new(tools::ReadTool::new(permission.clone(), ask_tx.clone())),
Box::new(tools::WriteTool::new(
Box::new(tools::ReadTool::with_cache(
permission.clone(),
ask_tx.clone(),
cache.clone(),
)),
Box::new(tools::WriteTool::with_cache(
permission.clone(),
ask_tx.clone(),
plan_file.clone(),
cache.clone(),
)),
Box::new(tools::EditTool::new(
Box::new(tools::EditTool::with_cache(
permission.clone(),
ask_tx.clone(),
plan_file.clone(),
cache.clone(),
)),
Box::new(tools::BashTool::new(
Box::new(tools::BashTool::with_cache(
permission.clone(),
ask_tx.clone(),
sandbox.clone(),
cache.clone(),
)),
Box::new(tools::GrepTool::with_cache(
permission.clone(),
ask_tx.clone(),
cache.clone(),
)),
Box::new(tools::FindFilesTool::with_cache(
permission.clone(),
ask_tx.clone(),
cache.clone(),
)),
Box::new(tools::GrepTool::new(permission.clone(), ask_tx.clone())),
Box::new(tools::FindFilesTool::new(
Box::new(tools::ListDirTool::with_cache(
permission.clone(),
ask_tx.clone(),
cache.clone(),
)),
Box::new(tools::ListDirTool::new(permission.clone(), ask_tx.clone())),
Box::new(tools::WriteTodoList::new(
permission.clone(),
ask_tx.clone(),
Expand Down Expand Up @@ -151,7 +170,7 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
}
}

builder.build()
(builder.build(), cache)
}
}

Expand Down
9 changes: 8 additions & 1 deletion src/agent/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use rig::message::ToolResultContent;
use rig::streaming::{StreamedAssistantContent, StreamedUserContent, StreamingChat};
use tokio::sync::mpsc;

use crate::agent::tools::ToolCache;
use crate::event::AgentEvent;
use crate::session::{MessageRole, Session};

Expand Down Expand Up @@ -35,12 +36,18 @@ pub fn convert_history(session: &Session) -> Vec<Message> {
messages
}

pub fn spawn_agent<M, P>(agent: Agent<M, P>, prompt: String, history: Vec<Message>) -> AgentRunner
pub fn spawn_agent<M, P>(
agent: Agent<M, P>,
prompt: String,
history: Vec<Message>,
cache: ToolCache,
) -> AgentRunner
where
M: CompletionModel + 'static,
M::StreamingResponse: Send + Sync + Unpin + Clone + 'static,
P: rig::agent::PromptHook<M> + 'static,
{
cache.clear();
let (event_tx, event_rx) = mpsc::channel::<AgentEvent>(256);

tokio::spawn(async move {
Expand Down
23 changes: 23 additions & 0 deletions src/agent/tools/bash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use rig::completion::ToolDefinition;
use rig::tool::Tool;
use tokio::time::{Duration, timeout};

use crate::agent::tools::cache::ToolCache;
use crate::agent::tools::{AskSender, BashArgs, PermCheck, ToolError, check_perm};

use crate::sandbox::Sandbox;
Expand All @@ -12,14 +13,31 @@ pub struct BashTool {
pub permission: Option<PermCheck>,
pub ask_tx: Option<AskSender>,
pub sandbox: Sandbox,
cache: Option<ToolCache>,
}

impl BashTool {
#[allow(dead_code)]
pub fn new(permission: Option<PermCheck>, ask_tx: Option<AskSender>, sandbox: Sandbox) -> Self {
BashTool {
permission,
ask_tx,
sandbox,
cache: None,
}
}

pub fn with_cache(
permission: Option<PermCheck>,
ask_tx: Option<AskSender>,
sandbox: Sandbox,
cache: ToolCache,
) -> Self {
BashTool {
permission,
ask_tx,
sandbox,
cache: Some(cache),
}
}
}
Expand Down Expand Up @@ -85,6 +103,11 @@ impl Tool for BashTool {
if exit_code != 0 {
result.push_str(&format!("\nExit code: {}", exit_code));
}
// Bash may have mutated the filesystem; conservatively invalidate the
// per-turn read/grep/list cache.
if let Some(ref cache) = self.cache {
cache.clear();
}
Ok(result)
}
}
Expand Down
92 changes: 92 additions & 0 deletions src/agent/tools/cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};

struct CacheEntry {
value: String,
generation: u64,
}

#[derive(Clone)]
pub struct ToolCache {
entries: Arc<Mutex<HashMap<String, CacheEntry>>>,
generation: Arc<AtomicU64>,
}

impl Default for ToolCache {
fn default() -> Self {
Self::new()
}
}

impl ToolCache {
pub fn new() -> Self {
Self {
entries: Arc::new(Mutex::new(HashMap::new())),
generation: Arc::new(AtomicU64::new(0)),
}
}

pub fn get(&self, key: &str) -> Option<String> {
let current_gen = self.generation.load(Ordering::Relaxed);
let guard = self.entries.lock().unwrap();
match guard.get(key) {
Some(e) if e.generation == current_gen => Some(e.value.clone()),
_ => None,
}
}

pub fn set(&self, key: &str, value: String) {
let current_gen = self.generation.load(Ordering::Relaxed);
self.entries.lock().unwrap().insert(
key.to_string(),
CacheEntry {
value,
generation: current_gen,
},
);
}

pub fn clear(&self) {
self.generation.fetch_add(1, Ordering::Relaxed);
self.entries.lock().unwrap().clear();
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_cache_hit_and_miss() {
let cache = ToolCache::new();
assert!(cache.get("key1").is_none());
cache.set("key1", "value1".to_string());
assert_eq!(cache.get("key1"), Some("value1".to_string()));
}

#[test]
fn test_cache_clear_invalidates_entries() {
let cache = ToolCache::new();
cache.set("key1", "value1".to_string());
cache.clear();
assert!(cache.get("key1").is_none());
}

#[test]
fn test_cache_clone_shares_state() {
let cache1 = ToolCache::new();
let cache2 = cache1.clone();
cache1.set("shared", "data".to_string());
assert_eq!(cache2.get("shared"), Some("data".to_string()));
}

#[test]
fn test_clear_in_one_clone_affects_other() {
let cache1 = ToolCache::new();
let cache2 = cache1.clone();
cache1.set("x", "y".to_string());
cache2.clear();
assert!(cache1.get("x").is_none());
}
}
22 changes: 22 additions & 0 deletions src/agent/tools/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@ use std::path::PathBuf;
use rig::completion::ToolDefinition;
use rig::tool::Tool;

use crate::agent::tools::cache::ToolCache;
use crate::agent::tools::{AskSender, EditArgs, PermCheck, ToolError, check_perm_path};

pub struct EditTool {
pub permission: Option<PermCheck>,
pub ask_tx: Option<AskSender>,
plan_file: Option<PathBuf>,
cache: Option<ToolCache>,
}

impl EditTool {
#[allow(dead_code)]
pub fn new(
permission: Option<PermCheck>,
ask_tx: Option<AskSender>,
Expand All @@ -21,6 +24,21 @@ impl EditTool {
permission,
ask_tx,
plan_file,
cache: None,
}
}

pub fn with_cache(
permission: Option<PermCheck>,
ask_tx: Option<AskSender>,
plan_file: Option<PathBuf>,
cache: ToolCache,
) -> Self {
EditTool {
permission,
ask_tx,
plan_file,
cache: Some(cache),
}
}

Expand Down Expand Up @@ -186,6 +204,10 @@ impl Tool for EditTool {
};

tokio::fs::write(&args.path, &output).await?;
// File mutated → invalidate cached reads/greps/listings for this turn.
if let Some(ref cache) = self.cache {
cache.clear();
}

let mut result = format!("Applied edit to {}", args.path);
if do_replace_all {
Expand Down
Loading
Loading