1package claudetool
2
3import (
4 "context"
5 "strings"
6 "sync"
7
8 "shelley.exe.dev/claudetool/browse"
9 "shelley.exe.dev/llm"
10)
11
12// WorkingDir is a thread-safe mutable working directory.
13type MutableWorkingDir struct {
14 mu sync.RWMutex
15 dir string
16}
17
18// NewMutableWorkingDir creates a new MutableWorkingDir with the given initial directory.
19func NewMutableWorkingDir(dir string) *MutableWorkingDir {
20 return &MutableWorkingDir{dir: dir}
21}
22
23// Get returns the current working directory.
24func (w *MutableWorkingDir) Get() string {
25 w.mu.RLock()
26 defer w.mu.RUnlock()
27 return w.dir
28}
29
30// Set updates the working directory.
31func (w *MutableWorkingDir) Set(dir string) {
32 w.mu.Lock()
33 defer w.mu.Unlock()
34 w.dir = dir
35}
36
37// ToolSetConfig contains configuration for creating a ToolSet.
38type ToolSetConfig struct {
39 // WorkingDir is the initial working directory for tools.
40 WorkingDir string
41 // LLMProvider provides access to LLM services for tool validation.
42 LLMProvider LLMServiceProvider
43 // EnableJITInstall enables just-in-time tool installation.
44 EnableJITInstall bool
45 // EnableBrowser enables browser tools.
46 EnableBrowser bool
47 // ModelID is the model being used for this conversation.
48 // Used to determine tool configuration (e.g., simplified patch schema for weaker models).
49 ModelID string
50 // OnWorkingDirChange is called when the working directory changes.
51 // This can be used to persist the change to a database.
52 OnWorkingDirChange func(newDir string)
53}
54
55// ToolSet holds a set of tools for a single conversation.
56// Each conversation should have its own ToolSet.
57type ToolSet struct {
58 tools []*llm.Tool
59 cleanup func()
60 wd *MutableWorkingDir
61}
62
63// Tools returns the tools in this set.
64func (ts *ToolSet) Tools() []*llm.Tool {
65 return ts.tools
66}
67
68// Cleanup releases resources held by the tools (e.g., browser).
69func (ts *ToolSet) Cleanup() {
70 if ts.cleanup != nil {
71 ts.cleanup()
72 }
73}
74
75// WorkingDir returns the shared working directory.
76func (ts *ToolSet) WorkingDir() *MutableWorkingDir {
77 return ts.wd
78}
79
80// NewToolSet creates a new set of tools for a conversation.
81// isStrongModel returns true for models that can handle complex tool schemas.
82func isStrongModel(modelID string) bool {
83 lower := strings.ToLower(modelID)
84 return strings.Contains(lower, "sonnet") || strings.Contains(lower, "opus")
85}
86
87func NewToolSet(ctx context.Context, cfg ToolSetConfig) *ToolSet {
88 workingDir := cfg.WorkingDir
89 if workingDir == "" {
90 workingDir = "/"
91 }
92 wd := NewMutableWorkingDir(workingDir)
93
94 bashTool := &BashTool{
95 WorkingDir: wd,
96 LLMProvider: cfg.LLMProvider,
97 EnableJITInstall: cfg.EnableJITInstall,
98 }
99
100 // Use simplified patch schema for weaker models, full schema for sonnet/opus
101 simplified := !isStrongModel(cfg.ModelID)
102 patchTool := &PatchTool{
103 Simplified: simplified,
104 WorkingDir: wd,
105 ClipboardEnabled: true,
106 }
107
108 keywordTool := NewKeywordToolWithWorkingDir(cfg.LLMProvider, wd)
109
110 changeDirTool := &ChangeDirTool{
111 WorkingDir: wd,
112 OnChange: cfg.OnWorkingDirChange,
113 }
114
115 tools := []*llm.Tool{
116 Think,
117 bashTool.Tool(),
118 patchTool.Tool(),
119 keywordTool.Tool(),
120 changeDirTool.Tool(),
121 }
122
123 var cleanup func()
124 if cfg.EnableBrowser {
125 // Get max image dimension from the LLM service
126 maxImageDimension := 0
127 if cfg.LLMProvider != nil && cfg.ModelID != "" {
128 if svc, err := cfg.LLMProvider.GetService(cfg.ModelID); err == nil {
129 maxImageDimension = svc.MaxImageDimension()
130 }
131 }
132 browserTools, browserCleanup := browse.RegisterBrowserTools(ctx, true, maxImageDimension)
133 if len(browserTools) > 0 {
134 tools = append(tools, browserTools...)
135 }
136 cleanup = browserCleanup
137 }
138
139 return &ToolSet{
140 tools: tools,
141 cleanup: cleanup,
142 wd: wd,
143 }
144}