toolset.go

  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		bashTool.Tool(),
130		patchTool.Tool(),
131		keywordTool.Tool(),
132		changeDirTool.Tool(),
133		outputIframeTool.Tool(),
134	}
135
136	// Add subagent tool if configured
137	if cfg.SubagentRunner != nil && cfg.SubagentDB != nil && cfg.ParentConversationID != "" {
138		subagentTool := &SubagentTool{
139			DB:                   cfg.SubagentDB,
140			ParentConversationID: cfg.ParentConversationID,
141			WorkingDir:           wd,
142			Runner:               cfg.SubagentRunner,
143		}
144		tools = append(tools, subagentTool.Tool())
145	}
146
147	var cleanup func()
148	if cfg.EnableBrowser {
149		// Get max image dimension from the LLM service
150		maxImageDimension := 0
151		if cfg.LLMProvider != nil && cfg.ModelID != "" {
152			if svc, err := cfg.LLMProvider.GetService(cfg.ModelID); err == nil {
153				maxImageDimension = svc.MaxImageDimension()
154			}
155		}
156		browserTools, browserCleanup := browse.RegisterBrowserTools(ctx, true, maxImageDimension)
157		if len(browserTools) > 0 {
158			tools = append(tools, browserTools...)
159		}
160		cleanup = browserCleanup
161	}
162
163	return &ToolSet{
164		tools:   tools,
165		cleanup: cleanup,
166		wd:      wd,
167	}
168}