diff --git a/src/agent/tools/bash.rs b/src/agent/tools/bash.rs index ccfad7c..9bffbcf 100644 --- a/src/agent/tools/bash.rs +++ b/src/agent/tools/bash.rs @@ -8,11 +8,10 @@ use anyhow::{Context, Result}; use serde::Deserialize; -use serde_json::json; use std::process::Stdio; use tokio::io::AsyncReadExt; -use super::{ToolDef, default_timeout}; +use super::default_timeout; /// RAII guard that SIGTERMs the process group on drop. /// Ensures child processes are cleaned up when a task is aborted. @@ -34,29 +33,12 @@ struct Args { } pub fn tool() -> super::Tool { - super::Tool { def: definition(), handler: |_a, v| Box::pin(async move { run_bash(&v).await }) } -} - -fn definition() -> ToolDef { - ToolDef::new( - "bash", - "Execute a bash command and return its output. \ - Use for git operations, building, running tests, and other terminal tasks.", - json!({ - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The bash command to execute" - }, - "timeout_secs": { - "type": "integer", - "description": "Timeout in seconds (default 120)" - } - }, - "required": ["command"] - }), - ) + super::Tool { + name: "bash", + description: "Execute a bash command and return its output. Use for git operations, building, running tests, and other terminal tasks.", + parameters_json: r#"{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute"},"timeout_secs":{"type":"integer","description":"Timeout in seconds (default 120)"}},"required":["command"]}"#, + handler: |_a, v| Box::pin(async move { run_bash(&v).await }), + } } async fn run_bash(args: &serde_json::Value) -> Result { diff --git a/src/agent/tools/channels.rs b/src/agent/tools/channels.rs index 10b0575..c74eccd 100644 --- a/src/agent/tools/channels.rs +++ b/src/agent/tools/channels.rs @@ -5,45 +5,29 @@ use anyhow::{Context, Result}; use serde::Deserialize; -use serde_json::json; -use super::{Tool, ToolDef}; +use super::Tool; // ── Tool registry ────────────────────────────────────────────── -pub fn tools() -> Vec { - vec![ - Tool { - def: ToolDef::new("channel_list", - "List all available channels and their status (connected, unread count).", - json!({"type": "object", "properties": {}})), - handler: |_a, _v| Box::pin(async { channel_list().await }), - }, - Tool { - def: ToolDef::new("channel_recv", - "Read messages from a channel.", - json!({"type": "object", "properties": { - "channel": {"type": "string", "description": "Channel path (e.g. irc.#bcachefs, telegram.kent)"}, - "all_new": {"type": "boolean", "description": "If true, return all unconsumed messages. If false, return scrollback.", "default": true}, - "min_count": {"type": "integer", "description": "Minimum number of lines to return", "default": 20} - }, "required": ["channel"]})), - handler: |_a, v| Box::pin(async move { channel_recv(&v).await }), - }, - Tool { - def: ToolDef::new("channel_send", - "Send a message to a channel.", - json!({"type": "object", "properties": { - "channel": {"type": "string", "description": "Channel path (e.g. irc.#bcachefs, irc.pm.nick, telegram.kent)"}, - "message": {"type": "string", "description": "Message to send"} - }, "required": ["channel", "message"]})), - handler: |_a, v| Box::pin(async move { channel_send(&v).await }), - }, - Tool { - def: ToolDef::new("channel_notifications", - "Get pending channel notifications (unread signals). Does not consume messages — use channel_recv for that.", - json!({"type": "object", "properties": {}})), - handler: |_a, _v| Box::pin(async { channel_notifications().await }), - }, +pub fn tools() -> [Tool; 4] { + [ + Tool { name: "channel_list", + description: "List all available channels and their status (connected, unread count).", + parameters_json: r#"{"type":"object","properties":{}}"#, + handler: |_a, _v| Box::pin(async { channel_list().await }) }, + Tool { name: "channel_recv", + description: "Read messages from a channel.", + parameters_json: r#"{"type":"object","properties":{"channel":{"type":"string","description":"Channel path (e.g. irc.#bcachefs, telegram.kent)"},"all_new":{"type":"boolean","description":"If true, return all unconsumed messages","default":true},"min_count":{"type":"integer","description":"Minimum number of lines to return","default":20}},"required":["channel"]}"#, + handler: |_a, v| Box::pin(async move { channel_recv(&v).await }) }, + Tool { name: "channel_send", + description: "Send a message to a channel.", + parameters_json: r#"{"type":"object","properties":{"channel":{"type":"string","description":"Channel path (e.g. irc.#bcachefs, irc.pm.nick, telegram.kent)"},"message":{"type":"string","description":"Message to send"}},"required":["channel","message"]}"#, + handler: |_a, v| Box::pin(async move { channel_send(&v).await }) }, + Tool { name: "channel_notifications", + description: "Get pending channel notifications (unread signals). Does not consume messages — use channel_recv for that.", + parameters_json: r#"{"type":"object","properties":{}}"#, + handler: |_a, _v| Box::pin(async { channel_notifications().await }) }, ] } diff --git a/src/agent/tools/control.rs b/src/agent/tools/control.rs index 3c13c91..0c17ba1 100644 --- a/src/agent/tools/control.rs +++ b/src/agent/tools/control.rs @@ -7,9 +7,8 @@ use anyhow::{Context, Result}; use super::ToolOutput; -use super::ToolDef; -pub(super) fn pause(_args: &serde_json::Value) -> Result { +fn pause(_args: &serde_json::Value) -> Result { Ok(ToolOutput { text: "Pausing autonomous behavior. Only user input will wake you.".to_string(), is_yield: true, @@ -19,7 +18,7 @@ pub(super) fn pause(_args: &serde_json::Value) -> Result { }) } -pub(super) fn switch_model(args: &serde_json::Value) -> Result { +fn switch_model(args: &serde_json::Value) -> Result { let model = args .get("model") .and_then(|v| v.as_str()) @@ -36,7 +35,7 @@ pub(super) fn switch_model(args: &serde_json::Value) -> Result { }) } -pub(super) fn yield_to_user(args: &serde_json::Value) -> Result { +fn yield_to_user(args: &serde_json::Value) -> Result { let msg = args .get("message") .and_then(|v| v.as_str()) @@ -50,73 +49,26 @@ pub(super) fn yield_to_user(args: &serde_json::Value) -> Result { }) } -pub(super) fn tools() -> Vec { +pub(super) fn tools() -> [super::Tool; 3] { use super::Tool; - let defs = definitions(); - vec![ - Tool { def: defs[0].clone(), // switch_model + [ + Tool { name: "switch_model", + description: "Switch to a different LLM model mid-conversation. Memories and history carry over.", + parameters_json: r#"{"type":"object","properties":{"model":{"type":"string","description":"Name of the model to switch to"}},"required":["model"]}"#, handler: |_a, v| Box::pin(async move { let model = v.get("model").and_then(|v| v.as_str()).unwrap_or(""); Ok(format!("Switching to model: {}", model)) }) }, - Tool { def: defs[1].clone(), // pause + Tool { name: "pause", + description: "Pause all autonomous behavior. Only the user can unpause (Ctrl+P or /wake).", + parameters_json: r#"{"type":"object","properties":{}}"#, handler: |_a, _v| Box::pin(async { Ok("Pausing autonomous behavior. Only user input will wake you.".into()) }) }, - Tool { def: defs[2].clone(), // yield_to_user + Tool { name: "yield_to_user", + description: "Wait for user input before continuing. The only way to enter a waiting state.", + parameters_json: r#"{"type":"object","properties":{"message":{"type":"string","description":"Optional status message"}}}"#, handler: |_a, v| Box::pin(async move { let msg = v.get("message").and_then(|v| v.as_str()).unwrap_or("(yielding to user)"); Ok(msg.to_string()) }) }, ] } - -fn definitions() -> Vec { - vec![ - ToolDef::new( - "switch_model", - "Switch to a different LLM model mid-conversation. The switch \ - takes effect after the current turn completes. Use this when \ - a task would benefit from a different model's strengths. \ - Your memories and conversation history carry over.", - serde_json::json!({ - "type": "object", - "properties": { - "model": { - "type": "string", - "description": "Name of the model to switch to (configured in config.json5)" - } - }, - "required": ["model"] - }), - ), - ToolDef::new( - "pause", - "Pause all autonomous behavior (DMN). You will only run when \ - the user types something. Use this as a safety valve when \ - you're stuck in a loop, confused, or want to fully stop. \ - NOTE: only the user can unpause (Ctrl+P or /wake) — you \ - cannot undo this yourself.", - serde_json::json!({ - "type": "object", - "properties": {} - }), - ), - ToolDef::new( - "yield_to_user", - "Signal that you want to wait for user input before continuing. \ - Call this when you have a question for the user, when you've \ - completed their request and want feedback, or when you genuinely \ - want to pause. This is the ONLY way to enter a waiting state — \ - without calling this tool, the agent loop will keep prompting you \ - after a brief interval.", - serde_json::json!({ - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Optional status message (e.g., 'Waiting for your thoughts on the design')" - } - } - }), - ), - ] -} diff --git a/src/agent/tools/edit.rs b/src/agent/tools/edit.rs index 2304625..52f13ee 100644 --- a/src/agent/tools/edit.rs +++ b/src/agent/tools/edit.rs @@ -1,77 +1,28 @@ // tools/edit.rs — Search-and-replace file editing -// -// The edit tool performs exact string replacement in files. This is the -// same pattern used by Claude Code and aider — it's more reliable than -// line-number-based editing because the model specifies what it sees, -// not where it thinks it is. -// -// Supports replace_all for bulk renaming (e.g. variable renames). use anyhow::{Context, Result}; use serde::Deserialize; -use serde_json::json; - -use super::ToolDef; - -#[derive(Deserialize)] -struct Args { - file_path: String, - old_string: String, - new_string: String, - #[serde(default)] - replace_all: bool, -} pub fn tool() -> super::Tool { - super::Tool { def: definition(), handler: |_a, v| Box::pin(async move { edit_file(&v) }) } + super::Tool { + name: "edit_file", + description: "Perform exact string replacement in a file. The old_string must appear exactly once (unless replace_all is true). Use read_file first to see current contents.", + parameters_json: r#"{"type":"object","properties":{"file_path":{"type":"string","description":"Absolute path to the file to edit"},"old_string":{"type":"string","description":"The exact text to find and replace"},"new_string":{"type":"string","description":"The replacement text"},"replace_all":{"type":"boolean","description":"Replace all occurrences (default false)"}},"required":["file_path","old_string","new_string"]}"#, + handler: |_a, v| Box::pin(async move { edit_file(&v) }), + } } -fn definition() -> ToolDef { - ToolDef::new( - "edit_file", - "Perform exact string replacement in a file. The old_string must appear \ - exactly once in the file (unless replace_all is true). Use read_file first \ - to see the current contents.", - json!({ - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Absolute path to the file to edit" - }, - "old_string": { - "type": "string", - "description": "The exact text to find and replace" - }, - "new_string": { - "type": "string", - "description": "The replacement text" - }, - "replace_all": { - "type": "boolean", - "description": "Replace all occurrences (default false)" - } - }, - "required": ["file_path", "old_string", "new_string"] - }), - ) -} +#[derive(Deserialize)] +struct Args { file_path: String, old_string: String, new_string: String, #[serde(default)] replace_all: bool } fn edit_file(args: &serde_json::Value) -> Result { - let a: Args = serde_json::from_value(args.clone()) - .context("invalid edit_file arguments")?; - - if a.old_string == a.new_string { - anyhow::bail!("old_string and new_string are identical"); - } + let a: Args = serde_json::from_value(args.clone()).context("invalid edit_file arguments")?; + if a.old_string == a.new_string { anyhow::bail!("old_string and new_string are identical"); } let content = std::fs::read_to_string(&a.file_path) .with_context(|| format!("Failed to read {}", a.file_path))?; - let count = content.matches(&*a.old_string).count(); - if count == 0 { - anyhow::bail!("old_string not found in {}", a.file_path); - } + if count == 0 { anyhow::bail!("old_string not found in {}", a.file_path); } if a.replace_all { let new_content = content.replace(&*a.old_string, &a.new_string); @@ -80,11 +31,7 @@ fn edit_file(args: &serde_json::Value) -> Result { Ok(format!("Replaced {} occurrences in {}", count, a.file_path)) } else { if count > 1 { - anyhow::bail!( - "old_string appears {} times in {} — use replace_all or provide more context \ - to make it unique", - count, a.file_path - ); + anyhow::bail!("old_string appears {} times in {} — use replace_all or provide more context", count, a.file_path); } let new_content = content.replacen(&*a.old_string, &a.new_string, 1); std::fs::write(&a.file_path, &new_content) diff --git a/src/agent/tools/glob.rs b/src/agent/tools/glob.rs index 1e3519f..18752bf 100644 --- a/src/agent/tools/glob.rs +++ b/src/agent/tools/glob.rs @@ -6,11 +6,8 @@ use anyhow::{Context, Result}; use serde::Deserialize; -use serde_json::json; use std::path::PathBuf; -use super::ToolDef; - #[derive(Deserialize)] struct Args { pattern: String, @@ -21,30 +18,12 @@ struct Args { fn default_path() -> String { ".".into() } pub fn tool() -> super::Tool { - super::Tool { def: definition(), handler: |_a, v| Box::pin(async move { glob_search(&v) }) } -} - -fn definition() -> ToolDef { - ToolDef::new( - "glob", - "Find files matching a glob pattern. Returns file paths sorted by \ - modification time (newest first). Use patterns like '**/*.rs', \ - 'src/**/*.ts', or 'Cargo.toml'.", - json!({ - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "Glob pattern to match files (e.g. '**/*.rs')" - }, - "path": { - "type": "string", - "description": "Base directory to search from (default: current directory)" - } - }, - "required": ["pattern"] - }), - ) + super::Tool { + name: "glob", + description: "Find files matching a glob pattern. Returns file paths sorted by modification time (newest first).", + parameters_json: r#"{"type":"object","properties":{"pattern":{"type":"string","description":"Glob pattern to match files (e.g. '**/*.rs')"},"path":{"type":"string","description":"Directory to search in (default: current directory)"}},"required":["pattern"]}"#, + handler: |_a, v| Box::pin(async move { glob_search(&v) }), + } } fn glob_search(args: &serde_json::Value) -> Result { diff --git a/src/agent/tools/grep.rs b/src/agent/tools/grep.rs index 66f85f2..b02379e 100644 --- a/src/agent/tools/grep.rs +++ b/src/agent/tools/grep.rs @@ -5,11 +5,8 @@ use anyhow::{Context, Result}; use serde::Deserialize; -use serde_json::json; use std::process::Command; -use super::ToolDef; - #[derive(Deserialize)] struct Args { pattern: String, @@ -24,41 +21,12 @@ struct Args { fn default_path() -> String { ".".into() } pub fn tool() -> super::Tool { - super::Tool { def: definition(), handler: |_a, v| Box::pin(async move { grep(&v) }) } -} - -fn definition() -> ToolDef { - ToolDef::new( - "grep", - "Search for a pattern in files. Returns matching file paths by default, \ - or matching lines with context.", - json!({ - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "Regex pattern to search for" - }, - "path": { - "type": "string", - "description": "Directory or file to search in (default: current directory)" - }, - "glob": { - "type": "string", - "description": "Glob pattern to filter files (e.g. '*.rs', '*.py')" - }, - "show_content": { - "type": "boolean", - "description": "Show matching lines instead of just file paths" - }, - "context_lines": { - "type": "integer", - "description": "Number of context lines around matches (requires show_content)" - } - }, - "required": ["pattern"] - }), - ) + super::Tool { + name: "grep", + description: "Search for a pattern in files. Returns matching file paths by default, or matching lines with context.", + parameters_json: r#"{"type":"object","properties":{"pattern":{"type":"string","description":"Regex pattern to search for"},"path":{"type":"string","description":"Directory or file to search in (default: current directory)"},"glob":{"type":"string","description":"Glob pattern to filter files (e.g. '*.rs')"},"show_content":{"type":"boolean","description":"Show matching lines instead of just file paths"},"context_lines":{"type":"integer","description":"Lines of context around matches"}},"required":["pattern"]}"#, + handler: |_a, v| Box::pin(async move { grep(&v) }), + } } /// Check if ripgrep is available (cached after first check). diff --git a/src/agent/tools/memory.rs b/src/agent/tools/memory.rs index bee2a8c..ae9c036 100644 --- a/src/agent/tools/memory.rs +++ b/src/agent/tools/memory.rs @@ -4,11 +4,9 @@ // One function per tool for use in the Tool registry. use anyhow::{Context, Result}; -use serde_json::json; use crate::hippocampus::memory::MemoryNode; use crate::store::StoreView; -use super::ToolDef; use crate::store::Store; // ── Helpers ──────────────────────────────────────────────────── @@ -29,41 +27,65 @@ fn provenance() -> &'static str { "manual" } // ── Definitions ──────────────────────────────────────────────── -pub fn memory_tools() -> Vec { +pub fn memory_tools() -> [super::Tool; 12] { use super::Tool; - vec![ - Tool { def: render_def(), handler: |_a, v| Box::pin(async move { render(&v) }) }, - Tool { def: write_def(), handler: |_a, v| Box::pin(async move { write(&v) }) }, - Tool { def: search_def(), handler: |_a, v| Box::pin(async move { search(&v) }) }, - Tool { def: links_def(), handler: |_a, v| Box::pin(async move { links(&v) }) }, - Tool { def: link_set_def(), handler: |_a, v| Box::pin(async move { link_set(&v) }) }, - Tool { def: link_add_def(), handler: |_a, v| Box::pin(async move { link_add(&v) }) }, - Tool { def: used_def(), handler: |_a, v| Box::pin(async move { used(&v) }) }, - Tool { def: weight_set_def(), handler: |_a, v| Box::pin(async move { weight_set(&v) }) }, - Tool { def: rename_def(), handler: |_a, v| Box::pin(async move { rename(&v) }) }, - Tool { def: supersede_def(), handler: |_a, v| Box::pin(async move { supersede(&v) }) }, - Tool { def: query_def(), handler: |_a, v| Box::pin(async move { query(&v) }) }, - Tool { def: output_def(), handler: |_a, v| Box::pin(async move { output(&v) }) }, + [ + Tool { name: "memory_render", description: "Read a memory node's content and links.", + parameters_json: r#"{"type":"object","properties":{"key":{"type":"string","description":"Node key"}},"required":["key"]}"#, + handler: |_a, v| Box::pin(async move { render(&v) }) }, + Tool { name: "memory_write", description: "Create or update a memory node.", + parameters_json: r#"{"type":"object","properties":{"key":{"type":"string","description":"Node key"},"content":{"type":"string","description":"Full content (markdown)"}},"required":["key","content"]}"#, + handler: |_a, v| Box::pin(async move { write(&v) }) }, + Tool { name: "memory_search", description: "Search the memory graph via spreading activation. Give 2-4 seed node keys.", + parameters_json: r#"{"type":"object","properties":{"keys":{"type":"array","items":{"type":"string"},"description":"Seed node keys to activate from"}},"required":["keys"]}"#, + handler: |_a, v| Box::pin(async move { search(&v) }) }, + Tool { name: "memory_links", description: "Show a node's neighbors with link strengths.", + parameters_json: r#"{"type":"object","properties":{"key":{"type":"string","description":"Node key"}},"required":["key"]}"#, + handler: |_a, v| Box::pin(async move { links(&v) }) }, + Tool { name: "memory_link_set", description: "Set link strength between two nodes.", + parameters_json: r#"{"type":"object","properties":{"source":{"type":"string"},"target":{"type":"string"},"strength":{"type":"number","description":"0.01 to 1.0"}},"required":["source","target","strength"]}"#, + handler: |_a, v| Box::pin(async move { link_set(&v) }) }, + Tool { name: "memory_link_add", description: "Add a new link between two nodes.", + parameters_json: r#"{"type":"object","properties":{"source":{"type":"string"},"target":{"type":"string"}},"required":["source","target"]}"#, + handler: |_a, v| Box::pin(async move { link_add(&v) }) }, + Tool { name: "memory_used", description: "Mark a node as useful (boosts weight).", + parameters_json: r#"{"type":"object","properties":{"key":{"type":"string","description":"Node key"}},"required":["key"]}"#, + handler: |_a, v| Box::pin(async move { used(&v) }) }, + Tool { name: "memory_weight_set", description: "Set a node's weight directly (0.01 to 1.0).", + parameters_json: r#"{"type":"object","properties":{"key":{"type":"string"},"weight":{"type":"number","description":"0.01 to 1.0"}},"required":["key","weight"]}"#, + handler: |_a, v| Box::pin(async move { weight_set(&v) }) }, + Tool { name: "memory_rename", description: "Rename a node key in place.", + parameters_json: r#"{"type":"object","properties":{"old_key":{"type":"string"},"new_key":{"type":"string"}},"required":["old_key","new_key"]}"#, + handler: |_a, v| Box::pin(async move { rename(&v) }) }, + Tool { name: "memory_supersede", description: "Mark a node as superseded by another (sets weight to 0.01).", + parameters_json: r#"{"type":"object","properties":{"old_key":{"type":"string"},"new_key":{"type":"string"},"reason":{"type":"string"}},"required":["old_key","new_key"]}"#, + handler: |_a, v| Box::pin(async move { supersede(&v) }) }, + Tool { name: "memory_query", description: "Run a structured query against the memory graph.", + parameters_json: r#"{"type":"object","properties":{"query":{"type":"string","description":"Query expression"}},"required":["query"]}"#, + handler: |_a, v| Box::pin(async move { query(&v) }) }, + Tool { name: "output", description: "Produce a named output value for passing between steps.", + parameters_json: r#"{"type":"object","properties":{"key":{"type":"string","description":"Output name"},"value":{"type":"string","description":"Output value"}},"required":["key","value"]}"#, + handler: |_a, v| Box::pin(async move { output(&v) }) }, ] } -pub fn journal_tools() -> Vec { +pub fn journal_tools() -> [super::Tool; 3] { use super::Tool; - vec![ - Tool { def: journal_tail_def(), handler: |_a, v| Box::pin(async move { journal_tail(&v) }) }, - Tool { def: journal_new_def(), handler: |_a, v| Box::pin(async move { journal_new(&v) }) }, - Tool { def: journal_update_def(), handler: |_a, v| Box::pin(async move { journal_update(&v) }) }, + [ + Tool { name: "journal_tail", description: "Read the last N journal entries (default 1).", + parameters_json: r#"{"type":"object","properties":{"count":{"type":"integer","description":"Number of entries (default 1)"}}}"#, + handler: |_a, v| Box::pin(async move { journal_tail(&v) }) }, + Tool { name: "journal_new", description: "Start a new journal entry.", + parameters_json: r#"{"type":"object","properties":{"name":{"type":"string","description":"Short node name (becomes the key)"},"title":{"type":"string","description":"Descriptive title"},"body":{"type":"string","description":"Entry body"}},"required":["name","title","body"]}"#, + handler: |_a, v| Box::pin(async move { journal_new(&v) }) }, + Tool { name: "journal_update", description: "Append text to the most recent journal entry.", + parameters_json: r#"{"type":"object","properties":{"body":{"type":"string","description":"Text to append"}},"required":["body"]}"#, + handler: |_a, v| Box::pin(async move { journal_update(&v) }) }, ] } // ── Memory tools ─────────────────────────────────────────────── -fn render_def() -> ToolDef { - ToolDef::new("memory_render", - "Read a memory node's content and links.", - json!({"type":"object","properties":{"key":{"type":"string","description":"Node key"}},"required":["key"]})) -} - fn render(args: &serde_json::Value) -> Result { let key = get_str(args, "key")?; Ok(MemoryNode::load(key) @@ -71,12 +93,6 @@ fn render(args: &serde_json::Value) -> Result { .render()) } -fn write_def() -> ToolDef { - ToolDef::new("memory_write", - "Create or update a memory node.", - json!({"type":"object","properties":{"key":{"type":"string","description":"Node key"},"content":{"type":"string","description":"Full content (markdown)"}},"required":["key","content"]})) -} - fn write(args: &serde_json::Value) -> Result { let key = get_str(args, "key")?; let content = get_str(args, "content")?; @@ -87,15 +103,6 @@ fn write(args: &serde_json::Value) -> Result { Ok(format!("{} '{}'", result, key)) } -fn search_def() -> ToolDef { - ToolDef::new("memory_search", - "Search the memory graph via spreading activation. Give 2-4 seed \ - node keys related to what you're looking for. Returns nodes ranked \ - by how strongly they connect to your seeds — bridging nodes score \ - highest. This finds conceptual connections, not just keyword matches.", - json!({"type":"object","properties":{"keys":{"type":"array","items":{"type":"string"},"description":"Seed node keys to activate from"}},"required":["keys"]})) -} - fn search(args: &serde_json::Value) -> Result { let keys: Vec = args.get("keys") .and_then(|v| v.as_array()) @@ -129,12 +136,6 @@ fn search(args: &serde_json::Value) -> Result { .collect::>().join("\n")) } -fn links_def() -> ToolDef { - ToolDef::new("memory_links", - "Show a node's neighbors with link strengths.", - json!({"type":"object","properties":{"key":{"type":"string","description":"Node key"}},"required":["key"]})) -} - fn links(args: &serde_json::Value) -> Result { let key = get_str(args, "key")?; let node = MemoryNode::load(key) @@ -147,12 +148,6 @@ fn links(args: &serde_json::Value) -> Result { Ok(out) } -fn link_set_def() -> ToolDef { - ToolDef::new("memory_link_set", - "Set link strength between two nodes.", - json!({"type":"object","properties":{"source":{"type":"string"},"target":{"type":"string"},"strength":{"type":"number","description":"0.01 to 1.0"}},"required":["source","target","strength"]})) -} - fn link_set(args: &serde_json::Value) -> Result { let mut store = load_store()?; let s = store.resolve_key(get_str(args, "source")?).map_err(|e| anyhow::anyhow!("{}", e))?; @@ -163,12 +158,6 @@ fn link_set(args: &serde_json::Value) -> Result { Ok(format!("{} ↔ {} strength {:.2} → {:.2}", s, t, old, strength)) } -fn link_add_def() -> ToolDef { - ToolDef::new("memory_link_add", - "Add a new link between two nodes.", - json!({"type":"object","properties":{"source":{"type":"string"},"target":{"type":"string"}},"required":["source","target"]})) -} - fn link_add(args: &serde_json::Value) -> Result { let mut store = load_store()?; let s = store.resolve_key(get_str(args, "source")?).map_err(|e| anyhow::anyhow!("{}", e))?; @@ -178,12 +167,6 @@ fn link_add(args: &serde_json::Value) -> Result { Ok(format!("linked {} → {} (strength={:.2})", s, t, strength)) } -fn used_def() -> ToolDef { - ToolDef::new("memory_used", - "Mark a node as useful (boosts weight).", - json!({"type":"object","properties":{"key":{"type":"string","description":"Node key"}},"required":["key"]})) -} - fn used(args: &serde_json::Value) -> Result { let key = get_str(args, "key")?; let mut store = load_store()?; @@ -195,12 +178,6 @@ fn used(args: &serde_json::Value) -> Result { Ok(format!("marked {} as used", key)) } -fn weight_set_def() -> ToolDef { - ToolDef::new("memory_weight_set", - "Set a node's weight directly (0.01 to 1.0).", - json!({"type":"object","properties":{"key":{"type":"string"},"weight":{"type":"number","description":"0.01 to 1.0"}},"required":["key","weight"]})) -} - fn weight_set(args: &serde_json::Value) -> Result { let mut store = load_store()?; let key = store.resolve_key(get_str(args, "key")?).map_err(|e| anyhow::anyhow!("{}", e))?; @@ -210,12 +187,6 @@ fn weight_set(args: &serde_json::Value) -> Result { Ok(format!("weight {} {:.2} → {:.2}", key, old, new)) } -fn rename_def() -> ToolDef { - ToolDef::new("memory_rename", - "Rename a node key in place. Same content, same links, new key.", - json!({"type":"object","properties":{"old_key":{"type":"string"},"new_key":{"type":"string"}},"required":["old_key","new_key"]})) -} - fn rename(args: &serde_json::Value) -> Result { let old_key = get_str(args, "old_key")?; let new_key = get_str(args, "new_key")?; @@ -226,12 +197,6 @@ fn rename(args: &serde_json::Value) -> Result { Ok(format!("Renamed '{}' → '{}'", resolved, new_key)) } -fn supersede_def() -> ToolDef { - ToolDef::new("memory_supersede", - "Mark a node as superseded by another (sets weight to 0.01).", - json!({"type":"object","properties":{"old_key":{"type":"string"},"new_key":{"type":"string"},"reason":{"type":"string"}},"required":["old_key","new_key"]})) -} - fn supersede(args: &serde_json::Value) -> Result { let old_key = get_str(args, "old_key")?; let new_key = get_str(args, "new_key")?; @@ -249,14 +214,6 @@ fn supersede(args: &serde_json::Value) -> Result { Ok(format!("superseded {} → {} ({})", old_key, new_key, reason)) } -fn query_def() -> ToolDef { - ToolDef::new("memory_query", - "Run a structured query against the memory graph. Supports filtering, \ - sorting, field selection. Examples: \"degree > 10 | sort weight | limit 5\", \ - \"neighbors('identity') | select strength\", \"key ~ 'journal.*' | count\"", - json!({"type":"object","properties":{"query":{"type":"string","description":"Query expression"}},"required":["query"]})) -} - fn query(args: &serde_json::Value) -> Result { let query_str = get_str(args, "query")?; let store = load_store()?; @@ -265,16 +222,6 @@ fn query(args: &serde_json::Value) -> Result { .map_err(|e| anyhow::anyhow!("{}", e)) } -fn output_def() -> ToolDef { - ToolDef::new("output", - "Produce a named output value. Use this to pass structured results \ - between steps — subsequent prompts can see these in the conversation history.", - json!({"type":"object","properties":{ - "key":{"type":"string","description":"Output name (e.g. 'relevant_memories')"}, - "value":{"type":"string","description":"Output value"} - },"required":["key","value"]})) -} - fn output(args: &serde_json::Value) -> Result { let key = get_str(args, "key")?; if key.starts_with("pid-") || key.contains('/') || key.contains("..") { @@ -291,14 +238,6 @@ fn output(args: &serde_json::Value) -> Result { // ── Journal tools ────────────────────────────────────────────── -fn journal_tail_def() -> ToolDef { - ToolDef::new("journal_tail", - "Read the last N journal entries (default 1).", - json!({"type":"object","properties":{ - "count":{"type":"integer","description":"Number of entries (default 1)"} - }})) -} - fn journal_tail(args: &serde_json::Value) -> Result { let count = args.get("count").and_then(|v| v.as_u64()).unwrap_or(1) as usize; let store = load_store()?; @@ -317,16 +256,6 @@ fn journal_tail(args: &serde_json::Value) -> Result { } } -fn journal_new_def() -> ToolDef { - ToolDef::new("journal_new", - "Start a new journal entry.", - json!({"type":"object","properties":{ - "name":{"type":"string","description":"Short node name (becomes the key)"}, - "title":{"type":"string","description":"Descriptive title for the heading"}, - "body":{"type":"string","description":"Entry body (2-3 paragraphs)"} - },"required":["name","title","body"]})) -} - fn journal_new(args: &serde_json::Value) -> Result { let name = get_str(args, "name")?; let title = get_str(args, "title")?; @@ -363,14 +292,6 @@ fn journal_new(args: &serde_json::Value) -> Result { Ok(format!("New entry '{}' ({} words)", title, word_count)) } -fn journal_update_def() -> ToolDef { - ToolDef::new("journal_update", - "Append text to the most recent journal entry (same thread continuing).", - json!({"type":"object","properties":{ - "body":{"type":"string","description":"Text to append to the last entry"} - },"required":["body"]})) -} - fn journal_update(args: &serde_json::Value) -> Result { let body = get_str(args, "body")?; let mut store = load_store()?; diff --git a/src/agent/tools/mod.rs b/src/agent/tools/mod.rs index d51d7f1..ac10f85 100644 --- a/src/agent/tools/mod.rs +++ b/src/agent/tools/mod.rs @@ -35,11 +35,40 @@ pub type ToolHandler = fn( ) -> Pin> + Send>>; /// A tool with its definition and handler — single source of truth. +/// Strings are static — the tool list JSON can be built without +/// serialization by interpolating these directly. pub struct Tool { - pub def: ToolDef, + pub name: &'static str, + pub description: &'static str, + pub parameters_json: &'static str, pub handler: ToolHandler, } +impl Tool { + /// Build the JSON for this tool's definition (for the API tools array). + pub fn to_json(&self) -> String { + format!( + r#"{{"type":"function","function":{{"name":"{}","description":"{}","parameters":{}}}}}"#, + self.name, + self.description.replace('"', r#"\""#), + self.parameters_json, + ) + } + + /// Build a ToolDef (for backward compat where ToolDef is still used). + pub fn to_tool_def(&self) -> ToolDef { + ToolDef { + tool_type: "function".to_string(), + function: FunctionDef { + name: self.name.to_string(), + description: self.description.to_string(), + parameters: serde_json::from_str(self.parameters_json) + .expect("invalid JSON in tool parameters"), + }, + } + } +} + /// Function call within a tool call — name + JSON arguments. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FunctionCall { @@ -55,6 +84,7 @@ pub struct FunctionDef { pub parameters: serde_json::Value, } + /// Partial function call within a streaming delta. #[derive(Debug, Deserialize)] pub struct FunctionCallDelta { @@ -173,7 +203,7 @@ pub async fn dispatch_with_agent( agent: Option>>, ) -> ToolOutput { for tool in tools() { - if tool.def.function.name == name { + if tool.name == name { return match (tool.handler)(agent, args.clone()).await { Ok(s) => ToolOutput::text(s), Err(e) => ToolOutput::error(e), @@ -190,7 +220,7 @@ pub async fn dispatch_shared( _provenance: Option<&str>, ) -> Option { for tool in tools() { - if tool.def.function.name == name { + if tool.name == name { return Some(match (tool.handler)(None, args.clone()).await { Ok(s) => ToolOutput::text(s), Err(e) => ToolOutput::error(e), @@ -214,16 +244,16 @@ pub fn tools() -> Vec { all } -/// Return all tool definitions (extracted from tools()). +/// Return all tool definitions for the API. pub fn definitions() -> Vec { - tools().into_iter().map(|t| t.def).collect() + tools().into_iter().map(|t| t.to_tool_def()).collect() } /// Return memory + journal tool definitions only. pub fn memory_and_journal_definitions() -> Vec { memory::memory_tools().into_iter() .chain(memory::journal_tools()) - .map(|t| t.def) + .map(|t| t.to_tool_def()) .collect() } diff --git a/src/agent/tools/read.rs b/src/agent/tools/read.rs index 24186e9..0320798 100644 --- a/src/agent/tools/read.rs +++ b/src/agent/tools/read.rs @@ -2,9 +2,15 @@ use anyhow::{Context, Result}; use serde::Deserialize; -use serde_json::json; -use super::ToolDef; +pub fn tool() -> super::Tool { + super::Tool { + name: "read_file", + description: "Read the contents of a file. Returns the file contents with line numbers.", + parameters_json: r#"{"type":"object","properties":{"file_path":{"type":"string","description":"Absolute path to the file to read"},"offset":{"type":"integer","description":"Line number to start reading from (1-based)"},"limit":{"type":"integer","description":"Maximum number of lines to read"}},"required":["file_path"]}"#, + handler: |_a, v| Box::pin(async move { read_file(&v) }), + } +} #[derive(Deserialize)] struct Args { @@ -13,57 +19,21 @@ struct Args { offset: usize, limit: Option, } - fn default_offset() -> usize { 1 } -pub fn tool() -> super::Tool { - super::Tool { def: definition(), handler: |_a, v| Box::pin(async move { read_file(&v) }) } -} - -fn definition() -> ToolDef { - ToolDef::new( - "read_file", - "Read the contents of a file. Returns the file contents with line numbers.", - json!({ - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Absolute path to the file to read" - }, - "offset": { - "type": "integer", - "description": "Line number to start reading from (1-based). Optional." - }, - "limit": { - "type": "integer", - "description": "Maximum number of lines to read. Optional." - } - }, - "required": ["file_path"] - }), - ) -} - fn read_file(args: &serde_json::Value) -> Result { - let args: Args = serde_json::from_value(args.clone()) - .context("invalid read_file arguments")?; - - let content = std::fs::read_to_string(&args.file_path) - .with_context(|| format!("Failed to read {}", args.file_path))?; - + let a: Args = serde_json::from_value(args.clone()).context("invalid read_file arguments")?; + let content = std::fs::read_to_string(&a.file_path) + .with_context(|| format!("Failed to read {}", a.file_path))?; let lines: Vec<&str> = content.lines().collect(); - let offset = args.offset.max(1) - 1; - let limit = args.limit.unwrap_or(lines.len()); - - let mut output = String::new(); - for (i, line) in lines.iter().skip(offset).take(limit).enumerate() { - output.push_str(&format!("{:>6}\t{}\n", offset + i + 1, line)); + let start = a.offset.saturating_sub(1); + let end = a.limit.map_or(lines.len(), |l| (start + l).min(lines.len())); + if start >= lines.len() { + return Ok(format!("(file has {} lines, offset {} is past end)", lines.len(), a.offset)); } - - if output.is_empty() { - output = "(empty file)\n".to_string(); + let mut out = String::new(); + for (i, line) in lines[start..end].iter().enumerate() { + out.push_str(&format!("{}\t{}\n", start + i + 1, line)); } - - Ok(output) + Ok(out) } diff --git a/src/agent/tools/vision.rs b/src/agent/tools/vision.rs index f5536c3..17e1c9d 100644 --- a/src/agent/tools/vision.rs +++ b/src/agent/tools/vision.rs @@ -9,7 +9,6 @@ use base64::Engine; use serde::Deserialize; use super::ToolOutput; -use super::ToolDef; #[derive(Deserialize)] struct Args { @@ -21,35 +20,13 @@ struct Args { fn default_lines() -> usize { 50 } -fn definition() -> ToolDef { - ToolDef::new( - "view_image", - "View an image file or capture a tmux pane screenshot. \ - Returns the image to your visual input so you can see it. \ - Supports PNG, JPEG, GIF, WebP files. \ - Use pane_id (e.g. '0:1.0') to capture a tmux pane instead.", - serde_json::json!({ - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to an image file (PNG, JPEG, GIF, WebP)" - }, - "pane_id": { - "type": "string", - "description": "Tmux pane ID to capture (e.g. '0:1.0'). Alternative to file_path." - }, - "lines": { - "type": "integer", - "description": "Number of lines to capture from tmux pane (default: 50)" - } - } - }), - ) -} - pub fn tool() -> super::Tool { - super::Tool { def: definition(), handler: |_a, v| Box::pin(async move { view_image_text(&v) }) } + super::Tool { + name: "view_image", + description: "View an image file or capture a tmux pane screenshot. Supports PNG, JPEG, GIF, WebP. Use pane_id to capture a tmux pane instead.", + parameters_json: r#"{"type":"object","properties":{"file_path":{"type":"string","description":"Path to an image file"},"pane_id":{"type":"string","description":"Tmux pane ID to capture (e.g. '0:1.0')"},"lines":{"type":"integer","description":"Lines to capture from tmux pane (default 50)"}}}"#, + handler: |_a, v| Box::pin(async move { view_image_text(&v) }), + } } /// Text-only version for the Tool registry. diff --git a/src/agent/tools/web.rs b/src/agent/tools/web.rs index dc4b459..8dce8b3 100644 --- a/src/agent/tools/web.rs +++ b/src/agent/tools/web.rs @@ -2,41 +2,26 @@ use anyhow::{Context, Result}; use serde::Deserialize; -use serde_json::json; - -use super::ToolDef; pub fn tools() -> [super::Tool; 2] { [ - super::Tool { def: fetch_definition(), handler: |_a, v| Box::pin(async move { web_fetch(&v).await }) }, - super::Tool { def: search_definition(), handler: |_a, v| Box::pin(async move { web_search(&v).await }) }, + super::Tool { + name: "web_fetch", + description: "Fetch content from a URL and return it as text. Use for reading web pages, API responses, documentation.", + parameters_json: r#"{"type":"object","properties":{"url":{"type":"string","description":"The URL to fetch"}},"required":["url"]}"#, + handler: |_a, v| Box::pin(async move { web_fetch(&v).await }), + }, + super::Tool { + name: "web_search", + description: "Search the web and return results. Use for finding documentation, looking up APIs, researching topics.", + parameters_json: r#"{"type":"object","properties":{"query":{"type":"string","description":"The search query"},"num_results":{"type":"integer","description":"Number of results to return (default 5)"}},"required":["query"]}"#, + handler: |_a, v| Box::pin(async move { web_search(&v).await }), + }, ] } -// ── Fetch ─────────────────────────────────────────────────────── - #[derive(Deserialize)] -struct FetchArgs { - url: String, -} - -fn fetch_definition() -> ToolDef { - ToolDef::new( - "web_fetch", - "Fetch content from a URL and return it as text. \ - Use for reading web pages, API responses, documentation.", - json!({ - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "The URL to fetch" - } - }, - "required": ["url"] - }), - ) -} +struct FetchArgs { url: String } async fn web_fetch(args: &serde_json::Value) -> Result { let a: FetchArgs = serde_json::from_value(args.clone()) @@ -71,28 +56,6 @@ struct SearchArgs { fn default_num_results() -> usize { 5 } -fn search_definition() -> ToolDef { - ToolDef::new( - "web_search", - "Search the web and return results. Use for finding \ - documentation, looking up APIs, researching topics.", - json!({ - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "The search query" - }, - "num_results": { - "type": "integer", - "description": "Number of results to return (default 5)" - } - }, - "required": ["query"] - }), - ) -} - async fn web_search(args: &serde_json::Value) -> Result { let a: SearchArgs = serde_json::from_value(args.clone()) .context("invalid web_search arguments")?; diff --git a/src/agent/tools/write.rs b/src/agent/tools/write.rs index 1917e26..ff1cdb0 100644 --- a/src/agent/tools/write.rs +++ b/src/agent/tools/write.rs @@ -2,54 +2,25 @@ use anyhow::{Context, Result}; use serde::Deserialize; -use serde_json::json; -use std::path::Path; - -use super::ToolDef; - -#[derive(Deserialize)] -struct Args { - file_path: String, - content: String, -} pub fn tool() -> super::Tool { - super::Tool { def: definition(), handler: |_a, v| Box::pin(async move { write_file(&v) }) } + super::Tool { + name: "write_file", + description: "Create or overwrite a file with the given content.", + parameters_json: r#"{"type":"object","properties":{"file_path":{"type":"string","description":"Absolute path to write"},"content":{"type":"string","description":"File content"}},"required":["file_path","content"]}"#, + handler: |_a, v| Box::pin(async move { write_file(&v) }), + } } -fn definition() -> ToolDef { - ToolDef::new( - "write_file", - "Write content to a file. Creates the file if it doesn't exist, \ - overwrites if it does. Creates parent directories as needed.", - json!({ - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Absolute path to the file to write" - }, - "content": { - "type": "string", - "description": "The content to write to the file" - } - }, - "required": ["file_path", "content"] - }), - ) -} +#[derive(Deserialize)] +struct Args { file_path: String, content: String } fn write_file(args: &serde_json::Value) -> Result { - let args: Args = serde_json::from_value(args.clone()) - .context("invalid write_file arguments")?; - - if let Some(parent) = Path::new(&args.file_path).parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("Failed to create directories for {}", args.file_path))?; + let a: Args = serde_json::from_value(args.clone()).context("invalid write_file arguments")?; + if let Some(parent) = std::path::Path::new(&a.file_path).parent() { + std::fs::create_dir_all(parent).ok(); } - - std::fs::write(&args.file_path, &args.content) - .with_context(|| format!("Failed to write {}", args.file_path))?; - - Ok(format!("Wrote {} lines to {}", args.content.lines().count(), args.file_path)) + std::fs::write(&a.file_path, &a.content) + .with_context(|| format!("Failed to write {}", a.file_path))?; + Ok(format!("Wrote {} bytes to {}", a.content.len(), a.file_path)) } diff --git a/src/claude/mcp-server.rs b/src/claude/mcp-server.rs index fdcc1b7..4100714 100644 --- a/src/claude/mcp-server.rs +++ b/src/claude/mcp-server.rs @@ -70,9 +70,9 @@ fn notify(method: &str, params: Value) { fn tool_definitions() -> Vec { poc_memory::agent::tools::tools().into_iter() .map(|t| json!({ - "name": t.def.function.name, - "description": t.def.function.description, - "inputSchema": t.def.function.parameters, + "name": t.name, + "description": t.description, + "inputSchema": serde_json::from_str::(t.parameters_json).unwrap_or(json!({})), })) .collect() } @@ -81,7 +81,7 @@ fn tool_definitions() -> Vec { fn dispatch_tool(name: &str, args: &Value) -> Result { let tools = poc_memory::agent::tools::tools(); - let tool = tools.iter().find(|t| t.def.function.name == name); + let tool = tools.iter().find(|t| t.name == name); let Some(tool) = tool else { return Err(format!("unknown tool: {name}")); };