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 // SubagentRunner is the runner for subagent conversations.
54 // If set, the subagent tool will be available.
55 SubagentRunner SubagentRunner
56 // SubagentDB is the database for subagent conversations.
57 SubagentDB SubagentDB
58 // ParentConversationID is the ID of the parent conversation (for subagent tool).
59 ParentConversationID string
60 // ConversationID is the ID of the conversation these tools belong to.
61 // This is exposed to bash commands via the SHELLEY_CONVERSATION_ID environment variable.
62 ConversationID string
63}
64
65// ToolSet holds a set of tools for a single conversation.
66// Each conversation should have its own ToolSet.
67type ToolSet struct {
68 tools []*llm.Tool
69 cleanup func()
70 wd *MutableWorkingDir
71}
72
73// Tools returns the tools in this set.
74func (ts *ToolSet) Tools() []*llm.Tool {
75 return ts.tools
76}
77
78// Cleanup releases resources held by the tools (e.g., browser).
79func (ts *ToolSet) Cleanup() {
80 if ts.cleanup != nil {
81 ts.cleanup()
82 }
83}
84
85// WorkingDir returns the shared working directory.
86func (ts *ToolSet) WorkingDir() *MutableWorkingDir {
87 return ts.wd
88}
89
90// NewToolSet creates a new set of tools for a conversation.
91// isStrongModel returns true for models that can handle complex tool schemas.
92func isStrongModel(modelID string) bool {
93 lower := strings.ToLower(modelID)
94 return strings.Contains(lower, "sonnet") || strings.Contains(lower, "opus")
95}
96
97func NewToolSet(ctx context.Context, cfg ToolSetConfig) *ToolSet {
98 workingDir := cfg.WorkingDir
99 if workingDir == "" {
100 workingDir = "/"
101 }
102 wd := NewMutableWorkingDir(workingDir)
103
104 bashTool := &BashTool{
105 WorkingDir: wd,
106 LLMProvider: cfg.LLMProvider,
107 EnableJITInstall: cfg.EnableJITInstall,
108 ConversationID: cfg.ConversationID,
109 }
110
111 // Use simplified patch schema for weaker models, full schema for sonnet/opus
112 simplified := !isStrongModel(cfg.ModelID)
113 patchTool := &PatchTool{
114 Simplified: simplified,
115 WorkingDir: wd,
116 ClipboardEnabled: true,
117 }
118
119 keywordTool := NewKeywordToolWithWorkingDir(cfg.LLMProvider, wd)
120
121 changeDirTool := &ChangeDirTool{
122 WorkingDir: wd,
123 OnChange: cfg.OnWorkingDirChange,
124 }
125
126 outputIframeTool := &OutputIframeTool{WorkingDir: wd}
127
128 tools := []*llm.Tool{
129 Think,
130 bashTool.Tool(),
131 patchTool.Tool(),
132 keywordTool.Tool(),
133 changeDirTool.Tool(),
134 outputIframeTool.Tool(),
135 }
136
137 // Add subagent tool if configured
138 if cfg.SubagentRunner != nil && cfg.SubagentDB != nil && cfg.ParentConversationID != "" {
139 subagentTool := &SubagentTool{
140 DB: cfg.SubagentDB,
141 ParentConversationID: cfg.ParentConversationID,
142 WorkingDir: wd,
143 Runner: cfg.SubagentRunner,
144 }
145 tools = append(tools, subagentTool.Tool())
146 }
147
148 var cleanup func()
149 if cfg.EnableBrowser {
150 // Get max image dimension from the LLM service
151 maxImageDimension := 0
152 if cfg.LLMProvider != nil && cfg.ModelID != "" {
153 if svc, err := cfg.LLMProvider.GetService(cfg.ModelID); err == nil {
154 maxImageDimension = svc.MaxImageDimension()
155 }
156 }
157 browserTools, browserCleanup := browse.RegisterBrowserTools(ctx, true, maxImageDimension)
158 if len(browserTools) > 0 {
159 tools = append(tools, browserTools...)
160 }
161 cleanup = browserCleanup
162 }
163
164 return &ToolSet{
165 tools: tools,
166 cleanup: cleanup,
167 wd: wd,
168 }
169}