feat(users): UserManager with per-user SQLCipher, and extract skald-core crate
Two changes developed together in one session; they share the same module
structure (db/mod.rs, the core lib root) and only compile together, so they
land as one commit.
## UserManager + per-user encryption (§9/§11)
New `users::UserManager`: owns the system.db pool plus a map
`userid -> SqlitePool` of unlocked databases. The pool *is* the unlock token —
its connect options carry the DEK as SQLCipher's raw key, so an open pool means
the key is in RAM until restart and dropping it re-locks (§9). Knows nothing
about cookies.
New `crypto` module: envelope encryption. A random 256-bit DEK encrypts
`{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under
`Argon2id(password, salt)`. The AEAD tag is the password verifier — one
derivation both authenticates and yields the key, so encrypted users store no
second hash. Cleartext users store the Argon2id output directly, compared in
constant time. Argon2 runs in spawn_blocking behind a 2-permit semaphore
(256 MiB per derivation).
- SQLCipher via `libsqlite3-sys` `bundled-sqlcipher-vendored-openssl`, pinned
<0.38 so it unifies with the one sqlx-sqlite links (a newer copy would apply
the feature to a SQLite sqlx never uses). OpenSSL is vendored and static, so
the binary stays self-contained.
- Schema split into `create_registry_tables` (instance-wide, no user key) and
`create_owner_tables` (one owner's content, identical in every file). No FK in
the owner bucket may reach the registry — enforced by a standalone test.
Dropped `chat_history.model_db_id` (write-only, and the only registry-crossing
key); moved `projects`/`project_tickets` into the owner bucket.
- Provisioning invariant: the file is written before the row, deleted after it,
so a crash leaves an orphan file, never a user without a database. `open_db`
never creates: a missing file is an error, not a silent empty database.
Not consumed yet: no login, call sites still use the shared system.db pool.
## Extract crates/skald-core
The headless core moves out of `src/` into its own crate; `skald` (server) and
the coming `skald-setup` are shells around it. Two dependencies on the shell
were inverted rather than dragged along, so the core names neither Tauri nor any
concrete plugin:
- `Plugin::tools(self: Arc<Self>)` — plugins contribute tools through this hook
(sibling of `http_router`), so the core no longer downcasts to
`MobileConnectorPlugin`.
- `tools::restart::set_restart_handler` — the desktop shell installs its
teardown-and-respawn; the core defaults to the supervisor exit code. The core
loses its `desktop` feature.
- `boot`'s stdout formatter moves to the binary (`src/boot_format.rs`); the core
only emits tracing events.
All 79 core tests pass; the binary boots and serves in a clean directory, and
the mobile-connector tools still register through the new hook.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::mcp::McpManager;
|
||||
use crate::tools::tool_names::CONFIG_GROUP;
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT};
|
||||
|
||||
/// Per-session (or per-stack) tool that activates **tool groups** on demand.
|
||||
///
|
||||
/// A group is either:
|
||||
/// - an **MCP server name** — loads that server's tools, or
|
||||
/// - the reserved keyword `"config"` — loads all built-in `Config`-category
|
||||
/// tools (system configuration: MCP/plugin/cron management, secrets).
|
||||
///
|
||||
/// When the LLM calls `activate_tools(["gmail", "config"])`:
|
||||
/// - The in-memory grant set is updated immediately, so the group's tools appear
|
||||
/// in the *next LLM round* of the current turn (via `all_tool_defs()`).
|
||||
/// - If `stack_id` is `None` (root agent): grants are persisted to
|
||||
/// `session_mcp_grants` — they survive across turns and restarts.
|
||||
/// - If `stack_id` is `Some(id)` (sub-agent): grants are persisted to
|
||||
/// `stack_mcp_grants` for that stack frame — they survive restarts but are
|
||||
/// deleted when the frame terminates (`dispatch_call_agent` calls
|
||||
/// `stack_mcp_grants::delete_for_stack` on cleanup).
|
||||
///
|
||||
/// The `session_mcp_grants` / `stack_mcp_grants` tables store the group string
|
||||
/// verbatim, so `"config"` is persisted just like an MCP server name.
|
||||
///
|
||||
/// Not in the global `ToolRegistry` — injected as an `InterfaceTool` in
|
||||
/// `build_agent_config` (root) and `dispatch_call_agent` (sub-agents).
|
||||
pub struct ActivateTools {
|
||||
pub pool: Arc<SqlitePool>,
|
||||
pub session_id: i64,
|
||||
/// `None` for root agents (session-scoped grants).
|
||||
/// `Some(stack_id)` for sub-agents (stack-scoped grants, deleted on frame exit).
|
||||
pub stack_id: Option<i64>,
|
||||
pub mcp: Arc<McpManager>,
|
||||
/// Shared in-memory grant set. Updated in-place on every call so subsequent
|
||||
/// rounds within the same turn see the new tools via `all_tool_defs()`.
|
||||
pub active_mcp_grants: Arc<RwLock<HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl Tool for ActivateTools {
|
||||
fn name(&self) -> &str { crate::tools::tool_names::ACTIVATE_TOOLS }
|
||||
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Activate one or more tool groups so their tools become available. \
|
||||
A group is either an MCP server name (see the MCP list) or the reserved \
|
||||
keyword `config`, which loads all system-configuration tools (managing \
|
||||
MCP servers, plugins, scheduled cron jobs, and secrets). \
|
||||
Pass an array of group names. \
|
||||
Once activated, the tools are available from the next tool-call round onward."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Tool groups to activate: MCP server names and/or the reserved \
|
||||
keyword \"config\" (e.g. [\"gmail\", \"config\"])."
|
||||
}
|
||||
},
|
||||
"required": ["groups"]
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String {
|
||||
let names = args["groups"]
|
||||
.as_array()
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", "))
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
truncate_label(&format!("activate tools [{names}]"), MAX_LABEL_SHORT)
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let names: Vec<String> = args["groups"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow::anyhow!("activate_tools: `groups` must be an array"))?
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
|
||||
if names.is_empty() {
|
||||
anyhow::bail!("activate_tools: `groups` is empty");
|
||||
}
|
||||
|
||||
let available: HashSet<String> = self.mcp.tools()
|
||||
.iter()
|
||||
.map(|t| t.server_name.clone())
|
||||
.collect();
|
||||
|
||||
let pool = Arc::clone(&self.pool);
|
||||
let session_id = self.session_id;
|
||||
let stack_id = self.stack_id;
|
||||
let grants_set = Arc::clone(&self.active_mcp_grants);
|
||||
|
||||
// Persist to DB (session-scoped or stack-scoped) and update in-memory set.
|
||||
// The reserved `config` group is stored verbatim, exactly like a server name.
|
||||
tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(async {
|
||||
for name in &names {
|
||||
match stack_id {
|
||||
None => {
|
||||
crate::db::session_mcp_grants::grant(&pool, session_id, name).await?;
|
||||
}
|
||||
Some(sid) => {
|
||||
crate::db::stack_mcp_grants::grant(&pool, sid, name).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
anyhow::Ok(())
|
||||
})
|
||||
})?;
|
||||
|
||||
// Update in-memory set so the next LLM round sees the new grants.
|
||||
{
|
||||
let mut set = grants_set.write()
|
||||
.map_err(|_| anyhow::anyhow!("activate_tools: lock poisoned"))?;
|
||||
for name in &names {
|
||||
set.insert(name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let activated: Vec<String> = names.iter()
|
||||
.map(|n| {
|
||||
if n == CONFIG_GROUP {
|
||||
// Built-in group — always available, no MCP server to reconnect.
|
||||
format!("{n} ✓")
|
||||
} else if available.contains(n) {
|
||||
format!("{n} ✓")
|
||||
} else {
|
||||
format!("{n} (registered but not yet running — tools will appear after reconnect)")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let scope = match stack_id {
|
||||
None => "session".to_string(),
|
||||
Some(s) => format!("stack {s}"),
|
||||
};
|
||||
|
||||
Ok(format!(
|
||||
"Tool groups activated for this {scope}: {}. \
|
||||
Their tools are available from the next tool-call round.",
|
||||
activated.join(", ")
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT};
|
||||
use crate::tools::fs::read_to_string;
|
||||
|
||||
pub struct AstOutline;
|
||||
|
||||
impl AstOutline {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl Tool for AstOutline {
|
||||
fn name(&self) -> &str { "get_ast_outline" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Return the structural outline of a source file: top-level definitions (functions, classes, \
|
||||
structs, methods, traits, interfaces, etc.) without their bodies. \
|
||||
Each entry is formatted as 'START-END | <kind>: <name>' where START and END are 1-based \
|
||||
line numbers of the full definition — same column format as read_file, so you can pass \
|
||||
START/END directly to read_file's start_line/end_line to read just that definition. \
|
||||
Much cheaper than reading the full file when you only need to understand the shape of the code. \
|
||||
Supported: .rs .py .js .mjs .ts .tsx .go .java .c .h .cpp .cc .hpp .swift .lua .rb .sh .ex .exs \
|
||||
.kt .json .toml .yaml .yml .html .css .md .sql"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to the source file. Relative to project root or absolute."
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String {
|
||||
let path = args["path"].as_str().unwrap_or("?");
|
||||
truncate_label(&format!("outline `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
|
||||
let ext = std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
match ext {
|
||||
"rs" => outline_rust(path),
|
||||
"py" => outline_ts(path, ts_python(), "Python"),
|
||||
"js" | "mjs" => outline_ts(path, ts_javascript(), "JavaScript"),
|
||||
"ts" => outline_ts(path, ts_typescript(false), "TypeScript"),
|
||||
"tsx" => outline_ts(path, ts_typescript(true), "TypeScript/TSX"),
|
||||
"go" => outline_ts(path, ts_go(), "Go"),
|
||||
"java" => outline_ts(path, ts_java(), "Java"),
|
||||
"c" | "h" => outline_ts(path, ts_c(), "C"),
|
||||
"cpp" | "cc" | "hpp" | "cxx"=> outline_ts(path, ts_cpp(), "C++"),
|
||||
"swift" => outline_ts(path, ts_swift(), "Swift"),
|
||||
"lua" => outline_ts(path, ts_lua(), "Lua"),
|
||||
"rb" => outline_ts(path, ts_ruby(), "Ruby"),
|
||||
"sh" | "bash" => outline_ts(path, ts_bash(), "Bash"),
|
||||
"ex" | "exs" => outline_ts(path, ts_elixir(), "Elixir"),
|
||||
"json" => outline_ts(path, ts_json(), "JSON"),
|
||||
"yaml" | "yml" => outline_ts(path, ts_yaml(), "YAML"),
|
||||
"html" => outline_ts(path, ts_html(), "HTML"),
|
||||
"css" => outline_ts(path, ts_css(), "CSS"),
|
||||
// text-based fallbacks for crates incompatible with tree-sitter 0.26
|
||||
"kt" | "kts" => outline_kotlin(path),
|
||||
"toml" => outline_toml(path),
|
||||
"sql" => outline_sql(path),
|
||||
"md" | "markdown" => outline_markdown(path),
|
||||
other => Ok(format!(
|
||||
"Language not supported for AST outline: .{other}\n\
|
||||
Supported: .rs .py .js .ts .tsx .go .java .c .cpp .swift .lua .rb .sh .ex \
|
||||
.kt .json .toml .yaml .html .css .md .sql"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── tree-sitter helpers ────────────────────────────────────────────────────
|
||||
|
||||
struct LangConfig {
|
||||
language: tree_sitter::Language,
|
||||
def_kinds: &'static [&'static str],
|
||||
name_field: &'static str,
|
||||
container_kinds: &'static [&'static str],
|
||||
}
|
||||
|
||||
fn outline_ts(path: &str, cfg: LangConfig, lang_label: &str) -> Result<String> {
|
||||
let source = read_to_string(path)?;
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
parser.set_language(&cfg.language)
|
||||
.map_err(|e| anyhow::anyhow!("tree-sitter language load error: {e}"))?;
|
||||
|
||||
let tree = parser.parse(source.as_bytes(), None)
|
||||
.ok_or_else(|| anyhow::anyhow!("tree-sitter parse returned None for {path}"))?;
|
||||
|
||||
let mut out = format!("--- {lang_label} outline: {path} ---\n\n");
|
||||
collect_nodes(tree.root_node(), &source, &cfg, 0, &mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn collect_nodes(
|
||||
node: tree_sitter::Node,
|
||||
source: &str,
|
||||
cfg: &LangConfig,
|
||||
depth: usize,
|
||||
out: &mut String,
|
||||
) {
|
||||
let kind = node.kind();
|
||||
|
||||
if cfg.def_kinds.contains(&kind) {
|
||||
let start = node.start_position().row + 1;
|
||||
let end = node.end_position().row + 1;
|
||||
let name = extract_name(node, source, cfg.name_field);
|
||||
let indent = " ".repeat(depth);
|
||||
out.push_str(&format!("{start:>4}-{end:>4} | {indent}{kind}: {name}\n"));
|
||||
|
||||
for i in 0..node.child_count() {
|
||||
let child = node.child(i as u32).unwrap();
|
||||
if cfg.container_kinds.contains(&child.kind()) {
|
||||
for j in 0..child.child_count() {
|
||||
let inner = child.child(j as u32).unwrap();
|
||||
if cfg.def_kinds.contains(&inner.kind()) {
|
||||
collect_nodes(inner, source, cfg, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if depth == 0 {
|
||||
for i in 0..node.child_count() {
|
||||
collect_nodes(node.child(i as u32).unwrap(), source, cfg, depth, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a display name for a node.
|
||||
/// 1. Try the named field (e.g. "name", "key").
|
||||
/// 2. Fall back to node text up to the first `{` or newline, max 120 chars,
|
||||
/// with whitespace normalised — works for CSS selectors, HTML tags, etc.
|
||||
fn extract_name(node: tree_sitter::Node, source: &str, name_field: &str) -> String {
|
||||
if !name_field.is_empty() {
|
||||
if let Some(n) = node.child_by_field_name(name_field) {
|
||||
return node_text(n, source);
|
||||
}
|
||||
}
|
||||
let text = source.get(node.byte_range()).unwrap_or("");
|
||||
let end = text.find('{')
|
||||
.or_else(|| text.find('\n'))
|
||||
.unwrap_or(text.len())
|
||||
.min(120);
|
||||
text[..end].split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn node_text(node: tree_sitter::Node, source: &str) -> String {
|
||||
source.get(node.byte_range()).unwrap_or("<?>").to_string()
|
||||
}
|
||||
|
||||
// ── language configs ───────────────────────────────────────────────────────
|
||||
|
||||
fn ts_python() -> LangConfig {
|
||||
LangConfig {
|
||||
language: tree_sitter_python::LANGUAGE.into(),
|
||||
def_kinds: &["function_definition", "async_function_definition", "class_definition", "decorated_definition"],
|
||||
name_field: "name",
|
||||
container_kinds: &["block"],
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_javascript() -> LangConfig {
|
||||
LangConfig {
|
||||
language: tree_sitter_javascript::LANGUAGE.into(),
|
||||
def_kinds: &[
|
||||
"function_declaration", "generator_function_declaration",
|
||||
"class_declaration", "method_definition",
|
||||
"lexical_declaration", "variable_declaration",
|
||||
],
|
||||
name_field: "name",
|
||||
container_kinds: &["class_body"],
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_typescript(tsx: bool) -> LangConfig {
|
||||
let language = if tsx {
|
||||
tree_sitter_typescript::LANGUAGE_TSX.into()
|
||||
} else {
|
||||
tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()
|
||||
};
|
||||
LangConfig {
|
||||
language,
|
||||
def_kinds: &[
|
||||
"function_declaration", "generator_function_declaration",
|
||||
"class_declaration", "method_definition",
|
||||
"interface_declaration", "type_alias_declaration",
|
||||
"enum_declaration", "abstract_class_declaration",
|
||||
"lexical_declaration", "variable_declaration",
|
||||
],
|
||||
name_field: "name",
|
||||
container_kinds: &["class_body"],
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_go() -> LangConfig {
|
||||
LangConfig {
|
||||
language: tree_sitter_go::LANGUAGE.into(),
|
||||
def_kinds: &["function_declaration", "method_declaration", "type_declaration", "const_declaration", "var_declaration"],
|
||||
name_field: "name",
|
||||
container_kinds: &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_java() -> LangConfig {
|
||||
LangConfig {
|
||||
language: tree_sitter_java::LANGUAGE.into(),
|
||||
def_kinds: &["class_declaration", "interface_declaration", "enum_declaration", "method_declaration", "constructor_declaration", "annotation_type_declaration"],
|
||||
name_field: "name",
|
||||
container_kinds: &["class_body", "interface_body", "enum_body"],
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_c() -> LangConfig {
|
||||
LangConfig {
|
||||
language: tree_sitter_c::LANGUAGE.into(),
|
||||
def_kinds: &["function_definition", "declaration", "struct_specifier", "enum_specifier", "typedef_declaration"],
|
||||
name_field: "declarator",
|
||||
container_kinds: &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_cpp() -> LangConfig {
|
||||
LangConfig {
|
||||
language: tree_sitter_cpp::LANGUAGE.into(),
|
||||
def_kinds: &["function_definition", "declaration", "class_specifier", "struct_specifier", "enum_specifier", "namespace_definition", "template_declaration"],
|
||||
name_field: "name",
|
||||
container_kinds: &["field_declaration_list"],
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_swift() -> LangConfig {
|
||||
LangConfig {
|
||||
language: tree_sitter_swift::LANGUAGE.into(),
|
||||
def_kinds: &["function_declaration", "class_declaration", "struct_declaration", "protocol_declaration", "enum_declaration", "extension_declaration"],
|
||||
name_field: "name",
|
||||
container_kinds: &["class_body", "struct_body", "enum_body", "protocol_body"],
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_lua() -> LangConfig {
|
||||
LangConfig {
|
||||
language: tree_sitter_lua::LANGUAGE.into(),
|
||||
def_kinds: &["function_declaration", "local_function", "assignment_statement"],
|
||||
name_field: "name",
|
||||
container_kinds: &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_ruby() -> LangConfig {
|
||||
LangConfig {
|
||||
language: tree_sitter_ruby::LANGUAGE.into(),
|
||||
def_kinds: &["method", "singleton_method", "class", "module", "singleton_class"],
|
||||
name_field: "name",
|
||||
container_kinds: &["body_statement"],
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_bash() -> LangConfig {
|
||||
LangConfig {
|
||||
language: tree_sitter_bash::LANGUAGE.into(),
|
||||
def_kinds: &["function_definition"],
|
||||
name_field: "name",
|
||||
container_kinds: &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_elixir() -> LangConfig {
|
||||
LangConfig {
|
||||
language: tree_sitter_elixir::LANGUAGE.into(),
|
||||
def_kinds: &["call"],
|
||||
name_field: "target",
|
||||
container_kinds: &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_json() -> LangConfig {
|
||||
LangConfig {
|
||||
language: tree_sitter_json::LANGUAGE.into(),
|
||||
def_kinds: &["pair"],
|
||||
name_field: "key",
|
||||
container_kinds: &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_yaml() -> LangConfig {
|
||||
LangConfig {
|
||||
language: tree_sitter_yaml::LANGUAGE.into(),
|
||||
def_kinds: &["block_mapping_pair"],
|
||||
name_field: "key",
|
||||
container_kinds: &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_html() -> LangConfig {
|
||||
LangConfig {
|
||||
language: tree_sitter_html::LANGUAGE.into(),
|
||||
def_kinds: &["element"],
|
||||
// tag_name is not a named field on element — use text-fallback (first line = opening tag)
|
||||
name_field: "",
|
||||
// recurse one level: html → head/body children
|
||||
container_kinds: &["element"],
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_css() -> LangConfig {
|
||||
LangConfig {
|
||||
language: tree_sitter_css::LANGUAGE.into(),
|
||||
def_kinds: &["rule_set", "at_rule"],
|
||||
// selectors is not a named field in tree-sitter-css — use text-fallback (text before `{`)
|
||||
name_field: "",
|
||||
container_kinds: &[],
|
||||
}
|
||||
}
|
||||
|
||||
// ── text-based fallbacks (crates incompatible with tree-sitter 0.26) ───────
|
||||
|
||||
fn outline_kotlin(path: &str) -> Result<String> {
|
||||
let source = read_to_string(path)?;
|
||||
let mut out = format!("--- Kotlin outline: {path} ---\n\n");
|
||||
let re = regex::Regex::new(
|
||||
r"(?m)^\s*((?:(?:public|private|protected|internal|open|abstract|override|suspend|inline|data|sealed|companion|object)\s+)*(?:fun|class|object|interface|enum\s+class|data\s+class|sealed\s+class)\s+[\w<>?]+)"
|
||||
).unwrap();
|
||||
for cap in re.captures_iter(&source) {
|
||||
let start = 1 + source[..cap.get(0).unwrap().start()].matches('\n').count();
|
||||
let end = 1 + source[..cap.get(0).unwrap().end()].matches('\n').count();
|
||||
out.push_str(&format!("{start:>4}-{end:>4} | {}\n", cap[1].trim()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn outline_toml(path: &str) -> Result<String> {
|
||||
let source = read_to_string(path)?;
|
||||
let mut out = format!("--- TOML outline: {path} ---\n\n");
|
||||
for (i, line) in source.lines().enumerate() {
|
||||
let t = line.trim();
|
||||
if (t.starts_with("[[") && t.ends_with("]]"))
|
||||
|| (t.starts_with('[') && t.ends_with(']') && !t.starts_with("[["))
|
||||
{
|
||||
let n = i + 1;
|
||||
out.push_str(&format!("{n:>4}-{n:>4} | {t}\n"));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn outline_sql(path: &str) -> Result<String> {
|
||||
let source = read_to_string(path)?;
|
||||
let mut out = format!("--- SQL outline: {path} ---\n\n");
|
||||
let re = regex::Regex::new(
|
||||
r#"(?im)^\s*(CREATE\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW|INDEX|UNIQUE\s+INDEX|FUNCTION|PROCEDURE|TRIGGER|SCHEMA|SEQUENCE|TYPE)\s+(?:IF\s+NOT\s+EXISTS\s+)?[\w."]+)"#
|
||||
).unwrap();
|
||||
for cap in re.captures_iter(&source) {
|
||||
let start = 1 + source[..cap.get(0).unwrap().start()].matches('\n').count();
|
||||
let end = 1 + source[..cap.get(0).unwrap().end()].matches('\n').count();
|
||||
out.push_str(&format!("{start:>4}-{end:>4} | {}\n", cap[1].trim()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn outline_markdown(path: &str) -> Result<String> {
|
||||
let source = read_to_string(path)?;
|
||||
let mut out = format!("--- Markdown outline: {path} ---\n\n");
|
||||
for (i, line) in source.lines().enumerate() {
|
||||
if line.starts_with('#') {
|
||||
let n = i + 1;
|
||||
out.push_str(&format!("{n:>4}-{n:>4} | {line}\n"));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ── Rust outline (syn-based) ───────────────────────────────────────────────
|
||||
|
||||
fn outline_rust(path: &str) -> Result<String> {
|
||||
use syn::{File, Item, ImplItem, TraitItem};
|
||||
use syn::spanned::Spanned;
|
||||
|
||||
let content = read_to_string(path)?;
|
||||
let file: File = syn::parse_file(&content)
|
||||
.map_err(|e| anyhow::anyhow!("Parse error in {path}: {e}"))?;
|
||||
|
||||
let mut out = format!("--- Rust outline: {path} ---\n\n");
|
||||
|
||||
for item in &file.items {
|
||||
match item {
|
||||
Item::Fn(f) => {
|
||||
let start = f.sig.fn_token.span().start().line;
|
||||
let end = f.span().end().line;
|
||||
let vis = tok(&f.vis);
|
||||
let sig = tok(&f.sig);
|
||||
out.push_str(&fmt_line(start, end, &format!("{vis}{sig}"), 0));
|
||||
}
|
||||
Item::Struct(s) => {
|
||||
let start = s.struct_token.span().start().line;
|
||||
let end = s.span().end().line;
|
||||
let vis = tok(&s.vis);
|
||||
let name = &s.ident;
|
||||
let generics = tok(&s.generics);
|
||||
out.push_str(&fmt_line(start, end, &format!("{vis}struct {name}{generics}"), 0));
|
||||
}
|
||||
Item::Enum(e) => {
|
||||
let start = e.enum_token.span().start().line;
|
||||
let end = e.span().end().line;
|
||||
let vis = tok(&e.vis);
|
||||
let name = &e.ident;
|
||||
let generics = tok(&e.generics);
|
||||
out.push_str(&fmt_line(start, end, &format!("{vis}enum {name}{generics}"), 0));
|
||||
for v in &e.variants {
|
||||
let vstart = v.ident.span().start().line;
|
||||
let vend = v.span().end().line;
|
||||
out.push_str(&fmt_line(vstart, vend, &v.ident.to_string(), 1));
|
||||
}
|
||||
}
|
||||
Item::Trait(t) => {
|
||||
let start = t.trait_token.span().start().line;
|
||||
let end = t.span().end().line;
|
||||
let vis = tok(&t.vis);
|
||||
let name = &t.ident;
|
||||
let generics = tok(&t.generics);
|
||||
out.push_str(&fmt_line(start, end, &format!("{vis}trait {name}{generics}"), 0));
|
||||
for item in &t.items {
|
||||
if let TraitItem::Fn(m) = item {
|
||||
let mstart = m.sig.fn_token.span().start().line;
|
||||
let mend = m.span().end().line;
|
||||
out.push_str(&fmt_line(mstart, mend, &tok(&m.sig), 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
Item::Impl(i) => {
|
||||
let start = i.impl_token.span().start().line;
|
||||
let end = i.span().end().line;
|
||||
let self_ty = tok(&*i.self_ty);
|
||||
let header = if let Some((_, tr, _)) = &i.trait_ {
|
||||
format!("impl {} for {self_ty}", tok(tr))
|
||||
} else {
|
||||
format!("impl {self_ty}")
|
||||
};
|
||||
out.push_str(&fmt_line(start, end, &header, 0));
|
||||
for item in &i.items {
|
||||
if let ImplItem::Fn(m) = item {
|
||||
let mstart = m.sig.fn_token.span().start().line;
|
||||
let mend = m.span().end().line;
|
||||
let vis = tok(&m.vis);
|
||||
let sig = tok(&m.sig);
|
||||
out.push_str(&fmt_line(mstart, mend, &format!("{vis}{sig}"), 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
Item::Type(t) => {
|
||||
let start = t.type_token.span().start().line;
|
||||
let end = t.span().end().line;
|
||||
let vis = tok(&t.vis);
|
||||
let name = &t.ident;
|
||||
let ty = tok(&*t.ty);
|
||||
out.push_str(&fmt_line(start, end, &format!("{vis}type {name} = {ty}"), 0));
|
||||
}
|
||||
Item::Const(c) => {
|
||||
let start = c.const_token.span().start().line;
|
||||
let end = c.span().end().line;
|
||||
let vis = tok(&c.vis);
|
||||
let name = &c.ident;
|
||||
let ty = tok(&*c.ty);
|
||||
out.push_str(&fmt_line(start, end, &format!("{vis}const {name}: {ty}"), 0));
|
||||
}
|
||||
Item::Mod(m) if m.content.is_some() => {
|
||||
let start = m.mod_token.span().start().line;
|
||||
let end = m.span().end().line;
|
||||
let vis = tok(&m.vis);
|
||||
out.push_str(&fmt_line(start, end, &format!("{vis}mod {}", m.ident), 0));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn tok<T: quote::ToTokens>(node: &T) -> String {
|
||||
normalize(node.to_token_stream().to_string())
|
||||
}
|
||||
|
||||
fn normalize(s: String) -> String {
|
||||
s.replace(" :: ", "::")
|
||||
.replace("& '", "&'")
|
||||
.replace(" ' ", "'")
|
||||
.replace("< ", "<")
|
||||
.replace(" >", ">")
|
||||
.replace("( ", "(")
|
||||
.replace(" )", ")")
|
||||
.replace(", )", ")")
|
||||
}
|
||||
|
||||
fn fmt_line(start: usize, end: usize, s: &str, indent: usize) -> String {
|
||||
let prefix = " ".repeat(indent);
|
||||
format!("{start:>4}-{end:>4} | {prefix}{}\n", s.trim())
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::plugin::PluginManager;
|
||||
use crate::tools::{Tool, ToolDescriptionLength};
|
||||
|
||||
pub struct ConfigurePlugin(pub Arc<PluginManager>);
|
||||
|
||||
impl Tool for ConfigurePlugin {
|
||||
fn name(&self) -> &str { "configure_plugin" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Update the configuration of a plugin and restart it immediately. \
|
||||
Use `list_items` (type=plugins) to see available plugin ids and their config schemas. \
|
||||
The config object must match the plugin's schema — extra keys are ignored."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Plugin id (e.g. \"remote_connectivity\", \"telegram\")."
|
||||
},
|
||||
"config": {
|
||||
"type": "object",
|
||||
"description": "Config object matching the plugin's schema. Existing keys not provided here are cleared.",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to enable the plugin. Defaults to true.",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"required": ["id", "config"]
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String {
|
||||
let id = args["id"].as_str().unwrap_or("?");
|
||||
let enabled = args["enabled"].as_bool().unwrap_or(true);
|
||||
let action = if enabled { "configure" } else { "disable" };
|
||||
format!("{action} plugin `{id}`")
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let id = args["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("configure_plugin: missing required argument `id`"))?;
|
||||
let config = args["config"].clone();
|
||||
if !config.is_object() {
|
||||
anyhow::bail!("configure_plugin: `config` must be an object");
|
||||
}
|
||||
let enabled = args["enabled"].as_bool().unwrap_or(true);
|
||||
|
||||
tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current()
|
||||
.block_on(self.0.update_config(id, enabled, config))
|
||||
})?;
|
||||
|
||||
Ok(format!(
|
||||
"Plugin '{}' configured and {}.",
|
||||
id,
|
||||
if enabled { "started" } else { "stopped" }
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cron::TaskManager;
|
||||
use crate::tools::{Tool, ToolDescriptionLength};
|
||||
|
||||
// ── execute_task ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// This struct is NOT registered in the global ToolRegistry. Instead it is
|
||||
// injected as an InterfaceTool (with the session_id captured in a closure)
|
||||
// by the session handler for interactive sessions (web, telegram).
|
||||
// Background sessions (cron, async) receive `execute_subtask` instead.
|
||||
//
|
||||
// The struct is public so skald.rs can call build_execute_task_interface_tool().
|
||||
|
||||
pub struct ExecuteTask(pub Arc<TaskManager>);
|
||||
|
||||
impl ExecuteTask {
|
||||
fn description_text() -> &'static str {
|
||||
"Create and run a task. Three modes:\n\
|
||||
• mode=cron — scheduled by a 7-field cron expression (sec min hour dom month dow year, \
|
||||
Europe/London timezone). Returns task_id and next scheduled run. Recurring unless the \
|
||||
expression can only fire once.\n\
|
||||
• mode=sync — run immediately, block until the agent finishes, and return the result inline. \
|
||||
Best for short tasks (a few seconds to a few minutes).\n\
|
||||
• mode=async — start the task in the background and return the task_id immediately. \
|
||||
When the task completes its result will be delivered back to this chat automatically."
|
||||
}
|
||||
|
||||
fn schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["mode", "title", "prompt", "agent_id"],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["cron", "sync", "async"],
|
||||
"description": "cron=scheduled; sync=run now and wait for result; async=run in background, result comes back to this chat"
|
||||
},
|
||||
"title": { "type": "string", "description": "Short name for this task" },
|
||||
"description": { "type": "string", "description": "What this task does" },
|
||||
"cron": { "type": "string", "description": "7-field cron expression — required when mode=cron (times in Europe/London). E.g. '0 0 9 * * * *' = every day at 09:00" },
|
||||
"prompt": { "type": "string", "description": "Prompt sent to the agent at each run" },
|
||||
"agent_id": { "type": "string", "description": "Task agent to run (required; e.g. software-engineer, researcher, generalist). Must be a `task` agent — chat/system agents are rejected." }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn execute_with_session(&self, args: &Value, session_id: i64, run_context: Option<String>) -> Result<String> {
|
||||
let mode = args["mode"].as_str().unwrap_or("").trim().to_string();
|
||||
let title = args["title"].as_str().unwrap_or("").trim().to_string();
|
||||
let desc = args["description"].as_str().unwrap_or("").trim().to_string();
|
||||
let cron = args["cron"].as_str().unwrap_or("").trim().to_string();
|
||||
let prompt = args["prompt"].as_str().unwrap_or("").trim().to_string();
|
||||
// No default: agent_id is required and validated as a `task` agent inside
|
||||
// TaskManager (require_task_agent) for every mode.
|
||||
let agent_id = args["agent_id"].as_str().unwrap_or("").trim().to_string();
|
||||
let rc_id = run_context.as_deref();
|
||||
|
||||
if title.is_empty() { anyhow::bail!("title is required"); }
|
||||
if prompt.is_empty() { anyhow::bail!("prompt is required"); }
|
||||
|
||||
match mode.as_str() {
|
||||
"cron" => {
|
||||
if cron.is_empty() { anyhow::bail!("cron expression is required for mode=cron"); }
|
||||
let job = self.0.add_job(&title, &desc, &cron, &prompt, &agent_id, false, "cron", None, rc_id)?;
|
||||
let kind = if job.single_run { "one-shot" } else { "recurring" };
|
||||
Ok(serde_json::to_string(&json!({
|
||||
"task_id": job.id,
|
||||
"mode": "cron",
|
||||
"recurring": !job.single_run,
|
||||
"next_run_at": job.next_run_at,
|
||||
"message": format!("Created {} cron task {} — '{}'", kind, job.id, job.title),
|
||||
}))?)
|
||||
}
|
||||
"sync" => {
|
||||
let result = self.0.add_job_sync(&title, &desc, &prompt, &agent_id, rc_id)?;
|
||||
Ok(result)
|
||||
}
|
||||
"async" => {
|
||||
let job = self.0.add_job_async(&title, &desc, &prompt, &agent_id, session_id, rc_id)?;
|
||||
Ok(serde_json::to_string(&json!({
|
||||
"task_id": job.id,
|
||||
"status": "started",
|
||||
"message": format!(
|
||||
"Task {} ('{}') is running in the background. \
|
||||
The system will automatically deliver the result to this conversation when complete. \
|
||||
Do NOT call read_agent_result or read_notifications — no polling needed. \
|
||||
Continue the conversation normally.",
|
||||
job.id, job.title
|
||||
),
|
||||
}))?)
|
||||
}
|
||||
_ => anyhow::bail!("mode must be one of: cron, sync, async"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the execute_task InterfaceTool with the session_id captured in a closure.
|
||||
/// Called from the session handler when building AgentRunConfig for interactive sessions.
|
||||
pub fn build_execute_task_interface_tool(
|
||||
task_mgr: Arc<TaskManager>,
|
||||
session_id: i64,
|
||||
run_context: Option<String>,
|
||||
) -> crate::session::handler::InterfaceTool {
|
||||
use crate::session::handler::{InterfaceTool, ToolFuture};
|
||||
|
||||
let tool = Arc::new(ExecuteTask(task_mgr));
|
||||
|
||||
InterfaceTool {
|
||||
definition: json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "execute_task",
|
||||
"description": ExecuteTask::description_text(),
|
||||
"parameters": ExecuteTask::schema(),
|
||||
}
|
||||
}),
|
||||
handler: Arc::new(move |args: Value| -> ToolFuture {
|
||||
let tool_clone = Arc::clone(&tool);
|
||||
let run_context = run_context.clone();
|
||||
Box::pin(async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
tool_clone.execute_with_session(&args, session_id, run_context)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("execute_task panicked: {e}"))?
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ── delete_cron_job ───────────────────────────────────────────────────────────
|
||||
|
||||
pub struct DeleteCronJob(pub Arc<TaskManager>);
|
||||
|
||||
impl Tool for DeleteCronJob {
|
||||
fn name(&self) -> &str { "delete_cron_job" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Permanently delete a scheduled task or cron job by its numeric id."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": { "type": "integer", "description": "Task id from list_items (type=cron)" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String {
|
||||
let id = args["id"].as_i64().map(|n| n.to_string()).unwrap_or_else(|| "?".to_string());
|
||||
format!("delete cron job #{id}")
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let id = args["id"].as_i64().ok_or_else(|| anyhow::anyhow!("id must be an integer"))?;
|
||||
if self.0.delete_job(id)? {
|
||||
Ok(format!("Task {id} deleted."))
|
||||
} else {
|
||||
Ok(format!("No task with id {id}."))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 120;
|
||||
const MAX_TIMEOUT_SECS: u64 = 600;
|
||||
const MAX_OUTPUT_BYTES: usize = 100_000;
|
||||
|
||||
pub struct ExecuteCmd;
|
||||
|
||||
impl Tool for ExecuteCmd {
|
||||
fn name(&self) -> &str { crate::tools::tool_names::EXECUTE_CMD }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Shell }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Execute a shell command (sh -c) on the host machine. \
|
||||
Reserve this for: builds, installs, git, tests, scripts, processes, network, package managers. \
|
||||
Do NOT use cat/head/tail to read files — use read_file instead. \
|
||||
Do NOT use grep/rg/find to search — use grep_files instead. \
|
||||
Do NOT use ls to list directories — use list_files instead. \
|
||||
Do NOT use sed/awk to edit files — use edit_file instead. \
|
||||
Do NOT use echo/cat heredoc to write files — use write_file instead. \
|
||||
Captures stdout and stderr. Requires user approval before running."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| ".".to_string());
|
||||
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "Full command line, passed to `sh -c`. May include pipes, redirects, and shell expansions."
|
||||
},
|
||||
"workdir": {
|
||||
"type": "string",
|
||||
"description": format!(
|
||||
"Working directory for the command (absolute path). \
|
||||
Omit to use the project root (currently: {cwd})."
|
||||
)
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": format!(
|
||||
"Max seconds to wait (default: {DEFAULT_TIMEOUT_SECS}, max: {MAX_TIMEOUT_SECS}). \
|
||||
The command returns immediately when it finishes — set high for long builds, \
|
||||
you won't wait unnecessarily."
|
||||
),
|
||||
"default": DEFAULT_TIMEOUT_SECS,
|
||||
"minimum": 1,
|
||||
"maximum": MAX_TIMEOUT_SECS
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let cmd = args["command"].as_str().unwrap_or("?");
|
||||
match length {
|
||||
ToolDescriptionLength::Short => {
|
||||
let binary = cmd.split_whitespace().next().unwrap_or(cmd);
|
||||
let name = binary.split('/').last().unwrap_or(binary);
|
||||
truncate_label(&format!("execute_cmd `{name}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
ToolDescriptionLength::Full => {
|
||||
truncate_label(&format!("execute_cmd `{cmd}`"), MAX_LABEL_FULL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(run_from_args(&args))
|
||||
})
|
||||
}
|
||||
|
||||
/// Genuinely async so the unified `ToolExecution` path can race it against the
|
||||
/// /stop token: on cancel the `SimpleExecution` drops this future and
|
||||
/// `kill_on_drop(true)` kills the spawned shell process. (The sync `execute`
|
||||
/// above — which blocks a worker thread — would not be cancellable.)
|
||||
fn execute_async<'a>(&'a self, args: Value) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
|
||||
Box::pin(async move { run_from_args(&args).await })
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse + run a shell command from tool arguments, as an awaitable future.
|
||||
///
|
||||
/// Driven by `ExecuteCmd::execute_async` through the unified `ToolExecution`
|
||||
/// path: on /stop the `SimpleExecution` drops this future and `kill_on_drop(true)`
|
||||
/// kills the child process. `Tool::execute` runs it synchronously via
|
||||
/// `block_in_place` only as a non-cancellable fallback.
|
||||
pub async fn run_from_args(args: &Value) -> Result<String> {
|
||||
let (command, workdir, timeout_secs) = parse_args(args)?;
|
||||
run(command, workdir, timeout_secs).await
|
||||
}
|
||||
|
||||
fn parse_args(args: &Value) -> Result<(String, Option<PathBuf>, u64)> {
|
||||
let command = args["command"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: command"))?
|
||||
.to_string();
|
||||
|
||||
let workdir = match args["workdir"].as_str() {
|
||||
Some(p) => {
|
||||
let path = PathBuf::from(p);
|
||||
if !path.is_absolute() {
|
||||
anyhow::bail!("workdir must be an absolute path, got: {p}");
|
||||
}
|
||||
if !path.is_dir() {
|
||||
anyhow::bail!("workdir does not exist or is not a directory: {p}");
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let timeout_secs = args["timeout"].as_u64()
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS)
|
||||
.clamp(1, MAX_TIMEOUT_SECS);
|
||||
|
||||
Ok((command, workdir, timeout_secs))
|
||||
}
|
||||
|
||||
async fn run(command: String, workdir: Option<PathBuf>, timeout_secs: u64) -> Result<String> {
|
||||
// Audit log: record every shell command before it runs. Auto-approved
|
||||
// commands (approval bypass active) otherwise leave no trace, so a command
|
||||
// that kills the process — or misbehaves — can't be reconstructed.
|
||||
let workdir_display = workdir
|
||||
.as_deref()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| ".".to_string());
|
||||
tracing::info!(
|
||||
command = %command,
|
||||
workdir = %workdir_display,
|
||||
timeout_secs,
|
||||
"execute_cmd: running shell command"
|
||||
);
|
||||
|
||||
let mut cmd = tokio::process::Command::new("sh");
|
||||
cmd.arg("-c")
|
||||
.arg(&command)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.stdin(Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
|
||||
if let Some(dir) = workdir {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
|
||||
let mut child = cmd.spawn()?;
|
||||
|
||||
let stdout = child.stdout.take().expect("stdout is piped");
|
||||
let stderr = child.stderr.take().expect("stderr is piped");
|
||||
|
||||
// Read stdout/stderr concurrently with wait() inside a single timeout.
|
||||
// Reading after wait() deadlocks when the pipe buffer fills (~64KB).
|
||||
// The timeout must also cover the reads — background processes spawned by
|
||||
// the command can hold pipe descriptors open indefinitely after sh exits.
|
||||
let result = tokio::time::timeout(Duration::from_secs(timeout_secs), async {
|
||||
let (out_res, err_res, status_res) = tokio::join!(
|
||||
async {
|
||||
let mut buf = String::new();
|
||||
tokio::io::BufReader::new(stdout).read_to_string(&mut buf).await?;
|
||||
Ok::<_, std::io::Error>(buf)
|
||||
},
|
||||
async {
|
||||
let mut buf = String::new();
|
||||
tokio::io::BufReader::new(stderr).read_to_string(&mut buf).await?;
|
||||
Ok::<_, std::io::Error>(buf)
|
||||
},
|
||||
child.wait(),
|
||||
);
|
||||
Ok::<_, anyhow::Error>((out_res?, err_res?, status_res?))
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok((out, err, status))) => {
|
||||
let code = status.code()
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| "signal".to_string());
|
||||
let combined = format!("exit: {code}\n--- stdout ---\n{out}\n--- stderr ---\n{err}");
|
||||
Ok(truncate_output(combined))
|
||||
}
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(_) => {
|
||||
let _ = child.start_kill();
|
||||
let _ = child.wait().await;
|
||||
anyhow::bail!("Command timed out after {timeout_secs}s: {command}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_output(s: String) -> String {
|
||||
if s.len() <= MAX_OUTPUT_BYTES {
|
||||
return s;
|
||||
}
|
||||
let head_size = MAX_OUTPUT_BYTES * 40 / 100;
|
||||
let tail_size = MAX_OUTPUT_BYTES - head_size;
|
||||
let head_end = floor_char_boundary(&s, head_size);
|
||||
let tail_start = floor_char_boundary(&s, s.len().saturating_sub(tail_size));
|
||||
format!(
|
||||
"{}\n\n[... {} bytes omitted (showing first 40% and last 60%) ...]\n\n{}",
|
||||
&s[..head_end],
|
||||
s.len().saturating_sub(MAX_OUTPUT_BYTES),
|
||||
&s[tail_start..]
|
||||
)
|
||||
}
|
||||
|
||||
fn floor_char_boundary(s: &str, idx: usize) -> usize {
|
||||
let mut i = idx.min(s.len());
|
||||
while !s.is_char_boundary(i) { i -= 1; }
|
||||
i
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT};
|
||||
use super::{read_to_string, write_string};
|
||||
|
||||
fn normalize_ws(s: &str) -> String {
|
||||
s.lines()
|
||||
.map(|l| {
|
||||
let mut out = String::with_capacity(l.len());
|
||||
let mut last_space = true;
|
||||
for ch in l.chars() {
|
||||
if ch.is_whitespace() {
|
||||
if !last_space { out.push(' '); }
|
||||
last_space = true;
|
||||
} else {
|
||||
out.push(ch);
|
||||
last_space = false;
|
||||
}
|
||||
}
|
||||
out.trim_end().to_owned()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn find_normalized(haystack: &str, normalized_needle: &str) -> Option<(usize, usize)> {
|
||||
let needle_lines: Vec<&str> = normalized_needle.lines().collect();
|
||||
let n = needle_lines.len();
|
||||
if n == 0 { return None; }
|
||||
|
||||
let hay_lines: Vec<&str> = haystack.lines().collect();
|
||||
let hay_count = hay_lines.len();
|
||||
|
||||
let mut offsets = Vec::with_capacity(hay_count + 1);
|
||||
offsets.push(0usize);
|
||||
for line in &hay_lines {
|
||||
let prev = *offsets.last().unwrap();
|
||||
offsets.push(prev + line.len() + 1);
|
||||
}
|
||||
|
||||
for start_idx in 0..=(hay_count.saturating_sub(n)) {
|
||||
let matches = (0..n).all(|i| {
|
||||
normalize_ws(hay_lines[start_idx + i]).as_str() == needle_lines[i]
|
||||
});
|
||||
if matches {
|
||||
let byte_start = offsets[start_idx];
|
||||
let byte_end = if start_idx + n < hay_count {
|
||||
offsets[start_idx + n]
|
||||
} else {
|
||||
haystack.len()
|
||||
};
|
||||
return Some((byte_start, byte_end));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub struct EditFile;
|
||||
|
||||
impl EditFile {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl Tool for EditFile {
|
||||
fn name(&self) -> &str { "edit_file" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Replace a substring in a file with new text. \
|
||||
Use instead of sed/awk in the terminal. \
|
||||
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
|
||||
By default `old` must be unique — include enough surrounding context to make it so. \
|
||||
Always call read_file first and copy text exactly as shown after '| ' (the ' N | ' prefix is NOT part of the file). \
|
||||
Set replace_all=true to replace every occurrence instead of requiring uniqueness."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string", "description": "File path. Relative to project root, or absolute." },
|
||||
"old": { "type": "string", "description": "Text to find and replace. Must be unique in the file unless replace_all=true." },
|
||||
"new": { "type": "string", "description": "Replacement text. Pass empty string to delete the matched text." },
|
||||
"replace_all": {
|
||||
"type": "boolean",
|
||||
"description": "Replace every occurrence of old instead of requiring a unique match (default: false).",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["path", "old", "new"]
|
||||
})
|
||||
}
|
||||
|
||||
fn target_path(&self, args: &Value) -> Option<String> {
|
||||
super::path_arg(args)
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let path = args["path"].as_str().unwrap_or("?");
|
||||
let _ = length;
|
||||
truncate_label(&format!("edit_file `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
let old = args["old"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: old"))?;
|
||||
let new = args["new"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: new"))?;
|
||||
|
||||
let replace_all = args["replace_all"].as_bool().unwrap_or(false);
|
||||
let content = read_to_string(user_path)?;
|
||||
|
||||
let updated = if replace_all {
|
||||
if !content.contains(old) {
|
||||
anyhow::bail!(
|
||||
"Text not found in {user_path}. \
|
||||
Call read_file first and copy the text exactly as shown after the '| ' prefix."
|
||||
);
|
||||
}
|
||||
content.replace(old, new)
|
||||
} else {
|
||||
let exact_count = content.matches(old).count();
|
||||
if exact_count > 1 {
|
||||
anyhow::bail!(
|
||||
"Text found {exact_count} times in {user_path}. \
|
||||
Include more surrounding context in `old` to make it unique, or set replace_all=true."
|
||||
);
|
||||
}
|
||||
if exact_count == 1 {
|
||||
content.replacen(old, new, 1)
|
||||
} else {
|
||||
let normalized_old = normalize_ws(old);
|
||||
let (start, end) = find_normalized(&content, &normalized_old)
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"Text not found in {user_path}. \
|
||||
Call read_file first and copy the text exactly as shown after the '| ' prefix."
|
||||
))?;
|
||||
format!("{}{}{}", &content[..start], new, &content[end..])
|
||||
}
|
||||
};
|
||||
|
||||
write_string(user_path, &updated)?;
|
||||
Ok(format!("Edited {user_path}."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
use anyhow::Result;
|
||||
use regex::Regex;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
||||
use super::resolve;
|
||||
|
||||
pub struct GrepFiles;
|
||||
|
||||
impl GrepFiles {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl Tool for GrepFiles {
|
||||
fn name(&self) -> &str { "grep_files" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Search for a regex pattern across files in a directory or a single file. \
|
||||
Use instead of grep/rg in the terminal. \
|
||||
Binary files and common build/cache directories (target/, .git/, node_modules/, .venv/) are skipped. \
|
||||
Use output_mode='files_only' to get just file paths (faster, lower token cost). \
|
||||
Use output_mode='count' for match counts per file. \
|
||||
Use context_lines to show surrounding lines around each match (like grep -C)."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory or file to search. Relative to project root, or absolute."
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Regex pattern to search for (case-insensitive by default)."
|
||||
},
|
||||
"case_sensitive": {
|
||||
"type": "boolean",
|
||||
"description": "If true, match is case-sensitive. Default: false.",
|
||||
"default": false
|
||||
},
|
||||
"include_glob": {
|
||||
"type": "string",
|
||||
"description": "Restrict search to files matching this glob pattern, e.g. '*.rs' or '*.py'."
|
||||
},
|
||||
"output_mode": {
|
||||
"type": "string",
|
||||
"enum": ["content", "files_only", "count"],
|
||||
"description": "'content' (default): matching lines with file path and line number. 'files_only': only the paths of files containing at least one match — use when you need to know which files match without reading content. 'count': number of matches per file.",
|
||||
"default": "content"
|
||||
},
|
||||
"context_lines": {
|
||||
"type": "integer",
|
||||
"description": "Lines of context to show before and after each match in content mode (default 0, max 10). Like grep -C.",
|
||||
"default": 0
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Stop after this many results (default 100).",
|
||||
"default": 100
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": "Skip the first N results for pagination (default 0).",
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"required": ["path", "pattern"]
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let pattern = args["pattern"].as_str().unwrap_or("?");
|
||||
match length {
|
||||
ToolDescriptionLength::Short => {
|
||||
truncate_label(&format!("grep_files `{pattern}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
ToolDescriptionLength::Full => {
|
||||
let path = args["path"].as_str().unwrap_or(".");
|
||||
truncate_label(&format!("grep_files `{pattern}` in {path}"), MAX_LABEL_FULL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str().ok_or_else(|| anyhow::anyhow!("Missing: path"))?;
|
||||
let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("Missing: pattern"))?;
|
||||
let case_sensitive = args["case_sensitive"].as_bool().unwrap_or(false);
|
||||
let include_glob = args["include_glob"].as_str();
|
||||
let output_mode = args["output_mode"].as_str().unwrap_or("content");
|
||||
let context_lines = args["context_lines"].as_u64().unwrap_or(0).min(10) as usize;
|
||||
let max_results = args["max_results"].as_u64().unwrap_or(100) as usize;
|
||||
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||||
|
||||
let re = {
|
||||
let pat = if case_sensitive { pattern.to_string() } else { format!("(?i){pattern}") };
|
||||
Regex::new(&pat).map_err(|e| anyhow::anyhow!("Invalid regex: {e}"))?
|
||||
};
|
||||
let glob_pattern = include_glob.and_then(|g| glob::Pattern::new(g).ok());
|
||||
let root = resolve(user_path)?;
|
||||
if !root.exists() {
|
||||
anyhow::bail!("Path not found: {user_path}");
|
||||
}
|
||||
|
||||
// Walkers emit absolute paths (the `path` arg is resolved to an absolute working
|
||||
// directory upstream). Strip the queried root so results are shown relative to it,
|
||||
// consistent with `list_files` — keeps the model from echoing absolute paths back.
|
||||
let root_prefix = format!("{}/", root.display());
|
||||
let rel = |s: String| s.strip_prefix(&root_prefix).map(str::to_string).unwrap_or(s);
|
||||
|
||||
match output_mode {
|
||||
"files_only" => {
|
||||
let mut files: Vec<String> = Vec::new();
|
||||
collect_matching_files(&root, &re, &glob_pattern, max_results + offset, &mut files)?;
|
||||
let files: Vec<String> = files.into_iter().skip(offset).take(max_results).map(rel).collect();
|
||||
if files.is_empty() {
|
||||
return Ok(format!("No files match {:?} in {user_path}.", pattern));
|
||||
}
|
||||
Ok(format!("{} file(s):\n{}", files.len(), files.join("\n")))
|
||||
}
|
||||
"count" => {
|
||||
let mut counts: Vec<(String, usize)> = Vec::new();
|
||||
collect_match_counts(&root, &re, &glob_pattern, max_results + offset, &mut counts)?;
|
||||
let counts: Vec<(String, usize)> = counts.into_iter().skip(offset).take(max_results).collect();
|
||||
if counts.is_empty() {
|
||||
return Ok(format!("No matches for {:?} in {user_path}.", pattern));
|
||||
}
|
||||
let lines: Vec<String> = counts.into_iter().map(|(f, n)| format!("{}: {n}", rel(f))).collect();
|
||||
Ok(format!("{} file(s):\n{}", lines.len(), lines.join("\n")))
|
||||
}
|
||||
_ => {
|
||||
let mut matches: Vec<String> = Vec::new();
|
||||
let mut output_bytes: usize = 0;
|
||||
let mut truncated = false;
|
||||
search_path(&root, &re, &glob_pattern, max_results + offset, context_lines, &mut matches, &mut output_bytes, &mut truncated)?;
|
||||
|
||||
let matches: Vec<String> = matches.into_iter().skip(offset).take(max_results).map(rel).collect();
|
||||
if matches.is_empty() {
|
||||
return Ok(format!("No matches for {:?} in {user_path}.", pattern));
|
||||
}
|
||||
let mut out = format!("{} match(es):\n", matches.len());
|
||||
out.push_str(&matches.join("\n"));
|
||||
if truncated {
|
||||
out.push_str(&format!(
|
||||
"\n\n[Output truncated at {MAX_OUTPUT_BYTES} bytes. Narrow your search with a more specific pattern, path, or include_glob.]"
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `secrets` is skipped so a recursive grep rooted at a parent (e.g. the auto-read
|
||||
// working directory) never descends into and leaks secret values.
|
||||
const SKIP_DIRS: &[&str] = &["target", ".git", "node_modules", ".venv", "__pycache__", "secrets"];
|
||||
const MAX_FILE_BYTES: u64 = 200_000;
|
||||
const MAX_OUTPUT_BYTES: usize = 60_000;
|
||||
const MAX_LINE_BYTES: usize = 500;
|
||||
|
||||
// ── files_only mode ───────────────────────────────────────────────────────────
|
||||
|
||||
fn collect_matching_files(
|
||||
path: &std::path::Path,
|
||||
re: &Regex,
|
||||
glob: &Option<glob::Pattern>,
|
||||
max: usize,
|
||||
out: &mut Vec<String>,
|
||||
) -> Result<()> {
|
||||
if out.len() >= max { return Ok(()); }
|
||||
if path.is_dir() {
|
||||
let mut entries: Vec<_> = std::fs::read_dir(path)?.filter_map(|e| e.ok()).collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
for entry in entries {
|
||||
if out.len() >= max { break; }
|
||||
let p = entry.path();
|
||||
if p.is_dir() {
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if SKIP_DIRS.contains(&name) { continue; }
|
||||
collect_matching_files(&p, re, glob, max, out)?;
|
||||
} else if file_has_match(&p, re, glob)? {
|
||||
out.push(p.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
} else if file_has_match(path, re, glob)? {
|
||||
out.push(path.to_string_lossy().into_owned());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn file_has_match(path: &std::path::Path, re: &Regex, glob: &Option<glob::Pattern>) -> Result<bool> {
|
||||
if let Some(pat) = glob {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if !pat.matches(name) { return Ok(false); }
|
||||
}
|
||||
if let Ok(meta) = path.metadata() {
|
||||
if meta.len() > MAX_FILE_BYTES { return Ok(false); }
|
||||
}
|
||||
let text = match std::fs::read(path) { Ok(b) => b, Err(_) => return Ok(false) };
|
||||
if text.iter().take(8000).any(|&b| b == 0) { return Ok(false); }
|
||||
let content = match std::str::from_utf8(&text) { Ok(s) => s, Err(_) => return Ok(false) };
|
||||
Ok(content.lines().any(|l| re.is_match(l)))
|
||||
}
|
||||
|
||||
// ── count mode ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn collect_match_counts(
|
||||
path: &std::path::Path,
|
||||
re: &Regex,
|
||||
glob: &Option<glob::Pattern>,
|
||||
max: usize,
|
||||
out: &mut Vec<(String, usize)>,
|
||||
) -> Result<()> {
|
||||
if out.len() >= max { return Ok(()); }
|
||||
if path.is_dir() {
|
||||
let mut entries: Vec<_> = std::fs::read_dir(path)?.filter_map(|e| e.ok()).collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
for entry in entries {
|
||||
if out.len() >= max { break; }
|
||||
let p = entry.path();
|
||||
if p.is_dir() {
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if SKIP_DIRS.contains(&name) { continue; }
|
||||
collect_match_counts(&p, re, glob, max, out)?;
|
||||
} else if let Some(n) = count_file_matches(&p, re, glob)? {
|
||||
if n > 0 { out.push((p.to_string_lossy().into_owned(), n)); }
|
||||
}
|
||||
}
|
||||
} else if let Some(n) = count_file_matches(path, re, glob)? {
|
||||
if n > 0 { out.push((path.to_string_lossy().into_owned(), n)); }
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn count_file_matches(path: &std::path::Path, re: &Regex, glob: &Option<glob::Pattern>) -> Result<Option<usize>> {
|
||||
if let Some(pat) = glob {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if !pat.matches(name) { return Ok(None); }
|
||||
}
|
||||
if let Ok(meta) = path.metadata() {
|
||||
if meta.len() > MAX_FILE_BYTES { return Ok(None); }
|
||||
}
|
||||
let text = match std::fs::read(path) { Ok(b) => b, Err(_) => return Ok(None) };
|
||||
if text.iter().take(8000).any(|&b| b == 0) { return Ok(None); }
|
||||
let content = match std::str::from_utf8(&text) { Ok(s) => s, Err(_) => return Ok(None) };
|
||||
Ok(Some(content.lines().filter(|l| re.is_match(l)).count()))
|
||||
}
|
||||
|
||||
// ── content mode ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn search_path(
|
||||
path: &std::path::Path,
|
||||
re: &Regex,
|
||||
glob: &Option<glob::Pattern>,
|
||||
max_results: usize,
|
||||
context_lines: usize,
|
||||
matches: &mut Vec<String>,
|
||||
output_bytes: &mut usize,
|
||||
truncated: &mut bool,
|
||||
) -> Result<()> {
|
||||
if matches.len() >= max_results || *truncated { return Ok(()); }
|
||||
if path.is_dir() {
|
||||
let mut entries: Vec<_> = std::fs::read_dir(path)?.filter_map(|e| e.ok()).collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
for entry in entries {
|
||||
if matches.len() >= max_results || *truncated { break; }
|
||||
let p = entry.path();
|
||||
if p.is_dir() {
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if SKIP_DIRS.contains(&name) { continue; }
|
||||
search_path(&p, re, glob, max_results, context_lines, matches, output_bytes, truncated)?;
|
||||
} else {
|
||||
grep_file(&p, re, glob, max_results, context_lines, matches, output_bytes, truncated)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
grep_file(path, re, glob, max_results, context_lines, matches, output_bytes, truncated)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn grep_file(
|
||||
path: &std::path::Path,
|
||||
re: &Regex,
|
||||
glob: &Option<glob::Pattern>,
|
||||
max_results: usize,
|
||||
context_lines: usize,
|
||||
matches: &mut Vec<String>,
|
||||
output_bytes: &mut usize,
|
||||
truncated: &mut bool,
|
||||
) -> Result<()> {
|
||||
if let Some(pat) = glob {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if !pat.matches(name) { return Ok(()); }
|
||||
}
|
||||
if let Ok(meta) = path.metadata() {
|
||||
if meta.len() > MAX_FILE_BYTES { return Ok(()); }
|
||||
}
|
||||
let text = match std::fs::read(path) { Ok(b) => b, Err(_) => return Ok(()) };
|
||||
if text.iter().take(8000).any(|&b| b == 0) { return Ok(()); }
|
||||
let content = match std::str::from_utf8(&text) { Ok(s) => s, Err(_) => return Ok(()) };
|
||||
let display = path.to_string_lossy();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
|
||||
if context_lines == 0 {
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if matches.len() >= max_results || *truncated { break; }
|
||||
if re.is_match(line) {
|
||||
let snippet = if line.len() > MAX_LINE_BYTES { format!("{}…", &line[..MAX_LINE_BYTES]) } else { line.to_string() };
|
||||
let entry = format!("{}:{}: {}", display, i + 1, snippet);
|
||||
*output_bytes += entry.len();
|
||||
if *output_bytes > MAX_OUTPUT_BYTES { *truncated = true; break; }
|
||||
matches.push(entry);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let match_indices: Vec<usize> = lines.iter().enumerate()
|
||||
.filter(|(_, l)| re.is_match(l))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if match_indices.is_empty() { return Ok(()); }
|
||||
|
||||
let mut windows: Vec<(usize, usize)> = Vec::new();
|
||||
for &m in &match_indices {
|
||||
let start = m.saturating_sub(context_lines);
|
||||
let end = (m + context_lines).min(lines.len().saturating_sub(1));
|
||||
if let Some(last) = windows.last_mut() {
|
||||
if start <= last.1 + 1 { last.1 = last.1.max(end); continue; }
|
||||
}
|
||||
windows.push((start, end));
|
||||
}
|
||||
|
||||
let match_set: std::collections::HashSet<usize> = match_indices.into_iter().collect();
|
||||
for (wi, (start, end)) in windows.iter().enumerate() {
|
||||
if matches.len() >= max_results || *truncated { break; }
|
||||
if wi > 0 {
|
||||
let sep = format!("{}:---", display);
|
||||
*output_bytes += sep.len();
|
||||
matches.push(sep);
|
||||
}
|
||||
for idx in *start..=*end {
|
||||
if matches.len() >= max_results || *truncated { break; }
|
||||
let marker = if match_set.contains(&idx) { ">" } else { " " };
|
||||
let line = lines[idx];
|
||||
let snippet = if line.len() > MAX_LINE_BYTES { format!("{}…", &line[..MAX_LINE_BYTES]) } else { line.to_string() };
|
||||
let entry = format!("{}{}: {}: {}", marker, display, idx + 1, snippet);
|
||||
*output_bytes += entry.len();
|
||||
if *output_bytes > MAX_OUTPUT_BYTES { *truncated = true; break; }
|
||||
matches.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
||||
use super::{read_to_string, write_string};
|
||||
|
||||
pub struct InsertAtLine;
|
||||
|
||||
impl InsertAtLine {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl Tool for InsertAtLine {
|
||||
fn name(&self) -> &str { "insert_at_line" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Insert new text immediately before or after a specific line number in a file. \
|
||||
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string", "description": "File path. Relative to project root, or absolute." },
|
||||
"line": { "type": "integer", "minimum": 1, "description": "1-based line number." },
|
||||
"content": { "type": "string", "description": "Text to insert. May span multiple lines." },
|
||||
"placement": {
|
||||
"type": "string",
|
||||
"enum": ["before", "after"],
|
||||
"description": "Whether to insert before or after the target line. Default: \"after\"."
|
||||
}
|
||||
},
|
||||
"required": ["path", "line", "content"]
|
||||
})
|
||||
}
|
||||
|
||||
fn target_path(&self, args: &Value) -> Option<String> {
|
||||
super::path_arg(args)
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let path = args["path"].as_str().unwrap_or("?");
|
||||
match length {
|
||||
ToolDescriptionLength::Short => {
|
||||
truncate_label(&format!("insert_at_line `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
ToolDescriptionLength::Full => {
|
||||
let line = args["line"].as_u64().map(|n| format!(" line {n}")).unwrap_or_default();
|
||||
truncate_label(&format!("insert_at_line `{path}`{line}"), MAX_LABEL_FULL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
let line_num = args["line"].as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: line"))? as usize;
|
||||
let new_text = args["content"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: content"))?;
|
||||
let placement = args["placement"].as_str().unwrap_or("after");
|
||||
|
||||
anyhow::ensure!(line_num >= 1, "line must be >= 1");
|
||||
|
||||
let text = read_to_string(user_path)?;
|
||||
let mut lines: Vec<&str> = text.split('\n').collect();
|
||||
let idx = (line_num - 1).min(lines.len().saturating_sub(1));
|
||||
let insert_idx = if placement == "before" { idx } else { idx + 1 };
|
||||
let new_lines: Vec<&str> = new_text.split('\n').collect();
|
||||
for (i, l) in new_lines.iter().enumerate() {
|
||||
lines.insert(insert_idx + i, l);
|
||||
}
|
||||
let updated = lines.join("\n");
|
||||
|
||||
write_string(user_path, &updated)?;
|
||||
|
||||
Ok(format!(
|
||||
"Inserted {} line(s) {} line {} in {user_path}.",
|
||||
new_lines.len(), placement, line_num
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT};
|
||||
use super::resolve;
|
||||
|
||||
/// Directories to skip unconditionally when walking.
|
||||
/// `secrets` is skipped so a recursive listing rooted at a parent (e.g. the auto-read
|
||||
/// working directory) never reveals the contents of the secrets store.
|
||||
const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules", ".cache", "secrets"];
|
||||
|
||||
pub struct ListFiles;
|
||||
|
||||
impl ListFiles {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl Tool for ListFiles {
|
||||
fn name(&self) -> &str { "list_files" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"List files and directories under a path. \
|
||||
Use instead of ls/find in the terminal. \
|
||||
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
|
||||
Skips .git, target, node_modules, .cache. \
|
||||
Returns a JSON array of paths relative to the requested directory. \
|
||||
Use depth=1 for immediate contents only, depth=2-3 for moderate exploration."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory to list. Defaults to project root if omitted."
|
||||
},
|
||||
"depth": {
|
||||
"type": "integer",
|
||||
"description": "Maximum recursion depth (default 3). Use 1 for immediate contents only."
|
||||
},
|
||||
"dirs_only": {
|
||||
"type": "boolean",
|
||||
"description": "If true, return only directories and omit files (default false)."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let path = args["path"].as_str().unwrap_or(".");
|
||||
let _ = length;
|
||||
truncate_label(&format!("list_files `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str().unwrap_or(".");
|
||||
let max_depth = args["depth"].as_u64().unwrap_or(3) as usize;
|
||||
let dirs_only = args["dirs_only"].as_bool().unwrap_or(false);
|
||||
let dir = resolve(user_path)?;
|
||||
|
||||
let mut paths: Vec<String> = Vec::new();
|
||||
walk(&dir, &dir, 0, max_depth, dirs_only, &mut paths)?;
|
||||
paths.sort();
|
||||
Ok(serde_json::to_string(&paths)?)
|
||||
}
|
||||
}
|
||||
|
||||
fn walk(root: &Path, dir: &Path, depth: usize, max_depth: usize, dirs_only: bool, out: &mut Vec<String>) -> Result<()> {
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
|
||||
if path.is_dir() {
|
||||
if SKIP_DIRS.contains(&name) { continue; }
|
||||
if dirs_only {
|
||||
let rel = path.strip_prefix(root)
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| path.to_string_lossy().to_string());
|
||||
out.push(rel);
|
||||
}
|
||||
if depth + 1 < max_depth {
|
||||
walk(root, &path, depth + 1, max_depth, dirs_only, out)?;
|
||||
}
|
||||
} else if path.is_file() && !dirs_only {
|
||||
let rel = path.strip_prefix(root)
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| path.to_string_lossy().to_string());
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
mod edit_file;
|
||||
mod grep_files;
|
||||
mod insert_at_line;
|
||||
mod list_files;
|
||||
mod read_file;
|
||||
mod replace_lines;
|
||||
mod search_file;
|
||||
mod write_file;
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
/// Extracts the `path` argument as an owned string, if present. Single-file
|
||||
/// tools use this to advertise their target to the UI via `Tool::target_path`,
|
||||
/// keeping the argument name in one place.
|
||||
pub(crate) fn path_arg(args: &Value) -> Option<String> {
|
||||
args.get("path").and_then(Value::as_str).map(str::to_string)
|
||||
}
|
||||
|
||||
pub use edit_file::EditFile;
|
||||
pub use grep_files::GrepFiles;
|
||||
pub use insert_at_line::InsertAtLine;
|
||||
pub use list_files::ListFiles;
|
||||
pub use read_file::ReadFile;
|
||||
pub use replace_lines::ReplaceLines;
|
||||
pub use search_file::SearchFile;
|
||||
pub use write_file::WriteFile;
|
||||
|
||||
/// Resolve a user-supplied path:
|
||||
/// - starts with `/` → absolute path, used as-is
|
||||
/// - otherwise → relative to the process working directory (project root)
|
||||
pub fn resolve(user_path: &str) -> Result<PathBuf> {
|
||||
let p = PathBuf::from(user_path);
|
||||
if p.is_absolute() {
|
||||
Ok(p)
|
||||
} else {
|
||||
let cwd = std::env::current_dir()
|
||||
.context("Failed to read current working directory")?;
|
||||
Ok(cwd.join(p))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves `path` (relative entries against `base`) to an absolute, canonical form
|
||||
/// suitable for security prefix-matching. `.`/`..` are resolved and symlinks in the
|
||||
/// existing portion of the path are followed: the longest existing ancestor is
|
||||
/// canonicalized via the OS, and any not-yet-existing tail (e.g. a write target that
|
||||
/// does not exist yet) is appended lexically. Falls back to a pure lexical normalization
|
||||
/// when nothing along the path can be canonicalized.
|
||||
///
|
||||
/// This closes `docs/../secrets/x` traversal and symlink escapes for both the allow
|
||||
/// fast-paths (`RunContext`) and the deny rules (`approval::normalize_path`).
|
||||
pub fn canonicalize_for_policy(path: &str, base: &Path) -> PathBuf {
|
||||
let raw = {
|
||||
let p = Path::new(path);
|
||||
if p.is_absolute() { p.to_path_buf() } else { base.join(p) }
|
||||
};
|
||||
let cleaned = lexical_normalize(&raw);
|
||||
|
||||
// Longest existing ancestor first (ancestors() yields self, then parents).
|
||||
for ancestor in cleaned.ancestors() {
|
||||
if let Ok(canon) = std::fs::canonicalize(ancestor) {
|
||||
// `canon.join("")` appends a trailing separator, so skip the join
|
||||
// when the tail is empty (the common case: the file itself is its
|
||||
// first canonicalizable ancestor). Otherwise the canonical path
|
||||
// ends in '/', leaking into display strings and /api/file requests.
|
||||
return match cleaned.strip_prefix(ancestor) {
|
||||
Ok(tail) if !tail.as_os_str().is_empty() => canon.join(tail),
|
||||
_ => canon,
|
||||
};
|
||||
}
|
||||
}
|
||||
cleaned
|
||||
}
|
||||
|
||||
/// Pure lexical normalization: resolves `.` and `..` components without touching the
|
||||
/// filesystem. Used as the base for `canonicalize_for_policy` and as its fallback.
|
||||
fn lexical_normalize(p: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for comp in p.components() {
|
||||
match comp {
|
||||
Component::ParentDir => { out.pop(); }
|
||||
Component::CurDir => {}
|
||||
other => out.push(other.as_os_str()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True if `child` is `base` itself or lies inside it. Both should already be canonical
|
||||
/// (e.g. produced by `canonicalize_for_policy`). Comparison is component-wise, so
|
||||
/// `/a/bc` is not considered to be under `/a/b`.
|
||||
pub fn path_under(child: &Path, base: &Path) -> bool {
|
||||
child.starts_with(base)
|
||||
}
|
||||
|
||||
/// Normalize a user path for display in the UI: relative to the project root when the
|
||||
/// file lives inside it, absolute otherwise. Resolves `.`/`..` and symlinks via
|
||||
/// `canonicalize_for_policy` so the same file always yields the same string — keeping
|
||||
/// the file viewer's "already loaded" check and its watcher subscription consistent.
|
||||
pub fn relativize_for_display(user_path: &str) -> String {
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
let abs = canonicalize_for_policy(user_path, &cwd);
|
||||
let cwd_canon = std::fs::canonicalize(&cwd).unwrap_or(cwd);
|
||||
match abs.strip_prefix(&cwd_canon) {
|
||||
Ok(rel) => rel.to_string_lossy().into_owned(),
|
||||
Err(_) => abs.to_string_lossy().into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn read_to_string(user_path: &str) -> Result<String> {
|
||||
let abs = resolve(user_path)?;
|
||||
std::fs::read_to_string(&abs)
|
||||
.with_context(|| format!("Cannot read file: {user_path}"))
|
||||
}
|
||||
|
||||
pub(super) fn write_string(user_path: &str, content: &str) -> Result<()> {
|
||||
let abs = resolve(user_path)?;
|
||||
if let Some(parent) = abs.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(&abs, content)
|
||||
.with_context(|| format!("Failed to write: {}", abs.display()))
|
||||
}
|
||||
|
||||
pub fn register_all(registry: &mut ToolRegistry) {
|
||||
registry.register(EditFile::new());
|
||||
registry.register(GrepFiles::new());
|
||||
registry.register(InsertAtLine::new());
|
||||
registry.register(ListFiles::new());
|
||||
registry.register(ReadFile::new());
|
||||
registry.register(ReplaceLines::new());
|
||||
registry.register(SearchFile::new());
|
||||
registry.register(WriteFile::new());
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
||||
use super::read_to_string;
|
||||
|
||||
pub struct ReadFile;
|
||||
|
||||
impl ReadFile {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl Tool for ReadFile {
|
||||
fn name(&self) -> &str { "read_file" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Read the content of a file with 1-based line numbers. \
|
||||
Use instead of cat/head/tail in the terminal. \
|
||||
Returns text prefixed as ' N | line'. When calling edit_file, copy the text after '| ' exactly. \
|
||||
For large files use start_line/end_line to read in chunks — files over ~2000 lines should never be read whole. \
|
||||
Use limit to cap output when end_line is unknown."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path. Relative to project root, or absolute (e.g. /etc/hosts)."
|
||||
},
|
||||
"start_line": {
|
||||
"type": "integer",
|
||||
"description": "First line to read (1-based, inclusive). Omit to start from the beginning."
|
||||
},
|
||||
"end_line": {
|
||||
"type": "integer",
|
||||
"description": "Last line to read (1-based, inclusive). Omit to read to the end of the file."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of lines to return (max 2000). Applied after start_line when end_line is omitted.",
|
||||
"maximum": 2000
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn target_path(&self, args: &Value) -> Option<String> {
|
||||
super::path_arg(args)
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let path = args["path"].as_str().unwrap_or("?");
|
||||
match length {
|
||||
ToolDescriptionLength::Short => {
|
||||
truncate_label(&format!("read_file `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
ToolDescriptionLength::Full => {
|
||||
let range = match (args["start_line"].as_u64(), args["end_line"].as_u64()) {
|
||||
(Some(s), Some(e)) => format!(" lines {s}-{e}"),
|
||||
(Some(s), None) => format!(" from line {s}"),
|
||||
(None, Some(e)) => format!(" to line {e}"),
|
||||
_ => String::new(),
|
||||
};
|
||||
truncate_label(&format!("read_file `{path}`{range}"), MAX_LABEL_FULL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
let content = read_to_string(user_path)?;
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let total = lines.len();
|
||||
|
||||
let limit = args["limit"].as_u64().map(|n| n.min(2000) as usize);
|
||||
let start = args["start_line"].as_u64()
|
||||
.map(|n| (n as usize).saturating_sub(1))
|
||||
.unwrap_or(0);
|
||||
let end = match (args["end_line"].as_u64(), limit) {
|
||||
(Some(e), _) => (e as usize).min(total),
|
||||
(None, Some(l)) => (start + l).min(total),
|
||||
(None, None) => total,
|
||||
};
|
||||
|
||||
if start >= total && total > 0 {
|
||||
return Ok(format!("(file has only {total} lines; start_line {start_line} is out of range)",
|
||||
start_line = start + 1));
|
||||
}
|
||||
|
||||
let end = end.max(start);
|
||||
|
||||
let width = total.to_string().len().max(3);
|
||||
let numbered = lines[start..end]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, line)| format!("{:>width$} | {line}", start + i + 1))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Ok(numbered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
||||
use super::{read_to_string, write_string};
|
||||
|
||||
pub struct ReplaceLines;
|
||||
|
||||
impl ReplaceLines {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl Tool for ReplaceLines {
|
||||
fn name(&self) -> &str { "replace_lines" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Replace a range of lines in a file with new text. \
|
||||
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
|
||||
Use the 1-based line numbers shown by read_file. `from_line` and `to_line` are inclusive."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string", "description": "File path. Relative to project root, or absolute." },
|
||||
"from_line": { "type": "integer", "description": "First line to replace (1-based, inclusive)." },
|
||||
"to_line": { "type": "integer", "description": "Last line to replace (1-based, inclusive)." },
|
||||
"new": { "type": "string", "description": "Replacement text." }
|
||||
},
|
||||
"required": ["path", "from_line", "to_line", "new"]
|
||||
})
|
||||
}
|
||||
|
||||
fn target_path(&self, args: &Value) -> Option<String> {
|
||||
super::path_arg(args)
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let path = args["path"].as_str().unwrap_or("?");
|
||||
match length {
|
||||
ToolDescriptionLength::Short => {
|
||||
truncate_label(&format!("replace_lines `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
ToolDescriptionLength::Full => {
|
||||
let from = args["from_line"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "?".into());
|
||||
let to = args["to_line"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "?".into());
|
||||
truncate_label(&format!("replace_lines `{path}` lines {from}-{to}"), MAX_LABEL_FULL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
let from_line = args["from_line"].as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: from_line"))? as usize;
|
||||
let to_line = args["to_line"].as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: to_line"))? as usize;
|
||||
let new = args["new"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: new"))?;
|
||||
|
||||
if from_line == 0 { anyhow::bail!("from_line must be >= 1"); }
|
||||
if to_line < from_line { anyhow::bail!("to_line must be >= from_line"); }
|
||||
|
||||
let content = read_to_string(user_path)?;
|
||||
let mut lines: Vec<&str> = content.lines().collect();
|
||||
let total = lines.len();
|
||||
if from_line > total {
|
||||
anyhow::bail!("from_line {from_line} exceeds file length ({total} lines)");
|
||||
}
|
||||
let to_clamped = to_line.min(total);
|
||||
let new_lines: Vec<&str> = new.lines().collect();
|
||||
lines.splice((from_line - 1)..to_clamped, new_lines);
|
||||
|
||||
let has_trailing = content.ends_with('\n');
|
||||
let mut updated = lines.join("\n");
|
||||
if has_trailing { updated.push('\n'); }
|
||||
|
||||
write_string(user_path, &updated)?;
|
||||
|
||||
Ok(format!(
|
||||
"Replaced lines {from_line}–{to_clamped} in {user_path} with {} new lines.",
|
||||
new.lines().count()
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
||||
use super::read_to_string;
|
||||
|
||||
pub struct SearchFile;
|
||||
|
||||
impl SearchFile {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl Tool for SearchFile {
|
||||
fn name(&self) -> &str { "search_file" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Search for lines containing a substring in a file. \
|
||||
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
|
||||
Returns each matching line with context, prefixed with 1-based line numbers in ' N | ' format."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string", "description": "File path. Relative to project root, or absolute." },
|
||||
"query": { "type": "string", "description": "Substring to search for (case-insensitive)." },
|
||||
"context_lines": {
|
||||
"type": "integer",
|
||||
"description": "Lines of context above and below each match (default 3, max 10).",
|
||||
"default": 3
|
||||
}
|
||||
},
|
||||
"required": ["path", "query"]
|
||||
})
|
||||
}
|
||||
|
||||
fn target_path(&self, args: &Value) -> Option<String> {
|
||||
super::path_arg(args)
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let path = args["path"].as_str().unwrap_or("?");
|
||||
match length {
|
||||
ToolDescriptionLength::Short => {
|
||||
truncate_label(&format!("search_file `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
ToolDescriptionLength::Full => {
|
||||
let query = args["query"].as_str().unwrap_or("?");
|
||||
truncate_label(&format!("search_file `{path}` for \"{query}\""), MAX_LABEL_FULL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
let query = args["query"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: query"))?;
|
||||
let context = args["context_lines"].as_u64().unwrap_or(3).min(10) as usize;
|
||||
|
||||
let text = read_to_string(user_path)?;
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let lower_query = query.to_lowercase();
|
||||
let width = lines.len().to_string().len().max(3);
|
||||
|
||||
let matches: Vec<usize> = lines.iter().enumerate()
|
||||
.filter(|(_, l)| l.to_lowercase().contains(&lower_query))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
if matches.is_empty() {
|
||||
return Ok(format!("No matches found for {:?} in {user_path}.", query));
|
||||
}
|
||||
|
||||
let mut chunks: Vec<(usize, usize)> = Vec::new();
|
||||
for &m in &matches {
|
||||
let start = m.saturating_sub(context);
|
||||
let end = (m + context).min(lines.len() - 1);
|
||||
if let Some(last) = chunks.last_mut() {
|
||||
if start <= last.1 + 1 { last.1 = last.1.max(end); continue; }
|
||||
}
|
||||
chunks.push((start, end));
|
||||
}
|
||||
|
||||
let match_set: std::collections::HashSet<usize> = matches.into_iter().collect();
|
||||
let mut out = format!("{} match(es) in {user_path}:\n", match_set.len());
|
||||
|
||||
for (ci, (start, end)) in chunks.iter().enumerate() {
|
||||
if ci > 0 { out.push_str(" ···\n"); }
|
||||
for idx in *start..=*end {
|
||||
let marker = if match_set.contains(&idx) { ">" } else { " " };
|
||||
out.push_str(&format!("{marker}{:>width$} | {}\n", idx + 1, lines[idx]));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT};
|
||||
use super::{resolve, write_string};
|
||||
|
||||
pub struct WriteFile;
|
||||
|
||||
impl WriteFile {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl Tool for WriteFile {
|
||||
fn name(&self) -> &str { "write_file" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Create a new file or fully overwrite an existing one. \
|
||||
Use instead of echo/cat heredoc in the terminal. \
|
||||
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
|
||||
OVERWRITES the entire file — for targeted edits to an existing file use edit_file instead."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path. Relative to project root, or absolute."
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Full content to write to the file."
|
||||
}
|
||||
},
|
||||
"required": ["path", "content"]
|
||||
})
|
||||
}
|
||||
|
||||
fn target_path(&self, args: &Value) -> Option<String> {
|
||||
super::path_arg(args)
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let path = args["path"].as_str().unwrap_or("?");
|
||||
let _ = length;
|
||||
truncate_label(&format!("write_file `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
let content = args["content"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: content"))?;
|
||||
|
||||
let abs = resolve(user_path)?;
|
||||
let existed = abs.exists();
|
||||
write_string(user_path, content)?;
|
||||
|
||||
if existed {
|
||||
Ok(format!("Overwrote {user_path} ({} bytes).", content.len()))
|
||||
} else {
|
||||
Ok(format!("Created {user_path} ({} bytes).", content.len()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::image_generate::ImageGeneratorManager;
|
||||
use crate::tools::{Tool, ToolCategory, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
||||
|
||||
// ── image_generate_providers_list ─────────────────────────────────────────────
|
||||
|
||||
pub struct ImageGenerateProvidersList {
|
||||
pub mgr: Arc<ImageGeneratorManager>,
|
||||
}
|
||||
|
||||
impl Tool for ImageGenerateProvidersList {
|
||||
fn name(&self) -> &str { "image_generate_providers_list" }
|
||||
fn category(&self) -> ToolCategory { ToolCategory::Introspection }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"List all registered image generation providers. \
|
||||
Returns an array of {id, name} objects. \
|
||||
Use the id with image_generate to pick a provider."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({ "type": "object", "properties": {} })
|
||||
}
|
||||
|
||||
fn describe(&self, _args: &Value, _length: ToolDescriptionLength) -> String {
|
||||
"list image providers".to_string()
|
||||
}
|
||||
|
||||
fn execute_async<'a>(&'a self, _args: Value) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
|
||||
let mgr = Arc::clone(&self.mgr);
|
||||
Box::pin(async move {
|
||||
let providers = mgr.list().await;
|
||||
Ok(serde_json::to_string_pretty(&providers)?)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── image_generate ────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct ImageGenerateTool {
|
||||
pub mgr: Arc<ImageGeneratorManager>,
|
||||
}
|
||||
|
||||
impl Tool for ImageGenerateTool {
|
||||
fn name(&self) -> &str { "image_generate" }
|
||||
fn category(&self) -> ToolCategory { ToolCategory::Config }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Generate an image from a text prompt. \
|
||||
Blocks until the image is ready, then returns the local path and a web URL."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["provider_id", "prompt"],
|
||||
"properties": {
|
||||
"provider_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the image generation provider (from image_generate_providers_list)"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "Text prompt describing the image to generate"
|
||||
},
|
||||
"extra_params": {
|
||||
"type": "object",
|
||||
"description": "Optional provider-specific parameters (e.g. width, height, steps). \
|
||||
See extra_params_schema in image_generate_providers_list for valid fields."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let provider = args["provider_id"].as_str().unwrap_or("?");
|
||||
let prompt = args["prompt"].as_str().unwrap_or("?");
|
||||
match length {
|
||||
ToolDescriptionLength::Short => truncate_label(&format!("generate image ({provider})"), MAX_LABEL_SHORT),
|
||||
ToolDescriptionLength::Full => truncate_label(&format!("generate image ({provider}): {prompt}"), MAX_LABEL_FULL),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_async<'a>(&'a self, args: Value) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
|
||||
let mgr = Arc::clone(&self.mgr);
|
||||
Box::pin(async move {
|
||||
let provider_id = args["provider_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing provider_id"))?
|
||||
.to_string();
|
||||
let prompt = args["prompt"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing prompt"))?
|
||||
.to_string();
|
||||
let extra_params = match &args["extra_params"] {
|
||||
Value::Object(_) => Some(args["extra_params"].clone()),
|
||||
_ => None,
|
||||
};
|
||||
let (path, url) = mgr.generate(&provider_id, &prompt, extra_params.as_ref()).await?;
|
||||
Ok(json!({ "path": path, "url": url }).to_string())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::agents;
|
||||
use crate::cron::TaskManager;
|
||||
use crate::mcp::McpManager;
|
||||
use crate::plugin::PluginManager;
|
||||
use crate::tools::{Tool, ToolDescriptionLength};
|
||||
|
||||
/// Unified read-only listing tool. Replaces the per-resource `list_mcp`,
|
||||
/// `list_plugins`, `list_cron_jobs` and `list_agents` tools: same operation
|
||||
/// (enumerate), uniform schema (a single `type` discriminator), so it merges
|
||||
/// cleanly without losing schema-level validation.
|
||||
///
|
||||
/// `list_secrets` is intentionally NOT folded in — it preserves a name-based
|
||||
/// access-control boundary (an agent granted `list_items` must not thereby gain
|
||||
/// the ability to enumerate secret key names) and carries a `pattern` filter
|
||||
/// that would only apply to that one type.
|
||||
pub struct ListItems {
|
||||
mcp: Arc<McpManager>,
|
||||
plugins: Arc<PluginManager>,
|
||||
cron: Arc<TaskManager>,
|
||||
}
|
||||
|
||||
impl ListItems {
|
||||
pub fn new(mcp: Arc<McpManager>, plugins: Arc<PluginManager>, cron: Arc<TaskManager>) -> Self {
|
||||
Self { mcp, plugins, cron }
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for ListItems {
|
||||
fn name(&self) -> &str { "list_items" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Introspection }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"List configured items of a given type. Pass `type`:\n\
|
||||
• `mcp` — MCP servers with status (running, error, disabled), description, friendly_name, and exposed tools.\n\
|
||||
• `plugins` — plugins with id, name, description, enabled flag (persisted), and running flag (live).\n\
|
||||
• `cron` — scheduled tasks/cron jobs with id, title, cron expression, agent_id, enabled, kind, last/next run.\n\
|
||||
• `agents` — sub-agents available to delegate to (id, name, description, optional `instructions` on how to call the agent well, optional client). Do NOT invoke the `main` agent.\n\
|
||||
To list stored secret names use `list_secrets` instead."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["mcp", "plugins", "cron", "agents"],
|
||||
"description": "Which kind of item to list."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String {
|
||||
let kind = args["type"].as_str().unwrap_or("?");
|
||||
format!("list {kind}")
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let kind = args["type"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("list_items: missing required argument `type`"))?;
|
||||
|
||||
match kind {
|
||||
"mcp" => {
|
||||
let infos = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.mcp.list())
|
||||
})?;
|
||||
Ok(serde_json::to_string_pretty(&infos)?)
|
||||
}
|
||||
"plugins" => {
|
||||
let plugins = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.plugins.list())
|
||||
})?;
|
||||
Ok(serde_json::to_string_pretty(&plugins)?)
|
||||
}
|
||||
"cron" => {
|
||||
let jobs = self.cron.list_jobs()?;
|
||||
if jobs.is_empty() {
|
||||
return Ok("No tasks configured.".into());
|
||||
}
|
||||
let arr: Vec<Value> = jobs.iter().map(|j| json!({
|
||||
"id": j.id,
|
||||
"title": j.title,
|
||||
"description": j.description,
|
||||
"cron": j.cron,
|
||||
"agent_id": j.agent_id,
|
||||
"enabled": j.enabled,
|
||||
"single_run": j.single_run,
|
||||
"kind": j.kind,
|
||||
"last_run_at": j.last_run_at,
|
||||
"next_run_at": j.next_run_at,
|
||||
"created_at": j.created_at,
|
||||
})).collect();
|
||||
Ok(serde_json::to_string_pretty(&arr)?)
|
||||
}
|
||||
"agents" => {
|
||||
let mut list = agents::discover()?;
|
||||
// Only dispatchable task agents are listed; chat + system are excluded.
|
||||
list.retain(|a| a.agent_type == agents::AgentType::Task);
|
||||
let arr: Vec<Value> = list
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let mut o = serde_json::Map::new();
|
||||
o.insert("id".into(), Value::String(a.id));
|
||||
o.insert("name".into(), Value::String(a.name));
|
||||
o.insert("description".into(), Value::String(a.description));
|
||||
// `instructions` (how to call the agent well) is surfaced here only,
|
||||
// and only when set — eager but scoped to task agents (already the
|
||||
// sole agents listed above).
|
||||
if let Some(i) = a.instructions {
|
||||
o.insert("instructions".into(), Value::String(i));
|
||||
}
|
||||
if let Some(c) = a.client {
|
||||
o.insert("client".into(), Value::String(c));
|
||||
}
|
||||
Value::Object(o)
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::to_string_pretty(&arr)?)
|
||||
}
|
||||
other => anyhow::bail!("list_items: unknown type `{other}` (expected one of: mcp, plugins, cron, agents)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::secrets::{SecretsApi, SecretsStore};
|
||||
use crate::tools::{Tool, ToolDescriptionLength};
|
||||
|
||||
pub struct ListSecrets(pub Arc<SecretsStore>);
|
||||
|
||||
impl Tool for ListSecrets {
|
||||
fn name(&self) -> &str { "list_secrets" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"List the names (not values) of stored secrets. \
|
||||
Optionally filter by glob pattern (e.g. 'GOOGLE_*', 'HF_*'). \
|
||||
Returns only keys that are currently set. \
|
||||
If a key you expect is absent from the result it has not been configured yet."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Optional glob pattern to filter key names (e.g. 'GOOGLE_*'). Omit to list all keys."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String {
|
||||
match args["pattern"].as_str() {
|
||||
Some(pat) => format!("list secrets ({pat})"),
|
||||
None => "list secrets".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let pattern = args["pattern"].as_str();
|
||||
|
||||
let keys = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.0.list_keys())
|
||||
});
|
||||
|
||||
let filtered: Vec<&str> = match pattern {
|
||||
None => keys.iter().map(String::as_str).collect(),
|
||||
Some(pat) => keys.iter()
|
||||
.filter(|k| glob_match(pat, k))
|
||||
.map(String::as_str)
|
||||
.collect(),
|
||||
};
|
||||
|
||||
Ok(serde_json::to_string_pretty(&filtered)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal glob: `*` matches any sequence of characters, everything else is literal.
|
||||
fn glob_match(pattern: &str, text: &str) -> bool {
|
||||
let parts: Vec<&str> = pattern.split('*').collect();
|
||||
if parts.len() == 1 {
|
||||
return pattern == text;
|
||||
}
|
||||
let mut remaining = text;
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if part.is_empty() { continue; }
|
||||
if i == 0 {
|
||||
if !remaining.starts_with(part) { return false; }
|
||||
remaining = &remaining[part.len()..];
|
||||
} else if i == parts.len() - 1 {
|
||||
return remaining.ends_with(part);
|
||||
} else {
|
||||
match remaining.find(part) {
|
||||
None => return false,
|
||||
Some(pos) => remaining = &remaining[pos + part.len()..],
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/// Tools that write or modify files on disk.
|
||||
/// Used by the approval gate (diff preview logic) and the LLM loop (FileChanged events).
|
||||
/// Update this list whenever a new file-write tool is added.
|
||||
pub const FILE_WRITE_TOOLS: &[&str] = &[
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"insert_at_line",
|
||||
"replace_lines",
|
||||
];
|
||||
|
||||
/// Returns `true` if `name` is a file-write tool (i.e. it modifies files on disk).
|
||||
pub fn is_file_write_tool(name: &str) -> bool {
|
||||
FILE_WRITE_TOOLS.contains(&name)
|
||||
}
|
||||
|
||||
/// Tools that read file contents or directory listings from disk.
|
||||
/// Used by the approval gate to apply the `RunContext` read fast-path (auto-allow
|
||||
/// working dir / `docs/` / `skills/` / `allow_fs_reads`). All take a `path` argument.
|
||||
/// Update this list whenever a new file-read tool is added.
|
||||
pub const FILE_READ_TOOLS: &[&str] = &[
|
||||
"read_file",
|
||||
"grep_files",
|
||||
"list_files",
|
||||
"search_file",
|
||||
"get_ast_outline",
|
||||
];
|
||||
|
||||
/// Returns `true` if `name` is a file-read tool (i.e. it reads files/dirs from disk).
|
||||
pub fn is_file_read_tool(name: &str) -> bool {
|
||||
FILE_READ_TOOLS.contains(&name)
|
||||
}
|
||||
|
||||
pub mod tool_names;
|
||||
pub mod activate_tools;
|
||||
pub mod ast_outline;
|
||||
pub mod configure_plugin;
|
||||
pub mod cron_jobs;
|
||||
pub mod exec;
|
||||
pub mod fs;
|
||||
pub mod image_generate;
|
||||
pub mod list_items;
|
||||
pub mod list_secrets;
|
||||
pub mod notify;
|
||||
pub mod set_secret;
|
||||
pub mod read_notification;
|
||||
pub mod register_mcp;
|
||||
pub mod restart;
|
||||
pub mod show_file;
|
||||
pub mod toggle_item;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
|
||||
pub use core_api::tool::{
|
||||
drive_execution, ExecutionOutcome, SimpleExecution, Tool, ToolCategory,
|
||||
ToolDescriptionLength, ToolExecution, ToolResult, truncate_label,
|
||||
};
|
||||
|
||||
|
||||
pub const MAX_LABEL_SHORT: usize = 60;
|
||||
pub const MAX_LABEL_FULL: usize = 120;
|
||||
|
||||
/// Registry of all available tools.
|
||||
pub struct ToolRegistry {
|
||||
tools: HashMap<String, Arc<dyn Tool>>,
|
||||
}
|
||||
|
||||
impl ToolRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self { tools: HashMap::new() }
|
||||
}
|
||||
|
||||
pub fn register(&mut self, tool: impl Tool + 'static) {
|
||||
self.tools.insert(tool.name().to_string(), Arc::new(tool));
|
||||
}
|
||||
|
||||
/// Register an already-boxed tool (e.g. plugin-provided tools whose
|
||||
/// constructors return `Arc<dyn Tool>`).
|
||||
pub fn register_arc(&mut self, tool: Arc<dyn Tool>) {
|
||||
self.tools.insert(tool.name().to_string(), tool);
|
||||
}
|
||||
|
||||
/// Tool definitions for the root agent (depth = 0): excludes sub_agents_only tools.
|
||||
pub fn openai_definitions(&self) -> Vec<Value> {
|
||||
self.tools.values()
|
||||
.filter(|t| !t.sub_agents_only())
|
||||
.map(|t| t.openai_definition())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Like [`openai_definitions`], but **excludes** `Config`-category tools.
|
||||
/// These are lazy-loaded on demand via `activate_tools(["config"])`, so they
|
||||
/// are not part of the always-on base tool set.
|
||||
pub fn openai_definitions_excluding_config(&self) -> Vec<Value> {
|
||||
self.tools.values()
|
||||
.filter(|t| !t.sub_agents_only() && t.category() != ToolCategory::Config)
|
||||
.map(|t| t.openai_definition())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Definitions of the `Config`-category tools only (the lazy `config` group).
|
||||
/// Injected dynamically by `all_tool_defs()` when the `config` group is granted.
|
||||
pub fn openai_definitions_config_only(&self) -> Vec<Value> {
|
||||
self.tools.values()
|
||||
.filter(|t| !t.sub_agents_only() && t.category() == ToolCategory::Config)
|
||||
.map(|t| t.openai_definition())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Tool definitions that are marked sub_agents_only. Used in dispatch_call_agent
|
||||
/// to augment the child config's base_tool_defs.
|
||||
pub fn openai_definitions_sub_agents_only(&self) -> Vec<Value> {
|
||||
self.tools.values()
|
||||
.filter(|t| t.sub_agents_only())
|
||||
.map(|t| t.openai_definition())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the names of all tools marked `root_agent_only`.
|
||||
pub fn root_agent_only_names(&self) -> Vec<String> {
|
||||
self.tools.values()
|
||||
.filter(|t| t.root_agent_only())
|
||||
.map(|t| t.name().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the names of all tools marked `interactive_only`.
|
||||
pub fn interactive_only_names(&self) -> Vec<String> {
|
||||
self.tools.values()
|
||||
.filter(|t| t.interactive_only())
|
||||
.map(|t| t.name().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns `(name, description)` for every registered tool.
|
||||
pub fn list_all(&self) -> Vec<(String, String)> {
|
||||
let mut v: Vec<(String, String)> = self.tools.values()
|
||||
.map(|t| (t.name().to_string(), t.description().to_string()))
|
||||
.collect();
|
||||
v.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
v
|
||||
}
|
||||
|
||||
/// Human-readable label for any tool call, including non-registry tools (call_agent, MCP, …).
|
||||
pub fn describe_call(&self, name: &str, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
if let Some(tool) = self.tools.get(name) {
|
||||
return tool.describe(args, length);
|
||||
}
|
||||
// Non-registry tools handled inline. `show_file_to_user` is an InterfaceTool
|
||||
// (injected in ws.rs), so it has no registry `describe`; surface its target
|
||||
// path in the label so the frontend renders it as a clickable file link.
|
||||
if name == tool_names::SHOW_FILE_TO_USER {
|
||||
if let Some(path) = args["path"].as_str() {
|
||||
let max = match length {
|
||||
ToolDescriptionLength::Short => MAX_LABEL_SHORT,
|
||||
ToolDescriptionLength::Full => MAX_LABEL_FULL,
|
||||
};
|
||||
return truncate_label(&format!("{name} `{path}`"), max);
|
||||
}
|
||||
}
|
||||
// Sub-agent delegation tools (`execute_task`, `execute_subtask`, and the
|
||||
// legacy `run_subtask` alias) are InterfaceTools, not in the registry.
|
||||
// Surface agent_id + description so the UI/Telegram shows what is being
|
||||
// delegated instead of the bare tool name.
|
||||
if name == tool_names::EXECUTE_TASK
|
||||
|| name == tool_names::EXECUTE_SUBTASK
|
||||
|| name == "run_subtask"
|
||||
{
|
||||
return describe_sub_agent_call(name, args, length);
|
||||
}
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
/// Returns the category of a registered tool, or `None` for unknown tools
|
||||
/// (MCP tools, interface tools, call_agent, etc.).
|
||||
pub fn category_of(&self, name: &str) -> Option<ToolCategory> {
|
||||
self.tools.get(name).map(|t| t.category())
|
||||
}
|
||||
|
||||
/// Path to a single viewable file targeted by this tool call, if any.
|
||||
/// `None` for non-file tools, directory tools, and unknown/non-registry tools.
|
||||
///
|
||||
/// `show_file_to_user` is an InterfaceTool (not in the registry) whose whole
|
||||
/// purpose is to open a file, so it is handled inline here as well — mirroring
|
||||
/// `describe_call`, so its label and clickable path use the same raw `path` arg.
|
||||
pub fn target_path(&self, name: &str, args: &Value) -> Option<String> {
|
||||
if let Some(tool) = self.tools.get(name) {
|
||||
return tool.target_path(args);
|
||||
}
|
||||
if name == tool_names::SHOW_FILE_TO_USER {
|
||||
return args["path"].as_str().map(str::to_string);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Dispatch a tool call by name.
|
||||
pub async fn dispatch(&self, name: &str, args: Value) -> Result<String> {
|
||||
match self.tools.get(name) {
|
||||
Some(tool) => tool.execute_async(args).await,
|
||||
None => anyhow::bail!("Unknown tool: {name}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a [`ToolExecution`] for a registered tool, or `None` if `name` is not
|
||||
/// in the registry (MCP / interface tools are handled by the caller). The
|
||||
/// returned handle borrows the registry, which outlives the turn.
|
||||
pub fn run(&self, name: &str, args: Value) -> Option<Box<dyn ToolExecution + '_>> {
|
||||
self.tools.get(name).map(|tool| tool.run(args))
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a human-readable label for a sub-agent delegation call
|
||||
/// (`execute_task` / `execute_subtask` / legacy `run_subtask`), all of which are
|
||||
/// InterfaceTools outside the registry. Shows `agent_id` + `description` (falling
|
||||
/// back to `title`, then to the bare name) so the UI/Telegram displays what is
|
||||
/// being delegated. When `mode` is present (only `execute_task` carries it) a
|
||||
/// single emoji is appended to the tool name as a compact mode marker:
|
||||
/// sync → ⚡, async → 🚀, cron → 📅.
|
||||
fn describe_sub_agent_call(name: &str, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let agent_id = args["agent_id"].as_str().map(|s| s.trim()).unwrap_or("");
|
||||
let subject = args["description"].as_str()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| args["title"].as_str().map(|s| s.trim()).filter(|s| !s.is_empty()))
|
||||
.unwrap_or("");
|
||||
let mode_emoji = match args["mode"].as_str().map(|s| s.trim()) {
|
||||
Some("sync") => Some("⚡"),
|
||||
Some("async") => Some("🚀"),
|
||||
Some("cron") => Some("📅"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let max = match length {
|
||||
ToolDescriptionLength::Short => MAX_LABEL_SHORT,
|
||||
ToolDescriptionLength::Full => MAX_LABEL_FULL,
|
||||
};
|
||||
|
||||
let prefix = match mode_emoji {
|
||||
Some(e) => format!("{name} {e}"),
|
||||
None => name.to_string(),
|
||||
};
|
||||
|
||||
let label = match (agent_id.is_empty(), subject.is_empty()) {
|
||||
(false, false) => format!("{prefix} → {agent_id}: {subject}"),
|
||||
(false, true) => format!("{prefix} → {agent_id}"),
|
||||
_ => prefix,
|
||||
};
|
||||
|
||||
truncate_label(&label, max)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::chat_hub::ChatHub;
|
||||
use crate::notification::Notification;
|
||||
use crate::session::handler::{InterfaceTool, ToolFuture};
|
||||
|
||||
/// Build a `notify` InterfaceTool bound to the given `ChatHub`.
|
||||
///
|
||||
/// `default_source` is used as the notification `source` only when the caller
|
||||
/// omits one (kept for callers like TIC that pass a fixed origin tag). Normally
|
||||
/// the agent supplies `source` explicitly from the event it is surfacing.
|
||||
pub fn make_tool(hub: Arc<ChatHub>, default_source: impl Into<String>) -> InterfaceTool {
|
||||
let default_source = default_source.into();
|
||||
let definition = json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": crate::tools::tool_names::NOTIFY,
|
||||
"description": "Surface a single event to the user's home conversation as a structured \
|
||||
notification. Call once per event worth surfacing. Provide factual, \
|
||||
third-person data about the event — do NOT write a message to the user; \
|
||||
the main agent composes the user-facing wording from these fields.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": ["gmail", "whatsapp", "gcal", "cron", "system"],
|
||||
"description": "Where the event originated."
|
||||
},
|
||||
"event_type": {
|
||||
"type": "string",
|
||||
"description": "Kind of event, e.g. \"new_email\", \"whatsapp_message\", \"new_calendar_event\"."
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "Neutral, third-person factual description of the event (NOT a message to the \
|
||||
user). Name the key facts and add relevant context. Plain prose, no markdown."
|
||||
},
|
||||
"event_time": {
|
||||
"type": "string",
|
||||
"description": "ISO 8601 timestamp of the event (copy it from the event's Received time)."
|
||||
},
|
||||
"refs": {
|
||||
"type": "object",
|
||||
"description": "Actionable references pulled from the event payload (e.g. message_id, thread_id, \
|
||||
from, event_id). Lets the main agent act on the event later.",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"required": ["source", "summary"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let handler = Arc::new(move |args: Value| -> ToolFuture {
|
||||
let hub = Arc::clone(&hub);
|
||||
let default_source = default_source.clone();
|
||||
Box::pin(async move {
|
||||
let summary = args["summary"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("notify: missing required parameter 'summary'"))?
|
||||
.to_string();
|
||||
let source = args["source"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| default_source.clone());
|
||||
let event_type = args["event_type"].as_str().unwrap_or("").to_string();
|
||||
let event_time = args["event_time"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
|
||||
let refs = args.get("refs").cloned().unwrap_or_else(|| json!({}));
|
||||
|
||||
hub.notify(Notification { source, event_type, summary, event_time, refs }).await?;
|
||||
Ok("Notification queued.".to_string())
|
||||
})
|
||||
});
|
||||
|
||||
InterfaceTool { definition, handler }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::tool_names as tn;
|
||||
use super::{Tool, ToolCategory, ToolDescriptionLength};
|
||||
|
||||
pub struct ReadNotification;
|
||||
|
||||
impl Tool for ReadNotification {
|
||||
fn name(&self) -> &str {
|
||||
tn::READ_NOTIFICATION
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Read any pending notifications forwarded by background agents. Returns a JSON array of \
|
||||
structured notification objects, each `{source, event_type, summary, event_time, refs}` \
|
||||
where `summary` is a neutral, third-person statement of fact. Present the relevant ones to \
|
||||
the user in your own voice — always name the source (email, WhatsApp, calendar, …) and add \
|
||||
context; do not echo the raw summary as if the user already knew about it."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, _args: &Value, _length: ToolDescriptionLength) -> String {
|
||||
"read notifications".to_string()
|
||||
}
|
||||
|
||||
fn execute(&self, _args: Value) -> Result<String> {
|
||||
Ok("[]".to_string())
|
||||
}
|
||||
|
||||
fn category(&self) -> ToolCategory {
|
||||
ToolCategory::Introspection
|
||||
}
|
||||
|
||||
fn root_agent_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn interactive_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::db::mcp_servers::UpsertParams;
|
||||
use crate::mcp::McpManager;
|
||||
use crate::tools::{Tool, ToolDescriptionLength};
|
||||
|
||||
pub struct RegisterMcp {
|
||||
mcp: Arc<McpManager>,
|
||||
}
|
||||
|
||||
impl RegisterMcp {
|
||||
pub fn new(mcp: Arc<McpManager>) -> Self { Self { mcp } }
|
||||
}
|
||||
|
||||
impl Tool for RegisterMcp {
|
||||
fn name(&self) -> &str { "register_mcp" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Register (or update) an MCP server and connect to it immediately. \
|
||||
For stdio servers supply `command` and optionally `args` and `env`. \
|
||||
For HTTP/SSE servers supply `url` and optionally `api_key`. \
|
||||
Optionally provide `description` (what the server does) and `friendly_name` (display name for UI). \
|
||||
Returns the list of tools exposed by the server once connected."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Unique name for this MCP server (used to reference it in tool calls)."
|
||||
},
|
||||
"transport": {
|
||||
"type": "string",
|
||||
"enum": ["stdio", "http", "sse"],
|
||||
"description": "Connection transport. Use `stdio` for local processes, `http` for remote servers."
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "stdio only: executable to spawn (e.g. `npx`, `uvx`, path to binary)."
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "stdio only: command-line arguments passed to the executable."
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"description": "stdio only: extra environment variables. Values support `${VAR}` interpolation."
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "http/sse only: base URL of the remote MCP server."
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"description": "http/sse only: API key sent as `Authorization: Bearer <key>`."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "A short description of what this MCP server provides (shown in list_items type=mcp)."
|
||||
},
|
||||
"friendly_name": {
|
||||
"type": "string",
|
||||
"description": "A human-readable display name for this MCP server (e.g. 'Google Calendar')."
|
||||
}
|
||||
},
|
||||
"required": ["name", "transport"]
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String {
|
||||
let name = args["name"].as_str().unwrap_or("?");
|
||||
format!("register MCP `{name}`")
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let name = args["name"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("register_mcp: missing required argument `name`"))?;
|
||||
let transport = args["transport"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("register_mcp: missing required argument `transport`"))?;
|
||||
|
||||
let args_json = args["args"].as_array()
|
||||
.map(|a| serde_json::to_string(a))
|
||||
.transpose()?;
|
||||
let env_json = args["env"].as_object()
|
||||
.map(|o| serde_json::to_string(o))
|
||||
.transpose()?;
|
||||
|
||||
let p = UpsertParams {
|
||||
name,
|
||||
transport,
|
||||
command: args["command"].as_str(),
|
||||
args_json,
|
||||
env_json,
|
||||
url: args["url"].as_str(),
|
||||
api_key: args["api_key"].as_str(),
|
||||
description: args["description"].as_str(),
|
||||
friendly_name: args["friendly_name"].as_str(),
|
||||
};
|
||||
|
||||
let tool_names = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.mcp.register(p))
|
||||
})?;
|
||||
|
||||
Ok(format!(
|
||||
"MCP server '{}' registered and connected. Tools: {}",
|
||||
name,
|
||||
if tool_names.is_empty() { "(none)".to_string() } else { tool_names.join(", ") },
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ── delete_mcp ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Destructive counterpart to `register_mcp`. Kept separate from `toggle_item`
|
||||
// (kind=mcp) for the same reason `delete_cron_job` is: toggling is reversible,
|
||||
// deletion is not, so the distinct tool can carry its own approval rule and the
|
||||
// LLM can't conflate "disable" with "remove". Both live here because both manage
|
||||
// the MCP-server lifecycle and hold only `Arc<McpManager>`.
|
||||
|
||||
pub struct DeleteMcp {
|
||||
mcp: Arc<McpManager>,
|
||||
}
|
||||
|
||||
impl DeleteMcp {
|
||||
pub fn new(mcp: Arc<McpManager>) -> Self { Self { mcp } }
|
||||
}
|
||||
|
||||
impl Tool for DeleteMcp {
|
||||
fn name(&self) -> &str { "delete_mcp" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Permanently delete (unregister) an MCP server by name: removes it from the \
|
||||
database and disconnects it. This is irreversible — to temporarily turn a \
|
||||
server off without losing its configuration, use \
|
||||
`toggle_item(kind=\"mcp\", enabled=false)` instead."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the MCP server to delete (from list_items type=mcp)."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String {
|
||||
let name = args["name"].as_str().unwrap_or("?");
|
||||
format!("delete MCP `{name}`")
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let name = args["name"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("delete_mcp: missing required argument `name`"))?;
|
||||
|
||||
tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.mcp.unregister(name))
|
||||
})?;
|
||||
|
||||
Ok(format!("MCP server '{name}' deleted and disconnected."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::tools::{Tool, ToolDescriptionLength};
|
||||
|
||||
/// How to restart, when exiting for a supervisor is not the answer.
|
||||
///
|
||||
/// A bundled desktop app has no supervisor watching its exit code: it must tear
|
||||
/// down its own webview and respawn itself. That is knowledge about the process
|
||||
/// shell, and the core does not have it — so the shell installs it here. Without
|
||||
/// a handler, `restart` falls back to the supervisor protocol.
|
||||
///
|
||||
/// Returns only on failure; a successful handler never comes back.
|
||||
pub type RestartHandler = Box<dyn Fn() -> Result<()> + Send + Sync>;
|
||||
|
||||
static HANDLER: OnceLock<RestartHandler> = OnceLock::new();
|
||||
|
||||
/// Called once by the process shell during startup, before any tool can run.
|
||||
pub fn set_restart_handler(handler: RestartHandler) {
|
||||
if HANDLER.set(handler).is_err() {
|
||||
warn!("a restart handler is already installed — ignoring this one");
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Restart;
|
||||
|
||||
impl Tool for Restart {
|
||||
fn name(&self) -> &str { crate::tools::tool_names::RESTART }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Shell }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Restart the skald process. Nothing is recompiled: the same binary is re-executed, \
|
||||
so this applies config.yml and database changes, which are only read at startup. \
|
||||
To load new code, build first (./build.sh), then restart. \
|
||||
Requires user approval."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, _args: &Value, _length: ToolDescriptionLength) -> String {
|
||||
"restart skald".to_string()
|
||||
}
|
||||
|
||||
fn execute(&self, _args: Value) -> Result<String> {
|
||||
// A bundled desktop app installs its own teardown-and-respawn. Normally
|
||||
// this never returns.
|
||||
if let Some(handler) = HANDLER.get() {
|
||||
info!("restart requested — delegating to the installed handler");
|
||||
handler()?;
|
||||
warn!("the restart handler returned without restarting — falling back");
|
||||
}
|
||||
|
||||
// Headless: exit with code -1 (= 255 on Unix), which `run.sh` reads as
|
||||
// "re-execute me". `_exit()` rather than `exit()` skips C atexit handlers
|
||||
// — whisper-rs's Metal GPU cleanup aborts with SIGABRT, turning the exit
|
||||
// code into 134 and stopping the supervisor instead of restarting it.
|
||||
info!("restart requested — exit(-1) → supervisor re-executes the binary");
|
||||
unsafe { libc::_exit(-1) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::secrets::{SecretsApi, SecretsStore};
|
||||
use crate::tools::{Tool, ToolDescriptionLength};
|
||||
|
||||
pub struct SetSecret(pub Arc<SecretsStore>);
|
||||
|
||||
impl Tool for SetSecret {
|
||||
fn name(&self) -> &str { "set_secret" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Store a secret value by key (e.g. HUGGINGFACE_TOKEN). \
|
||||
If value is an empty string or null the key is deleted. \
|
||||
Secrets are never returned by any tool — use list_secrets to check presence. \
|
||||
Keys are uppercase by convention (e.g. HUGGINGFACE_TOKEN, GMAPS_API_KEY)."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Secret key name, uppercase (e.g. HUGGINGFACE_TOKEN)."
|
||||
},
|
||||
"value": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Secret value. Empty string or null deletes the key."
|
||||
}
|
||||
},
|
||||
"required": ["key"]
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String {
|
||||
let key = args["key"].as_str().unwrap_or("?");
|
||||
format!("set secret {key}")
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let key = args["key"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("set_secret: missing required argument `key`"))?;
|
||||
|
||||
let value = args["value"].as_str();
|
||||
|
||||
tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(async {
|
||||
match value {
|
||||
Some(v) if !v.is_empty() => {
|
||||
self.0.set(key, v).await?;
|
||||
Ok(format!("Secret '{key}' set."))
|
||||
}
|
||||
_ => {
|
||||
self.0.delete(key).await?;
|
||||
Ok(format!("Secret '{key}' deleted."))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::chat_hub::ChatHub;
|
||||
use crate::events::{GlobalEvent, ServerEvent};
|
||||
use crate::session::handler::{InterfaceTool, ToolFuture};
|
||||
use crate::tools::fs;
|
||||
use crate::tools::tool_names::SHOW_FILE_TO_USER;
|
||||
|
||||
/// Build a `show_file_to_user` InterfaceTool bound to a `ChatHub` and a source.
|
||||
///
|
||||
/// Injected only for SPA clients (web copilot + mobile) at the WebSocket entry
|
||||
/// point, so Telegram — which has its own `send_attachment` — never sees it.
|
||||
///
|
||||
/// When called, it emits a `ServerEvent::OpenFile` to the source's connected
|
||||
/// clients. The frontend routes it: HTML opens in a new browser tab, everything
|
||||
/// else (Markdown / code / raster images / SVG / PDF / LaTeX — which is compiled
|
||||
/// to PDF server-side) opens in the file-viewer page.
|
||||
pub fn make_tool(hub: Arc<ChatHub>, source: String) -> InterfaceTool {
|
||||
let definition = json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": SHOW_FILE_TO_USER,
|
||||
"description": "Show a file to the user by opening it in their interface. \
|
||||
Supports Markdown, source code, plain text, raster images \
|
||||
(PNG/JPG/GIF/WebP/…), SVG, PDF, and LaTeX (.tex — compiled \
|
||||
to PDF automatically on the server). HTML files open in a \
|
||||
new browser tab. Use this to surface a file you created or \
|
||||
found so the user can look at it directly. One file per call. \
|
||||
The file must already exist on disk. \
|
||||
IMPORTANT for LaTeX: always pass the `.tex` source, never a \
|
||||
pre-built `.pdf` of a document you have the `.tex` for. The \
|
||||
`.tex` is compiled on the server and the view live-reloads \
|
||||
whenever any of its dependencies (\\input fragments, .sty/.cls, \
|
||||
images) change. A raw `.pdf` is served statically — never \
|
||||
recompiled and its dependencies are not watched — so the user \
|
||||
would keep seeing a stale render.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path of the file to show. Relative to the project root, or absolute."
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let handler = Arc::new(move |args: Value| -> ToolFuture {
|
||||
let hub = Arc::clone(&hub);
|
||||
let source = source.clone();
|
||||
Box::pin(async move {
|
||||
let path = args["path"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("show_file_to_user: missing required parameter 'path'"))?;
|
||||
|
||||
let abs = fs::resolve(path)?;
|
||||
if !abs.exists() {
|
||||
anyhow::bail!("show_file_to_user: file not found: {path}");
|
||||
}
|
||||
if abs.is_dir() {
|
||||
anyhow::bail!("show_file_to_user: '{path}' is a directory, not a file");
|
||||
}
|
||||
|
||||
let display = fs::relativize_for_display(path);
|
||||
hub.emit(GlobalEvent {
|
||||
source: Some(source),
|
||||
session_id: None,
|
||||
event: ServerEvent::OpenFile { path: display.clone() },
|
||||
});
|
||||
Ok(format!("Opened {display} in the user's viewer."))
|
||||
})
|
||||
});
|
||||
|
||||
InterfaceTool { definition, handler }
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cron::TaskManager;
|
||||
use crate::mcp::McpManager;
|
||||
use crate::plugin::PluginManager;
|
||||
use crate::tools::{Tool, ToolDescriptionLength};
|
||||
|
||||
/// Unified enable/disable tool. Replaces `toggle_mcp`, `toggle_plugin` and
|
||||
/// `toggle_cron_job`: same operation (flip an enabled flag), uniform schema
|
||||
/// (`kind` + `id` + `enabled`), all `required` validatable at schema level.
|
||||
///
|
||||
/// `delete_cron_job` is intentionally NOT folded in — it is destructive
|
||||
/// (irreversible) whereas toggling is reversible, and keeping it separate lets
|
||||
/// it carry a distinct approval rule.
|
||||
pub struct ToggleItem {
|
||||
mcp: Arc<McpManager>,
|
||||
plugins: Arc<PluginManager>,
|
||||
cron: Arc<TaskManager>,
|
||||
}
|
||||
|
||||
impl ToggleItem {
|
||||
pub fn new(mcp: Arc<McpManager>, plugins: Arc<PluginManager>, cron: Arc<TaskManager>) -> Self {
|
||||
Self { mcp, plugins, cron }
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for ToggleItem {
|
||||
fn name(&self) -> &str { "toggle_item" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Enable or disable an item by kind. Pass `kind`, `id`, and `enabled`:\n\
|
||||
• `mcp` — `id` is the server name. NOTE: a restart is required for the change to take full effect on running servers.\n\
|
||||
• `plugin` — `id` is the plugin id (e.g. \"telegram\"). Takes effect immediately (the plugin is started/stopped at once).\n\
|
||||
• `cron` — `id` is the numeric job id (from `list_items` type=cron). Re-enabling recalculates next_run_at.\n\
|
||||
Use `list_items` to find current names/ids and statuses."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["kind", "id", "enabled"],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["mcp", "plugin", "cron"],
|
||||
"description": "Which kind of item to toggle."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "MCP server name | plugin id | numeric cron job id (as a string)."
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "true to enable, false to disable."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String {
|
||||
let kind = args["kind"].as_str().unwrap_or("?");
|
||||
let id = args["id"].as_str().unwrap_or("?");
|
||||
let enabled = args["enabled"].as_bool().unwrap_or(true);
|
||||
let action = if enabled { "enable" } else { "disable" };
|
||||
format!("{action} {kind} `{id}`")
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
let kind = args["kind"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("toggle_item: missing required argument `kind`"))?;
|
||||
let id = args["id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("toggle_item: missing required argument `id`"))?;
|
||||
let enabled = args["enabled"].as_bool()
|
||||
.ok_or_else(|| anyhow::anyhow!("toggle_item: missing required argument `enabled`"))?;
|
||||
|
||||
match kind {
|
||||
"mcp" => {
|
||||
tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.mcp.set_enabled(id, enabled))
|
||||
})?;
|
||||
Ok(format!(
|
||||
"MCP server '{}' is now {}. Note: a restart is required for the change to take effect on running servers.",
|
||||
id,
|
||||
if enabled { "enabled" } else { "disabled" }
|
||||
))
|
||||
}
|
||||
"plugin" => {
|
||||
tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.plugins.toggle(id, enabled))
|
||||
})?;
|
||||
Ok(format!(
|
||||
"Plugin '{}' is now {}.",
|
||||
id,
|
||||
if enabled { "enabled and running" } else { "disabled and stopped" }
|
||||
))
|
||||
}
|
||||
"cron" => {
|
||||
let job_id = id.parse::<i64>()
|
||||
.map_err(|_| anyhow::anyhow!("toggle_item: for kind=cron, `id` must be a numeric job id (got '{id}')"))?;
|
||||
if self.cron.toggle_job(job_id, enabled)? {
|
||||
Ok(format!("Task {job_id} {}.", if enabled { "enabled" } else { "disabled" }))
|
||||
} else {
|
||||
Ok(format!("No task with id {job_id}."))
|
||||
}
|
||||
}
|
||||
other => anyhow::bail!("toggle_item: unknown kind `{other}` (expected one of: mcp, plugin, cron)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
pub const EXECUTE_TASK: &str = "execute_task";
|
||||
pub const EXECUTE_SUBTASK: &str = "execute_subtask";
|
||||
pub const RESTART: &str = "restart";
|
||||
pub const UPDATE_SCRATCHPAD: &str = "update_scratchpad";
|
||||
pub const WRITE_TODOS: &str = "write_todos";
|
||||
pub const ASK_USER_CLARIFICATION: &str = "ask_user_clarification";
|
||||
pub const ACTIVATE_TOOLS: &str = "activate_tools";
|
||||
/// Reserved `activate_tools` group name that loads all built-in `Config`-category
|
||||
/// tools (system configuration) instead of an MCP server's tools.
|
||||
pub const CONFIG_GROUP: &str = "config";
|
||||
pub const NOTIFY: &str = "notify";
|
||||
pub const READ_NOTIFICATION: &str = "read_notification";
|
||||
pub const EXECUTE_CMD: &str = "execute_cmd";
|
||||
pub const SHOW_FILE_TO_USER: &str = "show_file_to_user";
|
||||
pub const IMAGE_GENERATE: &str = "image_generate";
|
||||
Reference in New Issue
Block a user