working_stack now uses the Tool format with an Agent handle — it locks the agent and modifies the stack directly. The special-case interception in the turn loop is removed. All tools go through the unified registry dispatch. Also passes agent handle to all spawned tool tasks so any tool that needs Agent access can use it. Co-Authored-By: Proof of Concept <poc@bcachefs.org>
285 lines
8.1 KiB
Rust
285 lines
8.1 KiB
Rust
// tools/mod.rs — Agent-specific tool dispatch
|
|
//
|
|
// Shared tools (memory, files, bash, journal) live in thought/.
|
|
// This module handles agent-specific tools (control, vision,
|
|
// working_stack) and delegates everything else to thought::dispatch.
|
|
|
|
// Core tools
|
|
mod bash;
|
|
pub mod channels;
|
|
mod edit;
|
|
mod glob;
|
|
mod grep;
|
|
pub mod memory;
|
|
mod read;
|
|
mod web;
|
|
mod write;
|
|
|
|
// Agent-specific tools
|
|
mod control;
|
|
mod vision;
|
|
pub mod working_stack;
|
|
|
|
use serde::{Serialize, Deserialize};
|
|
use std::future::Future;
|
|
use std::pin::Pin;
|
|
use std::time::Instant;
|
|
|
|
fn default_timeout() -> u64 { 120 }
|
|
|
|
/// Async tool handler function.
|
|
/// Agent is None when called from contexts without an agent (MCP server, subconscious).
|
|
pub type ToolHandler = fn(
|
|
Option<std::sync::Arc<tokio::sync::Mutex<super::Agent>>>,
|
|
serde_json::Value,
|
|
) -> Pin<Box<dyn Future<Output = anyhow::Result<String>> + 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 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 {
|
|
pub name: String,
|
|
pub arguments: String,
|
|
}
|
|
|
|
/// Function definition for tool schema.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct FunctionDef {
|
|
pub name: String,
|
|
pub description: String,
|
|
pub parameters: serde_json::Value,
|
|
}
|
|
|
|
|
|
/// Partial function call within a streaming delta.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct FunctionCallDelta {
|
|
pub name: Option<String>,
|
|
pub arguments: Option<String>,
|
|
}
|
|
|
|
/// Tool definition sent to the model.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ToolDef {
|
|
#[serde(rename = "type")]
|
|
pub tool_type: String,
|
|
pub function: FunctionDef,
|
|
}
|
|
|
|
impl ToolDef {
|
|
pub fn new(name: &str, description: &str, parameters: serde_json::Value) -> Self {
|
|
Self {
|
|
tool_type: "function".to_string(),
|
|
function: FunctionDef {
|
|
name: name.to_string(),
|
|
description: description.to_string(),
|
|
parameters,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A tool call requested by the model.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ToolCall {
|
|
pub id: String,
|
|
#[serde(rename = "type")]
|
|
pub call_type: String,
|
|
pub function: FunctionCall,
|
|
}
|
|
|
|
/// A partial tool call within a streaming delta. The first chunk for a
|
|
/// given tool call carries the id and function name; subsequent chunks
|
|
/// carry argument fragments.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ToolCallDelta {
|
|
pub index: usize,
|
|
pub id: Option<String>,
|
|
#[serde(rename = "type")]
|
|
pub call_type: Option<String>,
|
|
pub function: Option<FunctionCallDelta>,
|
|
}
|
|
|
|
|
|
/// A tool call in flight — metadata for TUI + JoinHandle for
|
|
/// result collection and cancellation.
|
|
pub struct ActiveToolCall {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub detail: String,
|
|
pub started: Instant,
|
|
pub background: bool,
|
|
pub handle: tokio::task::JoinHandle<(ToolCall, String)>,
|
|
}
|
|
|
|
/// Truncate output if it exceeds max length, appending a truncation notice.
|
|
pub fn truncate_output(mut s: String, max: usize) -> String {
|
|
if s.len() > max {
|
|
s.truncate(max);
|
|
s.push_str("\n... (output truncated)");
|
|
}
|
|
s
|
|
}
|
|
|
|
/// Dispatch a tool call by name through the registry.
|
|
/// Dispatch a tool call by name. Returns the result text,
|
|
/// or an error string prefixed with "Error: ".
|
|
pub async fn dispatch(
|
|
name: &str,
|
|
args: &serde_json::Value,
|
|
) -> String {
|
|
dispatch_with_agent(name, args, None).await
|
|
}
|
|
|
|
/// Dispatch a tool call with optional agent context.
|
|
pub async fn dispatch_with_agent(
|
|
name: &str,
|
|
args: &serde_json::Value,
|
|
agent: Option<std::sync::Arc<tokio::sync::Mutex<super::Agent>>>,
|
|
) -> String {
|
|
for tool in tools() {
|
|
if tool.name == name {
|
|
return match (tool.handler)(agent, args.clone()).await {
|
|
Ok(s) => s,
|
|
Err(e) => format!("Error: {}", e),
|
|
};
|
|
}
|
|
}
|
|
format!("Error: Unknown tool: {}", name)
|
|
}
|
|
|
|
/// Dispatch shared tools — used by subconscious agents.
|
|
pub async fn dispatch_shared(
|
|
name: &str,
|
|
args: &serde_json::Value,
|
|
_provenance: Option<&str>,
|
|
) -> Option<String> {
|
|
for tool in tools() {
|
|
if tool.name == name {
|
|
return Some(match (tool.handler)(None, args.clone()).await {
|
|
Ok(s) => s,
|
|
Err(e) => format!("Error: {}", e),
|
|
});
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Return all registered tools with definitions + handlers.
|
|
pub fn tools() -> Vec<Tool> {
|
|
let mut all = vec![
|
|
read::tool(), write::tool(), edit::tool(),
|
|
grep::tool(), glob::tool(), bash::tool(),
|
|
vision::tool(),
|
|
working_stack::tool(),
|
|
];
|
|
all.extend(web::tools());
|
|
all.extend(memory::memory_tools());
|
|
all.extend(channels::tools());
|
|
all.extend(control::tools());
|
|
all
|
|
}
|
|
|
|
/// Return all tool definitions for the API.
|
|
pub fn definitions() -> Vec<ToolDef> {
|
|
tools().into_iter().map(|t| t.to_tool_def()).collect()
|
|
}
|
|
|
|
/// Return memory + journal tool definitions only.
|
|
pub fn memory_and_journal_definitions() -> Vec<ToolDef> {
|
|
memory::memory_tools().into_iter()
|
|
.chain(memory::journal_tools())
|
|
.map(|t| t.to_tool_def())
|
|
.collect()
|
|
}
|
|
|
|
/// Create a short summary of tool args for the tools pane header.
|
|
pub fn summarize_args(tool_name: &str, args: &serde_json::Value) -> String {
|
|
match tool_name {
|
|
"read_file" | "write_file" | "edit_file" => args["file_path"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.to_string(),
|
|
"bash" => {
|
|
let cmd = args["command"].as_str().unwrap_or("");
|
|
if cmd.len() > 60 {
|
|
let end = cmd.char_indices()
|
|
.map(|(i, _)| i)
|
|
.take_while(|&i| i <= 60)
|
|
.last()
|
|
.unwrap_or(0);
|
|
format!("{}...", &cmd[..end])
|
|
} else {
|
|
cmd.to_string()
|
|
}
|
|
}
|
|
"grep" => {
|
|
let pattern = args["pattern"].as_str().unwrap_or("");
|
|
let path = args["path"].as_str().unwrap_or(".");
|
|
format!("{} in {}", pattern, path)
|
|
}
|
|
"glob" => args["pattern"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.to_string(),
|
|
"view_image" => {
|
|
if let Some(pane) = args["pane_id"].as_str() {
|
|
format!("pane {}", pane)
|
|
} else {
|
|
args["file_path"].as_str().unwrap_or("").to_string()
|
|
}
|
|
}
|
|
"journal" => {
|
|
let entry = args["entry"].as_str().unwrap_or("");
|
|
if entry.len() > 60 {
|
|
format!("{}...", &entry[..60])
|
|
} else {
|
|
entry.to_string()
|
|
}
|
|
}
|
|
"yield_to_user" => args["message"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.to_string(),
|
|
"switch_model" => args["model"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.to_string(),
|
|
"pause" => String::new(),
|
|
_ => String::new(),
|
|
}
|
|
}
|