tools: static string definitions, no runtime JSON construction
Tool definitions are now &'static str (name, description, parameters_json) instead of runtime-constructed serde_json::Value. No more json!() macro, no more ToolDef::new() for tool definitions. The JSON schema strings are written directly as string literals. When sent to the API, they can be interpolated without serialization/deserialization. Multi-tool modules return fixed-size arrays instead of Vecs: - memory: [Tool; 12], journal: [Tool; 3] - channels: [Tool; 4] - control: [Tool; 3] - web: [Tool; 2] ToolDef/FunctionDef remain for backward compat (API wire format, summarize_args) but are no longer used in tool definitions. Co-Authored-By: Proof of Concept <poc@bcachefs.org> Signed-off-by: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
parent
ed150df628
commit
53ad8cc9df
13 changed files with 205 additions and 561 deletions
|
|
@ -8,11 +8,10 @@
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
use super::{ToolDef, default_timeout};
|
use super::default_timeout;
|
||||||
|
|
||||||
/// RAII guard that SIGTERMs the process group on drop.
|
/// RAII guard that SIGTERMs the process group on drop.
|
||||||
/// Ensures child processes are cleaned up when a task is aborted.
|
/// Ensures child processes are cleaned up when a task is aborted.
|
||||||
|
|
@ -34,29 +33,12 @@ struct Args {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tool() -> super::Tool {
|
pub fn tool() -> super::Tool {
|
||||||
super::Tool { def: definition(), handler: |_a, v| Box::pin(async move { run_bash(&v).await }) }
|
super::Tool {
|
||||||
}
|
name: "bash",
|
||||||
|
description: "Execute a bash command and return its output. Use for git operations, building, running tests, and other terminal tasks.",
|
||||||
fn definition() -> ToolDef {
|
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"]}"#,
|
||||||
ToolDef::new(
|
handler: |_a, v| Box::pin(async move { run_bash(&v).await }),
|
||||||
"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"]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_bash(args: &serde_json::Value) -> Result<String> {
|
async fn run_bash(args: &serde_json::Value) -> Result<String> {
|
||||||
|
|
|
||||||
|
|
@ -5,45 +5,29 @@
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use super::{Tool, ToolDef};
|
use super::Tool;
|
||||||
|
|
||||||
// ── Tool registry ──────────────────────────────────────────────
|
// ── Tool registry ──────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn tools() -> Vec<Tool> {
|
pub fn tools() -> [Tool; 4] {
|
||||||
vec![
|
[
|
||||||
Tool {
|
Tool { name: "channel_list",
|
||||||
def: ToolDef::new("channel_list",
|
description: "List all available channels and their status (connected, unread count).",
|
||||||
"List all available channels and their status (connected, unread count).",
|
parameters_json: r#"{"type":"object","properties":{}}"#,
|
||||||
json!({"type": "object", "properties": {}})),
|
handler: |_a, _v| Box::pin(async { channel_list().await }) },
|
||||||
handler: |_a, _v| Box::pin(async { channel_list().await }),
|
Tool { name: "channel_recv",
|
||||||
},
|
description: "Read messages from a channel.",
|
||||||
Tool {
|
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"]}"#,
|
||||||
def: ToolDef::new("channel_recv",
|
handler: |_a, v| Box::pin(async move { channel_recv(&v).await }) },
|
||||||
"Read messages from a channel.",
|
Tool { name: "channel_send",
|
||||||
json!({"type": "object", "properties": {
|
description: "Send a message to a channel.",
|
||||||
"channel": {"type": "string", "description": "Channel path (e.g. irc.#bcachefs, telegram.kent)"},
|
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"]}"#,
|
||||||
"all_new": {"type": "boolean", "description": "If true, return all unconsumed messages. If false, return scrollback.", "default": true},
|
handler: |_a, v| Box::pin(async move { channel_send(&v).await }) },
|
||||||
"min_count": {"type": "integer", "description": "Minimum number of lines to return", "default": 20}
|
Tool { name: "channel_notifications",
|
||||||
}, "required": ["channel"]})),
|
description: "Get pending channel notifications (unread signals). Does not consume messages — use channel_recv for that.",
|
||||||
handler: |_a, v| Box::pin(async move { channel_recv(&v).await }),
|
parameters_json: r#"{"type":"object","properties":{}}"#,
|
||||||
},
|
handler: |_a, _v| Box::pin(async { channel_notifications().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 }),
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,8 @@
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
use super::ToolOutput;
|
use super::ToolOutput;
|
||||||
use super::ToolDef;
|
|
||||||
|
|
||||||
pub(super) fn pause(_args: &serde_json::Value) -> Result<ToolOutput> {
|
fn pause(_args: &serde_json::Value) -> Result<ToolOutput> {
|
||||||
Ok(ToolOutput {
|
Ok(ToolOutput {
|
||||||
text: "Pausing autonomous behavior. Only user input will wake you.".to_string(),
|
text: "Pausing autonomous behavior. Only user input will wake you.".to_string(),
|
||||||
is_yield: true,
|
is_yield: true,
|
||||||
|
|
@ -19,7 +18,7 @@ pub(super) fn pause(_args: &serde_json::Value) -> Result<ToolOutput> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn switch_model(args: &serde_json::Value) -> Result<ToolOutput> {
|
fn switch_model(args: &serde_json::Value) -> Result<ToolOutput> {
|
||||||
let model = args
|
let model = args
|
||||||
.get("model")
|
.get("model")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
|
|
@ -36,7 +35,7 @@ pub(super) fn switch_model(args: &serde_json::Value) -> Result<ToolOutput> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn yield_to_user(args: &serde_json::Value) -> Result<ToolOutput> {
|
fn yield_to_user(args: &serde_json::Value) -> Result<ToolOutput> {
|
||||||
let msg = args
|
let msg = args
|
||||||
.get("message")
|
.get("message")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
|
|
@ -50,73 +49,26 @@ pub(super) fn yield_to_user(args: &serde_json::Value) -> Result<ToolOutput> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn tools() -> Vec<super::Tool> {
|
pub(super) fn tools() -> [super::Tool; 3] {
|
||||||
use super::Tool;
|
use super::Tool;
|
||||||
let defs = definitions();
|
[
|
||||||
vec![
|
Tool { name: "switch_model",
|
||||||
Tool { def: defs[0].clone(), // 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 {
|
handler: |_a, v| Box::pin(async move {
|
||||||
let model = v.get("model").and_then(|v| v.as_str()).unwrap_or("");
|
let model = v.get("model").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
Ok(format!("Switching to model: {}", model))
|
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()) }) },
|
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 {
|
handler: |_a, v| Box::pin(async move {
|
||||||
let msg = v.get("message").and_then(|v| v.as_str()).unwrap_or("(yielding to user)");
|
let msg = v.get("message").and_then(|v| v.as_str()).unwrap_or("(yielding to user)");
|
||||||
Ok(msg.to_string())
|
Ok(msg.to_string())
|
||||||
}) },
|
}) },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn definitions() -> Vec<ToolDef> {
|
|
||||||
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')"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,77 +1,28 @@
|
||||||
// tools/edit.rs — Search-and-replace file editing
|
// 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 anyhow::{Context, Result};
|
||||||
use serde::Deserialize;
|
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 {
|
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 {
|
#[derive(Deserialize)]
|
||||||
ToolDef::new(
|
struct Args { file_path: String, old_string: String, new_string: String, #[serde(default)] replace_all: bool }
|
||||||
"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"]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn edit_file(args: &serde_json::Value) -> Result<String> {
|
fn edit_file(args: &serde_json::Value) -> Result<String> {
|
||||||
let a: Args = serde_json::from_value(args.clone())
|
let a: Args = serde_json::from_value(args.clone()).context("invalid edit_file arguments")?;
|
||||||
.context("invalid edit_file arguments")?;
|
if a.old_string == a.new_string { anyhow::bail!("old_string and new_string are identical"); }
|
||||||
|
|
||||||
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)
|
let content = std::fs::read_to_string(&a.file_path)
|
||||||
.with_context(|| format!("Failed to read {}", a.file_path))?;
|
.with_context(|| format!("Failed to read {}", a.file_path))?;
|
||||||
|
|
||||||
let count = content.matches(&*a.old_string).count();
|
let count = content.matches(&*a.old_string).count();
|
||||||
if count == 0 {
|
if count == 0 { anyhow::bail!("old_string not found in {}", a.file_path); }
|
||||||
anyhow::bail!("old_string not found in {}", a.file_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.replace_all {
|
if a.replace_all {
|
||||||
let new_content = content.replace(&*a.old_string, &a.new_string);
|
let new_content = content.replace(&*a.old_string, &a.new_string);
|
||||||
|
|
@ -80,11 +31,7 @@ fn edit_file(args: &serde_json::Value) -> Result<String> {
|
||||||
Ok(format!("Replaced {} occurrences in {}", count, a.file_path))
|
Ok(format!("Replaced {} occurrences in {}", count, a.file_path))
|
||||||
} else {
|
} else {
|
||||||
if count > 1 {
|
if count > 1 {
|
||||||
anyhow::bail!(
|
anyhow::bail!("old_string appears {} times in {} — use replace_all or provide more context", count, a.file_path);
|
||||||
"old_string appears {} times in {} — use replace_all or provide more context \
|
|
||||||
to make it unique",
|
|
||||||
count, a.file_path
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let new_content = content.replacen(&*a.old_string, &a.new_string, 1);
|
let new_content = content.replacen(&*a.old_string, &a.new_string, 1);
|
||||||
std::fs::write(&a.file_path, &new_content)
|
std::fs::write(&a.file_path, &new_content)
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,8 @@
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use super::ToolDef;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Args {
|
struct Args {
|
||||||
pattern: String,
|
pattern: String,
|
||||||
|
|
@ -21,30 +18,12 @@ struct Args {
|
||||||
fn default_path() -> String { ".".into() }
|
fn default_path() -> String { ".".into() }
|
||||||
|
|
||||||
pub fn tool() -> super::Tool {
|
pub fn tool() -> super::Tool {
|
||||||
super::Tool { def: definition(), handler: |_a, v| Box::pin(async move { glob_search(&v) }) }
|
super::Tool {
|
||||||
}
|
name: "glob",
|
||||||
|
description: "Find files matching a glob pattern. Returns file paths sorted by modification time (newest first).",
|
||||||
fn definition() -> ToolDef {
|
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"]}"#,
|
||||||
ToolDef::new(
|
handler: |_a, v| Box::pin(async move { glob_search(&v) }),
|
||||||
"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"]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn glob_search(args: &serde_json::Value) -> Result<String> {
|
fn glob_search(args: &serde_json::Value) -> Result<String> {
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,8 @@
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use super::ToolDef;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Args {
|
struct Args {
|
||||||
pattern: String,
|
pattern: String,
|
||||||
|
|
@ -24,41 +21,12 @@ struct Args {
|
||||||
fn default_path() -> String { ".".into() }
|
fn default_path() -> String { ".".into() }
|
||||||
|
|
||||||
pub fn tool() -> super::Tool {
|
pub fn tool() -> super::Tool {
|
||||||
super::Tool { def: definition(), handler: |_a, v| Box::pin(async move { grep(&v) }) }
|
super::Tool {
|
||||||
}
|
name: "grep",
|
||||||
|
description: "Search for a pattern in files. Returns matching file paths by default, or matching lines with context.",
|
||||||
fn definition() -> ToolDef {
|
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"]}"#,
|
||||||
ToolDef::new(
|
handler: |_a, v| Box::pin(async move { grep(&v) }),
|
||||||
"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"]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if ripgrep is available (cached after first check).
|
/// Check if ripgrep is available (cached after first check).
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,9 @@
|
||||||
// One function per tool for use in the Tool registry.
|
// One function per tool for use in the Tool registry.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::hippocampus::memory::MemoryNode;
|
use crate::hippocampus::memory::MemoryNode;
|
||||||
use crate::store::StoreView;
|
use crate::store::StoreView;
|
||||||
use super::ToolDef;
|
|
||||||
use crate::store::Store;
|
use crate::store::Store;
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────
|
||||||
|
|
@ -29,41 +27,65 @@ fn provenance() -> &'static str { "manual" }
|
||||||
|
|
||||||
// ── Definitions ────────────────────────────────────────────────
|
// ── Definitions ────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn memory_tools() -> Vec<super::Tool> {
|
pub fn memory_tools() -> [super::Tool; 12] {
|
||||||
use super::Tool;
|
use super::Tool;
|
||||||
vec![
|
[
|
||||||
Tool { def: render_def(), handler: |_a, v| Box::pin(async move { render(&v) }) },
|
Tool { name: "memory_render", description: "Read a memory node's content and links.",
|
||||||
Tool { def: write_def(), handler: |_a, v| Box::pin(async move { write(&v) }) },
|
parameters_json: r#"{"type":"object","properties":{"key":{"type":"string","description":"Node key"}},"required":["key"]}"#,
|
||||||
Tool { def: search_def(), handler: |_a, v| Box::pin(async move { search(&v) }) },
|
handler: |_a, v| Box::pin(async move { render(&v) }) },
|
||||||
Tool { def: links_def(), handler: |_a, v| Box::pin(async move { links(&v) }) },
|
Tool { name: "memory_write", description: "Create or update a memory node.",
|
||||||
Tool { def: link_set_def(), handler: |_a, v| Box::pin(async move { link_set(&v) }) },
|
parameters_json: r#"{"type":"object","properties":{"key":{"type":"string","description":"Node key"},"content":{"type":"string","description":"Full content (markdown)"}},"required":["key","content"]}"#,
|
||||||
Tool { def: link_add_def(), handler: |_a, v| Box::pin(async move { link_add(&v) }) },
|
handler: |_a, v| Box::pin(async move { write(&v) }) },
|
||||||
Tool { def: used_def(), handler: |_a, v| Box::pin(async move { used(&v) }) },
|
Tool { name: "memory_search", description: "Search the memory graph via spreading activation. Give 2-4 seed node keys.",
|
||||||
Tool { def: weight_set_def(), handler: |_a, v| Box::pin(async move { weight_set(&v) }) },
|
parameters_json: r#"{"type":"object","properties":{"keys":{"type":"array","items":{"type":"string"},"description":"Seed node keys to activate from"}},"required":["keys"]}"#,
|
||||||
Tool { def: rename_def(), handler: |_a, v| Box::pin(async move { rename(&v) }) },
|
handler: |_a, v| Box::pin(async move { search(&v) }) },
|
||||||
Tool { def: supersede_def(), handler: |_a, v| Box::pin(async move { supersede(&v) }) },
|
Tool { name: "memory_links", description: "Show a node's neighbors with link strengths.",
|
||||||
Tool { def: query_def(), handler: |_a, v| Box::pin(async move { query(&v) }) },
|
parameters_json: r#"{"type":"object","properties":{"key":{"type":"string","description":"Node key"}},"required":["key"]}"#,
|
||||||
Tool { def: output_def(), handler: |_a, v| Box::pin(async move { output(&v) }) },
|
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<super::Tool> {
|
pub fn journal_tools() -> [super::Tool; 3] {
|
||||||
use super::Tool;
|
use super::Tool;
|
||||||
vec![
|
[
|
||||||
Tool { def: journal_tail_def(), handler: |_a, v| Box::pin(async move { journal_tail(&v) }) },
|
Tool { name: "journal_tail", description: "Read the last N journal entries (default 1).",
|
||||||
Tool { def: journal_new_def(), handler: |_a, v| Box::pin(async move { journal_new(&v) }) },
|
parameters_json: r#"{"type":"object","properties":{"count":{"type":"integer","description":"Number of entries (default 1)"}}}"#,
|
||||||
Tool { def: journal_update_def(), handler: |_a, v| Box::pin(async move { journal_update(&v) }) },
|
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 ───────────────────────────────────────────────
|
// ── 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<String> {
|
fn render(args: &serde_json::Value) -> Result<String> {
|
||||||
let key = get_str(args, "key")?;
|
let key = get_str(args, "key")?;
|
||||||
Ok(MemoryNode::load(key)
|
Ok(MemoryNode::load(key)
|
||||||
|
|
@ -71,12 +93,6 @@ fn render(args: &serde_json::Value) -> Result<String> {
|
||||||
.render())
|
.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<String> {
|
fn write(args: &serde_json::Value) -> Result<String> {
|
||||||
let key = get_str(args, "key")?;
|
let key = get_str(args, "key")?;
|
||||||
let content = get_str(args, "content")?;
|
let content = get_str(args, "content")?;
|
||||||
|
|
@ -87,15 +103,6 @@ fn write(args: &serde_json::Value) -> Result<String> {
|
||||||
Ok(format!("{} '{}'", result, key))
|
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<String> {
|
fn search(args: &serde_json::Value) -> Result<String> {
|
||||||
let keys: Vec<String> = args.get("keys")
|
let keys: Vec<String> = args.get("keys")
|
||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
|
|
@ -129,12 +136,6 @@ fn search(args: &serde_json::Value) -> Result<String> {
|
||||||
.collect::<Vec<_>>().join("\n"))
|
.collect::<Vec<_>>().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<String> {
|
fn links(args: &serde_json::Value) -> Result<String> {
|
||||||
let key = get_str(args, "key")?;
|
let key = get_str(args, "key")?;
|
||||||
let node = MemoryNode::load(key)
|
let node = MemoryNode::load(key)
|
||||||
|
|
@ -147,12 +148,6 @@ fn links(args: &serde_json::Value) -> Result<String> {
|
||||||
Ok(out)
|
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<String> {
|
fn link_set(args: &serde_json::Value) -> Result<String> {
|
||||||
let mut store = load_store()?;
|
let mut store = load_store()?;
|
||||||
let s = store.resolve_key(get_str(args, "source")?).map_err(|e| anyhow::anyhow!("{}", e))?;
|
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<String> {
|
||||||
Ok(format!("{} ↔ {} strength {:.2} → {:.2}", s, t, old, strength))
|
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<String> {
|
fn link_add(args: &serde_json::Value) -> Result<String> {
|
||||||
let mut store = load_store()?;
|
let mut store = load_store()?;
|
||||||
let s = store.resolve_key(get_str(args, "source")?).map_err(|e| anyhow::anyhow!("{}", e))?;
|
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<String> {
|
||||||
Ok(format!("linked {} → {} (strength={:.2})", s, t, strength))
|
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<String> {
|
fn used(args: &serde_json::Value) -> Result<String> {
|
||||||
let key = get_str(args, "key")?;
|
let key = get_str(args, "key")?;
|
||||||
let mut store = load_store()?;
|
let mut store = load_store()?;
|
||||||
|
|
@ -195,12 +178,6 @@ fn used(args: &serde_json::Value) -> Result<String> {
|
||||||
Ok(format!("marked {} as used", key))
|
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<String> {
|
fn weight_set(args: &serde_json::Value) -> Result<String> {
|
||||||
let mut store = load_store()?;
|
let mut store = load_store()?;
|
||||||
let key = store.resolve_key(get_str(args, "key")?).map_err(|e| anyhow::anyhow!("{}", e))?;
|
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<String> {
|
||||||
Ok(format!("weight {} {:.2} → {:.2}", key, old, new))
|
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<String> {
|
fn rename(args: &serde_json::Value) -> Result<String> {
|
||||||
let old_key = get_str(args, "old_key")?;
|
let old_key = get_str(args, "old_key")?;
|
||||||
let new_key = get_str(args, "new_key")?;
|
let new_key = get_str(args, "new_key")?;
|
||||||
|
|
@ -226,12 +197,6 @@ fn rename(args: &serde_json::Value) -> Result<String> {
|
||||||
Ok(format!("Renamed '{}' → '{}'", resolved, new_key))
|
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<String> {
|
fn supersede(args: &serde_json::Value) -> Result<String> {
|
||||||
let old_key = get_str(args, "old_key")?;
|
let old_key = get_str(args, "old_key")?;
|
||||||
let new_key = get_str(args, "new_key")?;
|
let new_key = get_str(args, "new_key")?;
|
||||||
|
|
@ -249,14 +214,6 @@ fn supersede(args: &serde_json::Value) -> Result<String> {
|
||||||
Ok(format!("superseded {} → {} ({})", old_key, new_key, reason))
|
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<String> {
|
fn query(args: &serde_json::Value) -> Result<String> {
|
||||||
let query_str = get_str(args, "query")?;
|
let query_str = get_str(args, "query")?;
|
||||||
let store = load_store()?;
|
let store = load_store()?;
|
||||||
|
|
@ -265,16 +222,6 @@ fn query(args: &serde_json::Value) -> Result<String> {
|
||||||
.map_err(|e| anyhow::anyhow!("{}", e))
|
.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<String> {
|
fn output(args: &serde_json::Value) -> Result<String> {
|
||||||
let key = get_str(args, "key")?;
|
let key = get_str(args, "key")?;
|
||||||
if key.starts_with("pid-") || key.contains('/') || key.contains("..") {
|
if key.starts_with("pid-") || key.contains('/') || key.contains("..") {
|
||||||
|
|
@ -291,14 +238,6 @@ fn output(args: &serde_json::Value) -> Result<String> {
|
||||||
|
|
||||||
// ── Journal tools ──────────────────────────────────────────────
|
// ── 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<String> {
|
fn journal_tail(args: &serde_json::Value) -> Result<String> {
|
||||||
let count = args.get("count").and_then(|v| v.as_u64()).unwrap_or(1) as usize;
|
let count = args.get("count").and_then(|v| v.as_u64()).unwrap_or(1) as usize;
|
||||||
let store = load_store()?;
|
let store = load_store()?;
|
||||||
|
|
@ -317,16 +256,6 @@ fn journal_tail(args: &serde_json::Value) -> Result<String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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<String> {
|
fn journal_new(args: &serde_json::Value) -> Result<String> {
|
||||||
let name = get_str(args, "name")?;
|
let name = get_str(args, "name")?;
|
||||||
let title = get_str(args, "title")?;
|
let title = get_str(args, "title")?;
|
||||||
|
|
@ -363,14 +292,6 @@ fn journal_new(args: &serde_json::Value) -> Result<String> {
|
||||||
Ok(format!("New entry '{}' ({} words)", title, word_count))
|
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<String> {
|
fn journal_update(args: &serde_json::Value) -> Result<String> {
|
||||||
let body = get_str(args, "body")?;
|
let body = get_str(args, "body")?;
|
||||||
let mut store = load_store()?;
|
let mut store = load_store()?;
|
||||||
|
|
|
||||||
|
|
@ -35,11 +35,40 @@ pub type ToolHandler = fn(
|
||||||
) -> Pin<Box<dyn Future<Output = anyhow::Result<String>> + Send>>;
|
) -> Pin<Box<dyn Future<Output = anyhow::Result<String>> + Send>>;
|
||||||
|
|
||||||
/// A tool with its definition and handler — single source of truth.
|
/// 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 struct Tool {
|
||||||
pub def: ToolDef,
|
pub name: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
pub parameters_json: &'static str,
|
||||||
pub handler: ToolHandler,
|
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.
|
/// Function call within a tool call — name + JSON arguments.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct FunctionCall {
|
pub struct FunctionCall {
|
||||||
|
|
@ -55,6 +84,7 @@ pub struct FunctionDef {
|
||||||
pub parameters: serde_json::Value,
|
pub parameters: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Partial function call within a streaming delta.
|
/// Partial function call within a streaming delta.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct FunctionCallDelta {
|
pub struct FunctionCallDelta {
|
||||||
|
|
@ -173,7 +203,7 @@ pub async fn dispatch_with_agent(
|
||||||
agent: Option<std::sync::Arc<tokio::sync::Mutex<super::Agent>>>,
|
agent: Option<std::sync::Arc<tokio::sync::Mutex<super::Agent>>>,
|
||||||
) -> ToolOutput {
|
) -> ToolOutput {
|
||||||
for tool in tools() {
|
for tool in tools() {
|
||||||
if tool.def.function.name == name {
|
if tool.name == name {
|
||||||
return match (tool.handler)(agent, args.clone()).await {
|
return match (tool.handler)(agent, args.clone()).await {
|
||||||
Ok(s) => ToolOutput::text(s),
|
Ok(s) => ToolOutput::text(s),
|
||||||
Err(e) => ToolOutput::error(e),
|
Err(e) => ToolOutput::error(e),
|
||||||
|
|
@ -190,7 +220,7 @@ pub async fn dispatch_shared(
|
||||||
_provenance: Option<&str>,
|
_provenance: Option<&str>,
|
||||||
) -> Option<ToolOutput> {
|
) -> Option<ToolOutput> {
|
||||||
for tool in tools() {
|
for tool in tools() {
|
||||||
if tool.def.function.name == name {
|
if tool.name == name {
|
||||||
return Some(match (tool.handler)(None, args.clone()).await {
|
return Some(match (tool.handler)(None, args.clone()).await {
|
||||||
Ok(s) => ToolOutput::text(s),
|
Ok(s) => ToolOutput::text(s),
|
||||||
Err(e) => ToolOutput::error(e),
|
Err(e) => ToolOutput::error(e),
|
||||||
|
|
@ -214,16 +244,16 @@ pub fn tools() -> Vec<Tool> {
|
||||||
all
|
all
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return all tool definitions (extracted from tools()).
|
/// Return all tool definitions for the API.
|
||||||
pub fn definitions() -> Vec<ToolDef> {
|
pub fn definitions() -> Vec<ToolDef> {
|
||||||
tools().into_iter().map(|t| t.def).collect()
|
tools().into_iter().map(|t| t.to_tool_def()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return memory + journal tool definitions only.
|
/// Return memory + journal tool definitions only.
|
||||||
pub fn memory_and_journal_definitions() -> Vec<ToolDef> {
|
pub fn memory_and_journal_definitions() -> Vec<ToolDef> {
|
||||||
memory::memory_tools().into_iter()
|
memory::memory_tools().into_iter()
|
||||||
.chain(memory::journal_tools())
|
.chain(memory::journal_tools())
|
||||||
.map(|t| t.def)
|
.map(|t| t.to_tool_def())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,15 @@
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::Deserialize;
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct Args {
|
struct Args {
|
||||||
|
|
@ -13,57 +19,21 @@ struct Args {
|
||||||
offset: usize,
|
offset: usize,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_offset() -> usize { 1 }
|
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<String> {
|
fn read_file(args: &serde_json::Value) -> Result<String> {
|
||||||
let args: Args = serde_json::from_value(args.clone())
|
let a: Args = serde_json::from_value(args.clone()).context("invalid read_file arguments")?;
|
||||||
.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 content = std::fs::read_to_string(&args.file_path)
|
|
||||||
.with_context(|| format!("Failed to read {}", args.file_path))?;
|
|
||||||
|
|
||||||
let lines: Vec<&str> = content.lines().collect();
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
let offset = args.offset.max(1) - 1;
|
let start = a.offset.saturating_sub(1);
|
||||||
let limit = args.limit.unwrap_or(lines.len());
|
let end = a.limit.map_or(lines.len(), |l| (start + l).min(lines.len()));
|
||||||
|
if start >= lines.len() {
|
||||||
let mut output = String::new();
|
return Ok(format!("(file has {} lines, offset {} is past end)", lines.len(), a.offset));
|
||||||
for (i, line) in lines.iter().skip(offset).take(limit).enumerate() {
|
|
||||||
output.push_str(&format!("{:>6}\t{}\n", offset + i + 1, line));
|
|
||||||
}
|
}
|
||||||
|
let mut out = String::new();
|
||||||
if output.is_empty() {
|
for (i, line) in lines[start..end].iter().enumerate() {
|
||||||
output = "(empty file)\n".to_string();
|
out.push_str(&format!("{}\t{}\n", start + i + 1, line));
|
||||||
}
|
}
|
||||||
|
Ok(out)
|
||||||
Ok(output)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ use base64::Engine;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use super::ToolOutput;
|
use super::ToolOutput;
|
||||||
use super::ToolDef;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Args {
|
struct Args {
|
||||||
|
|
@ -21,35 +20,13 @@ struct Args {
|
||||||
|
|
||||||
fn default_lines() -> usize { 50 }
|
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 {
|
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.
|
/// Text-only version for the Tool registry.
|
||||||
|
|
|
||||||
|
|
@ -2,41 +2,26 @@
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use super::ToolDef;
|
|
||||||
|
|
||||||
pub fn tools() -> [super::Tool; 2] {
|
pub fn tools() -> [super::Tool; 2] {
|
||||||
[
|
[
|
||||||
super::Tool { def: fetch_definition(), handler: |_a, v| Box::pin(async move { web_fetch(&v).await }) },
|
super::Tool {
|
||||||
super::Tool { def: search_definition(), handler: |_a, v| Box::pin(async move { web_search(&v).await }) },
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct FetchArgs {
|
struct FetchArgs { url: String }
|
||||||
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"]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn web_fetch(args: &serde_json::Value) -> Result<String> {
|
async fn web_fetch(args: &serde_json::Value) -> Result<String> {
|
||||||
let a: FetchArgs = serde_json::from_value(args.clone())
|
let a: FetchArgs = serde_json::from_value(args.clone())
|
||||||
|
|
@ -71,28 +56,6 @@ struct SearchArgs {
|
||||||
|
|
||||||
fn default_num_results() -> usize { 5 }
|
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<String> {
|
async fn web_search(args: &serde_json::Value) -> Result<String> {
|
||||||
let a: SearchArgs = serde_json::from_value(args.clone())
|
let a: SearchArgs = serde_json::from_value(args.clone())
|
||||||
.context("invalid web_search arguments")?;
|
.context("invalid web_search arguments")?;
|
||||||
|
|
|
||||||
|
|
@ -2,54 +2,25 @@
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::Deserialize;
|
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 {
|
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 {
|
#[derive(Deserialize)]
|
||||||
ToolDef::new(
|
struct Args { file_path: String, content: String }
|
||||||
"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"]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_file(args: &serde_json::Value) -> Result<String> {
|
fn write_file(args: &serde_json::Value) -> Result<String> {
|
||||||
let args: Args = serde_json::from_value(args.clone())
|
let a: Args = serde_json::from_value(args.clone()).context("invalid write_file arguments")?;
|
||||||
.context("invalid write_file arguments")?;
|
if let Some(parent) = std::path::Path::new(&a.file_path).parent() {
|
||||||
|
std::fs::create_dir_all(parent).ok();
|
||||||
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))?;
|
|
||||||
}
|
}
|
||||||
|
std::fs::write(&a.file_path, &a.content)
|
||||||
std::fs::write(&args.file_path, &args.content)
|
.with_context(|| format!("Failed to write {}", a.file_path))?;
|
||||||
.with_context(|| format!("Failed to write {}", args.file_path))?;
|
Ok(format!("Wrote {} bytes to {}", a.content.len(), a.file_path))
|
||||||
|
|
||||||
Ok(format!("Wrote {} lines to {}", args.content.lines().count(), args.file_path))
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,9 @@ fn notify(method: &str, params: Value) {
|
||||||
fn tool_definitions() -> Vec<Value> {
|
fn tool_definitions() -> Vec<Value> {
|
||||||
poc_memory::agent::tools::tools().into_iter()
|
poc_memory::agent::tools::tools().into_iter()
|
||||||
.map(|t| json!({
|
.map(|t| json!({
|
||||||
"name": t.def.function.name,
|
"name": t.name,
|
||||||
"description": t.def.function.description,
|
"description": t.description,
|
||||||
"inputSchema": t.def.function.parameters,
|
"inputSchema": serde_json::from_str::<Value>(t.parameters_json).unwrap_or(json!({})),
|
||||||
}))
|
}))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
@ -81,7 +81,7 @@ fn tool_definitions() -> Vec<Value> {
|
||||||
|
|
||||||
fn dispatch_tool(name: &str, args: &Value) -> Result<String, String> {
|
fn dispatch_tool(name: &str, args: &Value) -> Result<String, String> {
|
||||||
let tools = poc_memory::agent::tools::tools();
|
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 {
|
let Some(tool) = tool else {
|
||||||
return Err(format!("unknown tool: {name}"));
|
return Err(format!("unknown tool: {name}"));
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue