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:
2026-07-10 16:48:51 +01:00
parent 38494a85a9
commit 178a38357e
173 changed files with 2650 additions and 1106 deletions
@@ -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(", ")
))
}
}
+515
View File
@@ -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" }
))
}
}
+170
View File
@@ -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}."))
}
}
}
+224
View File
@@ -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
}
+148
View File
@@ -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(())
}
+139
View File
@@ -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());
}
+106
View File
@@ -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())
})
}
}
+130
View File
@@ -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
}
+253
View File
@@ -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)
}
+82
View File
@@ -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
}
}
+175
View File
@@ -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."))
}
}
+68
View File
@@ -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) }
}
}
+66
View File
@@ -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."))
}
}
})
})
}
}
+79
View File
@@ -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 }
}
+113
View File
@@ -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)"),
}
}
}
+15
View File
@@ -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";