Skip to content

Commit ff62bd6

Browse files
authored
feat(tools): configurable LLM tool list (#46)
* test(tools): RED tests for configurable LLM tool list Add failing tests that define the contract for a new tools configuration section: - internal/config/tools_test.go: ToolConfig loading from global/project config, env vars (ODEK_TOOLS_ENABLED/DISABLED), and CLI flags. Project config may only disable tools, not enable them. - internal/tool/filter_test.go: ToolFilter whitelist/blacklist semantics, required-tool preservation, and unknown-name tolerance. - cmd/odek/run_flags_tools_test.go: --tool/--no-tool CLI flag parsing. * feat(tools): configurable LLM tool list via tools config, env, and --tool/--no-tool Add a new config section with (whitelist) and (blacklist) plus CLI flags / and env vars / . This supports deployments where odek should only expose a subset of tools, e.g. a chatbot with web_search + voice but no shell or file writes. Key changes: - internal/config/loader.go: ToolConfig/ToolsConfig types, file/env/CLI merge logic, and project-level security restriction (project config can only disable, never enable). - internal/tool/registry.go: FilterTools helper with whitelist/blacklist and required-tool preservation. - odek.go: ToolFilterConfig on Config; memory tool is no longer appended unconditionally — it respects the filter. - cmd/odek/*.go: wire filtering into run, continue, repl, serve, telegram, subagent, schedule, and mcp surfaces. - Docs and help text updated with examples and env vars. Tests: - internal/config/tools_test.go - internal/tool/filter_test.go - cmd/odek/run_flags_tools_test.go - odek_test.go memory-filter regression tests All existing tests pass. * docs(tools): add TOOL_SELECTION.md user guide Explain default behaviour (all tools registered), the four configuration layers, whitelist vs blacklist semantics, security restriction on project config, and concrete deployment examples (chatbot, read-only research, locked-down CI, memory disable). Include the full tool-name reference table. * docs(tools): add ChatBot example and clarify Telegram-only tools Replace the confusing CLI chatbot example (which mixed whitelist and blacklist and referenced Telegram-only send_message) with a clear ChatBot config example suitable for ~/.odek/config.json and odek serve. Explain why each tool is included and note that send_message/clarify are only auto-injected by odek telegram. * docs(tools): clarify session tool surface Only session_search is exposed to the LLM; session management is handled by the odek session CLI command and --session/--continue flags. * fix(tools): apply filter after MCP tools, clean up config template and signatures - Remove empty tools block from default config template so odek config init does not disable every tool. - Apply tools.enabled/tools.disabled after MCP tools are loaded so MCP tool names can be filtered too (run, continue, repl, serve, subagent, mcp, schedule). - Correct ToolFilter comment to reflect that it only filters auto-registered tools, not caller-supplied Tools. - Return explicit errors for serve --tool/--no-tool without values. - Make FilterTools whitelist order deterministic. - Remove unused sliceContains helper and stale RED-test comments. - Document --tool/--no-tool for serve and repl in docs/CLI.md. - Flatten filterBuiltinTools variadic signature. - Add serve-mode tool flag parsing tests.
1 parent eda9efc commit ff62bd6

18 files changed

Lines changed: 1077 additions & 23 deletions

cmd/odek/main.go

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/BackendStack21/odek/internal/session"
2727
"github.com/BackendStack21/odek/internal/skills"
2828
"github.com/BackendStack21/odek/internal/telegram"
29+
"github.com/BackendStack21/odek/internal/tool"
2930
)
3031

3132
// version is set at build time via ldflags: -ldflags "-X main.version=v0.2.1"
@@ -253,8 +254,14 @@ type runFlags struct {
253254
NoAgents *bool // nil = not set
254255
PromptCaching *bool // nil = not set; true = enable prompt caching
255256
Session *bool // nil = not set; true = save session after run
256-
Learn *bool // nil = not set; true = enable skills learning mode
257-
Task string
257+
Learn *bool // nil = not set; true = enable skills learning mode
258+
Task string
259+
260+
// ToolsEnabled and ToolsDisabled control which tools are exposed to the LLM.
261+
// Repeated --tool/--no-tool flags accumulate. They are the highest priority
262+
// layer after file config and env vars.
263+
ToolsEnabled []string
264+
ToolsDisabled []string
258265
Ctx []string // --ctx files to attach
259266

260267
// Sandbox-specific CLI flags
@@ -333,6 +340,18 @@ func parseRunFlags(args []string) (runFlags, error) {
333340
case "--no-learn":
334341
f.Learn = boolPtr(false)
335342
i++
343+
case "--tool":
344+
if i+1 >= len(args) {
345+
return f, fmt.Errorf("--tool requires a value")
346+
}
347+
f.ToolsEnabled = append(f.ToolsEnabled, args[i+1])
348+
i += 2
349+
case "--no-tool":
350+
if i+1 >= len(args) {
351+
return f, fmt.Errorf("--no-tool requires a value")
352+
}
353+
f.ToolsDisabled = append(f.ToolsDisabled, args[i+1])
354+
i += 2
336355
case "--no-color":
337356
f.NoColor = boolPtr(true)
338357
i++
@@ -604,6 +623,8 @@ Run flags:
604623
--session Save conversation as a multi-turn session
605624
--learn Enable skill learning mode — on by default, no flag needed
606625
--no-learn Disable skill learning mode (overrides config/default)
626+
--tool <name> Enable a tool for the LLM (repeatable)
627+
--no-tool <name> Disable a tool for the LLM (repeatable)
607628
--system <prompt> System prompt override
608629
609630
Skill commands:
@@ -641,6 +662,8 @@ Environment variables:
641662
ODEK_NO_COLOR true/false — disable colors
642663
ODEK_NO_AGENTS true/false — skip AGENTS.md
643664
ODEK_SYSTEM System prompt override
665+
ODEK_TOOLS_ENABLED Comma-separated tool whitelist
666+
ODEK_TOOLS_DISABLED Comma-separated tool blacklist
644667
ODEK_SANDBOX_IMAGE Docker image for sandbox container
645668
ODEK_SANDBOX_NETWORK Network mode (none | bridge | host)
646669
ODEK_SANDBOX_READONLY true/false — mount read-only
@@ -835,6 +858,8 @@ func run(args []string) error {
835858
Learn: f.Learn,
836859
System: f.System,
837860
Task: f.Task,
861+
ToolsEnabled: f.ToolsEnabled,
862+
ToolsDisabled: f.ToolsDisabled,
838863

839864
SandboxImage: f.SandboxImage,
840865
SandboxNetwork: f.SandboxNetwork,
@@ -892,6 +917,10 @@ func run(args []string) error {
892917
defer mcpCleanup()
893918
}
894919

920+
// Apply tool filtering based on configuration (after MCP tools are loaded
921+
// so disabled/enabled lists can reference MCP tool names too).
922+
tools = filterBuiltinTools(tools, resolved.Tools, nil)
923+
895924
if resolved.Sandbox {
896925
var containerName string
897926
containerName, sandboxCleanup, err = setupSandbox(tools, sbCfg)
@@ -951,6 +980,7 @@ func run(args []string) error {
951980
ThinkingBudget: f.ThinkingBudget,
952981
Temperature: 0, // deterministic by default; override with --temperature
953982
Tools: tools,
983+
ToolFilter: odek.ToolFilterConfig{Enabled: resolved.Tools.Enabled, Disabled: resolved.Tools.Disabled},
954984
SandboxCleanup: sandboxCleanup,
955985
Renderer: rend,
956986
Skills: skillsCfg,
@@ -1235,6 +1265,34 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d
12351265
return tools
12361266
}
12371267

1268+
// filterBuiltinTools applies the configured tools.enabled / tools.disabled
1269+
// lists to a slice of tools. Unknown names are ignored. Required tools are
1270+
// always preserved.
1271+
func filterBuiltinTools(tools []odek.Tool, cfg config.ToolConfig, required map[string]bool) []odek.Tool {
1272+
adapted := make([]tool.Tool, len(tools))
1273+
for i, t := range tools {
1274+
adapted[i] = odekToolAdapter{t}
1275+
}
1276+
filtered := tool.FilterTools(adapted, cfg.Enabled, cfg.Disabled, required)
1277+
out := make([]odek.Tool, len(filtered))
1278+
for i, t := range filtered {
1279+
out[i] = t.(odekToolAdapter).tool
1280+
}
1281+
return out
1282+
}
1283+
1284+
// odekToolAdapter bridges odek.Tool to internal/tool.Tool.
1285+
type odekToolAdapter struct {
1286+
tool odek.Tool
1287+
}
1288+
1289+
func (a odekToolAdapter) Name() string { return a.tool.Name() }
1290+
func (a odekToolAdapter) Description() string { return a.tool.Description() }
1291+
func (a odekToolAdapter) Schema() any { return a.tool.Schema() }
1292+
func (a odekToolAdapter) Call(args string) (string, error) {
1293+
return a.tool.Call(args)
1294+
}
1295+
12381296
// loadMCPTools connects to configured MCP servers and appends their tools
12391297
// to the tool slice. Returns a cleanup function that closes all connections.
12401298
// The passed-in tool slice pointer is extended with ToolAdapters.
@@ -1792,7 +1850,6 @@ func continueCmd(args []string) error {
17921850
)
17931851
}
17941852
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{Transcription: resolved.Transcription, Vision: resolved.Vision, WebSearch: resolved.WebSearch}, store)
1795-
var sandboxCleanup func() error
17961853

17971854
// MCP server tools
17981855
var mcpCleanup func()
@@ -1805,9 +1862,14 @@ func continueCmd(args []string) error {
18051862
defer mcpCleanup()
18061863
}
18071864

1865+
// Apply tool filtering based on configuration (after MCP tools are loaded
1866+
// so disabled/enabled lists can reference MCP tool names too).
1867+
tools = filterBuiltinTools(tools, resolved.Tools, nil)
1868+
18081869
systemMessage := buildSystemPrompt(resolved)
18091870

1810-
// Sandbox (if enabled in config) (second occurrence)
1871+
var sandboxCleanup func() error
1872+
18111873
if resolved.Sandbox {
18121874
sbCfg := sandboxConfig{
18131875
Image: resolved.SandboxImage,
@@ -1853,6 +1915,7 @@ func continueCmd(args []string) error {
18531915
Thinking: resolved.Thinking,
18541916
Temperature: 0, // deterministic by default; override with --temperature
18551917
Tools: tools,
1918+
ToolFilter: odek.ToolFilterConfig{Enabled: resolved.Tools.Enabled, Disabled: resolved.Tools.Disabled},
18561919
SandboxCleanup: sandboxCleanup,
18571920
Renderer: rend,
18581921
Skills: skillsCfg,

cmd/odek/mcp.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ Flags:
8787
defer mcpCleanup()
8888
}
8989

90+
// Apply tool filtering based on configuration (after MCP tools are loaded
91+
// so disabled/enabled lists can reference MCP tool names too).
92+
toolSet = filterBuiltinTools(toolSet, resolved.Tools, nil)
93+
9094
// Sandbox setup (must happen after tools are created)
9195
var sandboxCleanup func() error
9296
if resolved.Sandbox {

cmd/odek/repl.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ func replCmd(args []string) error {
7979
)
8080
}
8181
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{WebSearch: resolved.WebSearch}, nil)
82-
var sandboxCleanup func() error
8382

8483
// MCP server tools
8584
var mcpCleanup func()
@@ -92,6 +91,12 @@ func replCmd(args []string) error {
9291
defer mcpCleanup()
9392
}
9493

94+
// Apply tool filtering based on configuration (after MCP tools are loaded
95+
// so disabled/enabled lists can reference MCP tool names too).
96+
tools = filterBuiltinTools(tools, resolved.Tools, nil)
97+
98+
var sandboxCleanup func() error
99+
95100
if resolved.Sandbox {
96101
sbCfg := sandboxConfig{
97102
Image: resolved.SandboxImage,
@@ -139,6 +144,7 @@ func replCmd(args []string) error {
139144
Thinking: resolved.Thinking,
140145
ThinkingBudget: f.ThinkingBudget,
141146
Tools: tools,
147+
ToolFilter: odek.ToolFilterConfig{Enabled: resolved.Tools.Enabled, Disabled: resolved.Tools.Disabled},
142148
SandboxCleanup: sandboxCleanup,
143149
Renderer: rend,
144150
Skills: skillsCfg,

cmd/odek/run_flags_tools_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package main
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func TestParseRunFlags_ToolWhitelist(t *testing.T) {
9+
f, err := parseRunFlags([]string{
10+
"--tool", "web_search",
11+
"--tool", "transcribe",
12+
"--tool", "vision",
13+
"hello",
14+
})
15+
if err != nil {
16+
t.Fatalf("parseRunFlags error: %v", err)
17+
}
18+
want := []string{"web_search", "transcribe", "vision"}
19+
if !reflect.DeepEqual(f.ToolsEnabled, want) {
20+
t.Errorf("ToolsEnabled = %v, want %v", f.ToolsEnabled, want)
21+
}
22+
}
23+
24+
func TestParseRunFlags_ToolBlacklist(t *testing.T) {
25+
f, err := parseRunFlags([]string{
26+
"--no-tool", "shell",
27+
"--no-tool", "write_file",
28+
"hello",
29+
})
30+
if err != nil {
31+
t.Fatalf("parseRunFlags error: %v", err)
32+
}
33+
want := []string{"shell", "write_file"}
34+
if !reflect.DeepEqual(f.ToolsDisabled, want) {
35+
t.Errorf("ToolsDisabled = %v, want %v", f.ToolsDisabled, want)
36+
}
37+
}
38+
39+
func TestParseRunFlags_ToolMixed(t *testing.T) {
40+
f, err := parseRunFlags([]string{
41+
"--tool", "web_search",
42+
"--no-tool", "shell",
43+
"--tool", "vision",
44+
"--no-tool", "delegate_tasks",
45+
"hello",
46+
})
47+
if err != nil {
48+
t.Fatalf("parseRunFlags error: %v", err)
49+
}
50+
wantEnabled := []string{"web_search", "vision"}
51+
wantDisabled := []string{"shell", "delegate_tasks"}
52+
if !reflect.DeepEqual(f.ToolsEnabled, wantEnabled) {
53+
t.Errorf("ToolsEnabled = %v, want %v", f.ToolsEnabled, wantEnabled)
54+
}
55+
if !reflect.DeepEqual(f.ToolsDisabled, wantDisabled) {
56+
t.Errorf("ToolsDisabled = %v, want %v", f.ToolsDisabled, wantDisabled)
57+
}
58+
}
59+
60+
func TestParseRunFlags_ToolRequiresValue(t *testing.T) {
61+
_, err := parseRunFlags([]string{"--tool"})
62+
if err == nil {
63+
t.Fatal("expected error for --tool without value")
64+
}
65+
_, err = parseRunFlags([]string{"--no-tool"})
66+
if err == nil {
67+
t.Fatal("expected error for --no-tool without value")
68+
}
69+
}
70+
71+
func TestParseRunFlags_ToolDefaults(t *testing.T) {
72+
f, err := parseRunFlags([]string{"hello"})
73+
if err != nil {
74+
t.Fatalf("parseRunFlags error: %v", err)
75+
}
76+
if len(f.ToolsEnabled) != 0 {
77+
t.Errorf("ToolsEnabled should default to empty, got %v", f.ToolsEnabled)
78+
}
79+
if len(f.ToolsDisabled) != 0 {
80+
t.Errorf("ToolsDisabled should default to empty, got %v", f.ToolsDisabled)
81+
}
82+
}

cmd/odek/schedule.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,8 +671,13 @@ func runTaskHeadless(ctx context.Context, resolved config.ResolvedConfig, system
671671
dangerCfg := buildHeadlessDangerConfig(resolved)
672672

673673
tools := builtinTools(dangerCfg, nil, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{Transcription: resolved.Transcription, Vision: resolved.Vision, WebSearch: resolved.WebSearch}, nil)
674+
674675
tools = append(tools, mcpTools...)
675676

677+
// Apply tool filtering based on configuration (after MCP tools are appended
678+
// so disabled/enabled lists can reference MCP tool names too).
679+
tools = filterBuiltinTools(tools, resolved.Tools, nil)
680+
676681
// Capture cumulative token usage from the final iteration so the Runner
677682
// can report it (the engine logs it; the bot bills it against the budget).
678683
// RunWithMessages drives the loop synchronously on this goroutine, so the
@@ -691,6 +696,7 @@ func runTaskHeadless(ctx context.Context, resolved config.ResolvedConfig, system
691696
Thinking: resolved.Thinking,
692697
Temperature: 0,
693698
Tools: tools,
699+
ToolFilter: odek.ToolFilterConfig{Enabled: resolved.Tools.Enabled, Disabled: resolved.Tools.Disabled},
694700
Renderer: render.New(io.Discard, false), // silent: unattended
695701
InteractionMode: "off",
696702
PromptCaching: resolved.PromptCaching,

cmd/odek/serve.go

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ func serveCmd(args []string) error {
185185
var sandboxReadonly *bool
186186
var promptCaching *bool
187187
var sandboxImage, sandboxNetwork, sandboxMemory, sandboxCPUs, sandboxUser string
188+
var toolsEnabled, toolsDisabled []string
188189

189190
for i := 0; i < len(args); i++ {
190191
switch args[i] {
@@ -232,6 +233,18 @@ func serveCmd(args []string) error {
232233
}
233234
case "--prompt-caching":
234235
promptCaching = boolPtr(true)
236+
case "--tool":
237+
i++
238+
if i >= len(args) {
239+
return fmt.Errorf("--tool requires a value")
240+
}
241+
toolsEnabled = append(toolsEnabled, args[i])
242+
case "--no-tool":
243+
i++
244+
if i >= len(args) {
245+
return fmt.Errorf("--no-tool requires a value")
246+
}
247+
toolsDisabled = append(toolsDisabled, args[i])
235248
default:
236249
return fmt.Errorf("unknown flag %q for serve", args[i])
237250
}
@@ -246,6 +259,8 @@ func serveCmd(args []string) error {
246259
SandboxMemory: sandboxMemory,
247260
SandboxCPUs: sandboxCPUs,
248261
SandboxUser: sandboxUser,
262+
ToolsEnabled: toolsEnabled,
263+
ToolsDisabled: toolsDisabled,
249264
})
250265
// Serve mode default-on for sandbox: the Web UI surface is the
251266
// largest blast radius (browser-driven tool calls, untrusted-page
@@ -331,6 +346,8 @@ Flags:
331346
--sandbox-memory limit Container memory limit (e.g. 512m, 2g)
332347
--sandbox-cpus limit Container CPU limit (e.g. 0.5, 2, 4)
333348
--sandbox-user user Container user (e.g. 1000:1000)
349+
--tool name Enable a tool for the LLM (repeatable)
350+
--no-tool name Disable a tool for the LLM (repeatable)
334351
--help, -h Show this help`)
335352
}
336353

@@ -412,6 +429,20 @@ func newServeAgent(resolved config.ResolvedConfig, system string, sendFn func(v
412429

413430
tools := builtinTools(resolved.Dangerous, sm, approver, resolved.MaxConcurrency, resolved.APIKey, toolConfig{WebSearch: resolved.WebSearch}, nil)
414431

432+
// MCP server tools
433+
var mcpCleanup func()
434+
if len(resolved.MCPServers) > 0 {
435+
cl, err := loadMCPTools(resolved, &tools)
436+
if err != nil {
437+
return nil, nil, nil, nil, fmt.Errorf("mcp: %w", err)
438+
}
439+
mcpCleanup = cl
440+
}
441+
442+
// Apply tool filtering based on configuration (after MCP tools are loaded
443+
// so disabled/enabled lists can reference MCP tool names too).
444+
tools = filterBuiltinTools(tools, resolved.Tools, nil)
445+
415446
// Find the delegateTasksTool to wire up sub-agent log streaming
416447
var subagentTool *delegateTasksTool
417448
for _, t := range tools {
@@ -441,16 +472,6 @@ func newServeAgent(resolved config.ResolvedConfig, system string, sendFn func(v
441472
}
442473
var sandboxCleanup func() error
443474

444-
// MCP server tools
445-
var mcpCleanup func()
446-
if len(resolved.MCPServers) > 0 {
447-
cl, err := loadMCPTools(resolved, &tools)
448-
if err != nil {
449-
return nil, nil, nil, nil, fmt.Errorf("mcp: %w", err)
450-
}
451-
mcpCleanup = cl
452-
}
453-
454475
if resolved.Sandbox {
455476
cfg := sandboxConfig{
456477
Image: resolved.SandboxImage,
@@ -503,6 +524,7 @@ func newServeAgent(resolved config.ResolvedConfig, system string, sendFn func(v
503524
Thinking: resolved.Thinking,
504525
InteractionMode: resolved.InteractionMode,
505526
Tools: tools,
527+
ToolFilter: odek.ToolFilterConfig{Enabled: resolved.Tools.Enabled, Disabled: resolved.Tools.Disabled},
506528
// SandboxCleanup is intentionally NOT passed here. In serve mode,
507529
// cleanup is the caller's responsibility (handleWS defers it).
508530
// Passing it here would cause agent.Close() to call docker rm -f,

0 commit comments

Comments
 (0)