Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -599,8 +599,61 @@ default_text_model = "deepseek-ai/deepseek-v4-pro"
# [runtime_api]
# cors_origins = ["http://localhost:5173", "http://127.0.0.1:5173"]

# ─────────────────────────────────────────────────────────────────────────────────
# Tool Overrides & Plugins ([tools])
# ─────────────────────────────────────────────────────────────────────────────────
# The `[tools]` table lets you replace any built-in tool with a custom
# implementation (script or command) or disable it entirely — without
# forking or recompiling the binary.
#
# Plugin scripts dropped in the plugin directory are auto-discovered and
# registered as model-visible tools alongside the built-in ones.
#
# Scripts receive the tool's JSON input on **stdin** and must return a
# JSON `ToolResult` (`{"content": "...", "success": true}`) on **stdout**.
#
# [tools]
# # Custom plugin directory (defaults to `~/.deepseek/tools/`)
# plugin_dir = "~/.deepseek/tools"
#
# [tools.overrides]
# # Disable a tool entirely — removes it from the model-visible catalog.
# "code_execution" = { type = "disabled" }
#
# # Replace a tool with a script. Relative paths resolve against plugin_dir.
# "exec_shell" = { type = "script", path = "audit-exec-shell.sh" }
#
# # Replace a tool with a command (binary on PATH or absolute path).
# "read_file" = { type = "command", command = "bat", args = ["--paging=never"] }
#
# # Scripts can also accept static arguments before the JSON input:
# "fetch_url" = { type = "script", path = "cached-fetch.sh", args = ["--ttl", "300"] }

# ──────────── Enterprise example: audit-logging exec_shell wrapper ──────────────
# Drop `audit-exec-shell.sh` in `~/.deepseek/tools/` and enable with:
#
# [tools.overrides]
# "exec_shell" = { type = "script", path = "audit-exec-shell.sh" }
#
# The wrapper logs every command to `~/.deepseek/audit/exec_shell.log` before
# executing it, then runs the real `exec_shell` tool logic via stdin/stdout
# passthrough. No code changes, no fork, no recompile.
#
# ```sh
# #!/usr/bin/env sh
# # name: exec_shell
# # description: Audit-logging wrapper for exec_shell
# # approval: required
# LOGDIR="${HOME}/.deepseek/audit"
# mkdir -p "$LOGDIR"
# LOGFILE="$LOGDIR/exec_shell.log"
# input=$(cat)
# echo "[$(date -Iseconds)] $input" >> "$LOGFILE"
# echo "$input" | exec /bin/sh -s
# ```

# ─────────────────────────────────────────────────────────────────────────────────
# Requirements (admin constraints) example file
# ─────────────────────────────────────────────────────────────────────────────────
# allowed_approval_policies = ["on-request", "untrusted", "never"]
# allowed_sandbox_modes = ["read-only", "workspace-write"]
# allowed_sandbox_modes = ["read-only", "workspace-write"]
40 changes: 40 additions & 0 deletions crates/tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,19 @@ pub struct ToolsConfig {
/// default core catalog. Unknown names are harmless and simply never match.
#[serde(default)]
pub always_load: Vec<String>,

/// Optional directory to scan for plugin tool scripts. Scripts with a
/// frontmatter header (`# name:`, `# description:`, `# schema:`) are
/// auto-discovered and registered as tools.
///
/// Defaults to `~/.deepseek/tools/` when `None`.
#[serde(default)]
pub plugin_dir: Option<String>,

/// Per-tool overrides keyed by built-in tool name.
/// Each override replaces or disables the named tool.
#[serde(default)]
pub overrides: Option<HashMap<String, ToolOverride>>,
}

/// One configurable footer item.
Expand Down Expand Up @@ -1152,6 +1165,33 @@ pub struct Config {
pub vision_model: Option<VisionModelConfig>,
}

/// How a user wants to replace or disable a built-in tool.
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolOverride {
/// Run a local script file. The script receives the tool's JSON input
/// on stdin and must return a JSON `ToolResult` on stdout.
Script {
/// Path to the script (absolute, or relative to `~/.deepseek/tools/`).
path: String,
/// Optional static arguments prepended before the tool's JSON input.
#[serde(default)]
args: Option<Vec<String>>,
},
/// Run an external command. The command receives the tool's JSON input
/// on stdin and must return a JSON `ToolResult` on stdout.
Command {
/// The command to run (binary name or absolute path).
command: String,
/// Optional static arguments prepended before the tool's JSON input.
#[serde(default)]
args: Option<Vec<String>>,
},
/// Completely disable a built-in tool. The tool will not appear in the
/// model-visible catalog and cannot be called.
Disabled,
}

/// Vision model configuration for the `image_analyze` tool.
/// Uses an OpenAI-compatible vision model API.
#[derive(Debug, Clone, Deserialize)]
Expand Down
66 changes: 63 additions & 3 deletions crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ pub struct EngineConfig {
/// through bubblewrap instead of relying solely on Landlock (#2184).
#[allow(dead_code)] // Wired through ShellManager in follow-up PR
pub prefer_bwrap: bool,
/// Tool override and plugin configuration (`[tools]` table in config.toml).
/// Applied to the per-turn tool registry after built-in tools are registered.
/// When `None`, no overrides or plugin loading occurs.
pub tools: Option<crate::config::ToolsConfig>,
}

impl Default for EngineConfig {
Expand Down Expand Up @@ -232,6 +236,7 @@ impl Default for EngineConfig {
),
tools_always_load: HashSet::new(),
prefer_bwrap: false,
tools: None,
}
}
}
Expand Down Expand Up @@ -1113,7 +1118,7 @@ impl Engine {
None
};

let tool_registry = match mode {
let mut tool_registry = match mode {
AppMode::Agent | AppMode::Yolo => {
if self.config.features.enabled(Feature::Subagents) {
let runtime = if let Some(client) = self.deepseek_client.clone() {
Expand Down Expand Up @@ -1161,18 +1166,73 @@ impl Engine {
_ => Some(builder.build(tool_context)),
};

// Load plugin tools from the user's tools directory and apply any
// config.toml overrides. Plugin scripts are auto-discovered and
// registered without requiring a `[tools]` config section — the
// default `~/.deepseek/tools/` directory is always checked.
let mut plugin_tool_names: std::collections::HashSet<String> =
std::collections::HashSet::new();
if let Some(ref mut tool_registry) = tool_registry {
let names_before: std::collections::HashSet<String> = tool_registry
.names()
.into_iter()
.map(|s| s.to_string())
.collect();

let default_dir = dirs::home_dir()
.map(|h| h.join(".deepseek").join("tools"))
.unwrap_or_else(|| PathBuf::from(".deepseek/tools"));
let plugin_dir = if let Some(ref tools_config) = self.config.tools
&& let Some(ref custom_dir) = tools_config.plugin_dir
{
let p = PathBuf::from(shellexpand::tilde(custom_dir).as_ref());
if !p.exists() {
tracing::warn!(
"Configured plugin directory {} does not exist, falling back to default",
p.display()
);
default_dir
} else {
p
}
} else {
default_dir
};

if let Some(ref tools_config) = self.config.tools
&& let Some(ref overrides) = tools_config.overrides
{
tool_registry.apply_overrides(overrides, &plugin_dir);
}

tool_registry.load_plugins(&plugin_dir);

let names_after: std::collections::HashSet<String> = tool_registry
.names()
.into_iter()
.map(|s| s.to_string())
.collect();
plugin_tool_names = &names_after - &names_before;
}

let mcp_tools = if self.config.features.enabled(Feature::Mcp) {
self.mcp_tools().await
} else {
Vec::new()
};
let tools = tool_registry.as_ref().map(|registry| {
build_model_tool_catalog(
let mut catalog = build_model_tool_catalog(
registry.to_api_tools_with_cache(true),
mcp_tools,
mode,
&self.config.tools_always_load,
)
);
for tool in &mut catalog {
if plugin_tool_names.contains(&tool.name) {
tool.defer_loading = Some(false);
}
}
catalog
});

// Main turn loop
Expand Down
7 changes: 6 additions & 1 deletion crates/tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1472,8 +1472,12 @@ fn tools_readme_template() -> &'static str {
"# Local tools\n\n\
Drop self-describing scripts here so they can be discovered by\n\
`codewhale-tui setup --status` and surfaced in `codewhale-tui doctor`.\n\n\
When `[tools.plugin_dir]` is set in config.toml (or when the default\n\
`~/.deepseek/tools/` directory exists), they are auto-discovered and\n\
registered as model-visible tools.\n\n\
Each script should start with a frontmatter-style header so the\n\
description is visible without executing the file:\n\n\
description is visible without executing the file and the agent knows\n\
the tool name, description, and input schema:\n\n\
```\n\
# name: my-tool\n\
# description: One-line summary of what this tool does\n\
Expand Down Expand Up @@ -5228,6 +5232,7 @@ async fn run_exec_agent(
search_provider: config.search_provider(),
search_api_key: config.search.as_ref().and_then(|s| s.api_key.clone()),
tools_always_load: config.tools_always_load(),
tools: config.tools.clone(),
};

let engine_handle = spawn_engine(engine_config, config);
Expand Down
1 change: 1 addition & 0 deletions crates/tui/src/runtime_threads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1996,6 +1996,7 @@ impl RuntimeThreadManager {
search_provider: self.config.search_provider(),
search_api_key: self.config.search.as_ref().and_then(|s| s.api_key.clone()),
tools_always_load: self.config.tools_always_load(),
tools: self.config.tools.clone(),
};

let engine = spawn_engine(engine_cfg, &self.config);
Expand Down
1 change: 1 addition & 0 deletions crates/tui/src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub mod notify;
pub mod pandoc;
pub mod parallel;
pub mod plan;
pub mod plugin;
pub mod project;
pub mod recall_archive;
pub mod registry;
Expand Down
Loading