on main help sign in
wip: remove old implementation
commitfa7fe6be3d2f5b9f00de5060faf9cc0b4aa14c33 authorkujtimiihoxha <kujtimii.h@gmail.com> dateThu 2 Oct 2025, 12:32 (1 year ago) parentc58a4d4
82 files changed+977−10274
go.mod+1−1 go.sum+2 internal/agent/agent-tool.go+98 internal/agent/agent.go+17−10 internal/agent/coder.go−18 internal/agent/common_test.go+4−4 internal/agent/coordinator.go+482−2 internal/agent/prompts.go+36 internal/agent/templates/agentTool.md+16 internal/agent/tools/mcp-tools.go+137−82 internal/app/app.go+32−33 internal/config/config.go+10−12 internal/config/load_test.go+6−6 internal/llm/agent/agent-tool.go−106 internal/llm/agent/agent.go−1133 internal/llm/agent/errors.go−15 internal/llm/agent/event.go−53 internal/llm/agent/mcp-tools.go−466 internal/llm/prompt/anthropic.md−108 internal/llm/prompt/coder.go−100 internal/llm/prompt/gemini.md−165 internal/llm/prompt/init.md−9 internal/llm/prompt/initialize.go−10 internal/llm/prompt/prompt.go−143 internal/llm/prompt/prompt_test.go−69 internal/llm/prompt/summarize.md−11 internal/llm/prompt/summarizer.go−10 internal/llm/prompt/task.go−15 internal/llm/prompt/title.go−10 internal/llm/prompt/title.md−8 internal/llm/prompt/v2.md−267 internal/llm/provider/anthropic.go−598 internal/llm/provider/azure.go−39 internal/llm/provider/bedrock.go−93 internal/llm/provider/gemini.go−579 internal/llm/provider/openai.go−604 internal/llm/provider/openai_test.go−166 internal/llm/provider/provider.go−208 internal/llm/provider/vertexai.go−40 internal/llm/tools/bash.go−395 internal/llm/tools/bash.md−161 internal/llm/tools/diagnostics.go−213 internal/llm/tools/diagnostics.md−21 internal/llm/tools/download.go−196 internal/llm/tools/download.md−34 internal/llm/tools/edit.go−486 internal/llm/tools/edit.md−60 internal/llm/tools/fetch.go−236 internal/llm/tools/fetch.md−34 internal/llm/tools/file.go−53 internal/llm/tools/glob.go−150 internal/llm/tools/glob.md−46 internal/llm/tools/grep.go−470 internal/llm/tools/grep.md−54 internal/llm/tools/grep_test.go−200 internal/llm/tools/ls.go−283 internal/llm/tools/ls.md−40 internal/llm/tools/multiedit.go−424 internal/llm/tools/multiedit.md−48 internal/llm/tools/rg.go−53 internal/llm/tools/safe.go−70 internal/llm/tools/sourcegraph.go−302 internal/llm/tools/sourcegraph.md−102 internal/llm/tools/tools.go−85 internal/llm/tools/view.go−343 internal/llm/tools/view.md−42 internal/llm/tools/write.go−208 internal/llm/tools/write.md−38 internal/tui/components/chat/chat.go+4−4 internal/tui/components/chat/editor/editor.go+3−3 internal/tui/components/chat/header/header.go+1−1 internal/tui/components/chat/messages/renderer.go+2−2 internal/tui/components/chat/messages/tool.go+2−2 internal/tui/components/chat/sidebar/sidebar.go+1−1 internal/tui/components/chat/splash/splash.go+3−3 internal/tui/components/dialogs/commands/commands.go+4−4 internal/tui/components/dialogs/compact/compact.go+46−150 internal/tui/components/dialogs/permissions/permissions.go+1−1 internal/tui/components/dialogs/reasoning/reasoning.go+1−1 internal/tui/components/mcp/mcp.go+6−6 internal/tui/page/chat/chat.go+27−21 internal/tui/tui.go+35−35
go.mod+1−1
@@ -97,7 +97,7 @@ require (
9797
9898require (
9999 github.com/anthropics/anthropic-sdk-go v1.12.0
100− github.com/charmbracelet/fantasy v0.0.0-20250930080945-d8adb55feb9a
100+ github.com/charmbracelet/fantasy v0.0.0-20251002051643-c96822199d77
101101 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
102102)
103103
go.sum+2
@@ -91,6 +91,8 @@ github.com/charmbracelet/fang v0.4.2 h1:nWr7Tb82/TTNNGMGG35aTZ1X68loAOQmpb0qxkKX
9191github.com/charmbracelet/fang v0.4.2/go.mod h1:wHJKQYO5ReYsxx+yZl+skDtrlKO/4LLEQ6EXsdHhRhg=
9292github.com/charmbracelet/fantasy v0.0.0-20250930080945-d8adb55feb9a h1:pD/ATiDbIRm8i5cKf8k1NR/0HhmSdAejY2kbIpmBiis=
9393github.com/charmbracelet/fantasy v0.0.0-20250930080945-d8adb55feb9a/go.mod h1:RZotHpq44tKZDe6Vf0kk1iDqnUgH7Scx+K/7uJ9Qwnw=
94+github.com/charmbracelet/fantasy v0.0.0-20251002051643-c96822199d77 h1:YHuUqaojkeu00YtQeXPqM/1RNJH/jqGNaQYFwa7JQTk=
95+github.com/charmbracelet/fantasy v0.0.0-20251002051643-c96822199d77/go.mod h1:RZotHpq44tKZDe6Vf0kk1iDqnUgH7Scx+K/7uJ9Qwnw=
9496github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 h1:PU4Zvpagsk5sgaDxn5W4sxHuLp9QRMBZB3bFSk40A4w=
9597github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018/go.mod h1:Z/GLmp9fzaqX4ze3nXG7StgWez5uBM5XtlLHK8V/qSk=
9698github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea h1:g1HfUgSMvye8mgecMD1mPscpt+pzJoDEiSA+p2QXzdQ=
internal/agent/agent-tool.gonew file+98
@@ -0,0 +1,98 @@
1+package agent
2+
3+import (
4+ "context"
5+ _ "embed"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+
10+ "github.com/charmbracelet/fantasy/ai"
11+
12+ "github.com/charmbracelet/crush/internal/agent/prompt"
13+ "github.com/charmbracelet/crush/internal/agent/tools"
14+ "github.com/charmbracelet/crush/internal/config"
15+)
16+
17+//go:embed templates/agentTool.md
18+var agentToolDescription []byte
19+
20+type AgentParams struct {
21+ Prompt string `json:"prompt" description:"The task for the agent to perform"`
22+}
23+
24+const (
25+ AgentToolName = "agent"
26+)
27+
28+func (c *coordinator) agentTool() (ai.AgentTool, error) {
29+ agentCfg, ok := c.cfg.Agents[config.AgentTask]
30+ if !ok {
31+ return nil, errors.New("task agent not configured")
32+ }
33+ prompt, err := taskPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
34+ if err != nil {
35+ return nil, err
36+ }
37+
38+ agent, err := c.buildAgent(prompt, agentCfg)
39+ if err != nil {
40+ return nil, err
41+ }
42+ return ai.NewAgentTool(
43+ AgentToolName,
44+ string(agentToolDescription),
45+ func(ctx context.Context, params AgentParams, call ai.ToolCall) (ai.ToolResponse, error) {
46+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
47+ return ai.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
48+ }
49+ if params.Prompt == "" {
50+ return ai.NewTextErrorResponse("prompt is required"), nil
51+ }
52+
53+ sessionID := tools.GetSessionFromContext(ctx)
54+ if sessionID == "" {
55+ return ai.ToolResponse{}, errors.New("session id missing from context")
56+ }
57+
58+ session, err := c.sessions.CreateTaskSession(ctx, call.ID, sessionID, "New Agent Session")
59+ if err != nil {
60+ return ai.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
61+ }
62+ model := agent.Model()
63+ maxTokens := model.CatwalkCfg.DefaultMaxTokens
64+ if model.ModelCfg.MaxTokens != 0 {
65+ maxTokens = model.ModelCfg.MaxTokens
66+ }
67+ result, err := agent.Run(ctx, SessionAgentCall{
68+ SessionID: sessionID,
69+ Prompt: params.Prompt,
70+ MaxOutputTokens: maxTokens,
71+ ProviderOptions: c.getProviderOptions(model),
72+ Temperature: model.ModelCfg.Temperature,
73+ TopP: model.ModelCfg.TopP,
74+ TopK: model.ModelCfg.TopK,
75+ FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
76+ PresencePenalty: model.ModelCfg.PresencePenalty,
77+ })
78+ if err != nil {
79+ return ai.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
80+ }
81+ updatedSession, err := c.sessions.Get(ctx, session.ID)
82+ if err != nil {
83+ return ai.ToolResponse{}, fmt.Errorf("error getting session: %s", err)
84+ }
85+ parentSession, err := c.sessions.Get(ctx, sessionID)
86+ if err != nil {
87+ return ai.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err)
88+ }
89+
90+ parentSession.Cost += updatedSession.Cost
91+
92+ _, err = c.sessions.Save(ctx, parentSession)
93+ if err != nil {
94+ return ai.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err)
95+ }
96+ return ai.NewTextResponse(result.Response.Content.Text()), nil
97+ }), nil
98+}
internal/agent/agent.go+17−10
@@ -12,6 +12,7 @@ import (
1212
1313 "github.com/charmbracelet/catwalk/pkg/catwalk"
1414 "github.com/charmbracelet/crush/internal/agent/tools"
15+ "github.com/charmbracelet/crush/internal/config"
1516 "github.com/charmbracelet/crush/internal/csync"
1617 "github.com/charmbracelet/crush/internal/message"
1718 "github.com/charmbracelet/crush/internal/permission"
@@ -50,11 +51,13 @@ type SessionAgent interface {
5051 QueuedPrompts(sessionID string) int
5152 ClearQueue(sessionID string)
5253 Summarize(context.Context, string) error
54+ Model() Model
5355}
5456
5557type Model struct {
56− model ai.LanguageModel
57− config catwalk.Model
58+ Model ai.LanguageModel
59+ CatwalkCfg catwalk.Model
60+ ModelCfg config.SelectedModel
5861}
5962
6063type sessionAgent struct {
@@ -116,7 +119,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*ai.Agen
116119 }
117120
118121 agent := ai.NewAgent(
119− a.largeModel.model,
122+ a.largeModel.Model,
120123 ai.WithSystemPrompt(a.systemPrompt),
121124 ai.WithTools(a.tools...),
122125 )
@@ -174,8 +177,8 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*ai.Agen
174177 assistantMsg, err = a.messages.Create(genCtx, call.SessionID, message.CreateMessageParams{
175178 Role: message.Assistant,
176179 Parts: []message.ContentPart{},
177− Model: a.largeModel.model.Model(),
178− Provider: a.largeModel.model.Provider(),
180+ Model: a.largeModel.ModelCfg.Model,
181+ Provider: a.largeModel.ModelCfg.Provider,
179182 })
180183 if err != nil {
181184 return prepared, err
@@ -384,13 +387,13 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string) error {
384387 defer a.activeRequests.Del(sessionID)
385388 defer cancel()
386389
387− agent := ai.NewAgent(a.largeModel.model,
390+ agent := ai.NewAgent(a.largeModel.Model,
388391 ai.WithSystemPrompt(string(summaryPrompt)),
389392 )
390393 summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
391394 Role: message.Assistant,
392− Model: a.largeModel.model.Model(),
393− Provider: a.largeModel.model.Provider(),
395+ Model: a.largeModel.Model.Model(),
396+ Provider: a.largeModel.Model.Provider(),
394397 })
395398 if err != nil {
396399 return err
@@ -516,7 +519,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, session session.Sessio
516519 return
517520 }
518521
519− agent := ai.NewAgent(a.smallModel.model,
522+ agent := ai.NewAgent(a.smallModel.Model,
520523 ai.WithSystemPrompt(string(titlePrompt)),
521524 ai.WithMaxOutputTokens(40),
522525 )
@@ -554,7 +557,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, session session.Sessio
554557}
555558
556559func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage ai.Usage) {
557− modelConfig := model.config
560+ modelConfig := model.CatwalkCfg
558561 cost := modelConfig.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
559562 modelConfig.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
560563 modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
@@ -641,3 +644,7 @@ func (a *sessionAgent) SetModels(large Model, small Model) {
641644func (a *sessionAgent) SetTools(tools []ai.AgentTool) {
642645 a.tools = tools
643646}
647+
648+func (a *sessionAgent) Model() Model {
649+ return a.largeModel
650+}
internal/agent/coder.godeleted−18
@@ -1,18 +0,0 @@
1−package agent
2−
3−import (
4− _ "embed"
5−
6− "github.com/charmbracelet/crush/internal/agent/prompt"
7−)
8−
9−//go:embed templates/coder.gotmpl
10−var coderPromptTmpl []byte
11−
12−func coderPrompt(opts ...prompt.Option) (*prompt.Prompt, error) {
13− systemPrompt, err := prompt.NewPrompt("coder", string(coderPromptTmpl), opts...)
14− if err != nil {
15− return nil, err
16− }
17− return systemPrompt, nil
18−}
internal/agent/common_test.go+4−4
@@ -114,14 +114,14 @@ func testEnv(t *testing.T) env {
114114
115115func testSessionAgent(env env, large, small ai.LanguageModel, systemPrompt string, tools ...ai.AgentTool) SessionAgent {
116116 largeModel := Model{
117− model: large,
118− config: catwalk.Model{
117+ Model: large,
118+ CatwalkCfg: catwalk.Model{
119119 // todo: add values
120120 },
121121 }
122122 smallModel := Model{
123− model: small,
124− config: catwalk.Model{
123+ Model: small,
124+ CatwalkCfg: catwalk.Model{
125125 // todo: add values
126126 },
127127 }
internal/agent/coordinator.go+482−2
@@ -2,19 +2,499 @@ package agent
22
33import (
44 "context"
5+ "errors"
6+ "slices"
7+ "strings"
58
9+ "github.com/charmbracelet/catwalk/pkg/catwalk"
10+ "github.com/charmbracelet/crush/internal/agent/prompt"
11+ "github.com/charmbracelet/crush/internal/agent/tools"
612 "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/csync"
14+ "github.com/charmbracelet/crush/internal/history"
15+ "github.com/charmbracelet/crush/internal/log"
16+ "github.com/charmbracelet/crush/internal/lsp"
717 "github.com/charmbracelet/crush/internal/message"
18+ "github.com/charmbracelet/crush/internal/permission"
19+ "github.com/charmbracelet/crush/internal/session"
820 "github.com/charmbracelet/fantasy/ai"
21+ "github.com/charmbracelet/fantasy/anthropic"
22+ "github.com/charmbracelet/fantasy/google"
23+ "github.com/charmbracelet/fantasy/openai"
24+ "github.com/charmbracelet/fantasy/openaicompat"
25+ "github.com/charmbracelet/fantasy/openrouter"
926)
1027
1128type Coordinator interface {
1229 // INFO: (kujtim) this is not used yet we will use this when we have multiple agents
13− SetMainAgent(string)
30+ // SetMainAgent(string)
1431 Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*ai.AgentResult, error)
32+ Cancel(sessionID string)
33+ CancelAll()
34+ IsSessionBusy(sessionID string) bool
35+ IsBusy() bool
36+ QueuedPrompts(sessionID string) int
37+ ClearQueue(sessionID string)
38+ Summarize(context.Context, string) error
39+ Model() Model
40+ UpdateModels() error
1541}
1642
1743type coordinator struct {
18− cfg *config.Config
44+ cfg *config.Config
45+ sessions session.Service
46+ messages message.Service
47+ permissions permission.Service
48+ history history.Service
49+ lspClients *csync.Map[string, *lsp.Client]
50+
1951 currentAgent SessionAgent
52+ agents map[string]SessionAgent
53+}
54+
55+func NewCoordinator(
56+ cfg *config.Config,
57+ sessions session.Service,
58+ messages message.Service,
59+ permissions permission.Service,
60+ history history.Service,
61+ lspClients *csync.Map[string, *lsp.Client],
62+) (Coordinator, error) {
63+ c := &coordinator{
64+ cfg: cfg,
65+ sessions: sessions,
66+ messages: messages,
67+ permissions: permissions,
68+ history: history,
69+ lspClients: lspClients,
70+ agents: make(map[string]SessionAgent),
71+ }
72+
73+ agentCfg, ok := cfg.Agents[config.AgentCoder]
74+ if !ok {
75+ return nil, errors.New("coder agent not configured")
76+ }
77+
78+ // TODO: make this dynamic when we support multiple agents
79+ prompt, err := coderPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
80+ if err != nil {
81+ return nil, err
82+ }
83+
84+ agent, err := c.buildAgent(prompt, agentCfg)
85+ if err != nil {
86+ return nil, err
87+ }
88+ c.currentAgent = agent
89+ c.agents[config.AgentCoder] = agent
90+ return c, nil
91+}
92+
93+// Run implements Coordinator.
94+func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*ai.AgentResult, error) {
95+ model := c.currentAgent.Model()
96+ maxTokens := model.CatwalkCfg.DefaultMaxTokens
97+ if model.ModelCfg.MaxTokens != 0 {
98+ maxTokens = model.ModelCfg.MaxTokens
99+ }
100+
101+ if !model.CatwalkCfg.SupportsImages && attachments != nil {
102+ attachments = nil
103+ }
104+
105+ return c.currentAgent.Run(ctx, SessionAgentCall{
106+ SessionID: sessionID,
107+ Prompt: prompt,
108+ Attachments: attachments,
109+ MaxOutputTokens: maxTokens,
110+ ProviderOptions: c.getProviderOptions(model),
111+ Temperature: model.ModelCfg.Temperature,
112+ TopP: model.ModelCfg.TopP,
113+ TopK: model.ModelCfg.TopK,
114+ FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
115+ PresencePenalty: model.ModelCfg.PresencePenalty,
116+ })
117+}
118+
119+func (c *coordinator) getProviderOptions(model Model) ai.ProviderOptions {
120+ options := ai.ProviderOptions{}
121+
122+ switch model.Model.Provider() {
123+ case openai.Name:
124+ parsed, err := openai.ParseOptions(model.ModelCfg.ProviderOptions)
125+ if err == nil {
126+ options[openai.Name] = parsed
127+ }
128+ case anthropic.Name:
129+ parsed, err := anthropic.ParseOptions(model.ModelCfg.ProviderOptions)
130+ if err == nil {
131+ options[anthropic.Name] = parsed
132+ }
133+ case openrouter.Name:
134+ parsed, err := openrouter.ParseOptions(model.ModelCfg.ProviderOptions)
135+ if err == nil {
136+ options[openrouter.Name] = parsed
137+ }
138+ case google.Name:
139+ parsed, err := google.ParseOptions(model.ModelCfg.ProviderOptions)
140+ if err == nil {
141+ options[google.Name] = parsed
142+ }
143+ case openaicompat.Name:
144+ parsed, err := openaicompat.ParseOptions(model.ModelCfg.ProviderOptions)
145+ if err == nil {
146+ options[openaicompat.Name] = parsed
147+ }
148+ }
149+
150+ return options
151+}
152+
153+func (c *coordinator) buildAgent(prompt *prompt.Prompt, agent config.Agent) (SessionAgent, error) {
154+ large, small, err := c.buildAgentModels()
155+ if err != nil {
156+ return nil, err
157+ }
158+
159+ systemPrompt, err := prompt.Build(large.Model.Provider(), large.Model.Model(), *c.cfg)
160+ if err != nil {
161+ return nil, err
162+ }
163+
164+ tools, err := c.buildTools(agent)
165+ if err != nil {
166+ return nil, err
167+ }
168+ return NewSessionAgent(large, small, systemPrompt, c.sessions, c.messages, tools...), nil
169+}
170+
171+func (c *coordinator) buildTools(agent config.Agent) ([]ai.AgentTool, error) {
172+ var allTools []ai.AgentTool
173+ if slices.Contains(agent.AllowedTools, AgentToolName) {
174+ agentTool, err := c.agentTool()
175+ if err != nil {
176+ return nil, err
177+ }
178+ allTools = append(allTools, agentTool)
179+ }
180+
181+ allTools = append(allTools,
182+ tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Options.Attribution),
183+ tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
184+ tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
185+ tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
186+ tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
187+ tools.NewGlobTool(c.cfg.WorkingDir()),
188+ tools.NewGrepTool(c.cfg.WorkingDir()),
189+ tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls),
190+ tools.NewSourcegraphTool(nil),
191+ tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
192+ tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
193+ )
194+
195+ var filteredTools []ai.AgentTool
196+ for _, tool := range allTools {
197+ if slices.Contains(agent.AllowedTools, tool.Info().Name) {
198+ filteredTools = append(filteredTools, tool)
199+ }
200+ }
201+
202+ mcpTools := tools.GetMCPTools(context.Background(), c.permissions, c.cfg)
203+
204+ for _, mcpTool := range mcpTools {
205+ if agent.AllowedMCP == nil {
206+ // No MCP restrictions
207+ filteredTools = append(filteredTools, mcpTool)
208+ } else if len(agent.AllowedMCP) == 0 {
209+ // no mcps allowed
210+ break
211+ }
212+
213+ for mcp, tools := range agent.AllowedMCP {
214+ if mcp == mcpTool.MCP() {
215+ if len(tools) == 0 {
216+ filteredTools = append(filteredTools, mcpTool)
217+ }
218+ for _, t := range tools {
219+ if t == mcpTool.MCPToolName() {
220+ filteredTools = append(filteredTools, mcpTool)
221+ }
222+ }
223+ break
224+ }
225+ }
226+ }
227+
228+ return filteredTools, nil
229+}
230+
231+// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
232+func (c *coordinator) buildAgentModels() (Model, Model, error) {
233+ largeModelCfg, ok := c.cfg.Models[config.SelectedModelTypeLarge]
234+ if !ok {
235+ return Model{}, Model{}, errors.New("large model not selected")
236+ }
237+ smallModelCfg, ok := c.cfg.Models[config.SelectedModelTypeSmall]
238+ if !ok {
239+ return Model{}, Model{}, errors.New("small model not selected")
240+ }
241+
242+ largeProviderCfg, ok := c.cfg.Providers.Get(largeModelCfg.Provider)
243+ if !ok {
244+ return Model{}, Model{}, errors.New("large model provider not configured")
245+ }
246+
247+ largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg)
248+ if err != nil {
249+ return Model{}, Model{}, err
250+ }
251+
252+ smallProviderCfg, ok := c.cfg.Providers.Get(smallModelCfg.Provider)
253+ if !ok {
254+ return Model{}, Model{}, errors.New("large model provider not configured")
255+ }
256+
257+ smallProvider, err := c.buildProvider(smallProviderCfg, largeModelCfg)
258+ if err != nil {
259+ return Model{}, Model{}, err
260+ }
261+
262+ var largeCatwalkModel *catwalk.Model
263+ var smallCatwalkModel *catwalk.Model
264+
265+ for _, m := range largeProviderCfg.Models {
266+ if m.ID == largeModelCfg.Model {
267+ largeCatwalkModel = &m
268+ }
269+ }
270+ for _, m := range smallProviderCfg.Models {
271+ if m.ID == smallModelCfg.Model {
272+ smallCatwalkModel = &m
273+ }
274+ }
275+
276+ if largeCatwalkModel == nil {
277+ return Model{}, Model{}, errors.New("large model not found in provider config")
278+ }
279+
280+ if smallCatwalkModel == nil {
281+ return Model{}, Model{}, errors.New("snall model not found in provider config")
282+ }
283+
284+ largeModel, err := largeProvider.LanguageModel(largeModelCfg.Model)
285+ if err != nil {
286+ return Model{}, Model{}, err
287+ }
288+ smallModel, err := smallProvider.LanguageModel(smallModelCfg.Model)
289+ if err != nil {
290+ return Model{}, Model{}, err
291+ }
292+
293+ return Model{
294+ Model: largeModel,
295+ CatwalkCfg: *largeCatwalkModel,
296+ ModelCfg: largeModelCfg,
297+ }, Model{
298+ Model: smallModel,
299+ CatwalkCfg: *smallCatwalkModel,
300+ ModelCfg: smallModelCfg,
301+ }, nil
302+}
303+
304+func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) ai.Provider {
305+ hasBearerAuth := false
306+ for key := range headers {
307+ if strings.ToLower(key) == "authorization" {
308+ hasBearerAuth = true
309+ break
310+ }
311+ }
312+ if hasBearerAuth {
313+ apiKey = "" // clear apiKey to avoid using X-Api-Key header
314+ }
315+
316+ var opts []anthropic.Option
317+
318+ if apiKey != "" {
319+ // Use standard X-Api-Key header
320+ opts = append(opts, anthropic.WithAPIKey(apiKey))
321+ }
322+
323+ if len(headers) > 0 {
324+ opts = append(opts, anthropic.WithHeaders(headers))
325+ }
326+
327+ if baseURL != "" {
328+ opts = append(opts, anthropic.WithBaseURL(baseURL))
329+ }
330+
331+ if c.cfg.Options.Debug {
332+ httpClient := log.NewHTTPClient()
333+ opts = append(opts, anthropic.WithHTTPClient(httpClient))
334+ }
335+
336+ return anthropic.New(opts...)
337+}
338+
339+func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) ai.Provider {
340+ opts := []openai.Option{
341+ openai.WithAPIKey(apiKey),
342+ }
343+ if c.cfg.Options.Debug {
344+ httpClient := log.NewHTTPClient()
345+ opts = append(opts, openai.WithHTTPClient(httpClient))
346+ }
347+ if len(headers) > 0 {
348+ opts = append(opts, openai.WithHeaders(headers))
349+ }
350+ if baseURL != "" {
351+ opts = append(opts, openai.WithBaseURL(baseURL))
352+ }
353+ return openai.New(opts...)
354+}
355+
356+func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) ai.Provider {
357+ opts := []openrouter.Option{
358+ openrouter.WithAPIKey(apiKey),
359+ openrouter.WithLanguageUniqueToolCallIds(),
360+ }
361+ if c.cfg.Options.Debug {
362+ httpClient := log.NewHTTPClient()
363+ opts = append(opts, openrouter.WithHTTPClient(httpClient))
364+ }
365+ if len(headers) > 0 {
366+ opts = append(opts, openrouter.WithHeaders(headers))
367+ }
368+ return openrouter.New(opts...)
369+}
370+
371+func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string) ai.Provider {
372+ opts := []openaicompat.Option{
373+ openaicompat.WithAPIKey(apiKey),
374+ }
375+ if c.cfg.Options.Debug {
376+ httpClient := log.NewHTTPClient()
377+ opts = append(opts, openaicompat.WithHTTPClient(httpClient))
378+ }
379+ if len(headers) > 0 {
380+ opts = append(opts, openaicompat.WithHeaders(headers))
381+ }
382+
383+ return openaicompat.New(baseURL, opts...)
384+}
385+
386+// TODO: add baseURL for google
387+func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) ai.Provider {
388+ opts := []google.Option{
389+ google.WithAPIKey(apiKey),
390+ }
391+ if c.cfg.Options.Debug {
392+ httpClient := log.NewHTTPClient()
393+ opts = append(opts, google.WithHTTPClient(httpClient))
394+ }
395+ if len(headers) > 0 {
396+ opts = append(opts, google.WithHeaders(headers))
397+ }
398+ return google.New(opts...)
399+}
400+
401+func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
402+ if model.Think {
403+ return true
404+ }
405+
406+ if model.ProviderOptions == nil {
407+ return false
408+ }
409+
410+ opts, err := anthropic.ParseOptions(model.ProviderOptions)
411+ if err != nil {
412+ return false
413+ }
414+ if opts.Thinking != nil {
415+ return true
416+ }
417+ return false
418+}
419+
420+func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (ai.Provider, error) {
421+ headers := providerCfg.ExtraHeaders
422+
423+ // handle special headers for anthropic
424+ if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
425+ headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
426+ }
427+
428+ // TODO: make sure we have
429+ apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
430+ baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
431+ var provider ai.Provider
432+ switch providerCfg.Type {
433+ case openai.Name:
434+ provider = c.buildOpenaiProvider(baseURL, apiKey, headers)
435+ case anthropic.Name:
436+ provider = c.buildAnthropicProvider(baseURL, apiKey, headers)
437+ case openrouter.Name:
438+ provider = c.buildOpenrouterProvider(baseURL, apiKey, headers)
439+ case google.Name:
440+ provider = c.buildGoogleProvider(baseURL, apiKey, headers)
441+ case openaicompat.Name:
442+ provider = c.buildOpenaiCompatProvider(baseURL, apiKey, headers)
443+ default:
444+ return nil, errors.New("provider type not supported")
445+ }
446+ return provider, nil
447+}
448+
449+func (c *coordinator) Cancel(sessionID string) {
450+ c.currentAgent.Cancel(sessionID)
451+}
452+
453+func (c *coordinator) CancelAll() {
454+ c.currentAgent.CancelAll()
455+}
456+
457+func (c *coordinator) ClearQueue(sessionID string) {
458+ c.currentAgent.ClearQueue(sessionID)
459+}
460+
461+func (c *coordinator) IsBusy() bool {
462+ return c.currentAgent.IsBusy()
463+}
464+
465+func (c *coordinator) IsSessionBusy(sessionID string) bool {
466+ return c.currentAgent.IsSessionBusy(sessionID)
467+}
468+
469+func (c *coordinator) Model() Model {
470+ return c.currentAgent.Model()
471+}
472+
473+func (c *coordinator) UpdateModels() error {
474+ // build the models again so we make sure we get the latest config
475+ large, small, err := c.buildAgentModels()
476+ if err != nil {
477+ return err
478+ }
479+ c.currentAgent.SetModels(large, small)
480+
481+ agentCfg, ok := c.cfg.Agents[config.AgentCoder]
482+ if !ok {
483+ return errors.New("coder agent not configured")
484+ }
485+
486+ tools, err := c.buildTools(agentCfg)
487+ if err != nil {
488+ return err
489+ }
490+ c.currentAgent.SetTools(tools)
491+ return nil
492+}
493+
494+func (c *coordinator) QueuedPrompts(sessionID string) int {
495+ return c.currentAgent.QueuedPrompts(sessionID)
496+}
497+
498+func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
499+ return c.currentAgent.Summarize(ctx, sessionID)
20500}
internal/agent/prompts.gonew file+36
@@ -0,0 +1,36 @@
1+package agent
2+
3+import (
4+ _ "embed"
5+
6+ "github.com/charmbracelet/crush/internal/agent/prompt"
7+)
8+
9+//go:embed templates/coder.gotmpl
10+var coderPromptTmpl []byte
11+
12+//go:embed templates/task.gotmpl
13+var taskPromptTmpl []byte
14+
15+//go:embed templates/initialize.md
16+var initializePrompt []byte
17+
18+func coderPrompt(opts ...prompt.Option) (*prompt.Prompt, error) {
19+ systemPrompt, err := prompt.NewPrompt("coder", string(coderPromptTmpl), opts...)
20+ if err != nil {
21+ return nil, err
22+ }
23+ return systemPrompt, nil
24+}
25+
26+func taskPrompt(opts ...prompt.Option) (*prompt.Prompt, error) {
27+ systemPrompt, err := prompt.NewPrompt("task", string(taskPromptTmpl), opts...)
28+ if err != nil {
29+ return nil, err
30+ }
31+ return systemPrompt, nil
32+}
33+
34+func InitializePrompt() string {
35+ return string(initializePrompt)
36+}
internal/agent/templates/agentTool.mdnew file+16
@@ -0,0 +1,16 @@
1+Launch a new agent that has access to the following tools: GlobTool, GrepTool, LS, View. When you are searching for a keyword or file and are not confident that you will find the right match on the first try, use the Agent tool to perform the search for you.
2+<usage>
3+
4+- If you are searching for a keyword like "config" or "logger", or for questions like "which file does X?", the Agent tool is strongly recommended
5+- If you want to read a specific file path, use the View or GlobTool tool instead of the Agent tool, to find the match more quickly
6+- If you are searching for a specific class definition like "class Foo", use the GlobTool tool instead, to find the match more quickly
7+ </usage>
8+
9+<usage_notes>
10+
11+1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
12+2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.
13+3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
14+4. The agent's outputs should generally be trusted
15+5. IMPORTANT: The agent can not use Bash, Replace, Edit, so can not modify files. If you want to use these tools, use them directly instead of going through the agent.
16+ </usage_notes>
internal/agent/tools/mcp-tools.go+137−82
@@ -54,7 +54,8 @@ func (s MCPState) String() string {
5454type MCPEventType string
5555
5656const (
57− MCPEventStateChanged MCPEventType = "state_changed"
57+ MCPEventStateChanged MCPEventType = "state_changed"
58+ MCPEventToolsListChanged MCPEventType = "tools_list_changed"
5859)
5960
6061// MCPEvent represents an event in the MCP system
@@ -77,11 +78,12 @@ type MCPClientInfo struct {
7778}
7879
7980var (
80− mcpToolsOnce sync.Once
81− mcpTools []ai.AgentTool
82− mcpClients = csync.NewMap[string, *client.Client]()
83− mcpStates = csync.NewMap[string, MCPClientInfo]()
84− mcpBroker = pubsub.NewBroker[MCPEvent]()
81+ mcpToolsOnce sync.Once
82+ mcpTools = csync.NewMap[string, *McpTool]()
83+ mcpClient2Tools = csync.NewMap[string, []*McpTool]()
84+ mcpClients = csync.NewMap[string, *client.Client]()
85+ mcpStates = csync.NewMap[string, MCPClientInfo]()
86+ mcpBroker = pubsub.NewBroker[MCPEvent]()
8587)
8688
8789type McpTool struct {
@@ -100,22 +102,30 @@ func (m *McpTool) ProviderOptions() ai.ProviderOptions {
100102 return m.providerOptions
101103}
102104
103−func (b *McpTool) Name() string {
104− return fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name)
105+func (m *McpTool) Name() string {
106+ return fmt.Sprintf("mcp_%s_%s", m.mcpName, m.tool.Name)
105107}
106108
107−func (b *McpTool) Info() ai.ToolInfo {
108− required := b.tool.InputSchema.Required
109+func (m *McpTool) MCP() string {
110+ return m.mcpName
111+}
112+
113+func (m *McpTool) MCPToolName() string {
114+ return m.tool.Name
115+}
116+
117+func (m *McpTool) Info() ai.ToolInfo {
118+ required := m.tool.InputSchema.Required
109119 if required == nil {
110120 required = make([]string, 0)
111121 }
112− parameters := b.tool.InputSchema.Properties
122+ parameters := m.tool.InputSchema.Properties
113123 if parameters == nil {
114124 parameters = make(map[string]any)
115125 }
116126 return ai.ToolInfo{
117− Name: fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name),
118− Description: b.tool.Description,
127+ Name: fmt.Sprintf("mcp_%s_%s", m.mcpName, m.tool.Name),
128+ Description: m.tool.Description,
119129 Parameters: parameters,
120130 Required: required,
121131 }
@@ -181,18 +191,18 @@ func getOrRenewClient(ctx context.Context, name string) (*client.Client, error)
181191 return c, nil
182192}
183193
184−func (b *McpTool) Run(ctx context.Context, params ai.ToolCall) (ai.ToolResponse, error) {
194+func (m *McpTool) Run(ctx context.Context, params ai.ToolCall) (ai.ToolResponse, error) {
185195 sessionID := GetSessionFromContext(ctx)
186196 if sessionID == "" {
187197 return ai.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
188198 }
189− permissionDescription := fmt.Sprintf("execute %s with the following parameters:", b.Info().Name)
190− p := b.permissions.Request(
199+ permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name)
200+ p := m.permissions.Request(
191201 permission.CreatePermissionRequest{
192202 SessionID: sessionID,
193203 ToolCallID: params.ID,
194− Path: b.workingDir,
195− ToolName: b.Info().Name,
204+ Path: m.workingDir,
205+ ToolName: m.Info().Name,
196206 Action: "execute",
197207 Description: permissionDescription,
198208 Params: params.Input,
@@ -202,19 +212,15 @@ func (b *McpTool) Run(ctx context.Context, params ai.ToolCall) (ai.ToolResponse,
202212 return ai.ToolResponse{}, permission.ErrorPermissionDenied
203213 }
204214
205− return runTool(ctx, b.mcpName, b.tool.Name, params.Input)
215+ return runTool(ctx, m.mcpName, m.tool.Name, params.Input)
206216}
207217
208−func getTools(ctx context.Context, name string, permissions permission.Service, c *client.Client, workingDir string) []ai.AgentTool {
218+func getTools(ctx context.Context, name string, permissions permission.Service, c *client.Client, workingDir string) ([]*McpTool, error) {
209219 result, err := c.ListTools(ctx, mcp.ListToolsRequest{})
210220 if err != nil {
211− slog.Error("error listing tools", "error", err)
212− updateMCPState(name, MCPStateError, err, nil, 0)
213− c.Close()
214− mcpClients.Del(name)
215− return nil
221+ return nil, err
216222 }
217− mcpTools := make([]ai.AgentTool, 0, len(result.Tools))
223+ mcpTools := make([]*McpTool, 0, len(result.Tools))
218224 for _, tool := range result.Tools {
219225 mcpTools = append(mcpTools, &McpTool{
220226 mcpName: name,
@@ -223,7 +229,7 @@ func getTools(ctx context.Context, name string, permissions permission.Service,
223229 workingDir: workingDir,
224230 })
225231 }
226− return mcpTools
232+ return mcpTools, nil
227233}
228234
229235// SubscribeMCPEvents returns a channel for MCP events
@@ -250,8 +256,12 @@ func updateMCPState(name string, state MCPState, err error, client *client.Clien
250256 Client: client,
251257 ToolCount: toolCount,
252258 }
253− if state == MCPStateConnected {
259+ switch state {
260+ case MCPStateConnected:
254261 info.ConnectedAt = time.Now()
262+ case MCPStateError:
263+ updateMcpTools(name, nil)
264+ mcpClients.Del(name)
255265 }
256266 mcpStates.Set(name, info)
257267
@@ -265,6 +275,14 @@ func updateMCPState(name string, state MCPState, err error, client *client.Clien
265275 })
266276}
267277
278+// publishMCPEventToolsListChanged publishes a tool list changed event
279+func publishMCPEventToolsListChanged(name string) {
280+ mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{
281+ Type: MCPEventToolsListChanged,
282+ Name: name,
283+ })
284+}
285+
268286// CloseMCPClients closes all MCP clients. This should be called during application shutdown.
269287func CloseMCPClients() error {
270288 var errs []error
@@ -287,55 +305,79 @@ var mcpInitRequest = mcp.InitializeRequest{
287305 },
288306}
289307
290−func GetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []ai.AgentTool {
291− var wg sync.WaitGroup
292− result := csync.NewSlice[ai.AgentTool]()
293−
294− // Initialize states for all configured MCPs
295− for name, m := range cfg.MCP {
296− if m.Disabled {
297− updateMCPState(name, MCPStateDisabled, nil, nil, 0)
298− slog.Debug("skipping disabled mcp", "name", name)
299− continue
300− }
308+func GetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []*McpTool {
309+ mcpToolsOnce.Do(func() {
310+ var wg sync.WaitGroup
311+ // Initialize states for all configured MCPs
312+ for name, m := range cfg.MCP {
313+ if m.Disabled {
314+ updateMCPState(name, MCPStateDisabled, nil, nil, 0)
315+ slog.Debug("skipping disabled mcp", "name", name)
316+ continue
317+ }
301318
302− // Set initial starting state
303− updateMCPState(name, MCPStateStarting, nil, nil, 0)
304−
305− wg.Add(1)
306− go func(name string, m config.MCPConfig) {
307− defer func() {
308− wg.Done()
309− if r := recover(); r != nil {
310− var err error
311− switch v := r.(type) {
312− case error:
313− err = v
314− case string:
315− err = fmt.Errorf("panic: %s", v)
316− default:
317− err = fmt.Errorf("panic: %v", v)
319+ // Set initial starting state
320+ updateMCPState(name, MCPStateStarting, nil, nil, 0)
321+
322+ wg.Add(1)
323+ go func(name string, m config.MCPConfig) {
324+ defer func() {
325+ wg.Done()
326+ if r := recover(); r != nil {
327+ var err error
328+ switch v := r.(type) {
329+ case error:
330+ err = v
331+ case string:
332+ err = fmt.Errorf("panic: %s", v)
333+ default:
334+ err = fmt.Errorf("panic: %v", v)
335+ }
336+ updateMCPState(name, MCPStateError, err, nil, 0)
337+ slog.Error("panic in mcp client initialization", "error", err, "name", name)
318338 }
339+ }()
340+
341+ mcpCtx, cancel := context.WithTimeout(ctx, mcpTimeout(m))
342+ defer cancel()
343+
344+ c, err := createAndInitializeClient(mcpCtx, name, m, cfg.Resolver())
345+ if err != nil {
346+ return
347+ }
348+
349+ mcpClients.Set(name, c)
350+
351+ tools, err := getTools(mcpCtx, name, permissions, c, cfg.WorkingDir())
352+ if err != nil {
353+ slog.Error("error listing tools", "error", err)
319354 updateMCPState(name, MCPStateError, err, nil, 0)
320− slog.Error("panic in mcp client initialization", "error", err, "name", name)
355+ c.Close()
356+ return
321357 }
322− }()
323358
324− ctx, cancel := context.WithTimeout(ctx, mcpTimeout(m))
325− defer cancel()
326− c, err := createAndInitializeClient(ctx, name, m, cfg.Resolver())
327− if err != nil {
328− return
329− }
330− mcpClients.Set(name, c)
359+ updateMcpTools(name, tools)
360+ mcpClients.Set(name, c)
361+ updateMCPState(name, MCPStateConnected, nil, c, len(tools))
362+ }(name, m)
363+ }
364+ wg.Wait()
365+ })
366+ return slices.Collect(mcpTools.Seq())
367+}
331368
332− tools := getTools(ctx, name, permissions, c, cfg.WorkingDir())
333− updateMCPState(name, MCPStateConnected, nil, c, len(tools))
334− result.Append(tools...)
335− }(name, m)
369+// updateMcpTools updates the global mcpTools and mcpClient2Tools maps
370+func updateMcpTools(mcpName string, tools []*McpTool) {
371+ if len(tools) == 0 {
372+ mcpClient2Tools.Del(mcpName)
373+ } else {
374+ mcpClient2Tools.Set(mcpName, tools)
375+ }
376+ for _, tools := range mcpClient2Tools.Seq2() {
377+ for _, t := range tools {
378+ mcpTools.Set(t.Info().Name, t)
379+ }
336380 }
337− wg.Wait()
338− return slices.Collect(result.Seq())
339381}
340382
341383func createAndInitializeClient(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) {
@@ -346,32 +388,45 @@ func createAndInitializeClient(ctx context.Context, name string, m config.MCPCon
346388 return nil, err
347389 }
348390
391+ c.OnNotification(func(n mcp.JSONRPCNotification) {
392+ slog.Debug("Received MCP notification", "name", name, "notification", n)
393+ switch n.Method {
394+ case "notifications/tools/list_changed":
395+ publishMCPEventToolsListChanged(name)
396+ default:
397+ slog.Debug("Unhandled MCP notification", "name", name, "method", n.Method)
398+ }
399+ })
400+
401+ // XXX: ideally we should be able to use context.WithTimeout here, but,
402+ // the SSE MCP client will start failing once that context is canceled.
349403 timeout := mcpTimeout(m)
350− initCtx, cancel := context.WithTimeout(ctx, timeout)
351− defer cancel()
404+ mcpCtx, cancel := context.WithCancel(ctx)
405+ cancelTimer := time.AfterFunc(timeout, cancel)
352406
353− // Only call Start() for non-stdio clients, as stdio clients auto-start
354− if m.Type != config.MCPStdio {
355− if err := c.Start(initCtx); err != nil {
356− updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
357− slog.Error("error starting mcp client", "error", err, "name", name)
358− _ = c.Close()
359− return nil, err
360− }
407+ if err := c.Start(mcpCtx); err != nil {
408+ updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
409+ slog.Error("error starting mcp client", "error", err, "name", name)
410+ _ = c.Close()
411+ cancel()
412+ return nil, err
361413 }
362− if _, err := c.Initialize(initCtx, mcpInitRequest); err != nil {
414+
415+ if _, err := c.Initialize(mcpCtx, mcpInitRequest); err != nil {
363416 updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
364417 slog.Error("error initializing mcp client", "error", err, "name", name)
365418 _ = c.Close()
419+ cancel()
366420 return nil, err
367421 }
368422
423+ cancelTimer.Stop()
369424 slog.Info("Initialized mcp client", "name", name)
370425 return c, nil
371426}
372427
373428func maybeTimeoutErr(err error, timeout time.Duration) error {
374− if errors.Is(err, context.DeadlineExceeded) {
429+ if errors.Is(err, context.Canceled) {
375430 return fmt.Errorf("timed out after %s", timeout)
376431 }
377432 return err
internal/app/app.go+32−33
@@ -10,14 +10,16 @@ import (
1010 "time"
1111
1212 tea "github.com/charmbracelet/bubbletea/v2"
13+ "github.com/charmbracelet/crush/internal/agent"
14+ "github.com/charmbracelet/crush/internal/agent/tools"
1315 "github.com/charmbracelet/crush/internal/config"
1416 "github.com/charmbracelet/crush/internal/csync"
1517 "github.com/charmbracelet/crush/internal/db"
1618 "github.com/charmbracelet/crush/internal/format"
1719 "github.com/charmbracelet/crush/internal/history"
18− "github.com/charmbracelet/crush/internal/llm/agent"
1920 "github.com/charmbracelet/crush/internal/log"
2021 "github.com/charmbracelet/crush/internal/pubsub"
22+ "github.com/charmbracelet/fantasy/ai"
2123
2224 "github.com/charmbracelet/crush/internal/lsp"
2325 "github.com/charmbracelet/crush/internal/message"
@@ -32,7 +34,7 @@ type App struct {
3234 Permissions permission.Service
3335
3436 // TODO: (kujtim) remove this when fully implemented
35− CoderAgent agent.Service
37+ AgentCoordinator agent.Coordinator
3638
3739 LSPClients *csync.Map[string, *lsp.Client]
3840
@@ -144,10 +146,23 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
144146 // Automatically approve all permission requests for this non-interactive session
145147 app.Permissions.AutoApproveSession(sess.ID)
146148
147− done, err := app.CoderAgent.Run(ctx, sess.ID, prompt)
148− if err != nil {
149− return fmt.Errorf("failed to start agent processing stream: %w", err)
149+ type response struct {
150+ result *ai.AgentResult
151+ err error
150152 }
153+ done := make(chan response, 1)
154+
155+ go func(ctx context.Context, sessionID, prompt string) {
156+ result, err := app.AgentCoordinator.Run(ctx, sess.ID, prompt)
157+ if err != nil {
158+ done <- response{
159+ err: fmt.Errorf("failed to start agent processing stream: %w", err),
160+ }
161+ }
162+ done <- response{
163+ result: result,
164+ }
165+ }(ctx, sess.ID, prompt)
151166
152167 messageEvents := app.Messages.Subscribe(ctx)
153168 messageReadBytes := make(map[string]int)
@@ -156,26 +171,13 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
156171 select {
157172 case result := <-done:
158173 stopSpinner()
159−
160− if result.Error != nil {
161− if errors.Is(result.Error, context.Canceled) || errors.Is(result.Error, agent.ErrRequestCancelled) {
174+ if result.err != nil {
175+ if errors.Is(result.err, context.Canceled) || errors.Is(result.err, agent.ErrRequestCancelled) {
162176 slog.Info("Non-interactive: agent processing cancelled", "session_id", sess.ID)
163177 return nil
164178 }
165− return fmt.Errorf("agent processing failed: %w", result.Error)
179+ return fmt.Errorf("agent processing failed: %w", result.err)
166180 }
167−
168− msgContent := result.Message.Content().String()
169− readBts := messageReadBytes[result.Message.ID]
170−
171− if len(msgContent) < readBts {
172− slog.Error("Non-interactive: message content is shorter than read bytes", "message_length", len(msgContent), "read_bytes", readBts)
173− return fmt.Errorf("message content is shorter than read bytes: %d < %d", len(msgContent), readBts)
174− }
175− fmt.Println(msgContent[readBts:])
176− messageReadBytes[result.Message.ID] = len(msgContent)
177−
178− slog.Info("Non-interactive: run completed", "session_id", sess.ID)
179181 return nil
180182
181183 case event := <-messageEvents:
@@ -204,7 +206,7 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
204206}
205207
206208func (app *App) UpdateAgentModel() error {
207− return app.CoderAgent.UpdateModel()
209+ return app.AgentCoordinator.UpdateModels()
208210}
209211
210212func (app *App) setupEvents() {
@@ -215,7 +217,7 @@ func (app *App) setupEvents() {
215217 setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
216218 setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
217219 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
218− setupSubscriber(ctx, app.serviceEventsWG, "mcp", agent.SubscribeMCPEvents, app.events)
220+ setupSubscriber(ctx, app.serviceEventsWG, "mcp", tools.SubscribeMCPEvents, app.events)
219221 setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
220222 cleanupFunc := func() error {
221223 cancel()
@@ -259,17 +261,16 @@ func setupSubscriber[T any](
259261}
260262
261263func (app *App) InitCoderAgent() error {
262− coderAgentCfg := app.config.Agents["coder"]
264+ coderAgentCfg := app.config.Agents[config.AgentCoder]
263265 if coderAgentCfg.ID == "" {
264266 return fmt.Errorf("coder agent configuration is missing")
265267 }
266268 var err error
267− app.CoderAgent, err = agent.NewAgent(
268− app.globalCtx,
269− coderAgentCfg,
270− app.Permissions,
269+ app.AgentCoordinator, err = agent.NewCoordinator(
270+ app.config,
271271 app.Sessions,
272272 app.Messages,
273+ app.Permissions,
273274 app.History,
274275 app.LSPClients,
275276 )
@@ -279,9 +280,7 @@ func (app *App) InitCoderAgent() error {
279280 }
280281
281282 // Add MCP client cleanup to shutdown process
282− app.cleanupFuncs = append(app.cleanupFuncs, agent.CloseMCPClients)
283−
284− setupSubscriber(app.eventsCtx, app.serviceEventsWG, "coderAgent", app.CoderAgent.Subscribe, app.events)
283+ app.cleanupFuncs = append(app.cleanupFuncs, tools.CloseMCPClients)
285284 return nil
286285}
287286
@@ -319,8 +318,8 @@ func (app *App) Subscribe(program *tea.Program) {
319318
320319// Shutdown performs a graceful shutdown of the application.
321320func (app *App) Shutdown() {
322− if app.CoderAgent != nil {
323− app.CoderAgent.CancelAll()
321+ if app.AgentCoordinator != nil {
322+ app.AgentCoordinator.CancelAll()
324323 }
325324
326325 // Shutdown all LSP clients.
internal/config/config.go+10−12
@@ -48,6 +48,11 @@ const (
4848 SelectedModelTypeSmall SelectedModelType = "small"
4949)
5050
51+const (
52+ AgentCoder string = "coder"
53+ AgentTask string = "task"
54+)
55+
5156type SelectedModel struct {
5257 // The model id as used by the provider API.
5358 // Required.
@@ -103,9 +108,6 @@ type ProviderConfig struct {
103108
104109 // The provider models
105110 Models []catwalk.Model `json:"models,omitempty" jsonschema:"description=List of models available from this provider"`
106−
107− // Override provider specific options.
108− ProviderOptions map[string]any `json:"provider_options,omitempty" jsonschema:"description=Additional provider-specific options for the model"`
109111}
110112
111113type MCPType string
@@ -264,10 +266,6 @@ type Agent struct {
264266 // if the string array is nil, all tools from the AllowedMCP are available
265267 AllowedMCP map[string][]string `json:"allowed_mcp,omitempty"`
266268
267− // The list of LSPs that this agent can use
268− // if this is nil, all LSPs are available
269− AllowedLSP []string `json:"allowed_lsp,omitempty"`
270−
271269 // Overrides the context paths for this agent
272270 ContextPaths []string `json:"context_paths,omitempty"`
273271}
@@ -514,16 +512,17 @@ func (c *Config) SetupAgents() {
514512 allowedTools := resolveAllowedTools(allToolNames(), c.Options.DisabledTools)
515513
516514 agents := map[string]Agent{
517− "coder": {
518− ID: "coder",
515+ AgentCoder: {
516+ ID: AgentCoder,
519517 Name: "Coder",
520518 Description: "An agent that helps with executing coding tasks.",
521519 Model: SelectedModelTypeLarge,
522520 ContextPaths: c.Options.ContextPaths,
523521 AllowedTools: allowedTools,
524522 },
525− "task": {
526− ID: "task",
523+
524+ AgentTask: {
525+ ID: AgentCoder,
527526 Name: "Task",
528527 Description: "An agent that helps with searching for context and finding implementation details.",
529528 Model: SelectedModelTypeLarge,
@@ -531,7 +530,6 @@ func (c *Config) SetupAgents() {
531530 AllowedTools: resolveReadOnlyTools(allowedTools),
532531 // NO MCPs or LSPs by default
533532 AllowedMCP: map[string][]string{},
534− AllowedLSP: []string{},
535533 },
536534 }
537535 c.Agents = agents
internal/config/load_test.go+6−6
@@ -462,11 +462,11 @@ func TestConfig_setupAgentsWithNoDisabledTools(t *testing.T) {
462462 }
463463
464464 cfg.SetupAgents()
465− coderAgent, ok := cfg.Agents["coder"]
465+ coderAgent, ok := cfg.Agents[AgentCoder]
466466 require.True(t, ok)
467467 assert.Equal(t, allToolNames(), coderAgent.AllowedTools)
468468
469− taskAgent, ok := cfg.Agents["task"]
469+ taskAgent, ok := cfg.Agents[AgentTask]
470470 require.True(t, ok)
471471 assert.Equal(t, []string{"glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
472472}
@@ -483,11 +483,11 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
483483 }
484484
485485 cfg.SetupAgents()
486− coderAgent, ok := cfg.Agents["coder"]
486+ coderAgent, ok := cfg.Agents[AgentCoder]
487487 require.True(t, ok)
488488 assert.Equal(t, []string{"agent", "bash", "multiedit", "fetch", "glob", "ls", "sourcegraph", "view", "write"}, coderAgent.AllowedTools)
489489
490− taskAgent, ok := cfg.Agents["task"]
490+ taskAgent, ok := cfg.Agents[AgentTask]
491491 require.True(t, ok)
492492 assert.Equal(t, []string{"glob", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
493493}
@@ -506,11 +506,11 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
506506 }
507507
508508 cfg.SetupAgents()
509− coderAgent, ok := cfg.Agents["coder"]
509+ coderAgent, ok := cfg.Agents[AgentCoder]
510510 require.True(t, ok)
511511 assert.Equal(t, []string{"agent", "bash", "download", "edit", "multiedit", "fetch", "write"}, coderAgent.AllowedTools)
512512
513− taskAgent, ok := cfg.Agents["task"]
513+ taskAgent, ok := cfg.Agents[AgentTask]
514514 require.True(t, ok)
515515 assert.Equal(t, []string{}, taskAgent.AllowedTools)
516516}
internal/llm/agent/agent-tool.godeleted−106
@@ -1,106 +0,0 @@
1−package agent
2−
3−import (
4− "context"
5− "encoding/json"
6− "fmt"
7−
8− "github.com/charmbracelet/crush/internal/llm/tools"
9− "github.com/charmbracelet/crush/internal/message"
10− "github.com/charmbracelet/crush/internal/session"
11−)
12−
13−type agentTool struct {
14− agent Service
15− sessions session.Service
16− messages message.Service
17−}
18−
19−const (
20− AgentToolName = "agent"
21−)
22−
23−type AgentParams struct {
24− Prompt string `json:"prompt"`
25−}
26−
27−func (b *agentTool) Name() string {
28− return AgentToolName
29−}
30−
31−func (b *agentTool) Info() tools.ToolInfo {
32− return tools.ToolInfo{
33− Name: AgentToolName,
34− Description: "Launch a new agent that has access to the following tools: GlobTool, GrepTool, LS, View. When you are searching for a keyword or file and are not confident that you will find the right match on the first try, use the Agent tool to perform the search for you. For example:\n\n- If you are searching for a keyword like \"config\" or \"logger\", or for questions like \"which file does X?\", the Agent tool is strongly recommended\n- If you want to read a specific file path, use the View or GlobTool tool instead of the Agent tool, to find the match more quickly\n- If you are searching for a specific class definition like \"class Foo\", use the GlobTool tool instead, to find the match more quickly\n\nUsage notes:\n1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses\n2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.\n3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.\n4. The agent's outputs should generally be trusted\n5. IMPORTANT: The agent can not use Bash, Replace, Edit, so can not modify files. If you want to use these tools, use them directly instead of going through the agent.",
35− Parameters: map[string]any{
36− "prompt": map[string]any{
37− "type": "string",
38− "description": "The task for the agent to perform",
39− },
40− },
41− Required: []string{"prompt"},
42− }
43−}
44−
45−func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolResponse, error) {
46− var params AgentParams
47− if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
48− return tools.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
49− }
50− if params.Prompt == "" {
51− return tools.NewTextErrorResponse("prompt is required"), nil
52− }
53−
54− sessionID, messageID := tools.GetContextValues(ctx)
55− if sessionID == "" || messageID == "" {
56− return tools.ToolResponse{}, fmt.Errorf("session_id and message_id are required")
57− }
58−
59− session, err := b.sessions.CreateTaskSession(ctx, call.ID, sessionID, "New Agent Session")
60− if err != nil {
61− return tools.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
62− }
63−
64− done, err := b.agent.Run(ctx, session.ID, params.Prompt)
65− if err != nil {
66− return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
67− }
68− result := <-done
69− if result.Error != nil {
70− return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Error)
71− }
72−
73− response := result.Message
74− if response.Role != message.Assistant {
75− return tools.NewTextErrorResponse("no response"), nil
76− }
77−
78− updatedSession, err := b.sessions.Get(ctx, session.ID)
79− if err != nil {
80− return tools.ToolResponse{}, fmt.Errorf("error getting session: %s", err)
81− }
82− parentSession, err := b.sessions.Get(ctx, sessionID)
83− if err != nil {
84− return tools.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err)
85− }
86−
87− parentSession.Cost += updatedSession.Cost
88−
89− _, err = b.sessions.Save(ctx, parentSession)
90− if err != nil {
91− return tools.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err)
92− }
93− return tools.NewTextResponse(response.Content().String()), nil
94−}
95−
96−func NewAgentTool(
97− agent Service,
98− sessions session.Service,
99− messages message.Service,
100−) tools.BaseTool {
101− return &agentTool{
102− sessions: sessions,
103− messages: messages,
104− agent: agent,
105− }
106−}
internal/llm/agent/agent.godeleted−1133
@@ -1,1133 +0,0 @@
1−package agent
2−
3−import (
4− "context"
5− "errors"
6− "fmt"
7− "log/slog"
8− "maps"
9− "slices"
10− "strings"
11− "time"
12−
13− "github.com/charmbracelet/catwalk/pkg/catwalk"
14− "github.com/charmbracelet/crush/internal/config"
15− "github.com/charmbracelet/crush/internal/csync"
16− "github.com/charmbracelet/crush/internal/event"
17− "github.com/charmbracelet/crush/internal/history"
18− "github.com/charmbracelet/crush/internal/llm/prompt"
19− "github.com/charmbracelet/crush/internal/llm/provider"
20− "github.com/charmbracelet/crush/internal/llm/tools"
21− "github.com/charmbracelet/crush/internal/log"
22− "github.com/charmbracelet/crush/internal/lsp"
23− "github.com/charmbracelet/crush/internal/message"
24− "github.com/charmbracelet/crush/internal/permission"
25− "github.com/charmbracelet/crush/internal/pubsub"
26− "github.com/charmbracelet/crush/internal/session"
27− "github.com/charmbracelet/crush/internal/shell"
28−)
29−
30−type AgentEventType string
31−
32−const (
33− AgentEventTypeError AgentEventType = "error"
34− AgentEventTypeResponse AgentEventType = "response"
35− AgentEventTypeSummarize AgentEventType = "summarize"
36−)
37−
38−type AgentEvent struct {
39− Type AgentEventType
40− Message message.Message
41− Error error
42−
43− // When summarizing
44− SessionID string
45− Progress string
46− Done bool
47−}
48−
49−type Service interface {
50− pubsub.Suscriber[AgentEvent]
51− Model() catwalk.Model
52− Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
53− Cancel(sessionID string)
54− CancelAll()
55− IsSessionBusy(sessionID string) bool
56− IsBusy() bool
57− Summarize(ctx context.Context, sessionID string) error
58− UpdateModel() error
59− QueuedPrompts(sessionID string) int
60− ClearQueue(sessionID string)
61−}
62−
63−type agent struct {
64− *pubsub.Broker[AgentEvent]
65− agentCfg config.Agent
66− sessions session.Service
67− messages message.Service
68− permissions permission.Service
69− baseTools *csync.Map[string, tools.BaseTool]
70− mcpTools *csync.Map[string, tools.BaseTool]
71− lspClients *csync.Map[string, *lsp.Client]
72−
73− // We need this to be able to update it when model changes
74− agentToolFn func() (tools.BaseTool, error)
75− cleanupFuncs []func()
76−
77− provider provider.Provider
78− providerID string
79−
80− titleProvider provider.Provider
81− summarizeProvider provider.Provider
82− summarizeProviderID string
83−
84− activeRequests *csync.Map[string, context.CancelFunc]
85− promptQueue *csync.Map[string, []string]
86−}
87−
88−var agentPromptMap = map[string]prompt.PromptID{
89− "coder": prompt.PromptCoder,
90− "task": prompt.PromptTask,
91−}
92−
93−func NewAgent(
94− ctx context.Context,
95− agentCfg config.Agent,
96− // These services are needed in the tools
97− permissions permission.Service,
98− sessions session.Service,
99− messages message.Service,
100− history history.Service,
101− lspClients *csync.Map[string, *lsp.Client],
102−) (Service, error) {
103− cfg := config.Get()
104−
105− var agentToolFn func() (tools.BaseTool, error)
106− if agentCfg.ID == "coder" && slices.Contains(agentCfg.AllowedTools, AgentToolName) {
107− agentToolFn = func() (tools.BaseTool, error) {
108− taskAgentCfg := config.Get().Agents["task"]
109− if taskAgentCfg.ID == "" {
110− return nil, fmt.Errorf("task agent not found in config")
111− }
112− taskAgent, err := NewAgent(ctx, taskAgentCfg, permissions, sessions, messages, history, lspClients)
113− if err != nil {
114− return nil, fmt.Errorf("failed to create task agent: %w", err)
115− }
116− return NewAgentTool(taskAgent, sessions, messages), nil
117− }
118− }
119−
120− providerCfg := config.Get().GetProviderForModel(agentCfg.Model)
121− if providerCfg == nil {
122− return nil, fmt.Errorf("provider for agent %s not found in config", agentCfg.Name)
123− }
124− model := config.Get().GetModelByType(agentCfg.Model)
125−
126− if model == nil {
127− return nil, fmt.Errorf("model not found for agent %s", agentCfg.Name)
128− }
129−
130− promptID := agentPromptMap[agentCfg.ID]
131− if promptID == "" {
132− promptID = prompt.PromptDefault
133− }
134− opts := []provider.ProviderClientOption{
135− provider.WithModel(agentCfg.Model),
136− provider.WithSystemMessage(prompt.GetPrompt(promptID, providerCfg.ID, config.Get().Options.ContextPaths...)),
137− }
138− agentProvider, err := provider.NewProvider(*providerCfg, opts...)
139− if err != nil {
140− return nil, err
141− }
142−
143− smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
144− var smallModelProviderCfg *config.ProviderConfig
145− if smallModelCfg.Provider == providerCfg.ID {
146− smallModelProviderCfg = providerCfg
147− } else {
148− smallModelProviderCfg = cfg.GetProviderForModel(config.SelectedModelTypeSmall)
149−
150− if smallModelProviderCfg.ID == "" {
151− return nil, fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
152− }
153− }
154− smallModel := cfg.GetModelByType(config.SelectedModelTypeSmall)
155− if smallModel.ID == "" {
156− return nil, fmt.Errorf("model %s not found in provider %s", smallModelCfg.Model, smallModelProviderCfg.ID)
157− }
158−
159− titleOpts := []provider.ProviderClientOption{
160− provider.WithModel(config.SelectedModelTypeSmall),
161− provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
162− }
163− titleProvider, err := provider.NewProvider(*smallModelProviderCfg, titleOpts...)
164− if err != nil {
165− return nil, err
166− }
167−
168− summarizeOpts := []provider.ProviderClientOption{
169− provider.WithModel(config.SelectedModelTypeLarge),
170− provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, providerCfg.ID)),
171− }
172− summarizeProvider, err := provider.NewProvider(*providerCfg, summarizeOpts...)
173− if err != nil {
174− return nil, err
175− }
176−
177− baseToolsFn := func() map[string]tools.BaseTool {
178− slog.Info("Initializing agent base tools", "agent", agentCfg.ID)
179− defer func() {
180− slog.Info("Initialized agent base tools", "agent", agentCfg.ID)
181− }()
182−
183− // Base tools available to all agents
184− cwd := cfg.WorkingDir()
185− result := make(map[string]tools.BaseTool)
186− for _, tool := range []tools.BaseTool{
187− tools.NewBashTool(permissions, cwd, cfg.Options.Attribution),
188− tools.NewDownloadTool(permissions, cwd),
189− tools.NewEditTool(lspClients, permissions, history, cwd),
190− tools.NewMultiEditTool(lspClients, permissions, history, cwd),
191− tools.NewFetchTool(permissions, cwd),
192− tools.NewGlobTool(cwd),
193− tools.NewGrepTool(cwd),
194− tools.NewLsTool(permissions, cwd),
195− tools.NewSourcegraphTool(),
196− tools.NewViewTool(lspClients, permissions, cwd),
197− tools.NewWriteTool(lspClients, permissions, history, cwd),
198− } {
199− result[tool.Name()] = tool
200− }
201− return result
202− }
203− mcpToolsFn := func() map[string]tools.BaseTool {
204− slog.Info("Initializing agent mcp tools", "agent", agentCfg.ID)
205− defer func() {
206− slog.Info("Initialized agent mcp tools", "agent", agentCfg.ID)
207− }()
208−
209− mcpToolsOnce.Do(func() {
210− doGetMCPTools(ctx, permissions, cfg)
211− })
212−
213− return maps.Collect(mcpTools.Seq2())
214− }
215−
216− a := &agent{
217− Broker: pubsub.NewBroker[AgentEvent](),
218− agentCfg: agentCfg,
219− provider: agentProvider,
220− providerID: string(providerCfg.ID),
221− messages: messages,
222− sessions: sessions,
223− titleProvider: titleProvider,
224− summarizeProvider: summarizeProvider,
225− summarizeProviderID: string(providerCfg.ID),
226− agentToolFn: agentToolFn,
227− activeRequests: csync.NewMap[string, context.CancelFunc](),
228− mcpTools: csync.NewLazyMap(mcpToolsFn),
229− baseTools: csync.NewLazyMap(baseToolsFn),
230− promptQueue: csync.NewMap[string, []string](),
231− permissions: permissions,
232− lspClients: lspClients,
233− }
234− a.setupEvents(ctx)
235− return a, nil
236−}
237−
238−func (a *agent) Model() catwalk.Model {
239− return *config.Get().GetModelByType(a.agentCfg.Model)
240−}
241−
242−func (a *agent) Cancel(sessionID string) {
243− // Cancel regular requests
244− if cancel, ok := a.activeRequests.Take(sessionID); ok && cancel != nil {
245− slog.Info("Request cancellation initiated", "session_id", sessionID)
246− cancel()
247− }
248−
249− // Also check for summarize requests
250− if cancel, ok := a.activeRequests.Take(sessionID + "-summarize"); ok && cancel != nil {
251− slog.Info("Summarize cancellation initiated", "session_id", sessionID)
252− cancel()
253− }
254−
255− if a.QueuedPrompts(sessionID) > 0 {
256− slog.Info("Clearing queued prompts", "session_id", sessionID)
257− a.promptQueue.Del(sessionID)
258− }
259−}
260−
261−func (a *agent) IsBusy() bool {
262− var busy bool
263− for cancelFunc := range a.activeRequests.Seq() {
264− if cancelFunc != nil {
265− busy = true
266− break
267− }
268− }
269− return busy
270−}
271−
272−func (a *agent) IsSessionBusy(sessionID string) bool {
273− _, busy := a.activeRequests.Get(sessionID)
274− return busy
275−}
276−
277−func (a *agent) QueuedPrompts(sessionID string) int {
278− l, ok := a.promptQueue.Get(sessionID)
279− if !ok {
280− return 0
281− }
282− return len(l)
283−}
284−
285−func (a *agent) generateTitle(ctx context.Context, sessionID string, content string) error {
286− if content == "" {
287− return nil
288− }
289− if a.titleProvider == nil {
290− return nil
291− }
292− session, err := a.sessions.Get(ctx, sessionID)
293− if err != nil {
294− return err
295− }
296− parts := []message.ContentPart{message.TextContent{
297− Text: fmt.Sprintf("Generate a concise title for the following content:\n\n%s", content),
298− }}
299−
300− // Use streaming approach like summarization
301− response := a.titleProvider.StreamResponse(
302− ctx,
303− []message.Message{
304− {
305− Role: message.User,
306− Parts: parts,
307− },
308− },
309− nil,
310− )
311−
312− var finalResponse *provider.ProviderResponse
313− for r := range response {
314− if r.Error != nil {
315− return r.Error
316− }
317− finalResponse = r.Response
318− }
319−
320− if finalResponse == nil {
321− return fmt.Errorf("no response received from title provider")
322− }
323−
324− title := strings.ReplaceAll(finalResponse.Content, "\n", " ")
325−
326− if idx := strings.Index(title, "</think>"); idx > 0 {
327− title = title[idx+len("</think>"):]
328− }
329−
330− title = strings.TrimSpace(title)
331− if title == "" {
332− return nil
333− }
334−
335− session.Title = title
336− _, err = a.sessions.Save(ctx, session)
337− return err
338−}
339−
340−func (a *agent) err(err error) AgentEvent {
341− return AgentEvent{
342− Type: AgentEventTypeError,
343− Error: err,
344− }
345−}
346−
347−func (a *agent) Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error) {
348− if !a.Model().SupportsImages && attachments != nil {
349− attachments = nil
350− }
351− events := make(chan AgentEvent, 1)
352− if a.IsSessionBusy(sessionID) {
353− existing, ok := a.promptQueue.Get(sessionID)
354− if !ok {
355− existing = []string{}
356− }
357− existing = append(existing, content)
358− a.promptQueue.Set(sessionID, existing)
359− return nil, nil
360− }
361−
362− genCtx, cancel := context.WithCancel(ctx)
363− a.activeRequests.Set(sessionID, cancel)
364− startTime := time.Now()
365−
366− go func() {
367− slog.Debug("Request started", "sessionID", sessionID)
368− defer log.RecoverPanic("agent.Run", func() {
369− events <- a.err(fmt.Errorf("panic while running the agent"))
370− })
371− var attachmentParts []message.ContentPart
372− for _, attachment := range attachments {
373− attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
374− }
375− result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
376− if result.Error != nil {
377− if isCancelledErr(result.Error) {
378− slog.Error("Request canceled", "sessionID", sessionID)
379− } else {
380− slog.Error("Request errored", "sessionID", sessionID, "error", result.Error.Error())
381− event.Error(result.Error)
382− }
383− } else {
384− slog.Debug("Request completed", "sessionID", sessionID)
385− }
386− a.eventPromptResponded(sessionID, time.Since(startTime).Truncate(time.Second))
387− a.activeRequests.Del(sessionID)
388− cancel()
389− a.Publish(pubsub.CreatedEvent, result)
390− events <- result
391− close(events)
392− }()
393− a.eventPromptSent(sessionID)
394− return events, nil
395−}
396−
397−func (a *agent) processGeneration(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) AgentEvent {
398− cfg := config.Get()
399− // List existing messages; if none, start title generation asynchronously.
400− msgs, err := a.messages.List(ctx, sessionID)
401− if err != nil {
402− return a.err(fmt.Errorf("failed to list messages: %w", err))
403− }
404− if len(msgs) == 0 {
405− go func() {
406− defer log.RecoverPanic("agent.Run", func() {
407− slog.Error("panic while generating title")
408− })
409− titleErr := a.generateTitle(ctx, sessionID, content)
410− if titleErr != nil && !errors.Is(titleErr, context.Canceled) && !errors.Is(titleErr, context.DeadlineExceeded) {
411− slog.Error("failed to generate title", "error", titleErr)
412− }
413− }()
414− }
415− session, err := a.sessions.Get(ctx, sessionID)
416− if err != nil {
417− return a.err(fmt.Errorf("failed to get session: %w", err))
418− }
419− if session.SummaryMessageID != "" {
420− summaryMsgInex := -1
421− for i, msg := range msgs {
422− if msg.ID == session.SummaryMessageID {
423− summaryMsgInex = i
424− break
425− }
426− }
427− if summaryMsgInex != -1 {
428− msgs = msgs[summaryMsgInex:]
429− msgs[0].Role = message.User
430− }
431− }
432−
433− userMsg, err := a.createUserMessage(ctx, sessionID, content, attachmentParts)
434− if err != nil {
435− return a.err(fmt.Errorf("failed to create user message: %w", err))
436− }
437− // Append the new user message to the conversation history.
438− msgHistory := append(msgs, userMsg)
439−
440− for {
441− // Check for cancellation before each iteration
442− select {
443− case <-ctx.Done():
444− return a.err(ctx.Err())
445− default:
446− // Continue processing
447− }
448− agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, msgHistory)
449− if err != nil {
450− if errors.Is(err, context.Canceled) {
451− agentMessage.AddFinish(message.FinishReasonCanceled, "Request cancelled", "")
452− a.messages.Update(context.Background(), agentMessage)
453− return a.err(ErrRequestCancelled)
454− }
455− return a.err(fmt.Errorf("failed to process events: %w", err))
456− }
457− if cfg.Options.Debug {
458− slog.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults)
459− }
460− if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil {
461− // We are not done, we need to respond with the tool response
462− msgHistory = append(msgHistory, agentMessage, *toolResults)
463− // If there are queued prompts, process the next one
464− nextPrompt, ok := a.promptQueue.Take(sessionID)
465− if ok {
466− for _, prompt := range nextPrompt {
467− // Create a new user message for the queued prompt
468− userMsg, err := a.createUserMessage(ctx, sessionID, prompt, nil)
469− if err != nil {
470− return a.err(fmt.Errorf("failed to create user message for queued prompt: %w", err))
471− }
472− // Append the new user message to the conversation history
473− msgHistory = append(msgHistory, userMsg)
474− }
475− }
476−
477− continue
478− } else if agentMessage.FinishReason() == message.FinishReasonEndTurn {
479− queuePrompts, ok := a.promptQueue.Take(sessionID)
480− if ok {
481− for _, prompt := range queuePrompts {
482− if prompt == "" {
483− continue
484− }
485− userMsg, err := a.createUserMessage(ctx, sessionID, prompt, nil)
486− if err != nil {
487− return a.err(fmt.Errorf("failed to create user message for queued prompt: %w", err))
488− }
489− msgHistory = append(msgHistory, userMsg)
490− }
491− continue
492− }
493− }
494− if agentMessage.FinishReason() == "" {
495− // Kujtim: could not track down where this is happening but this means its cancelled
496− agentMessage.AddFinish(message.FinishReasonCanceled, "Request cancelled", "")
497− _ = a.messages.Update(context.Background(), agentMessage)
498− return a.err(ErrRequestCancelled)
499− }
500− return AgentEvent{
501− Type: AgentEventTypeResponse,
502− Message: agentMessage,
503− Done: true,
504− }
505− }
506−}
507−
508−func (a *agent) createUserMessage(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) (message.Message, error) {
509− parts := []message.ContentPart{message.TextContent{Text: content}}
510− parts = append(parts, attachmentParts...)
511− return a.messages.Create(ctx, sessionID, message.CreateMessageParams{
512− Role: message.User,
513− Parts: parts,
514− })
515−}
516−
517−func (a *agent) getAllTools() ([]tools.BaseTool, error) {
518− var allTools []tools.BaseTool
519− for tool := range a.baseTools.Seq() {
520− if a.agentCfg.AllowedTools == nil || slices.Contains(a.agentCfg.AllowedTools, tool.Name()) {
521− allTools = append(allTools, tool)
522− }
523− }
524− if a.agentCfg.ID == "coder" {
525− allTools = slices.AppendSeq(allTools, a.mcpTools.Seq())
526− if a.lspClients.Len() > 0 {
527− allTools = append(allTools, tools.NewDiagnosticsTool(a.lspClients))
528− }
529− }
530− if a.agentToolFn != nil {
531− agentTool, agentToolErr := a.agentToolFn()
532− if agentToolErr != nil {
533− return nil, agentToolErr
534− }
535− allTools = append(allTools, agentTool)
536− }
537− return allTools, nil
538−}
539−
540−func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) {
541− ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
542−
543− // Create the assistant message first so the spinner shows immediately
544− assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
545− Role: message.Assistant,
546− Parts: []message.ContentPart{},
547− Model: a.Model().ID,
548− Provider: a.providerID,
549− })
550− if err != nil {
551− return assistantMsg, nil, fmt.Errorf("failed to create assistant message: %w", err)
552− }
553−
554− allTools, toolsErr := a.getAllTools()
555− if toolsErr != nil {
556− return assistantMsg, nil, toolsErr
557− }
558− // Now collect tools (which may block on MCP initialization)
559− eventChan := a.provider.StreamResponse(ctx, msgHistory, allTools)
560−
561− // Add the session and message ID into the context if needed by tools.
562− ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID)
563−
564−loop:
565− for {
566− select {
567− case event, ok := <-eventChan:
568− if !ok {
569− break loop
570− }
571− if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil {
572− if errors.Is(processErr, context.Canceled) {
573− a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
574− } else {
575− a.finishMessage(ctx, &assistantMsg, message.FinishReasonError, "API Error", processErr.Error())
576− }
577− return assistantMsg, nil, processErr
578− }
579− case <-ctx.Done():
580− a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
581− return assistantMsg, nil, ctx.Err()
582− }
583− }
584−
585− toolResults := make([]message.ToolResult, len(assistantMsg.ToolCalls()))
586− toolCalls := assistantMsg.ToolCalls()
587− for i, toolCall := range toolCalls {
588− select {
589− case <-ctx.Done():
590− a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
591− // Make all future tool calls cancelled
592− for j := i; j < len(toolCalls); j++ {
593− toolResults[j] = message.ToolResult{
594− ToolCallID: toolCalls[j].ID,
595− Content: "Tool execution canceled by user",
596− IsError: true,
597− }
598− }
599− goto out
600− default:
601− // Continue processing
602− var tool tools.BaseTool
603− allTools, _ = a.getAllTools()
604− for _, availableTool := range allTools {
605− if availableTool.Info().Name == toolCall.Name {
606− tool = availableTool
607− break
608− }
609− }
610−
611− // Tool not found
612− if tool == nil {
613− toolResults[i] = message.ToolResult{
614− ToolCallID: toolCall.ID,
615− Content: fmt.Sprintf("Tool not found: %s", toolCall.Name),
616− IsError: true,
617− }
618− continue
619− }
620−
621− // Run tool in goroutine to allow cancellation
622− type toolExecResult struct {
623− response tools.ToolResponse
624− err error
625− }
626− resultChan := make(chan toolExecResult, 1)
627−
628− go func() {
629− response, err := tool.Run(ctx, tools.ToolCall{
630− ID: toolCall.ID,
631− Name: toolCall.Name,
632− Input: toolCall.Input,
633− })
634− resultChan <- toolExecResult{response: response, err: err}
635− }()
636−
637− var toolResponse tools.ToolResponse
638− var toolErr error
639−
640− select {
641− case <-ctx.Done():
642− a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
643− // Mark remaining tool calls as cancelled
644− for j := i; j < len(toolCalls); j++ {
645− toolResults[j] = message.ToolResult{
646− ToolCallID: toolCalls[j].ID,
647− Content: "Tool execution canceled by user",
648− IsError: true,
649− }
650− }
651− goto out
652− case result := <-resultChan:
653− toolResponse = result.response
654− toolErr = result.err
655− }
656−
657− if toolErr != nil {
658− slog.Error("Tool execution error", "toolCall", toolCall.ID, "error", toolErr)
659− if errors.Is(toolErr, permission.ErrorPermissionDenied) {
660− toolResults[i] = message.ToolResult{
661− ToolCallID: toolCall.ID,
662− Content: "Permission denied",
663− IsError: true,
664− }
665− for j := i + 1; j < len(toolCalls); j++ {
666− toolResults[j] = message.ToolResult{
667− ToolCallID: toolCalls[j].ID,
668− Content: "Tool execution canceled by user",
669− IsError: true,
670− }
671− }
672− a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied, "Permission denied", "")
673− break
674− }
675− }
676− toolResults[i] = message.ToolResult{
677− ToolCallID: toolCall.ID,
678− Content: toolResponse.Content,
679− Metadata: toolResponse.Metadata,
680− IsError: toolResponse.IsError,
681− }
682− }
683− }
684−out:
685− if len(toolResults) == 0 {
686− return assistantMsg, nil, nil
687− }
688− parts := make([]message.ContentPart, 0)
689− for _, tr := range toolResults {
690− parts = append(parts, tr)
691− }
692− msg, err := a.messages.Create(context.Background(), assistantMsg.SessionID, message.CreateMessageParams{
693− Role: message.Tool,
694− Parts: parts,
695− Provider: a.providerID,
696− })
697− if err != nil {
698− return assistantMsg, nil, fmt.Errorf("failed to create cancelled tool message: %w", err)
699− }
700−
701− return assistantMsg, &msg, err
702−}
703−
704−func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReason message.FinishReason, message, details string) {
705− msg.AddFinish(finishReason, message, details)
706− _ = a.messages.Update(ctx, *msg)
707−}
708−
709−func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg *message.Message, event provider.ProviderEvent) error {
710− select {
711− case <-ctx.Done():
712− return ctx.Err()
713− default:
714− // Continue processing.
715− }
716−
717− switch event.Type {
718− case provider.EventThinkingDelta:
719− assistantMsg.AppendReasoningContent(event.Thinking)
720− return a.messages.Update(ctx, *assistantMsg)
721− case provider.EventSignatureDelta:
722− assistantMsg.AppendReasoningSignature(event.Signature)
723− return a.messages.Update(ctx, *assistantMsg)
724− case provider.EventContentDelta:
725− assistantMsg.FinishThinking()
726− assistantMsg.AppendContent(event.Content)
727− return a.messages.Update(ctx, *assistantMsg)
728− case provider.EventToolUseStart:
729− assistantMsg.FinishThinking()
730− slog.Info("Tool call started", "toolCall", event.ToolCall)
731− assistantMsg.AddToolCall(*event.ToolCall)
732− return a.messages.Update(ctx, *assistantMsg)
733− case provider.EventToolUseDelta:
734− assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
735− return a.messages.Update(ctx, *assistantMsg)
736− case provider.EventToolUseStop:
737− slog.Info("Finished tool call", "toolCall", event.ToolCall)
738− assistantMsg.FinishToolCall(event.ToolCall.ID)
739− return a.messages.Update(ctx, *assistantMsg)
740− case provider.EventError:
741− return event.Error
742− case provider.EventComplete:
743− assistantMsg.FinishThinking()
744− assistantMsg.SetToolCalls(event.Response.ToolCalls)
745− assistantMsg.AddFinish(event.Response.FinishReason, "", "")
746− if err := a.messages.Update(ctx, *assistantMsg); err != nil {
747− return fmt.Errorf("failed to update message: %w", err)
748− }
749− return a.trackUsage(ctx, sessionID, a.Model(), event.Response.Usage)
750− }
751−
752− return nil
753−}
754−
755−func (a *agent) trackUsage(ctx context.Context, sessionID string, model catwalk.Model, usage provider.TokenUsage) error {
756− sess, err := a.sessions.Get(ctx, sessionID)
757− if err != nil {
758− return fmt.Errorf("failed to get session: %w", err)
759− }
760−
761− cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
762− model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
763− model.CostPer1MIn/1e6*float64(usage.InputTokens) +
764− model.CostPer1MOut/1e6*float64(usage.OutputTokens)
765−
766− a.eventTokensUsed(sessionID, usage, cost)
767−
768− sess.Cost += cost
769− sess.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
770− sess.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
771−
772− _, err = a.sessions.Save(ctx, sess)
773− if err != nil {
774− return fmt.Errorf("failed to save session: %w", err)
775− }
776− return nil
777−}
778−
779−func (a *agent) Summarize(ctx context.Context, sessionID string) error {
780− if a.summarizeProvider == nil {
781− return fmt.Errorf("summarize provider not available")
782− }
783−
784− // Check if session is busy
785− if a.IsSessionBusy(sessionID) {
786− return ErrSessionBusy
787− }
788−
789− // Create a new context with cancellation
790− summarizeCtx, cancel := context.WithCancel(ctx)
791−
792− // Store the cancel function in activeRequests to allow cancellation
793− a.activeRequests.Set(sessionID+"-summarize", cancel)
794−
795− go func() {
796− defer a.activeRequests.Del(sessionID + "-summarize")
797− defer cancel()
798− event := AgentEvent{
799− Type: AgentEventTypeSummarize,
800− Progress: "Starting summarization...",
801− }
802−
803− a.Publish(pubsub.CreatedEvent, event)
804− // Get all messages from the session
805− msgs, err := a.messages.List(summarizeCtx, sessionID)
806− if err != nil {
807− event = AgentEvent{
808− Type: AgentEventTypeError,
809− Error: fmt.Errorf("failed to list messages: %w", err),
810− Done: true,
811− }
812− a.Publish(pubsub.CreatedEvent, event)
813− return
814− }
815− summarizeCtx = context.WithValue(summarizeCtx, tools.SessionIDContextKey, sessionID)
816−
817− if len(msgs) == 0 {
818− event = AgentEvent{
819− Type: AgentEventTypeError,
820− Error: fmt.Errorf("no messages to summarize"),
821− Done: true,
822− }
823− a.Publish(pubsub.CreatedEvent, event)
824− return
825− }
826−
827− event = AgentEvent{
828− Type: AgentEventTypeSummarize,
829− Progress: "Analyzing conversation...",
830− }
831− a.Publish(pubsub.CreatedEvent, event)
832−
833− // Add a system message to guide the summarization
834− summarizePrompt := "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next."
835−
836− // Create a new message with the summarize prompt
837− promptMsg := message.Message{
838− Role: message.User,
839− Parts: []message.ContentPart{message.TextContent{Text: summarizePrompt}},
840− }
841−
842− // Append the prompt to the messages
843− msgsWithPrompt := append(msgs, promptMsg)
844−
845− event = AgentEvent{
846− Type: AgentEventTypeSummarize,
847− Progress: "Generating summary...",
848− }
849−
850− a.Publish(pubsub.CreatedEvent, event)
851−
852− // Send the messages to the summarize provider
853− response := a.summarizeProvider.StreamResponse(
854− summarizeCtx,
855− msgsWithPrompt,
856− nil,
857− )
858− var finalResponse *provider.ProviderResponse
859− for r := range response {
860− if r.Error != nil {
861− event = AgentEvent{
862− Type: AgentEventTypeError,
863− Error: fmt.Errorf("failed to summarize: %w", r.Error),
864− Done: true,
865− }
866− a.Publish(pubsub.CreatedEvent, event)
867− return
868− }
869− finalResponse = r.Response
870− }
871−
872− summary := strings.TrimSpace(finalResponse.Content)
873− if summary == "" {
874− event = AgentEvent{
875− Type: AgentEventTypeError,
876− Error: fmt.Errorf("empty summary returned"),
877− Done: true,
878− }
879− a.Publish(pubsub.CreatedEvent, event)
880− return
881− }
882− shell := shell.GetPersistentShell(config.Get().WorkingDir())
883− summary += "\n\n**Current working directory of the persistent shell**\n\n" + shell.GetWorkingDir()
884− event = AgentEvent{
885− Type: AgentEventTypeSummarize,
886− Progress: "Creating new session...",
887− }
888−
889− a.Publish(pubsub.CreatedEvent, event)
890− oldSession, err := a.sessions.Get(summarizeCtx, sessionID)
891− if err != nil {
892− event = AgentEvent{
893− Type: AgentEventTypeError,
894− Error: fmt.Errorf("failed to get session: %w", err),
895− Done: true,
896− }
897−
898− a.Publish(pubsub.CreatedEvent, event)
899− return
900− }
901− // Create a message in the new session with the summary
902− msg, err := a.messages.Create(summarizeCtx, oldSession.ID, message.CreateMessageParams{
903− Role: message.Assistant,
904− Parts: []message.ContentPart{
905− message.TextContent{Text: summary},
906− message.Finish{
907− Reason: message.FinishReasonEndTurn,
908− Time: time.Now().Unix(),
909− },
910− },
911− Model: a.summarizeProvider.Model().ID,
912− Provider: a.summarizeProviderID,
913− })
914− if err != nil {
915− event = AgentEvent{
916− Type: AgentEventTypeError,
917− Error: fmt.Errorf("failed to create summary message: %w", err),
918− Done: true,
919− }
920−
921− a.Publish(pubsub.CreatedEvent, event)
922− return
923− }
924− oldSession.SummaryMessageID = msg.ID
925− oldSession.CompletionTokens = finalResponse.Usage.OutputTokens
926− oldSession.PromptTokens = 0
927− model := a.summarizeProvider.Model()
928− usage := finalResponse.Usage
929− cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
930− model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
931− model.CostPer1MIn/1e6*float64(usage.InputTokens) +
932− model.CostPer1MOut/1e6*float64(usage.OutputTokens)
933− oldSession.Cost += cost
934− _, err = a.sessions.Save(summarizeCtx, oldSession)
935− if err != nil {
936− event = AgentEvent{
937− Type: AgentEventTypeError,
938− Error: fmt.Errorf("failed to save session: %w", err),
939− Done: true,
940− }
941− a.Publish(pubsub.CreatedEvent, event)
942− }
943−
944− event = AgentEvent{
945− Type: AgentEventTypeSummarize,
946− SessionID: oldSession.ID,
947− Progress: "Summary complete",
948− Done: true,
949− }
950− a.Publish(pubsub.CreatedEvent, event)
951− // Send final success event with the new session ID
952− }()
953−
954− return nil
955−}
956−
957−func (a *agent) ClearQueue(sessionID string) {
958− if a.QueuedPrompts(sessionID) > 0 {
959− slog.Info("Clearing queued prompts", "session_id", sessionID)
960− a.promptQueue.Del(sessionID)
961− }
962−}
963−
964−func (a *agent) CancelAll() {
965− if !a.IsBusy() {
966− return
967− }
968− for key := range a.activeRequests.Seq2() {
969− a.Cancel(key) // key is sessionID
970− }
971−
972− for _, cleanup := range a.cleanupFuncs {
973− if cleanup != nil {
974− cleanup()
975− }
976− }
977−
978− timeout := time.After(5 * time.Second)
979− for a.IsBusy() {
980− select {
981− case <-timeout:
982− return
983− default:
984− time.Sleep(200 * time.Millisecond)
985− }
986− }
987−}
988−
989−func (a *agent) UpdateModel() error {
990− cfg := config.Get()
991−
992− // Get current provider configuration
993− currentProviderCfg := cfg.GetProviderForModel(a.agentCfg.Model)
994− if currentProviderCfg == nil || currentProviderCfg.ID == "" {
995− return fmt.Errorf("provider for agent %s not found in config", a.agentCfg.Name)
996− }
997−
998− // Check if provider has changed
999− if string(currentProviderCfg.ID) != a.providerID {
1000− // Provider changed, need to recreate the main provider
1001− model := cfg.GetModelByType(a.agentCfg.Model)
1002− if model.ID == "" {
1003− return fmt.Errorf("model not found for agent %s", a.agentCfg.Name)
1004− }
1005−
1006− promptID := agentPromptMap[a.agentCfg.ID]
1007− if promptID == "" {
1008− promptID = prompt.PromptDefault
1009− }
1010−
1011− opts := []provider.ProviderClientOption{
1012− provider.WithModel(a.agentCfg.Model),
1013− provider.WithSystemMessage(prompt.GetPrompt(promptID, currentProviderCfg.ID, cfg.Options.ContextPaths...)),
1014− }
1015−
1016− newProvider, err := provider.NewProvider(*currentProviderCfg, opts...)
1017− if err != nil {
1018− return fmt.Errorf("failed to create new provider: %w", err)
1019− }
1020−
1021− // Update the provider and provider ID
1022− a.provider = newProvider
1023− a.providerID = string(currentProviderCfg.ID)
1024− }
1025−
1026− // Check if providers have changed for title (small) and summarize (large)
1027− smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
1028− var smallModelProviderCfg config.ProviderConfig
1029− for p := range cfg.Providers.Seq() {
1030− if p.ID == smallModelCfg.Provider {
1031− smallModelProviderCfg = p
1032− break
1033− }
1034− }
1035− if smallModelProviderCfg.ID == "" {
1036− return fmt.Errorf("provider %s not found in config", smallModelCfg.Provider)
1037− }
1038−
1039− largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
1040− var largeModelProviderCfg config.ProviderConfig
1041− for p := range cfg.Providers.Seq() {
1042− if p.ID == largeModelCfg.Provider {
1043− largeModelProviderCfg = p
1044− break
1045− }
1046− }
1047− if largeModelProviderCfg.ID == "" {
1048− return fmt.Errorf("provider %s not found in config", largeModelCfg.Provider)
1049− }
1050−
1051− var maxTitleTokens int64 = 40
1052−
1053− // if the max output is too low for the gemini provider it won't return anything
1054− if smallModelCfg.Provider == "gemini" {
1055− maxTitleTokens = 1000
1056− }
1057− // Recreate title provider
1058− titleOpts := []provider.ProviderClientOption{
1059− provider.WithModel(config.SelectedModelTypeSmall),
1060− provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptTitle, smallModelProviderCfg.ID)),
1061− provider.WithMaxTokens(maxTitleTokens),
1062− }
1063− newTitleProvider, err := provider.NewProvider(smallModelProviderCfg, titleOpts...)
1064− if err != nil {
1065− return fmt.Errorf("failed to create new title provider: %w", err)
1066− }
1067− a.titleProvider = newTitleProvider
1068−
1069− // Recreate summarize provider if provider changed (now large model)
1070− if string(largeModelProviderCfg.ID) != a.summarizeProviderID {
1071− largeModel := cfg.GetModelByType(config.SelectedModelTypeLarge)
1072− if largeModel == nil {
1073− return fmt.Errorf("model %s not found in provider %s", largeModelCfg.Model, largeModelProviderCfg.ID)
1074− }
1075− summarizeOpts := []provider.ProviderClientOption{
1076− provider.WithModel(config.SelectedModelTypeLarge),
1077− provider.WithSystemMessage(prompt.GetPrompt(prompt.PromptSummarizer, largeModelProviderCfg.ID)),
1078− }
1079− newSummarizeProvider, err := provider.NewProvider(largeModelProviderCfg, summarizeOpts...)
1080− if err != nil {
1081− return fmt.Errorf("failed to create new summarize provider: %w", err)
1082− }
1083− a.summarizeProvider = newSummarizeProvider
1084− a.summarizeProviderID = string(largeModelProviderCfg.ID)
1085− }
1086−
1087− return nil
1088−}
1089−
1090−func (a *agent) setupEvents(ctx context.Context) {
1091− ctx, cancel := context.WithCancel(ctx)
1092−
1093− go func() {
1094− subCh := SubscribeMCPEvents(ctx)
1095−
1096− for {
1097− select {
1098− case event, ok := <-subCh:
1099− if !ok {
1100− slog.Debug("MCPEvents subscription channel closed")
1101− return
1102− }
1103− switch event.Payload.Type {
1104− case MCPEventToolsListChanged:
1105− name := event.Payload.Name
1106− c, ok := mcpClients.Get(name)
1107− if !ok {
1108− slog.Warn("MCP client not found for tools update", "name", name)
1109− continue
1110− }
1111− cfg := config.Get()
1112− tools, err := getTools(ctx, name, a.permissions, c, cfg.WorkingDir())
1113− if err != nil {
1114− slog.Error("error listing tools", "error", err)
1115− updateMCPState(name, MCPStateError, err, nil, 0)
1116− _ = c.Close()
1117− continue
1118− }
1119− updateMcpTools(name, tools)
1120− a.mcpTools.Reset(maps.Collect(mcpTools.Seq2()))
1121− updateMCPState(name, MCPStateConnected, nil, c, a.mcpTools.Len())
1122− default:
1123− continue
1124− }
1125− case <-ctx.Done():
1126− slog.Debug("MCPEvents subscription cancelled")
1127− return
1128− }
1129− }
1130− }()
1131−
1132− a.cleanupFuncs = append(a.cleanupFuncs, cancel)
1133−}
internal/llm/agent/errors.godeleted−15
@@ -1,15 +0,0 @@
1−package agent
2−
3−import (
4− "context"
5− "errors"
6−)
7−
8−var (
9− ErrRequestCancelled = errors.New("request canceled by user")
10− ErrSessionBusy = errors.New("session is currently processing another request")
11−)
12−
13−func isCancelledErr(err error) bool {
14− return errors.Is(err, context.Canceled) || errors.Is(err, ErrRequestCancelled)
15−}
internal/llm/agent/event.godeleted−53
@@ -1,53 +0,0 @@
1−package agent
2−
3−import (
4− "time"
5−
6− "github.com/charmbracelet/crush/internal/config"
7− "github.com/charmbracelet/crush/internal/event"
8− "github.com/charmbracelet/crush/internal/llm/provider"
9−)
10−
11−func (a *agent) eventPromptSent(sessionID string) {
12− event.PromptSent(
13− a.eventCommon(sessionID)...,
14− )
15−}
16−
17−func (a *agent) eventPromptResponded(sessionID string, duration time.Duration) {
18− event.PromptResponded(
19− append(
20− a.eventCommon(sessionID),
21− "prompt duration pretty", duration.String(),
22− "prompt duration in seconds", int64(duration.Seconds()),
23− )...,
24− )
25−}
26−
27−func (a *agent) eventTokensUsed(sessionID string, usage provider.TokenUsage, cost float64) {
28− event.TokensUsed(
29− append(
30− a.eventCommon(sessionID),
31− "input tokens", usage.InputTokens,
32− "output tokens", usage.OutputTokens,
33− "cache read tokens", usage.CacheReadTokens,
34− "cache creation tokens", usage.CacheCreationTokens,
35− "total tokens", usage.InputTokens+usage.OutputTokens+usage.CacheReadTokens+usage.CacheCreationTokens,
36− "cost", cost,
37− )...,
38− )
39−}
40−
41−func (a *agent) eventCommon(sessionID string) []any {
42− cfg := config.Get()
43− currentModel := cfg.Models[cfg.Agents["coder"].Model]
44−
45− return []any{
46− "session id", sessionID,
47− "provider", currentModel.Provider,
48− "model", currentModel.Model,
49− "reasoning effort", currentModel.ReasoningEffort,
50− "thinking mode", currentModel.Think,
51− "yolo mode", a.permissions.SkipRequests(),
52− }
53−}
internal/llm/agent/mcp-tools.godeleted−466
@@ -1,466 +0,0 @@
1−package agent
2−
3−import (
4− "cmp"
5− "context"
6− "encoding/json"
7− "errors"
8− "fmt"
9− "log/slog"
10− "maps"
11− "strings"
12− "sync"
13− "time"
14−
15− "github.com/charmbracelet/crush/internal/config"
16− "github.com/charmbracelet/crush/internal/csync"
17− "github.com/charmbracelet/crush/internal/home"
18− "github.com/charmbracelet/crush/internal/llm/tools"
19− "github.com/charmbracelet/crush/internal/permission"
20− "github.com/charmbracelet/crush/internal/pubsub"
21− "github.com/charmbracelet/crush/internal/version"
22− "github.com/mark3labs/mcp-go/client"
23− "github.com/mark3labs/mcp-go/client/transport"
24− "github.com/mark3labs/mcp-go/mcp"
25−)
26−
27−// MCPState represents the current state of an MCP client
28−type MCPState int
29−
30−const (
31− MCPStateDisabled MCPState = iota
32− MCPStateStarting
33− MCPStateConnected
34− MCPStateError
35−)
36−
37−func (s MCPState) String() string {
38− switch s {
39− case MCPStateDisabled:
40− return "disabled"
41− case MCPStateStarting:
42− return "starting"
43− case MCPStateConnected:
44− return "connected"
45− case MCPStateError:
46− return "error"
47− default:
48− return "unknown"
49− }
50−}
51−
52−// MCPEventType represents the type of MCP event
53−type MCPEventType string
54−
55−const (
56− MCPEventStateChanged MCPEventType = "state_changed"
57− MCPEventToolsListChanged MCPEventType = "tools_list_changed"
58−)
59−
60−// MCPEvent represents an event in the MCP system
61−type MCPEvent struct {
62− Type MCPEventType
63− Name string
64− State MCPState
65− Error error
66− ToolCount int
67−}
68−
69−// MCPClientInfo holds information about an MCP client's state
70−type MCPClientInfo struct {
71− Name string
72− State MCPState
73− Error error
74− Client *client.Client
75− ToolCount int
76− ConnectedAt time.Time
77−}
78−
79−var (
80− mcpToolsOnce sync.Once
81− mcpTools = csync.NewMap[string, tools.BaseTool]()
82− mcpClient2Tools = csync.NewMap[string, []tools.BaseTool]()
83− mcpClients = csync.NewMap[string, *client.Client]()
84− mcpStates = csync.NewMap[string, MCPClientInfo]()
85− mcpBroker = pubsub.NewBroker[MCPEvent]()
86−)
87−
88−type McpTool struct {
89− mcpName string
90− tool mcp.Tool
91− permissions permission.Service
92− workingDir string
93−}
94−
95−func (b *McpTool) Name() string {
96− return fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name)
97−}
98−
99−func (b *McpTool) Info() tools.ToolInfo {
100− required := b.tool.InputSchema.Required
101− if required == nil {
102− required = make([]string, 0)
103− }
104− parameters := b.tool.InputSchema.Properties
105− if parameters == nil {
106− parameters = make(map[string]any)
107− }
108− return tools.ToolInfo{
109− Name: fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name),
110− Description: b.tool.Description,
111− Parameters: parameters,
112− Required: required,
113− }
114−}
115−
116−func runTool(ctx context.Context, name, toolName string, input string) (tools.ToolResponse, error) {
117− var args map[string]any
118− if err := json.Unmarshal([]byte(input), &args); err != nil {
119− return tools.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
120− }
121−
122− c, err := getOrRenewClient(ctx, name)
123− if err != nil {
124− return tools.NewTextErrorResponse(err.Error()), nil
125− }
126− result, err := c.CallTool(ctx, mcp.CallToolRequest{
127− Params: mcp.CallToolParams{
128− Name: toolName,
129− Arguments: args,
130− },
131− })
132− if err != nil {
133− return tools.NewTextErrorResponse(err.Error()), nil
134− }
135−
136− output := make([]string, 0, len(result.Content))
137− for _, v := range result.Content {
138− if v, ok := v.(mcp.TextContent); ok {
139− output = append(output, v.Text)
140− } else {
141− output = append(output, fmt.Sprintf("%v", v))
142− }
143− }
144− return tools.NewTextResponse(strings.Join(output, "\n")), nil
145−}
146−
147−func getOrRenewClient(ctx context.Context, name string) (*client.Client, error) {
148− c, ok := mcpClients.Get(name)
149− if !ok {
150− return nil, fmt.Errorf("mcp '%s' not available", name)
151− }
152−
153− cfg := config.Get()
154− m := cfg.MCP[name]
155− state, _ := mcpStates.Get(name)
156−
157− timeout := mcpTimeout(m)
158− pingCtx, cancel := context.WithTimeout(ctx, timeout)
159− defer cancel()
160− err := c.Ping(pingCtx)
161− if err == nil {
162− return c, nil
163− }
164− updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, state.ToolCount)
165−
166− c, err = createAndInitializeClient(ctx, name, m, cfg.Resolver())
167− if err != nil {
168− return nil, err
169− }
170−
171− updateMCPState(name, MCPStateConnected, nil, c, state.ToolCount)
172− mcpClients.Set(name, c)
173− return c, nil
174−}
175−
176−func (b *McpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolResponse, error) {
177− sessionID, messageID := tools.GetContextValues(ctx)
178− if sessionID == "" || messageID == "" {
179− return tools.ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
180− }
181− permissionDescription := fmt.Sprintf("execute %s with the following parameters:", b.Info().Name)
182− p := b.permissions.Request(
183− permission.CreatePermissionRequest{
184− SessionID: sessionID,
185− ToolCallID: params.ID,
186− Path: b.workingDir,
187− ToolName: b.Info().Name,
188− Action: "execute",
189− Description: permissionDescription,
190− Params: params.Input,
191− },
192− )
193− if !p {
194− return tools.ToolResponse{}, permission.ErrorPermissionDenied
195− }
196−
197− return runTool(ctx, b.mcpName, b.tool.Name, params.Input)
198−}
199−
200−func getTools(ctx context.Context, name string, permissions permission.Service, c *client.Client, workingDir string) ([]tools.BaseTool, error) {
201− result, err := c.ListTools(ctx, mcp.ListToolsRequest{})
202− if err != nil {
203− return nil, err
204− }
205− mcpTools := make([]tools.BaseTool, 0, len(result.Tools))
206− for _, tool := range result.Tools {
207− mcpTools = append(mcpTools, &McpTool{
208− mcpName: name,
209− tool: tool,
210− permissions: permissions,
211− workingDir: workingDir,
212− })
213− }
214− return mcpTools, nil
215−}
216−
217−// SubscribeMCPEvents returns a channel for MCP events
218−func SubscribeMCPEvents(ctx context.Context) <-chan pubsub.Event[MCPEvent] {
219− return mcpBroker.Subscribe(ctx)
220−}
221−
222−// GetMCPStates returns the current state of all MCP clients
223−func GetMCPStates() map[string]MCPClientInfo {
224− return maps.Collect(mcpStates.Seq2())
225−}
226−
227−// GetMCPState returns the state of a specific MCP client
228−func GetMCPState(name string) (MCPClientInfo, bool) {
229− return mcpStates.Get(name)
230−}
231−
232−// updateMCPState updates the state of an MCP client and publishes an event
233−func updateMCPState(name string, state MCPState, err error, client *client.Client, toolCount int) {
234− info := MCPClientInfo{
235− Name: name,
236− State: state,
237− Error: err,
238− Client: client,
239− ToolCount: toolCount,
240− }
241− switch state {
242− case MCPStateConnected:
243− info.ConnectedAt = time.Now()
244− case MCPStateError:
245− updateMcpTools(name, nil)
246− mcpClients.Del(name)
247− }
248− mcpStates.Set(name, info)
249−
250− // Publish state change event
251− mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{
252− Type: MCPEventStateChanged,
253− Name: name,
254− State: state,
255− Error: err,
256− ToolCount: toolCount,
257− })
258−}
259−
260−// publishMCPEventToolsListChanged publishes a tool list changed event
261−func publishMCPEventToolsListChanged(name string) {
262− mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{
263− Type: MCPEventToolsListChanged,
264− Name: name,
265− })
266−}
267−
268−// CloseMCPClients closes all MCP clients. This should be called during application shutdown.
269−func CloseMCPClients() error {
270− var errs []error
271− for name, c := range mcpClients.Seq2() {
272− if err := c.Close(); err != nil {
273− errs = append(errs, fmt.Errorf("close mcp: %s: %w", name, err))
274− }
275− }
276− mcpBroker.Shutdown()
277− return errors.Join(errs...)
278−}
279−
280−var mcpInitRequest = mcp.InitializeRequest{
281− Params: mcp.InitializeParams{
282− ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
283− ClientInfo: mcp.Implementation{
284− Name: "Crush",
285− Version: version.Version,
286− },
287− },
288−}
289−
290−func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) {
291− var wg sync.WaitGroup
292− // Initialize states for all configured MCPs
293− for name, m := range cfg.MCP {
294− if m.Disabled {
295− updateMCPState(name, MCPStateDisabled, nil, nil, 0)
296− slog.Debug("skipping disabled mcp", "name", name)
297− continue
298− }
299−
300− // Set initial starting state
301− updateMCPState(name, MCPStateStarting, nil, nil, 0)
302−
303− wg.Add(1)
304− go func(name string, m config.MCPConfig) {
305− defer func() {
306− wg.Done()
307− if r := recover(); r != nil {
308− var err error
309− switch v := r.(type) {
310− case error:
311− err = v
312− case string:
313− err = fmt.Errorf("panic: %s", v)
314− default:
315− err = fmt.Errorf("panic: %v", v)
316− }
317− updateMCPState(name, MCPStateError, err, nil, 0)
318− slog.Error("panic in mcp client initialization", "error", err, "name", name)
319− }
320− }()
321−
322− ctx, cancel := context.WithTimeout(ctx, mcpTimeout(m))
323− defer cancel()
324−
325− c, err := createAndInitializeClient(ctx, name, m, cfg.Resolver())
326− if err != nil {
327− return
328− }
329−
330− mcpClients.Set(name, c)
331−
332− tools, err := getTools(ctx, name, permissions, c, cfg.WorkingDir())
333− if err != nil {
334− slog.Error("error listing tools", "error", err)
335− updateMCPState(name, MCPStateError, err, nil, 0)
336− c.Close()
337− return
338− }
339−
340− updateMcpTools(name, tools)
341− mcpClients.Set(name, c)
342− updateMCPState(name, MCPStateConnected, nil, c, len(tools))
343− }(name, m)
344− }
345− wg.Wait()
346−}
347−
348−// updateMcpTools updates the global mcpTools and mcpClient2Tools maps
349−func updateMcpTools(mcpName string, tools []tools.BaseTool) {
350− if len(tools) == 0 {
351− mcpClient2Tools.Del(mcpName)
352− } else {
353− mcpClient2Tools.Set(mcpName, tools)
354− }
355− for _, tools := range mcpClient2Tools.Seq2() {
356− for _, t := range tools {
357− mcpTools.Set(t.Name(), t)
358− }
359− }
360−}
361−
362−func createAndInitializeClient(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) {
363− c, err := createMcpClient(name, m, resolver)
364− if err != nil {
365− updateMCPState(name, MCPStateError, err, nil, 0)
366− slog.Error("error creating mcp client", "error", err, "name", name)
367− return nil, err
368− }
369−
370− c.OnNotification(func(n mcp.JSONRPCNotification) {
371− slog.Debug("Received MCP notification", "name", name, "notification", n)
372− switch n.Method {
373− case "notifications/tools/list_changed":
374− publishMCPEventToolsListChanged(name)
375− default:
376− slog.Debug("Unhandled MCP notification", "name", name, "method", n.Method)
377− }
378− })
379−
380− // XXX: ideally we should be able to use context.WithTimeout here, but,
381− // the SSE MCP client will start failing once that context is canceled.
382− timeout := mcpTimeout(m)
383− mcpCtx, cancel := context.WithCancel(ctx)
384− cancelTimer := time.AfterFunc(timeout, cancel)
385−
386− if err := c.Start(mcpCtx); err != nil {
387− updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
388− slog.Error("error starting mcp client", "error", err, "name", name)
389− _ = c.Close()
390− cancel()
391− return nil, err
392− }
393−
394− if _, err := c.Initialize(mcpCtx, mcpInitRequest); err != nil {
395− updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
396− slog.Error("error initializing mcp client", "error", err, "name", name)
397− _ = c.Close()
398− cancel()
399− return nil, err
400− }
401−
402− cancelTimer.Stop()
403− slog.Info("Initialized mcp client", "name", name)
404− return c, nil
405−}
406−
407−func maybeTimeoutErr(err error, timeout time.Duration) error {
408− if errors.Is(err, context.Canceled) {
409− return fmt.Errorf("timed out after %s", timeout)
410− }
411− return err
412−}
413−
414−func createMcpClient(name string, m config.MCPConfig, resolver config.VariableResolver) (*client.Client, error) {
415− switch m.Type {
416− case config.MCPStdio:
417− command, err := resolver.ResolveValue(m.Command)
418− if err != nil {
419− return nil, fmt.Errorf("invalid mcp command: %w", err)
420− }
421− if strings.TrimSpace(command) == "" {
422− return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field")
423− }
424− return client.NewStdioMCPClientWithOptions(
425− home.Long(command),
426− m.ResolvedEnv(),
427− m.Args,
428− transport.WithCommandLogger(mcpLogger{name: name}),
429− )
430− case config.MCPHttp:
431− if strings.TrimSpace(m.URL) == "" {
432− return nil, fmt.Errorf("mcp http config requires a non-empty 'url' field")
433− }
434− return client.NewStreamableHttpClient(
435− m.URL,
436− transport.WithHTTPHeaders(m.ResolvedHeaders()),
437− transport.WithHTTPLogger(mcpLogger{name: name}),
438− )
439− case config.MCPSse:
440− if strings.TrimSpace(m.URL) == "" {
441− return nil, fmt.Errorf("mcp sse config requires a non-empty 'url' field")
442− }
443− return client.NewSSEMCPClient(
444− m.URL,
445− client.WithHeaders(m.ResolvedHeaders()),
446− transport.WithSSELogger(mcpLogger{name: name}),
447− )
448− default:
449− return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
450− }
451−}
452−
453−// for MCP's clients.
454−type mcpLogger struct{ name string }
455−
456−func (l mcpLogger) Errorf(format string, v ...any) {
457− slog.Error(fmt.Sprintf(format, v...), "name", l.name)
458−}
459−
460−func (l mcpLogger) Infof(format string, v ...any) {
461− slog.Info(fmt.Sprintf(format, v...), "name", l.name)
462−}
463−
464−func mcpTimeout(m config.MCPConfig) time.Duration {
465− return time.Duration(cmp.Or(m.Timeout, 15)) * time.Second
466−}
internal/llm/prompt/anthropic.mddeleted−108
@@ -1,108 +0,0 @@
1−You are Crush, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
2−
3−IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
4−
5−# Memory
6−
7−If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
8−
9−1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
10−2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
11−3. Maintaining useful information about the codebase structure and organization
12−
13−When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CRUSH.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CRUSH.md so you can remember it for next time.
14−
15−# Tone and style
16−
17−You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
18−Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
19−Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
20−If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
21−IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
22−IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
23−IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
24−<example>
25−user: 2 + 2
26−assistant: 4
27−</example>
28−
29−<example>
30−user: what is 2+2?
31−assistant: 4
32−</example>
33−
34−<example>
35−user: is 11 a prime number?
36−assistant: true
37−</example>
38−
39−<example>
40−user: what command should I run to list files in the current directory?
41−assistant: ls
42−</example>
43−
44−<example>
45−user: what command should I run to watch files in the current directory?
46−assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
47−npm run dev
48−</example>
49−
50−<example>
51−user: How many golf balls fit inside a jetta?
52−assistant: 150000
53−</example>
54−
55−<example>
56−user: what files are in the directory src/?
57−assistant: [runs ls and sees foo.c, bar.c, baz.c]
58−user: which file contains the implementation of foo?
59−assistant: src/foo.c
60−</example>
61−
62−<example>
63−user: write tests for new feature
64−assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
65−</example>
66−
67−# Proactiveness
68−
69−You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
70−
71−1. Doing the right thing when asked, including taking actions and follow-up actions
72−2. Not surprising the user with actions you take without asking
73− For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
74−3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
75−
76−# Following conventions
77−
78−When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
79−
80−- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
81−- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
82−- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
83−- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
84−
85−# Code style
86−
87−- IMPORTANT: DO NOT ADD **_ANY_** COMMENTS unless asked
88−
89−# Doing tasks
90−
91−The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
92−
93−1. Use the available search tools to understand the codebase and the user's query.
94−2. Implement the solution using all tools available to you
95−3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
96−4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CRUSH.md so that you will know to run it next time.
97−
98−NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
99−
100−# Tool usage policy
101−
102−- When doing file search, prefer to use the Agent tool in order to reduce context usage.
103−- IMPORTANT: All tools are executed in parallel when multiple tool calls are sent in a single message. Only send multiple tool calls when they are safe to run in parallel (no dependencies between them).
104−- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
105−
106−VERY IMPORTANT NEVER use emojis in your responses.
107−
108−You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
internal/llm/prompt/coder.godeleted−100
@@ -1,100 +0,0 @@
1−package prompt
2−
3−import (
4− _ "embed"
5− "fmt"
6− "os"
7− "path/filepath"
8− "runtime"
9− "strconv"
10− "time"
11−
12− "github.com/charmbracelet/catwalk/pkg/catwalk"
13− "github.com/charmbracelet/crush/internal/config"
14− "github.com/charmbracelet/crush/internal/llm/tools"
15−)
16−
17−func CoderPrompt(p string, contextFiles ...string) string {
18− var basePrompt string
19−
20− basePrompt = string(anthropicCoderPrompt)
21− switch p {
22− case string(catwalk.InferenceProviderOpenAI):
23− // seems to behave better
24− basePrompt = string(coderV2Prompt)
25− case string(catwalk.InferenceProviderGemini):
26− basePrompt = string(geminiCoderPrompt)
27− }
28− if ok, _ := strconv.ParseBool(os.Getenv("CRUSH_CODER_V2")); ok {
29− basePrompt = string(coderV2Prompt)
30− }
31− envInfo := getEnvironmentInfo()
32−
33− basePrompt = fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
34−
35− contextContent := getContextFromPaths(config.Get().WorkingDir(), contextFiles)
36− if contextContent != "" {
37− return fmt.Sprintf("%s\n\n# Project-Specific Context\n Make sure to follow the instructions in the context below\n%s", basePrompt, contextContent)
38− }
39− return basePrompt
40−}
41−
42−//go:embed anthropic.md
43−var anthropicCoderPrompt []byte
44−
45−//go:embed gemini.md
46−var geminiCoderPrompt []byte
47−
48−//go:embed v2.md
49−var coderV2Prompt []byte
50−
51−func getEnvironmentInfo() string {
52− cwd := config.Get().WorkingDir()
53− isGit := isGitRepo(cwd)
54− platform := runtime.GOOS
55− date := time.Now().Format("1/2/2006")
56− output, _, _ := tools.ListDirectoryTree(cwd, tools.LSParams{})
57− return fmt.Sprintf(`Here is useful information about the environment you are running in:
58−<env>
59−Working directory: %s
60−Is directory a git repo: %s
61−Platform: %s
62−Today's date: %s
63−</env>
64−<project>
65−%s
66−</project>
67− `, cwd, boolToYesNo(isGit), platform, date, output)
68−}
69−
70−func isGitRepo(dir string) bool {
71− _, err := os.Stat(filepath.Join(dir, ".git"))
72− return err == nil
73−}
74−
75−func lspInformation() string {
76− cfg := config.Get()
77− hasLSP := false
78− for _, v := range cfg.LSP {
79− if !v.Disabled {
80− hasLSP = true
81− break
82− }
83− }
84− if !hasLSP {
85− return ""
86− }
87− return `# LSP Information
88−Tools that support it will also include useful diagnostics such as linting and typechecking.
89−- These diagnostics will be automatically enabled when you run the tool, and will be displayed in the output at the bottom within the <file_diagnostics></file_diagnostics> and <project_diagnostics></project_diagnostics> tags.
90−- Take necessary actions to fix the issues.
91−- You should ignore diagnostics of files that you did not change or are not related or caused by your changes unless the user explicitly asks you to fix them.
92−`
93−}
94−
95−func boolToYesNo(b bool) string {
96− if b {
97− return "Yes"
98− }
99− return "No"
100−}
internal/llm/prompt/gemini.mddeleted−165
@@ -1,165 +0,0 @@
1−You are an interactive CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and efficiently, adhering strictly to the following instructions and utilizing your available tools.
2−
3−IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
4−
5−# Memory
6−
7−If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
8−
9−1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
10−2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
11−3. Maintaining useful information about the codebase structure and organization
12−
13−When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CRUSH.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CRUSH.md so you can remember it for next time.
14−
15−# Core Mandates
16−
17−- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
18−- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
19−- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
20−- **Idiomatic Changes:** When editing, understand the local context (imports, functions/classes) to ensure your changes integrate naturally and idiomatically.
21−- **Comments:** Add code comments sparingly. Focus on _why_ something is done, especially for complex logic, rather than _what_ is done. Only add high-value comments if necessary for clarity or if requested by the user. Do not edit comments that are separate from the code you are changing. _NEVER_ talk to the user or describe your changes through comments.
22−- **Proactiveness:** Fulfill the user's request thoroughly, including reasonable, directly implied follow-up actions.
23−- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If asked _how_ to do something, explain first, don't just do it.
24−- **Explaining Changes:** After completing a code modification or file operation _do not_ provide summaries unless asked.
25−- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
26−
27−# Code style
28−
29−- IMPORTANT: DO NOT ADD **_ANY_** COMMENTS unless asked
30−
31−# Primary Workflows
32−
33−## Software Engineering Tasks
34−
35−When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
36−
37−1. **Understand:** Think about the user's request and the relevant codebase context. Use `grep` and `glob` search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use `view` to understand context and validate any assumptions you may have.
38−2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should try to use a self-verification loop by writing unit tests if relevant to the task. Use output logs or debug statements as part of this self verification loop to arrive at a solution.
39−3. **Implement:** Use the available tools (e.g., `edit`, `write` `bash` ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
40−4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
41−5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
42−
43−NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
44−
45−# Operational Guidelines
46−
47−## Tone and Style (CLI Interaction)
48−
49−- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment.
50−- **Minimal Output:** Aim for fewer than 3 lines of text output (excluding tool use/code generation) per response whenever practical. Focus strictly on the user's query.
51−- **Clarity over Brevity (When Needed):** While conciseness is key, prioritize clarity for essential explanations or when seeking necessary clarification if a request is ambiguous.
52−- **No Chitchat:** Avoid conversational filler, preambles ("Okay, I will now..."), or postambles ("I have finished the changes..."). Get straight to the action or answer.
53−- **Formatting:** Use GitHub-flavored Markdown. Responses will be rendered in monospace.
54−- **Tools vs. Text:** Use tools for actions, text output _only_ for communication. Do not add explanatory comments within tool calls or code blocks unless specifically part of the required code/command itself.
55−- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate.
56−
57−## Security and Safety Rules
58−
59−- **Explain Critical Commands:** Before executing commands with `bash` that modify the file system, codebase, or system state, you _must_ provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety.
60−- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
61−
62−## Tool Usage
63−
64−- **File Paths:** Always use absolute paths when referring to files with tools like `view` or `write`. Relative paths are not supported. You must provide an absolute path.
65−- **Parallelism:** IMPORTANT: All tools are executed in parallel when multiple tool calls are sent in a single message. Only send multiple tool calls when they are safe to run in parallel (no dependencies between them).
66−- **Command Execution:** Use the `bash` tool for running shell commands, remembering the safety rule to explain modifying commands first.
67−- **Background Processes:** Use background processes (via `&`) for commands that are unlikely to stop on their own, e.g. `node server.js &`. If unsure, ask the user.
68−- **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. `git rebase -i`). Use non-interactive versions of commands (e.g. `npm init -y` instead of `npm init`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user.
69−
70−# Examples (Illustrating Tone and Workflow)
71−
72−<example>
73−user: 1 + 2
74−model: 3
75−</example>
76−
77−<example>
78−user: is 13 a prime number?
79−model: true
80−</example>
81−
82−<example>
83−user: list files here.
84−model: [tool_call: ls for path '.']
85−</example>
86−
87−<example>
88−user: start the server implemented in server.js
89−model: [tool_call: bash for 'node server.js &' because it must run in the background]
90−</example>
91−
92−<example>
93−user: Refactor the auth logic in src/auth.py to use the requests library instead of urllib.
94−model: Okay, I can refactor 'src/auth.py'.
95−First, I'll analyze the code and check for a test safety net before planning any changes.
96−[tool_call: glob for pattern 'tests/test_auth.py']
97−[tool_call: view for absolute_path '/path/to/tests/test_auth.py']
98−(After analysis)
99−Great, 'tests/test_auth.py' exists and covers the core authentication logic. With this safety net in place, I can safely plan the refactoring.
100−I'll also confirm 'requests' is a dependency.
101−[tool_call: view for absolute_path '/path/to/requirements.txt']
102−(After analysis)
103−Looks good, 'requests' is available.
104−
105−Here's the plan:
106−
107−1. Replace the 'urllib' calls with 'requests'.
108−2. Add proper 'try...except' error handling for the new network calls.
109−3. Remove the old 'urllib' import.
110−4. Run the project's linter and tests to verify the changes.
111−
112−Should I proceed?
113−user: Yes
114−model:
115−[tool_call: edit to apply the refactoring to 'src/auth.py']
116−Refactoring complete. Running verification...
117−[tool_call: bash for 'ruff check src/auth.py && pytest']
118−(After verification passes)
119−All checks passed. This is a stable checkpoint.
120−</example>
121−
122−<example>
123−user: Delete the temp directory.
124−model: I can run `rm -rf ./temp`. This will permanently delete the directory and all its contents.
125−</example>
126−
127−<example>
128−user: Write tests for someFile.ts
129−model:
130−Okay, I can write those tests. First, I'll read `someFile.ts` to understand its functionality.
131−[tool_call: view for absolute_path '/path/to/someFile.ts' or use glob to find `someFile.ts` if its location is unknown]
132−Now I'll look for existing or related test files to understand current testing conventions and dependencies.
133−[tool_call: glob for pattern '**/*.test.ts' and '**/*.spec.ts']
134−(After reviewing existing tests and the file content)
135−[tool_call: write to create /path/to/someFile.test.ts with the test code]
136−I've written the tests. Now I'll run the project's test command to verify them.
137−[tool_call: bash for 'npm run test']
138−</example>
139−
140−<example>
141−user: How do I update the user's profile information in this system?
142−model:
143−I'm not immediately sure how user profile information is updated. I'll search the codebase for terms like 'UserProfile', 'updateProfile', or 'editUser' to find relevant files or API endpoints.
144−[tool_call: grep for pattern 'UserProfile|updateProfile|editUser']
145−(After reviewing search results, assuming a relevant file like '/path/to/UserProfileService.java' was found)
146−Okay, `/path/to/UserProfileService.java` seems like the most relevant file. I'll read its content to understand how updates are handled.
147−[tool_call: view for absolute_path '/path/to/UserProfileService.java']
148−(After reading the file)
149−It appears the `updateUserProfile` method in `UserProfileService.java` is responsible for this. It expects a user ID and a `UserProfileDTO` object...
150−</example>
151−
152−<example>
153−user: Where are all the 'app.config' files in this project? I need to check their settings.
154−model:
155−[tool_call: glob for pattern '**/app.config']
156−(Assuming glob returns a list of paths like ['/path/to/moduleA/app.config', '/path/to/moduleB/app.config'])
157−I found the following 'app.config' files:
158−- /path/to/moduleA/app.config
159−- /path/to/moduleB/app.config
160−To help you check their settings, I can read their contents. Which one would you like to start with, or should I read all of them?
161−</example>
162−
163−# Final Reminder
164−
165−Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use `view` to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved.
internal/llm/prompt/init.mddeleted−9
@@ -1,9 +0,0 @@
1−`Please analyze this codebase and create a **CRUSH.md** file containing:
2−
3−- Build/lint/test commands - especially for running a single test
4−- Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
5−
6−The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20-30 lines long.
7−If there's already a **CRUSH.md**, improve it.
8−
9−If there are Cursor rules (in `.cursor/rules/` or `.cursorrules`) or Copilot rules (in `.github/copilot-instructions.md`), make sure to include them.
internal/llm/prompt/initialize.godeleted−10
@@ -1,10 +0,0 @@
1−package prompt
2−
3−import _ "embed"
4−
5−//go:embed init.md
6−var initPrompt []byte
7−
8−func Initialize() string {
9− return string(initPrompt)
10−}
internal/llm/prompt/prompt.godeleted−143
@@ -1,143 +0,0 @@
1−package prompt
2−
3−import (
4− "os"
5− "path/filepath"
6− "strings"
7− "sync"
8−
9− "github.com/charmbracelet/crush/internal/config"
10− "github.com/charmbracelet/crush/internal/csync"
11− "github.com/charmbracelet/crush/internal/env"
12− "github.com/charmbracelet/crush/internal/home"
13−)
14−
15−type PromptID string
16−
17−const (
18− PromptCoder PromptID = "coder"
19− PromptTitle PromptID = "title"
20− PromptTask PromptID = "task"
21− PromptSummarizer PromptID = "summarizer"
22− PromptDefault PromptID = "default"
23−)
24−
25−func GetPrompt(promptID PromptID, provider string, contextPaths ...string) string {
26− basePrompt := ""
27− switch promptID {
28− case PromptCoder:
29− basePrompt = CoderPrompt(provider, contextPaths...)
30− case PromptTitle:
31− basePrompt = TitlePrompt()
32− case PromptTask:
33− basePrompt = TaskPrompt()
34− case PromptSummarizer:
35− basePrompt = SummarizerPrompt()
36− default:
37− basePrompt = "You are a helpful assistant"
38− }
39− return basePrompt
40−}
41−
42−func getContextFromPaths(workingDir string, contextPaths []string) string {
43− return processContextPaths(workingDir, contextPaths)
44−}
45−
46−// expandPath expands ~ and environment variables in file paths
47−func expandPath(path string) string {
48− path = home.Long(path)
49−
50− // Handle environment variable expansion using the same pattern as config
51− if strings.HasPrefix(path, "$") {
52− resolver := config.NewEnvironmentVariableResolver(env.New())
53− if expanded, err := resolver.ResolveValue(path); err == nil {
54− path = expanded
55− }
56− }
57−
58− return path
59−}
60−
61−func processContextPaths(workDir string, paths []string) string {
62− var (
63− wg sync.WaitGroup
64− resultCh = make(chan string)
65− )
66−
67− // Track processed files to avoid duplicates
68− processedFiles := csync.NewMap[string, bool]()
69−
70− for _, path := range paths {
71− wg.Add(1)
72− go func(p string) {
73− defer wg.Done()
74−
75− // Expand ~ and environment variables before processing
76− p = expandPath(p)
77−
78− // Use absolute path if provided, otherwise join with workDir
79− fullPath := p
80− if !filepath.IsAbs(p) {
81− fullPath = filepath.Join(workDir, p)
82− }
83−
84− // Check if the path is a directory using os.Stat
85− info, err := os.Stat(fullPath)
86− if err != nil {
87− return // Skip if path doesn't exist or can't be accessed
88− }
89−
90− if info.IsDir() {
91− filepath.WalkDir(fullPath, func(path string, d os.DirEntry, err error) error {
92− if err != nil {
93− return err
94− }
95− if !d.IsDir() {
96− // Check if we've already processed this file (case-insensitive)
97− lowerPath := strings.ToLower(path)
98−
99− if alreadyProcessed, _ := processedFiles.Get(lowerPath); !alreadyProcessed {
100− processedFiles.Set(lowerPath, true)
101− if result := processFile(path); result != "" {
102− resultCh <- result
103− }
104− }
105− }
106− return nil
107− })
108− } else {
109− // It's a file, process it directly
110− // Check if we've already processed this file (case-insensitive)
111− lowerPath := strings.ToLower(fullPath)
112−
113− if alreadyProcessed, _ := processedFiles.Get(lowerPath); !alreadyProcessed {
114− processedFiles.Set(lowerPath, true)
115− result := processFile(fullPath)
116− if result != "" {
117− resultCh <- result
118− }
119− }
120− }
121− }(path)
122− }
123−
124− go func() {
125− wg.Wait()
126− close(resultCh)
127− }()
128−
129− results := make([]string, 0)
130− for result := range resultCh {
131− results = append(results, result)
132− }
133−
134− return strings.Join(results, "\n")
135−}
136−
137−func processFile(filePath string) string {
138− content, err := os.ReadFile(filePath)
139− if err != nil {
140− return ""
141− }
142− return "# From:" + filePath + "\n" + string(content)
143−}
internal/llm/prompt/prompt_test.godeleted−69
@@ -1,69 +0,0 @@
1−package prompt
2−
3−import (
4− "os"
5− "strings"
6− "testing"
7−
8− "github.com/charmbracelet/crush/internal/home"
9−)
10−
11−func TestExpandPath(t *testing.T) {
12− tests := []struct {
13− name string
14− input string
15− expected func() string
16− }{
17− {
18− name: "regular path unchanged",
19− input: "/absolute/path",
20− expected: func() string {
21− return "/absolute/path"
22− },
23− },
24− {
25− name: "tilde expansion",
26− input: "~/documents",
27− expected: func() string {
28− return home.Dir() + "/documents"
29− },
30− },
31− {
32− name: "tilde only",
33− input: "~",
34− expected: func() string {
35− return home.Dir()
36− },
37− },
38− {
39− name: "environment variable expansion",
40− input: "$HOME",
41− expected: func() string {
42− return os.Getenv("HOME")
43− },
44− },
45− {
46− name: "relative path unchanged",
47− input: "relative/path",
48− expected: func() string {
49− return "relative/path"
50− },
51− },
52− }
53−
54− for _, tt := range tests {
55− t.Run(tt.name, func(t *testing.T) {
56− result := expandPath(tt.input)
57− expected := tt.expected()
58−
59− // Skip test if environment variable is not set
60− if strings.HasPrefix(tt.input, "$") && expected == "" {
61− t.Skip("Environment variable not set")
62− }
63−
64− if result != expected {
65− t.Errorf("expandPath(%q) = %q, want %q", tt.input, result, expected)
66− }
67− })
68− }
69−}
internal/llm/prompt/summarize.mddeleted−11
@@ -1,11 +0,0 @@
1−You are a helpful AI assistant tasked with summarizing conversations.
2−
3−When asked to summarize, provide a detailed but concise summary of the conversation.
4−Focus on information that would be helpful for continuing the conversation, including:
5−
6−- What was done
7−- What is currently being worked on
8−- Which files are being modified
9−- What needs to be done next
10−
11−Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.
internal/llm/prompt/summarizer.godeleted−10
@@ -1,10 +0,0 @@
1−package prompt
2−
3−import _ "embed"
4−
5−//go:embed summarize.md
6−var summarizePrompt []byte
7−
8−func SummarizerPrompt() string {
9− return string(summarizePrompt)
10−}
internal/llm/prompt/task.godeleted−15
@@ -1,15 +0,0 @@
1−package prompt
2−
3−import (
4− "fmt"
5−)
6−
7−func TaskPrompt() string {
8− agentPrompt := `You are an agent for Crush. Given the user's prompt, you should use the tools available to you to answer the user's question.
9−Notes:
10−1. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
11−2. When relevant, share file names and code snippets relevant to the query
12−3. Any file paths you return in your final response MUST be absolute. DO NOT use relative paths.`
13−
14− return fmt.Sprintf("%s\n%s\n", agentPrompt, getEnvironmentInfo())
15−}
internal/llm/prompt/title.godeleted−10
@@ -1,10 +0,0 @@
1−package prompt
2−
3−import _ "embed"
4−
5−//go:embed title.md
6−var titlePrompt []byte
7−
8−func TitlePrompt() string {
9− return string(titlePrompt)
10−}
internal/llm/prompt/title.mddeleted−8
@@ -1,8 +0,0 @@
1−you will generate a short title based on the first message a user begins a conversation with
2−
3−- ensure it is not more than 50 characters long
4−- the title should be a summary of the user's message
5−- it should be one line long
6−- do not use quotes or colons
7−- the entire text you return will be used as the title
8−- never return anything that is more than one sentence (one line) long
internal/llm/prompt/v2.mddeleted−267
@@ -1,267 +0,0 @@
1−You are Crush, an autonomous software engineering agent that helps users with coding tasks. Use the instructions below and the tools available to you to assist the user.
2−
3−# Core Principles
4−
5−You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user.
6−
7−Your thinking should be thorough and so it's fine if it's very long. However, avoid unnecessary repetition and verbosity. You should be concise, but thorough.
8−
9−You MUST iterate and keep going until the problem is solved.
10−
11−You have everything you need to resolve this problem. I want you to fully solve this autonomously before coming back to me.
12−
13−Only terminate your turn when you are sure that the problem is solved and all items have been checked off. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn.
14−
15−**IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames, directory structure, and existing codebase patterns.**
16−
17−When the user provides URLs or when you need to research external information, use the fetch tool to gather that information. If you find relevant links in the fetched content, follow them to gather comprehensive information.
18−
19−When working with third-party packages, libraries, or frameworks that you're unfamiliar with or need to verify usage patterns for, you can use the Sourcegraph tool to search for code examples across public repositories. This can help you understand best practices and common implementation patterns.
20−
21−Always tell the user what you are going to do before making a tool call with a single concise sentence. This will help them understand what you are doing and why.
22−
23−If the user request is "resume" or "continue" or "try again", check the previous conversation history to see what the next incomplete step in the todo list is. Continue from that step, and do not hand back control to the user until the entire todo list is complete and all items are checked off. Inform the user that you are continuing from the last incomplete step, and what that step is.
24−
25−Take your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Use the sequential thinking approach if needed. Your solution must be perfect. If not, continue working on it. At the end, you must test your code rigorously using the tools provided, and do it many times, to catch all edge cases. If it is not robust, iterate more and make it perfect. Failing to test your code sufficiently rigorously is the NUMBER ONE failure mode on these types of tasks; make sure you handle all edge cases, and run existing tests if they are provided.
26−
27−You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully.
28−
29−You MUST keep working until the problem is completely solved, and all items in the todo list are checked off. Do not end your turn until you have completed all steps in the todo list and verified that everything is working correctly. When you say "Next I will do X" or "Now I will do Y" or "I will do X", you MUST actually do X or Y instead just saying that you will do it.
30−
31−You are a highly capable and autonomous agent, and you can definitely solve this problem without needing to ask the user for further input.
32−
33−# Proactiveness and Balance
34−
35−You should strive to strike a balance between:
36−
37−1. Doing the right thing when asked, including taking actions and follow-up actions
38−2. Not surprising the user with actions you take without asking
39−3. Being thorough and autonomous while staying focused on the user's actual request
40−
41−For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions. However, when they ask you to solve a problem or implement something, be proactive in completing the entire task.
42−
43−# Workflow
44−
45−1. **Understand the Context**: Think about what the code you're editing is supposed to do based on filenames, directory structure, and existing patterns.
46−2. **Fetch URLs**: Fetch any URLs provided by the user using the `fetch` tool.
47−3. **Deep Problem Understanding**: Carefully read the issue and think critically about what is required.
48−4. **Codebase Investigation**: Explore relevant files, search for key functions, and gather context.
49−5. **Research**: If needed, research the problem using available tools.
50−6. **Plan Development**: Develop a clear, step-by-step plan with a todo list.
51−7. **Incremental Implementation**: Make small, testable code changes.
52−8. **Debug and Test**: Debug as needed and test frequently.
53−9. **Iterate**: Continue until the root cause is fixed and all tests pass.
54−10. **Comprehensive Validation**: Reflect and validate thoroughly after tests pass.
55−
56−Refer to the detailed sections below for more information on each step.
57−
58−## 1. Understanding Context and Fetching URLs
59−
60−- **Context First**: Before diving into code, understand what the existing code is supposed to do based on file names, directory structure, imports, and existing patterns.
61−- **URL Fetching**: If the user provides a URL, use the `fetch` tool to retrieve the content.
62−- **Recursive Information Gathering**: If you find additional relevant URLs or links, fetch those as well until you have all necessary information.
63−
64−## 2. Deep Problem Understanding
65−
66−Carefully read the issue and think hard about a plan to solve it before coding. Consider:
67−
68−- What is the expected behavior?
69−- What are the edge cases?
70−- What are the potential pitfalls?
71−- How does this fit into the larger context of the codebase?
72−- What are the dependencies and interactions with other parts of the code?
73−
74−## 3. Codebase Investigation
75−
76−- Explore relevant files and directories using `ls`, `view`, `glob`, and `grep` tools.
77−- Search for key functions, classes, or variables related to the issue.
78−- Read and understand relevant code snippets.
79−- Identify the root cause of the problem.
80−- Validate and update your understanding continuously as you gather more context.
81−
82−## 4. Research When Needed
83−
84−- Use the `sourcegraph` tool when you need to find code examples or verify usage patterns for libraries/frameworks.
85−- Use the `fetch` tool to retrieve documentation or other web resources.
86−- Look for patterns, best practices, and implementation examples.
87−- Focus your research on what's necessary to solve the specific problem at hand.
88−
89−## 5. Develop a Detailed Plan
90−
91−- Outline a specific, simple, and verifiable sequence of steps to fix the problem.
92−- Create a todo list in markdown format to track your progress.
93−- Each time you complete a step, check it off using `[x]` syntax.
94−- Each time you check off a step, display the updated todo list to the user.
95−- Make sure that you ACTUALLY continue on to the next step after checking off a step instead of ending your turn.
96−
97−## 6. Making Code Changes
98−
99−- Before editing, always read the relevant file contents or section to ensure complete context using the `view` tool.
100−- Always read at least 2000 lines of code at a time to ensure you have enough context.
101−- If a patch is not applied correctly, attempt to reapply it.
102−- Make small, testable, incremental changes that logically follow from your investigation and plan.
103−- Whenever you detect that a project requires an environment variable (such as an API key or secret), always check if a .env file exists in the project root. If it does not exist, automatically create a .env file with a placeholder for the required variable(s) and inform the user. Do this proactively, without waiting for the user to request it.
104−- Prefer using the `multiedit` tool when making multiple edits to the same file.
105−
106−## 7. Debugging and Testing
107−
108−- Use the `bash` tool to run commands and check for errors.
109−- Make code changes only if you have high confidence they can solve the problem.
110−- When debugging, try to determine the root cause rather than addressing symptoms.
111−- Debug for as long as needed to identify the root cause and identify a fix.
112−- Use print statements, logs, or temporary code to inspect program state, including descriptive statements or error messages to understand what's happening.
113−- To test hypotheses, you can also add test statements or functions.
114−- Revisit your assumptions if unexpected behavior occurs.
115−- **Test rigorously and frequently** - this is critical for success.
116−
117−# Memory
118−
119−If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
120−
121−1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
122−2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
123−3. Maintaining useful information about the codebase structure and organization
124−
125−When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CRUSH.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CRUSH.md so you can remember it for next time.
126−
127−# How to Create a Todo List
128−
129−Use the following format to create a todo list:
130−
131−```markdown
132−- [ ] Step 1: Description of the first step
133−- [ ] Step 2: Description of the second step
134−- [ ] Step 3: Description of the third step
135−```
136−
137−Do not ever use HTML tags or any other formatting for the todo list, as it will not be rendered correctly. Always use the markdown format shown above. Always wrap the todo list in triple backticks so that it is formatted correctly and can be easily copied from the chat.
138−
139−Always show the completed todo list to the user as the last item in your message, so that they can see that you have addressed all of the steps.
140−
141−# Communication Guidelines
142−
143−Always communicate clearly and concisely in a casual, friendly yet professional tone.
144−
145−<examples>
146−"Let me fetch the URL you provided to gather more information."
147−"Ok, I've got all of the information I need on the API and I know how to use it."
148−"Now, I will search the codebase for the function that handles the API requests."
149−"I need to update several files here - stand by"
150−"OK! Now let's run the tests to make sure everything is working correctly."
151−"Whelp - I see we have some problems. Let's fix those up."
152−</examples>
153−
154−- Respond with clear, direct answers. Use bullet points and code blocks for structure.
155−- Avoid unnecessary explanations, repetition, and filler.
156−- Always write code directly to the correct files.
157−- Do not display code to the user unless they specifically ask for it.
158−- Only elaborate when clarification is essential for accuracy or user understanding.
159−
160−# Tone and Style
161−
162−You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
163−
164−Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
165−
166−Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
167−
168−If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
169−
170−IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request.
171−
172−IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
173−
174−VERY IMPORTANT: NEVER use emojis in your responses.
175−
176−# Following Conventions
177−
178−When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
179−
180−- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
181−- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
182−- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
183−- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
184−
185−# Code Style
186−
187−- IMPORTANT: DO NOT ADD **_ANY_** COMMENTS unless asked
188−
189−# Task Execution
190−
191−The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
192−
193−1. Use the available search tools to understand the codebase and the user's query.
194−2. Implement the solution using all tools available to you
195−3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
196−4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CRUSH.md so that you will know to run it next time.
197−
198−NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
199−
200−# Tool Usage Policy
201−
202−- When doing file search, prefer to use the Agent tool in order to reduce context usage.
203−- **IMPORTANT**: If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel for efficiency.
204−- **IMPORTANT**: The user does not see the full output of the tool responses, so if you need the output of the tool for your response, make sure to summarize it for the user.
205−- All tools are executed in parallel when multiple tool calls are sent in a single message. Only send multiple tool calls when they are safe to run in parallel (no dependencies between them).
206−
207−# Reading Files and Folders
208−
209−**Always check if you have already read a file, folder, or workspace structure before reading it again.**
210−
211−- If you have already read the content and it has not changed, do NOT re-read it.
212−- Only re-read files or folders if:
213− - You suspect the content has changed since your last read.
214− - You have made edits to the file or folder.
215− - You encounter an error that suggests the context may be stale or incomplete.
216−- Use your internal memory and previous context to avoid redundant reads.
217−- This will save time, reduce unnecessary operations, and make your workflow more efficient.
218−
219−# Directory Context and Navigation
220−
221−**Always maintain awareness of your current working directory by tracking it mentally from the command history.**
222−
223−- **Remember directory changes**: When you use `cd` to change directories, mentally note and remember the new location for all subsequent operations.
224−- **Track your location from context**: Use the command history and previous `cd` commands to know where you currently are without constantly checking.
225−- **Check location only when commands fail**: If a command fails unexpectedly with file/path errors, then use `pwd` to verify your current directory as the failure might be due to being in the wrong location.
226−- **Use relative paths confidently**: Once you know your location, use relative paths appropriately based on your mental model of the current directory.
227−- **Maintain directory awareness across operations**: Keep track of where you are throughout a multi-step task, especially when working with files in different directories.
228−
229−**When to verify with `pwd`:**
230−
231−- After a command fails with "file not found" or similar path-related or `exit status 1` errors
232−- When resuming work or continuing from a previous step if uncertain
233−- When you realize you may have lost track of your current location
234−
235−**Mental tracking example:**
236−
237−```bash
238−# You start in /project/root
239−cd src/components # Now mentally note: I'm in /project/root/src/components
240−# Work with files here using relative paths
241−ls ./Button.tsx # This should work because I know I'm in components/
242−# If this fails, THEN run pwd to double-check location
243−```
244−
245−# Git and Version Control
246−
247−If the user tells you to stage and commit, you may do so.
248−
249−You are NEVER allowed to stage and commit files automatically. Only do this when explicitly requested.
250−
251−# Error Handling and Recovery
252−
253−- When you encounter errors, don't give up - analyze the error carefully and try alternative approaches.
254−- If a tool fails, try a different tool or approach to accomplish the same goal.
255−- When debugging, be systematic: isolate the problem, test hypotheses, and iterate until resolved.
256−- Always validate your solutions work correctly before considering the task complete.
257−
258−# Final Validation
259−
260−Before completing any task:
261−
262−1. Ensure all todo items are checked off
263−2. Run all relevant tests
264−3. Run linting and type checking if available
265−4. Verify the original problem is solved
266−5. Test edge cases and boundary conditions
267−6. Confirm no regressions were introduced
internal/llm/provider/anthropic.godeleted−598
@@ -1,598 +0,0 @@
1−package provider
2−
3−import (
4− "context"
5− "encoding/json"
6− "errors"
7− "fmt"
8− "io"
9− "log/slog"
10− "net/http"
11− "regexp"
12− "strconv"
13− "strings"
14− "time"
15−
16− "github.com/anthropics/anthropic-sdk-go"
17− "github.com/anthropics/anthropic-sdk-go/bedrock"
18− "github.com/anthropics/anthropic-sdk-go/option"
19− "github.com/anthropics/anthropic-sdk-go/vertex"
20− "github.com/charmbracelet/catwalk/pkg/catwalk"
21− "github.com/charmbracelet/crush/internal/config"
22− "github.com/charmbracelet/crush/internal/llm/tools"
23− "github.com/charmbracelet/crush/internal/log"
24− "github.com/charmbracelet/crush/internal/message"
25−)
26−
27−// Pre-compiled regex for parsing context limit errors.
28−var contextLimitRegex = regexp.MustCompile(`input length and ` + "`max_tokens`" + ` exceed context limit: (\d+) \+ (\d+) > (\d+)`)
29−
30−type anthropicClient struct {
31− providerOptions providerClientOptions
32− tp AnthropicClientType
33− client anthropic.Client
34− adjustedMaxTokens int // Used when context limit is hit
35−}
36−
37−type AnthropicClient ProviderClient
38−
39−type AnthropicClientType string
40−
41−const (
42− AnthropicClientTypeNormal AnthropicClientType = "normal"
43− AnthropicClientTypeBedrock AnthropicClientType = "bedrock"
44− AnthropicClientTypeVertex AnthropicClientType = "vertex"
45−)
46−
47−func newAnthropicClient(opts providerClientOptions, tp AnthropicClientType) AnthropicClient {
48− return &anthropicClient{
49− providerOptions: opts,
50− tp: tp,
51− client: createAnthropicClient(opts, tp),
52− }
53−}
54−
55−func createAnthropicClient(opts providerClientOptions, tp AnthropicClientType) anthropic.Client {
56− anthropicClientOptions := []option.RequestOption{}
57−
58− // Check if Authorization header is provided in extra headers
59− hasBearerAuth := false
60− if opts.extraHeaders != nil {
61− for key := range opts.extraHeaders {
62− if strings.ToLower(key) == "authorization" {
63− hasBearerAuth = true
64− break
65− }
66− }
67− }
68−
69− isBearerToken := strings.HasPrefix(opts.apiKey, "Bearer ")
70−
71− if opts.apiKey != "" && !hasBearerAuth {
72− if isBearerToken {
73− slog.Debug("API key starts with 'Bearer ', using as Authorization header")
74− anthropicClientOptions = append(anthropicClientOptions, option.WithHeader("Authorization", opts.apiKey))
75− } else {
76− // Use standard X-Api-Key header
77− anthropicClientOptions = append(anthropicClientOptions, option.WithAPIKey(opts.apiKey))
78− }
79− } else if hasBearerAuth {
80− slog.Debug("Skipping X-Api-Key header because Authorization header is provided")
81− }
82−
83− if opts.baseURL != "" {
84− resolvedBaseURL, err := config.Get().Resolve(opts.baseURL)
85− if err == nil && resolvedBaseURL != "" {
86− anthropicClientOptions = append(anthropicClientOptions, option.WithBaseURL(resolvedBaseURL))
87− }
88− }
89−
90− if config.Get().Options.Debug {
91− httpClient := log.NewHTTPClient()
92− anthropicClientOptions = append(anthropicClientOptions, option.WithHTTPClient(httpClient))
93− }
94−
95− switch tp {
96− case AnthropicClientTypeBedrock:
97− anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background()))
98− case AnthropicClientTypeVertex:
99− project := opts.extraParams["project"]
100− location := opts.extraParams["location"]
101− anthropicClientOptions = append(anthropicClientOptions, vertex.WithGoogleAuth(context.Background(), location, project))
102− }
103− for key, header := range opts.extraHeaders {
104− anthropicClientOptions = append(anthropicClientOptions, option.WithHeaderAdd(key, header))
105− }
106− for key, value := range opts.extraBody {
107− anthropicClientOptions = append(anthropicClientOptions, option.WithJSONSet(key, value))
108− }
109− return anthropic.NewClient(anthropicClientOptions...)
110−}
111−
112−func (a *anthropicClient) convertMessages(messages []message.Message) (anthropicMessages []anthropic.MessageParam) {
113− for i, msg := range messages {
114− cache := false
115− if i > len(messages)-3 {
116− cache = true
117− }
118− switch msg.Role {
119− case message.User:
120− content := anthropic.NewTextBlock(msg.Content().String())
121− if cache && !a.providerOptions.disableCache {
122− content.OfText.CacheControl = anthropic.CacheControlEphemeralParam{
123− Type: "ephemeral",
124− }
125− }
126− var contentBlocks []anthropic.ContentBlockParamUnion
127− contentBlocks = append(contentBlocks, content)
128− for _, binaryContent := range msg.BinaryContent() {
129− base64Image := binaryContent.String(catwalk.InferenceProviderAnthropic)
130− imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image)
131− contentBlocks = append(contentBlocks, imageBlock)
132− }
133− anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(contentBlocks...))
134−
135− case message.Assistant:
136− blocks := []anthropic.ContentBlockParamUnion{}
137−
138− // Add thinking blocks first if present (required when thinking is enabled with tool use)
139− if reasoningContent := msg.ReasoningContent(); reasoningContent.Thinking != "" {
140− thinkingBlock := anthropic.NewThinkingBlock(reasoningContent.Signature, reasoningContent.Thinking)
141− blocks = append(blocks, thinkingBlock)
142− }
143−
144− if msg.Content().String() != "" {
145− content := anthropic.NewTextBlock(msg.Content().String())
146− if cache && !a.providerOptions.disableCache {
147− content.OfText.CacheControl = anthropic.CacheControlEphemeralParam{
148− Type: "ephemeral",
149− }
150− }
151− blocks = append(blocks, content)
152− }
153−
154− for _, toolCall := range msg.ToolCalls() {
155− if !toolCall.Finished {
156− continue
157− }
158− var inputMap map[string]any
159− err := json.Unmarshal([]byte(toolCall.Input), &inputMap)
160− if err != nil {
161− continue
162− }
163− blocks = append(blocks, anthropic.NewToolUseBlock(toolCall.ID, inputMap, toolCall.Name))
164− }
165−
166− if len(blocks) == 0 {
167− continue
168− }
169− anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
170−
171− case message.Tool:
172− results := make([]anthropic.ContentBlockParamUnion, len(msg.ToolResults()))
173− for i, toolResult := range msg.ToolResults() {
174− results[i] = anthropic.NewToolResultBlock(toolResult.ToolCallID, toolResult.Content, toolResult.IsError)
175− }
176− anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(results...))
177− }
178− }
179− return anthropicMessages
180−}
181−
182−func (a *anthropicClient) convertTools(tools []tools.BaseTool) []anthropic.ToolUnionParam {
183− if len(tools) == 0 {
184− return nil
185− }
186− anthropicTools := make([]anthropic.ToolUnionParam, len(tools))
187−
188− for i, tool := range tools {
189− info := tool.Info()
190− toolParam := anthropic.ToolParam{
191− Name: info.Name,
192− Description: anthropic.String(info.Description),
193− InputSchema: anthropic.ToolInputSchemaParam{
194− Properties: info.Parameters,
195− Required: info.Required,
196− },
197− }
198−
199− if i == len(tools)-1 && !a.providerOptions.disableCache {
200− toolParam.CacheControl = anthropic.CacheControlEphemeralParam{
201− Type: "ephemeral",
202− }
203− }
204−
205− anthropicTools[i] = anthropic.ToolUnionParam{OfTool: &toolParam}
206− }
207−
208− return anthropicTools
209−}
210−
211−func (a *anthropicClient) finishReason(reason string) message.FinishReason {
212− switch reason {
213− case "end_turn":
214− return message.FinishReasonEndTurn
215− case "max_tokens":
216− return message.FinishReasonMaxTokens
217− case "tool_use":
218− return message.FinishReasonToolUse
219− case "stop_sequence":
220− return message.FinishReasonEndTurn
221− default:
222− return message.FinishReasonUnknown
223− }
224−}
225−
226−func (a *anthropicClient) isThinkingEnabled() bool {
227− cfg := config.Get()
228− modelConfig := cfg.Models[config.SelectedModelTypeLarge]
229− if a.providerOptions.modelType == config.SelectedModelTypeSmall {
230− modelConfig = cfg.Models[config.SelectedModelTypeSmall]
231− }
232− return a.Model().CanReason && modelConfig.Think
233−}
234−
235−func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, tools []anthropic.ToolUnionParam) anthropic.MessageNewParams {
236− model := a.providerOptions.model(a.providerOptions.modelType)
237− var thinkingParam anthropic.ThinkingConfigParamUnion
238− cfg := config.Get()
239− modelConfig := cfg.Models[config.SelectedModelTypeLarge]
240− if a.providerOptions.modelType == config.SelectedModelTypeSmall {
241− modelConfig = cfg.Models[config.SelectedModelTypeSmall]
242− }
243− temperature := anthropic.Float(0)
244−
245− maxTokens := model.DefaultMaxTokens
246− if modelConfig.MaxTokens > 0 {
247− maxTokens = modelConfig.MaxTokens
248− }
249− if a.isThinkingEnabled() {
250− thinkingParam = anthropic.ThinkingConfigParamOfEnabled(int64(float64(maxTokens) * 0.8))
251− temperature = anthropic.Float(1)
252− }
253− // Override max tokens if set in provider options
254− if a.providerOptions.maxTokens > 0 {
255− maxTokens = a.providerOptions.maxTokens
256− }
257−
258− // Use adjusted max tokens if context limit was hit
259− if a.adjustedMaxTokens > 0 {
260− maxTokens = int64(a.adjustedMaxTokens)
261− }
262−
263− systemBlocks := []anthropic.TextBlockParam{}
264−
265− // Add custom system prompt prefix if configured
266− if a.providerOptions.systemPromptPrefix != "" {
267− systemBlocks = append(systemBlocks, anthropic.TextBlockParam{
268− Text: a.providerOptions.systemPromptPrefix,
269− })
270− }
271−
272− systemBlocks = append(systemBlocks, anthropic.TextBlockParam{
273− Text: a.providerOptions.systemMessage,
274− CacheControl: anthropic.CacheControlEphemeralParam{
275− Type: "ephemeral",
276− },
277− })
278−
279− return anthropic.MessageNewParams{
280− Model: anthropic.Model(model.ID),
281− MaxTokens: maxTokens,
282− Temperature: temperature,
283− Messages: messages,
284− Tools: tools,
285− Thinking: thinkingParam,
286− System: systemBlocks,
287− }
288−}
289−
290−func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
291− attempts := 0
292− for {
293− attempts++
294− // Prepare messages on each attempt in case max_tokens was adjusted
295− preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
296−
297− var opts []option.RequestOption
298− if a.isThinkingEnabled() {
299− opts = append(opts, option.WithHeaderAdd("anthropic-beta", "interleaved-thinking-2025-05-14"))
300− }
301− anthropicResponse, err := a.client.Messages.New(
302− ctx,
303− preparedMessages,
304− opts...,
305− )
306− // If there is an error we are going to see if we can retry the call
307− if err != nil {
308− retry, after, retryErr := a.shouldRetry(attempts, err)
309− if retryErr != nil {
310− return nil, retryErr
311− }
312− if retry {
313− slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries, "error", err)
314− select {
315− case <-ctx.Done():
316− return nil, ctx.Err()
317− case <-time.After(time.Duration(after) * time.Millisecond):
318− continue
319− }
320− }
321− return nil, retryErr
322− }
323−
324− content := ""
325− for _, block := range anthropicResponse.Content {
326− if text, ok := block.AsAny().(anthropic.TextBlock); ok {
327− content += text.Text
328− }
329− }
330−
331− return &ProviderResponse{
332− Content: content,
333− ToolCalls: a.toolCalls(*anthropicResponse),
334− Usage: a.usage(*anthropicResponse),
335− }, nil
336− }
337−}
338−
339−func (a *anthropicClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
340− attempts := 0
341− eventChan := make(chan ProviderEvent)
342− go func() {
343− for {
344− attempts++
345− // Prepare messages on each attempt in case max_tokens was adjusted
346− preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
347−
348− var opts []option.RequestOption
349− if a.isThinkingEnabled() {
350− opts = append(opts, option.WithHeaderAdd("anthropic-beta", "interleaved-thinking-2025-05-14"))
351− }
352−
353− anthropicStream := a.client.Messages.NewStreaming(
354− ctx,
355− preparedMessages,
356− opts...,
357− )
358− accumulatedMessage := anthropic.Message{}
359−
360− currentToolCallID := ""
361− for anthropicStream.Next() {
362− event := anthropicStream.Current()
363− err := accumulatedMessage.Accumulate(event)
364− if err != nil {
365− slog.Warn("Error accumulating message", "error", err)
366− continue
367− }
368−
369− switch event := event.AsAny().(type) {
370− case anthropic.ContentBlockStartEvent:
371− switch event.ContentBlock.Type {
372− case "text":
373− eventChan <- ProviderEvent{Type: EventContentStart}
374− case "tool_use":
375− currentToolCallID = event.ContentBlock.ID
376− eventChan <- ProviderEvent{
377− Type: EventToolUseStart,
378− ToolCall: &message.ToolCall{
379− ID: event.ContentBlock.ID,
380− Name: event.ContentBlock.Name,
381− Finished: false,
382− },
383− }
384− }
385−
386− case anthropic.ContentBlockDeltaEvent:
387− if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
388− eventChan <- ProviderEvent{
389− Type: EventThinkingDelta,
390− Thinking: event.Delta.Thinking,
391− }
392− } else if event.Delta.Type == "signature_delta" && event.Delta.Signature != "" {
393− eventChan <- ProviderEvent{
394− Type: EventSignatureDelta,
395− Signature: event.Delta.Signature,
396− }
397− } else if event.Delta.Type == "text_delta" && event.Delta.Text != "" {
398− eventChan <- ProviderEvent{
399− Type: EventContentDelta,
400− Content: event.Delta.Text,
401− }
402− } else if event.Delta.Type == "input_json_delta" {
403− if currentToolCallID != "" {
404− eventChan <- ProviderEvent{
405− Type: EventToolUseDelta,
406− ToolCall: &message.ToolCall{
407− ID: currentToolCallID,
408− Finished: false,
409− Input: event.Delta.PartialJSON,
410− },
411− }
412− }
413− }
414− case anthropic.ContentBlockStopEvent:
415− if currentToolCallID != "" {
416− eventChan <- ProviderEvent{
417− Type: EventToolUseStop,
418− ToolCall: &message.ToolCall{
419− ID: currentToolCallID,
420− },
421− }
422− currentToolCallID = ""
423− } else {
424− eventChan <- ProviderEvent{Type: EventContentStop}
425− }
426−
427− case anthropic.MessageStopEvent:
428− content := ""
429− for _, block := range accumulatedMessage.Content {
430− if text, ok := block.AsAny().(anthropic.TextBlock); ok {
431− content += text.Text
432− }
433− }
434−
435− eventChan <- ProviderEvent{
436− Type: EventComplete,
437− Response: &ProviderResponse{
438− Content: content,
439− ToolCalls: a.toolCalls(accumulatedMessage),
440− Usage: a.usage(accumulatedMessage),
441− FinishReason: a.finishReason(string(accumulatedMessage.StopReason)),
442− },
443− Content: content,
444− }
445− }
446− }
447−
448− err := anthropicStream.Err()
449− if err == nil || errors.Is(err, io.EOF) {
450− close(eventChan)
451− return
452− }
453−
454− // If there is an error we are going to see if we can retry the call
455− retry, after, retryErr := a.shouldRetry(attempts, err)
456− if retryErr != nil {
457− eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
458− close(eventChan)
459− return
460− }
461− if retry {
462− slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries, "error", err)
463− select {
464− case <-ctx.Done():
465− // context cancelled
466− if ctx.Err() != nil {
467− eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
468− }
469− close(eventChan)
470− return
471− case <-time.After(time.Duration(after) * time.Millisecond):
472− continue
473− }
474− }
475− if ctx.Err() != nil {
476− eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
477− }
478−
479− close(eventChan)
480− return
481− }
482− }()
483− return eventChan
484−}
485−
486−func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, error) {
487− var apiErr *anthropic.Error
488− if !errors.As(err, &apiErr) {
489− return false, 0, err
490− }
491−
492− if attempts > maxRetries {
493− return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
494− }
495−
496− if apiErr.StatusCode == http.StatusUnauthorized {
497− prev := a.providerOptions.apiKey
498− // in case the key comes from a script, we try to re-evaluate it.
499− a.providerOptions.apiKey, err = config.Get().Resolve(a.providerOptions.config.APIKey)
500− if err != nil {
501− return false, 0, fmt.Errorf("failed to resolve API key: %w", err)
502− }
503− // if it didn't change, do not retry.
504− if prev == a.providerOptions.apiKey {
505− return false, 0, err
506− }
507− a.client = createAnthropicClient(a.providerOptions, a.tp)
508− return true, 0, nil
509− }
510−
511− // Handle context limit exceeded error (400 Bad Request)
512− if apiErr.StatusCode == http.StatusBadRequest {
513− if adjusted, ok := a.handleContextLimitError(apiErr); ok {
514− a.adjustedMaxTokens = adjusted
515− slog.Debug("Adjusted max_tokens due to context limit", "new_max_tokens", adjusted)
516− return true, 0, nil
517− }
518− }
519−
520− isOverloaded := strings.Contains(apiErr.Error(), "overloaded") || strings.Contains(apiErr.Error(), "rate limit exceeded")
521− // 529 (unofficial): The service is overloaded
522− if apiErr.StatusCode != http.StatusTooManyRequests && apiErr.StatusCode != 529 && !isOverloaded {
523− return false, 0, err
524− }
525−
526− retryMs := 0
527− retryAfterValues := apiErr.Response.Header.Values("Retry-After")
528−
529− backoffMs := 2000 * (1 << (attempts - 1))
530− jitterMs := int(float64(backoffMs) * 0.2)
531− retryMs = backoffMs + jitterMs
532− if len(retryAfterValues) > 0 {
533− if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil {
534− retryMs = retryMs * 1000
535− }
536− }
537− return true, int64(retryMs), nil
538−}
539−
540−// handleContextLimitError parses context limit error and returns adjusted max_tokens
541−func (a *anthropicClient) handleContextLimitError(apiErr *anthropic.Error) (int, bool) {
542− // Parse error message like: "input length and max_tokens exceed context limit: 154978 + 50000 > 200000"
543− errorMsg := apiErr.Error()
544−
545− matches := contextLimitRegex.FindStringSubmatch(errorMsg)
546−
547− if len(matches) != 4 {
548− return 0, false
549− }
550−
551− inputTokens, err1 := strconv.Atoi(matches[1])
552− contextLimit, err2 := strconv.Atoi(matches[3])
553−
554− if err1 != nil || err2 != nil {
555− return 0, false
556− }
557−
558− // Calculate safe max_tokens with a buffer of 1000 tokens
559− safeMaxTokens := contextLimit - inputTokens - 1000
560−
561− // Ensure we don't go below a minimum threshold
562− safeMaxTokens = max(safeMaxTokens, 1000)
563−
564− return safeMaxTokens, true
565−}
566−
567−func (a *anthropicClient) toolCalls(msg anthropic.Message) []message.ToolCall {
568− var toolCalls []message.ToolCall
569−
570− for _, block := range msg.Content {
571− switch variant := block.AsAny().(type) {
572− case anthropic.ToolUseBlock:
573− toolCall := message.ToolCall{
574− ID: variant.ID,
575− Name: variant.Name,
576− Input: string(variant.Input),
577− Type: string(variant.Type),
578− Finished: true,
579− }
580− toolCalls = append(toolCalls, toolCall)
581− }
582− }
583−
584− return toolCalls
585−}
586−
587−func (a *anthropicClient) usage(msg anthropic.Message) TokenUsage {
588− return TokenUsage{
589− InputTokens: msg.Usage.InputTokens,
590− OutputTokens: msg.Usage.OutputTokens,
591− CacheCreationTokens: msg.Usage.CacheCreationInputTokens,
592− CacheReadTokens: msg.Usage.CacheReadInputTokens,
593− }
594−}
595−
596−func (a *anthropicClient) Model() catwalk.Model {
597− return a.providerOptions.model(a.providerOptions.modelType)
598−}
internal/llm/provider/azure.godeleted−39
@@ -1,39 +0,0 @@
1−package provider
2−
3−import (
4− "github.com/charmbracelet/crush/internal/config"
5− "github.com/charmbracelet/crush/internal/log"
6− "github.com/openai/openai-go"
7− "github.com/openai/openai-go/azure"
8− "github.com/openai/openai-go/option"
9−)
10−
11−type azureClient struct {
12− *openaiClient
13−}
14−
15−type AzureClient ProviderClient
16−
17−func newAzureClient(opts providerClientOptions) AzureClient {
18− apiVersion := opts.extraParams["apiVersion"]
19− if apiVersion == "" {
20− apiVersion = "2025-01-01-preview"
21− }
22−
23− reqOpts := []option.RequestOption{
24− azure.WithEndpoint(opts.baseURL, apiVersion),
25− }
26−
27− if config.Get().Options.Debug {
28− httpClient := log.NewHTTPClient()
29− reqOpts = append(reqOpts, option.WithHTTPClient(httpClient))
30− }
31−
32− reqOpts = append(reqOpts, azure.WithAPIKey(opts.apiKey))
33− base := &openaiClient{
34− providerOptions: opts,
35− client: openai.NewClient(reqOpts...),
36− }
37−
38− return &azureClient{openaiClient: base}
39−}
internal/llm/provider/bedrock.godeleted−93
@@ -1,93 +0,0 @@
1−package provider
2−
3−import (
4− "context"
5− "errors"
6− "fmt"
7− "strings"
8−
9− "github.com/charmbracelet/catwalk/pkg/catwalk"
10− "github.com/charmbracelet/crush/internal/config"
11− "github.com/charmbracelet/crush/internal/llm/tools"
12− "github.com/charmbracelet/crush/internal/message"
13−)
14−
15−type bedrockClient struct {
16− providerOptions providerClientOptions
17− childProvider ProviderClient
18−}
19−
20−type BedrockClient ProviderClient
21−
22−func newBedrockClient(opts providerClientOptions) BedrockClient {
23− // Get AWS region from environment
24− region := opts.extraParams["region"]
25− if region == "" {
26− region = "us-east-1" // default region
27− }
28− if len(region) < 2 {
29− return &bedrockClient{
30− providerOptions: opts,
31− childProvider: nil, // Will cause an error when used
32− }
33− }
34−
35− opts.model = func(modelType config.SelectedModelType) catwalk.Model {
36− model := config.Get().GetModelByType(modelType)
37−
38− // Prefix the model name with region
39− regionPrefix := region[:2]
40− modelName := model.ID
41− model.ID = fmt.Sprintf("%s.%s", regionPrefix, modelName)
42− return *model
43− }
44−
45− model := opts.model(opts.modelType)
46−
47− // Determine which provider to use based on the model
48− if strings.Contains(string(model.ID), "anthropic") {
49− // Create Anthropic client with Bedrock configuration
50− anthropicOpts := opts
51− // TODO: later find a way to check if the AWS account has caching enabled
52− opts.disableCache = true // Disable cache for Bedrock
53− return &bedrockClient{
54− providerOptions: opts,
55− childProvider: newAnthropicClient(anthropicOpts, AnthropicClientTypeBedrock),
56− }
57− }
58−
59− // Return client with nil childProvider if model is not supported
60− // This will cause an error when used
61− return &bedrockClient{
62− providerOptions: opts,
63− childProvider: nil,
64− }
65−}
66−
67−func (b *bedrockClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
68− if b.childProvider == nil {
69− return nil, errors.New("unsupported model for bedrock provider")
70− }
71− return b.childProvider.send(ctx, messages, tools)
72−}
73−
74−func (b *bedrockClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
75− eventChan := make(chan ProviderEvent)
76−
77− if b.childProvider == nil {
78− go func() {
79− eventChan <- ProviderEvent{
80− Type: EventError,
81− Error: errors.New("unsupported model for bedrock provider"),
82− }
83− close(eventChan)
84− }()
85− return eventChan
86− }
87−
88− return b.childProvider.stream(ctx, messages, tools)
89−}
90−
91−func (b *bedrockClient) Model() catwalk.Model {
92− return b.providerOptions.model(b.providerOptions.modelType)
93−}
internal/llm/provider/gemini.godeleted−579
@@ -1,579 +0,0 @@
1−package provider
2−
3−import (
4− "context"
5− "encoding/json"
6− "errors"
7− "fmt"
8− "io"
9− "log/slog"
10− "strings"
11− "time"
12−
13− "github.com/charmbracelet/catwalk/pkg/catwalk"
14− "github.com/charmbracelet/crush/internal/config"
15− "github.com/charmbracelet/crush/internal/llm/tools"
16− "github.com/charmbracelet/crush/internal/log"
17− "github.com/charmbracelet/crush/internal/message"
18− "github.com/google/uuid"
19− "google.golang.org/genai"
20−)
21−
22−type geminiClient struct {
23− providerOptions providerClientOptions
24− client *genai.Client
25−}
26−
27−type GeminiClient ProviderClient
28−
29−func newGeminiClient(opts providerClientOptions) GeminiClient {
30− client, err := createGeminiClient(opts)
31− if err != nil {
32− slog.Error("Failed to create Gemini client", "error", err)
33− return nil
34− }
35−
36− return &geminiClient{
37− providerOptions: opts,
38− client: client,
39− }
40−}
41−
42−func createGeminiClient(opts providerClientOptions) (*genai.Client, error) {
43− cc := &genai.ClientConfig{
44− APIKey: opts.apiKey,
45− Backend: genai.BackendGeminiAPI,
46− }
47− if opts.baseURL != "" {
48− resolvedBaseURL, err := config.Get().Resolve(opts.baseURL)
49− if err == nil && resolvedBaseURL != "" {
50− cc.HTTPOptions = genai.HTTPOptions{
51− BaseURL: resolvedBaseURL,
52− }
53− }
54− }
55− if config.Get().Options.Debug {
56− cc.HTTPClient = log.NewHTTPClient()
57− }
58− client, err := genai.NewClient(context.Background(), cc)
59− if err != nil {
60− return nil, err
61− }
62− return client, nil
63−}
64−
65−func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Content {
66− var history []*genai.Content
67− for _, msg := range messages {
68− switch msg.Role {
69− case message.User:
70− var parts []*genai.Part
71− parts = append(parts, &genai.Part{Text: msg.Content().String()})
72− for _, binaryContent := range msg.BinaryContent() {
73− parts = append(parts, &genai.Part{InlineData: &genai.Blob{
74− MIMEType: binaryContent.MIMEType,
75− Data: binaryContent.Data,
76− }})
77− }
78− history = append(history, &genai.Content{
79− Parts: parts,
80− Role: genai.RoleUser,
81− })
82− case message.Assistant:
83− var assistantParts []*genai.Part
84−
85− if msg.Content().String() != "" {
86− assistantParts = append(assistantParts, &genai.Part{Text: msg.Content().String()})
87− }
88−
89− if len(msg.ToolCalls()) > 0 {
90− for _, call := range msg.ToolCalls() {
91− if !call.Finished {
92− continue
93− }
94− args, _ := parseJSONToMap(call.Input)
95− assistantParts = append(assistantParts, &genai.Part{
96− FunctionCall: &genai.FunctionCall{
97− Name: call.Name,
98− Args: args,
99− },
100− })
101− }
102− }
103−
104− if len(assistantParts) > 0 {
105− history = append(history, &genai.Content{
106− Role: genai.RoleModel,
107− Parts: assistantParts,
108− })
109− }
110−
111− case message.Tool:
112− var toolParts []*genai.Part
113− for _, result := range msg.ToolResults() {
114− response := map[string]any{"result": result.Content}
115− parsed, err := parseJSONToMap(result.Content)
116− if err == nil {
117− response = parsed
118− }
119−
120− var toolCall message.ToolCall
121− for _, m := range messages {
122− if m.Role == message.Assistant {
123− for _, call := range m.ToolCalls() {
124− if call.ID == result.ToolCallID {
125− toolCall = call
126− break
127− }
128− }
129− }
130− }
131−
132− toolParts = append(toolParts, &genai.Part{
133− FunctionResponse: &genai.FunctionResponse{
134− Name: toolCall.Name,
135− Response: response,
136− },
137− })
138− }
139− if len(toolParts) > 0 {
140− history = append(history, &genai.Content{
141− Parts: toolParts,
142− Role: genai.RoleUser,
143− })
144− }
145− }
146− }
147−
148− return history
149−}
150−
151−func (g *geminiClient) convertTools(tools []tools.BaseTool) []*genai.Tool {
152− geminiTool := &genai.Tool{}
153− geminiTool.FunctionDeclarations = make([]*genai.FunctionDeclaration, 0, len(tools))
154−
155− for _, tool := range tools {
156− info := tool.Info()
157− declaration := &genai.FunctionDeclaration{
158− Name: info.Name,
159− Description: info.Description,
160− Parameters: &genai.Schema{
161− Type: genai.TypeObject,
162− Properties: convertSchemaProperties(info.Parameters),
163− Required: info.Required,
164− },
165− }
166−
167− geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, declaration)
168− }
169−
170− return []*genai.Tool{geminiTool}
171−}
172−
173−func (g *geminiClient) finishReason(reason genai.FinishReason) message.FinishReason {
174− switch reason {
175− case genai.FinishReasonStop:
176− return message.FinishReasonEndTurn
177− case genai.FinishReasonMaxTokens:
178− return message.FinishReasonMaxTokens
179− default:
180− return message.FinishReasonUnknown
181− }
182−}
183−
184−func (g *geminiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
185− // Convert messages
186− geminiMessages := g.convertMessages(messages)
187− model := g.providerOptions.model(g.providerOptions.modelType)
188− cfg := config.Get()
189−
190− modelConfig := cfg.Models[config.SelectedModelTypeLarge]
191− if g.providerOptions.modelType == config.SelectedModelTypeSmall {
192− modelConfig = cfg.Models[config.SelectedModelTypeSmall]
193− }
194−
195− maxTokens := model.DefaultMaxTokens
196− if modelConfig.MaxTokens > 0 {
197− maxTokens = modelConfig.MaxTokens
198− }
199− systemMessage := g.providerOptions.systemMessage
200− if g.providerOptions.systemPromptPrefix != "" {
201− systemMessage = g.providerOptions.systemPromptPrefix + "\n" + systemMessage
202− }
203− history := geminiMessages[:len(geminiMessages)-1] // All but last message
204− lastMsg := geminiMessages[len(geminiMessages)-1]
205− config := &genai.GenerateContentConfig{
206− MaxOutputTokens: int32(maxTokens),
207− SystemInstruction: &genai.Content{
208− Parts: []*genai.Part{{Text: systemMessage}},
209− },
210− }
211− config.Tools = g.convertTools(tools)
212− chat, _ := g.client.Chats.Create(ctx, model.ID, config, history)
213−
214− attempts := 0
215− for {
216− attempts++
217− var toolCalls []message.ToolCall
218−
219− var lastMsgParts []genai.Part
220− for _, part := range lastMsg.Parts {
221− lastMsgParts = append(lastMsgParts, *part)
222− }
223− resp, err := chat.SendMessage(ctx, lastMsgParts...)
224− // If there is an error we are going to see if we can retry the call
225− if err != nil {
226− retry, after, retryErr := g.shouldRetry(attempts, err)
227− if retryErr != nil {
228− return nil, retryErr
229− }
230− if retry {
231− slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries, "error", err)
232− select {
233− case <-ctx.Done():
234− return nil, ctx.Err()
235− case <-time.After(time.Duration(after) * time.Millisecond):
236− continue
237− }
238− }
239− return nil, retryErr
240− }
241−
242− content := ""
243−
244− if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
245− for _, part := range resp.Candidates[0].Content.Parts {
246− switch {
247− case part.Text != "":
248− content = string(part.Text)
249− case part.FunctionCall != nil:
250− id := "call_" + uuid.New().String()
251− args, _ := json.Marshal(part.FunctionCall.Args)
252− toolCalls = append(toolCalls, message.ToolCall{
253− ID: id,
254− Name: part.FunctionCall.Name,
255− Input: string(args),
256− Type: "function",
257− Finished: true,
258− })
259− }
260− }
261− }
262− finishReason := message.FinishReasonEndTurn
263− if len(resp.Candidates) > 0 {
264− finishReason = g.finishReason(resp.Candidates[0].FinishReason)
265− }
266− if len(toolCalls) > 0 {
267− finishReason = message.FinishReasonToolUse
268− }
269−
270− return &ProviderResponse{
271− Content: content,
272− ToolCalls: toolCalls,
273− Usage: g.usage(resp),
274− FinishReason: finishReason,
275− }, nil
276− }
277−}
278−
279−func (g *geminiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
280− // Convert messages
281− geminiMessages := g.convertMessages(messages)
282−
283− model := g.providerOptions.model(g.providerOptions.modelType)
284− cfg := config.Get()
285−
286− modelConfig := cfg.Models[config.SelectedModelTypeLarge]
287− if g.providerOptions.modelType == config.SelectedModelTypeSmall {
288− modelConfig = cfg.Models[config.SelectedModelTypeSmall]
289− }
290− maxTokens := model.DefaultMaxTokens
291− if modelConfig.MaxTokens > 0 {
292− maxTokens = modelConfig.MaxTokens
293− }
294−
295− // Override max tokens if set in provider options
296− if g.providerOptions.maxTokens > 0 {
297− maxTokens = g.providerOptions.maxTokens
298− }
299− systemMessage := g.providerOptions.systemMessage
300− if g.providerOptions.systemPromptPrefix != "" {
301− systemMessage = g.providerOptions.systemPromptPrefix + "\n" + systemMessage
302− }
303− history := geminiMessages[:len(geminiMessages)-1] // All but last message
304− lastMsg := geminiMessages[len(geminiMessages)-1]
305− config := &genai.GenerateContentConfig{
306− MaxOutputTokens: int32(maxTokens),
307− SystemInstruction: &genai.Content{
308− Parts: []*genai.Part{{Text: systemMessage}},
309− },
310− }
311− config.Tools = g.convertTools(tools)
312− chat, _ := g.client.Chats.Create(ctx, model.ID, config, history)
313−
314− attempts := 0
315− eventChan := make(chan ProviderEvent)
316−
317− go func() {
318− defer close(eventChan)
319−
320− for {
321− attempts++
322−
323− currentContent := ""
324− toolCalls := []message.ToolCall{}
325− var finalResp *genai.GenerateContentResponse
326−
327− eventChan <- ProviderEvent{Type: EventContentStart}
328−
329− var lastMsgParts []genai.Part
330−
331− for _, part := range lastMsg.Parts {
332− lastMsgParts = append(lastMsgParts, *part)
333− }
334−
335− for resp, err := range chat.SendMessageStream(ctx, lastMsgParts...) {
336− if err != nil {
337− retry, after, retryErr := g.shouldRetry(attempts, err)
338− if retryErr != nil {
339− eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
340− return
341− }
342− if retry {
343− slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries, "error", err)
344− select {
345− case <-ctx.Done():
346− if ctx.Err() != nil {
347− eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
348− }
349−
350− return
351− case <-time.After(time.Duration(after) * time.Millisecond):
352− continue
353− }
354− } else {
355− eventChan <- ProviderEvent{Type: EventError, Error: err}
356− return
357− }
358− }
359−
360− finalResp = resp
361−
362− if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
363− for _, part := range resp.Candidates[0].Content.Parts {
364− switch {
365− case part.Text != "":
366− delta := string(part.Text)
367− if delta != "" {
368− eventChan <- ProviderEvent{
369− Type: EventContentDelta,
370− Content: delta,
371− }
372− currentContent += delta
373− }
374− case part.FunctionCall != nil:
375− id := "call_" + uuid.New().String()
376− args, _ := json.Marshal(part.FunctionCall.Args)
377− newCall := message.ToolCall{
378− ID: id,
379− Name: part.FunctionCall.Name,
380− Input: string(args),
381− Type: "function",
382− Finished: true,
383− }
384−
385− toolCalls = append(toolCalls, newCall)
386− }
387− }
388− } else {
389− // no content received
390− break
391− }
392− }
393−
394− eventChan <- ProviderEvent{Type: EventContentStop}
395−
396− if finalResp != nil {
397− finishReason := message.FinishReasonEndTurn
398− if len(finalResp.Candidates) > 0 {
399− finishReason = g.finishReason(finalResp.Candidates[0].FinishReason)
400− }
401− if len(toolCalls) > 0 {
402− finishReason = message.FinishReasonToolUse
403− }
404− eventChan <- ProviderEvent{
405− Type: EventComplete,
406− Response: &ProviderResponse{
407− Content: currentContent,
408− ToolCalls: toolCalls,
409− Usage: g.usage(finalResp),
410− FinishReason: finishReason,
411− },
412− }
413− return
414− } else {
415− eventChan <- ProviderEvent{
416− Type: EventError,
417− Error: errors.New("no content received"),
418− }
419− }
420− }
421− }()
422−
423− return eventChan
424−}
425−
426−func (g *geminiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
427− // Check if error is a rate limit error
428− if attempts > maxRetries {
429− return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
430− }
431−
432− // Gemini doesn't have a standard error type we can check against
433− // So we'll check the error message for rate limit indicators
434− if errors.Is(err, io.EOF) {
435− return false, 0, err
436− }
437−
438− errMsg := err.Error()
439− isRateLimit := contains(errMsg, "rate limit", "quota exceeded", "too many requests")
440−
441− // Check for token expiration (401 Unauthorized)
442− if contains(errMsg, "unauthorized", "invalid api key", "api key expired") {
443− prev := g.providerOptions.apiKey
444− // in case the key comes from a script, we try to re-evaluate it.
445− g.providerOptions.apiKey, err = config.Get().Resolve(g.providerOptions.config.APIKey)
446− if err != nil {
447− return false, 0, fmt.Errorf("failed to resolve API key: %w", err)
448− }
449− // if it didn't change, do not retry.
450− if prev == g.providerOptions.apiKey {
451− return false, 0, err
452− }
453− g.client, err = createGeminiClient(g.providerOptions)
454− if err != nil {
455− return false, 0, fmt.Errorf("failed to create Gemini client after API key refresh: %w", err)
456− }
457− return true, 0, nil
458− }
459−
460− // Check for common rate limit error messages
461−
462− if !isRateLimit {
463− return false, 0, err
464− }
465−
466− // Calculate backoff with jitter
467− backoffMs := 2000 * (1 << (attempts - 1))
468− jitterMs := int(float64(backoffMs) * 0.2)
469− retryMs := backoffMs + jitterMs
470−
471− return true, int64(retryMs), nil
472−}
473−
474−func (g *geminiClient) usage(resp *genai.GenerateContentResponse) TokenUsage {
475− if resp == nil || resp.UsageMetadata == nil {
476− return TokenUsage{}
477− }
478−
479− return TokenUsage{
480− InputTokens: int64(resp.UsageMetadata.PromptTokenCount),
481− OutputTokens: int64(resp.UsageMetadata.CandidatesTokenCount),
482− CacheCreationTokens: 0, // Not directly provided by Gemini
483− CacheReadTokens: int64(resp.UsageMetadata.CachedContentTokenCount),
484− }
485−}
486−
487−func (g *geminiClient) Model() catwalk.Model {
488− return g.providerOptions.model(g.providerOptions.modelType)
489−}
490−
491−// Helper functions
492−func parseJSONToMap(jsonStr string) (map[string]any, error) {
493− var result map[string]any
494− err := json.Unmarshal([]byte(jsonStr), &result)
495− return result, err
496−}
497−
498−func convertSchemaProperties(parameters map[string]any) map[string]*genai.Schema {
499− properties := make(map[string]*genai.Schema)
500−
501− for name, param := range parameters {
502− properties[name] = convertToSchema(param)
503− }
504−
505− return properties
506−}
507−
508−func convertToSchema(param any) *genai.Schema {
509− schema := &genai.Schema{Type: genai.TypeString}
510−
511− paramMap, ok := param.(map[string]any)
512− if !ok {
513− return schema
514− }
515−
516− if desc, ok := paramMap["description"].(string); ok {
517− schema.Description = desc
518− }
519−
520− typeVal, hasType := paramMap["type"]
521− if !hasType {
522− return schema
523− }
524−
525− typeStr, ok := typeVal.(string)
526− if !ok {
527− return schema
528− }
529−
530− schema.Type = mapJSONTypeToGenAI(typeStr)
531−
532− switch typeStr {
533− case "array":
534− schema.Items = processArrayItems(paramMap)
535− case "object":
536− if props, ok := paramMap["properties"].(map[string]any); ok {
537− schema.Properties = convertSchemaProperties(props)
538− }
539− }
540−
541− return schema
542−}
543−
544−func processArrayItems(paramMap map[string]any) *genai.Schema {
545− items, ok := paramMap["items"].(map[string]any)
546− if !ok {
547− return nil
548− }
549−
550− return convertToSchema(items)
551−}
552−
553−func mapJSONTypeToGenAI(jsonType string) genai.Type {
554− switch jsonType {
555− case "string":
556− return genai.TypeString
557− case "number":
558− return genai.TypeNumber
559− case "integer":
560− return genai.TypeInteger
561− case "boolean":
562− return genai.TypeBoolean
563− case "array":
564− return genai.TypeArray
565− case "object":
566− return genai.TypeObject
567− default:
568− return genai.TypeString // Default to string for unknown types
569− }
570−}
571−
572−func contains(s string, substrs ...string) bool {
573− for _, substr := range substrs {
574− if strings.Contains(strings.ToLower(s), strings.ToLower(substr)) {
575− return true
576− }
577− }
578− return false
579−}
internal/llm/provider/openai.godeleted−604
@@ -1,604 +0,0 @@
1−package provider
2−
3−import (
4− "context"
5− "encoding/json"
6− "errors"
7− "fmt"
8− "io"
9− "log/slog"
10− "net/http"
11− "strings"
12− "time"
13−
14− "github.com/charmbracelet/catwalk/pkg/catwalk"
15− "github.com/charmbracelet/crush/internal/config"
16− "github.com/charmbracelet/crush/internal/llm/tools"
17− "github.com/charmbracelet/crush/internal/log"
18− "github.com/charmbracelet/crush/internal/message"
19− "github.com/google/uuid"
20− "github.com/openai/openai-go"
21− "github.com/openai/openai-go/option"
22− "github.com/openai/openai-go/packages/param"
23− "github.com/openai/openai-go/shared"
24−)
25−
26−type openaiClient struct {
27− providerOptions providerClientOptions
28− client openai.Client
29−}
30−
31−type OpenAIClient ProviderClient
32−
33−func newOpenAIClient(opts providerClientOptions) OpenAIClient {
34− return &openaiClient{
35− providerOptions: opts,
36− client: createOpenAIClient(opts),
37− }
38−}
39−
40−func createOpenAIClient(opts providerClientOptions) openai.Client {
41− openaiClientOptions := []option.RequestOption{}
42− if opts.apiKey != "" {
43− openaiClientOptions = append(openaiClientOptions, option.WithAPIKey(opts.apiKey))
44− }
45− if opts.baseURL != "" {
46− resolvedBaseURL, err := config.Get().Resolve(opts.baseURL)
47− if err == nil && resolvedBaseURL != "" {
48− openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(resolvedBaseURL))
49− }
50− }
51−
52− if config.Get().Options.Debug {
53− httpClient := log.NewHTTPClient()
54− openaiClientOptions = append(openaiClientOptions, option.WithHTTPClient(httpClient))
55− }
56−
57− for key, value := range opts.extraHeaders {
58− openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
59− }
60−
61− for extraKey, extraValue := range opts.extraBody {
62− openaiClientOptions = append(openaiClientOptions, option.WithJSONSet(extraKey, extraValue))
63− }
64−
65− return openai.NewClient(openaiClientOptions...)
66−}
67−
68−func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
69− isAnthropicModel := o.providerOptions.config.ID == string(catwalk.InferenceProviderOpenRouter) && strings.HasPrefix(o.Model().ID, "anthropic/")
70− // Add system message first
71− systemMessage := o.providerOptions.systemMessage
72− if o.providerOptions.systemPromptPrefix != "" {
73− systemMessage = o.providerOptions.systemPromptPrefix + "\n" + systemMessage
74− }
75−
76− system := openai.SystemMessage(systemMessage)
77− if isAnthropicModel && !o.providerOptions.disableCache {
78− systemTextBlock := openai.ChatCompletionContentPartTextParam{Text: systemMessage}
79− systemTextBlock.SetExtraFields(
80− map[string]any{
81− "cache_control": map[string]string{
82− "type": "ephemeral",
83− },
84− },
85− )
86− var content []openai.ChatCompletionContentPartTextParam
87− content = append(content, systemTextBlock)
88− system = openai.SystemMessage(content)
89− }
90− openaiMessages = append(openaiMessages, system)
91−
92− for i, msg := range messages {
93− cache := false
94− if i > len(messages)-3 {
95− cache = true
96− }
97− switch msg.Role {
98− case message.User:
99− var content []openai.ChatCompletionContentPartUnionParam
100−
101− textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
102− content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
103− hasBinaryContent := false
104− for _, binaryContent := range msg.BinaryContent() {
105− hasBinaryContent = true
106− imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(catwalk.InferenceProviderOpenAI)}
107− imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
108−
109− content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
110− }
111− if cache && !o.providerOptions.disableCache && isAnthropicModel {
112− textBlock.SetExtraFields(map[string]any{
113− "cache_control": map[string]string{
114− "type": "ephemeral",
115− },
116− })
117− }
118− if hasBinaryContent || (isAnthropicModel && !o.providerOptions.disableCache) {
119− openaiMessages = append(openaiMessages, openai.UserMessage(content))
120− } else {
121− openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String()))
122− }
123−
124− case message.Assistant:
125− assistantMsg := openai.ChatCompletionAssistantMessageParam{
126− Role: "assistant",
127− }
128−
129− // Only include finished tool calls; interrupted tool calls must not be resent.
130− if len(msg.ToolCalls()) > 0 {
131− finished := make([]message.ToolCall, 0, len(msg.ToolCalls()))
132− for _, call := range msg.ToolCalls() {
133− if call.Finished {
134− finished = append(finished, call)
135− }
136− }
137− if len(finished) > 0 {
138− assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(finished))
139− for i, call := range finished {
140− assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
141− ID: call.ID,
142− Type: "function",
143− Function: openai.ChatCompletionMessageToolCallFunctionParam{
144− Name: call.Name,
145− Arguments: call.Input,
146− },
147− }
148− }
149− }
150− }
151− if msg.Content().String() != "" {
152− assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
153− OfString: param.NewOpt(msg.Content().Text),
154− }
155− }
156−
157− if cache && !o.providerOptions.disableCache && isAnthropicModel {
158− assistantMsg.SetExtraFields(map[string]any{
159− "cache_control": map[string]string{
160− "type": "ephemeral",
161− },
162− })
163− }
164− // Skip empty assistant messages (no content and no finished tool calls)
165− if msg.Content().String() == "" && len(assistantMsg.ToolCalls) == 0 {
166− continue
167− }
168−
169− openaiMessages = append(openaiMessages, openai.ChatCompletionMessageParamUnion{
170− OfAssistant: &assistantMsg,
171− })
172−
173− case message.Tool:
174− for _, result := range msg.ToolResults() {
175− openaiMessages = append(openaiMessages,
176− openai.ToolMessage(result.Content, result.ToolCallID),
177− )
178− }
179− }
180− }
181−
182− return openaiMessages
183−}
184−
185−func (o *openaiClient) convertTools(tools []tools.BaseTool) []openai.ChatCompletionToolParam {
186− openaiTools := make([]openai.ChatCompletionToolParam, len(tools))
187−
188− for i, tool := range tools {
189− info := tool.Info()
190− openaiTools[i] = openai.ChatCompletionToolParam{
191− Function: openai.FunctionDefinitionParam{
192− Name: info.Name,
193− Description: openai.String(info.Description),
194− Parameters: openai.FunctionParameters{
195− "type": "object",
196− "properties": info.Parameters,
197− "required": info.Required,
198− },
199− },
200− }
201− }
202−
203− return openaiTools
204−}
205−
206−func (o *openaiClient) finishReason(reason string) message.FinishReason {
207− switch reason {
208− case "stop":
209− return message.FinishReasonEndTurn
210− case "length":
211− return message.FinishReasonMaxTokens
212− case "tool_calls":
213− return message.FinishReasonToolUse
214− default:
215− return message.FinishReasonUnknown
216− }
217−}
218−
219−func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
220− model := o.providerOptions.model(o.providerOptions.modelType)
221− cfg := config.Get()
222−
223− modelConfig := cfg.Models[config.SelectedModelTypeLarge]
224− if o.providerOptions.modelType == config.SelectedModelTypeSmall {
225− modelConfig = cfg.Models[config.SelectedModelTypeSmall]
226− }
227−
228− reasoningEffort := modelConfig.ReasoningEffort
229−
230− params := openai.ChatCompletionNewParams{
231− Model: openai.ChatModel(model.ID),
232− Messages: messages,
233− Tools: tools,
234− }
235−
236− maxTokens := model.DefaultMaxTokens
237− if modelConfig.MaxTokens > 0 {
238− maxTokens = modelConfig.MaxTokens
239− }
240−
241− // Override max tokens if set in provider options
242− if o.providerOptions.maxTokens > 0 {
243− maxTokens = o.providerOptions.maxTokens
244− }
245− if model.CanReason {
246− params.MaxCompletionTokens = openai.Int(maxTokens)
247− switch reasoningEffort {
248− case "low":
249− params.ReasoningEffort = shared.ReasoningEffortLow
250− case "medium":
251− params.ReasoningEffort = shared.ReasoningEffortMedium
252− case "high":
253− params.ReasoningEffort = shared.ReasoningEffortHigh
254− case "minimal":
255− params.ReasoningEffort = shared.ReasoningEffort("minimal")
256− default:
257− params.ReasoningEffort = shared.ReasoningEffort(reasoningEffort)
258− }
259− } else {
260− params.MaxTokens = openai.Int(maxTokens)
261− }
262−
263− return params
264−}
265−
266−func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
267− params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
268− attempts := 0
269− for {
270− attempts++
271− openaiResponse, err := o.client.Chat.Completions.New(
272− ctx,
273− params,
274− )
275− // If there is an error we are going to see if we can retry the call
276− if err != nil {
277− retry, after, retryErr := o.shouldRetry(attempts, err)
278− if retryErr != nil {
279− return nil, retryErr
280− }
281− if retry {
282− slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries, "error", err)
283− select {
284− case <-ctx.Done():
285− return nil, ctx.Err()
286− case <-time.After(time.Duration(after) * time.Millisecond):
287− continue
288− }
289− }
290− return nil, retryErr
291− }
292−
293− if len(openaiResponse.Choices) == 0 {
294− return nil, fmt.Errorf("received empty response from OpenAI API - check endpoint configuration")
295− }
296−
297− content := ""
298− if openaiResponse.Choices[0].Message.Content != "" {
299− content = openaiResponse.Choices[0].Message.Content
300− }
301−
302− toolCalls := o.toolCalls(*openaiResponse)
303− finishReason := o.finishReason(string(openaiResponse.Choices[0].FinishReason))
304−
305− if len(toolCalls) > 0 {
306− finishReason = message.FinishReasonToolUse
307− }
308−
309− return &ProviderResponse{
310− Content: content,
311− ToolCalls: toolCalls,
312− Usage: o.usage(*openaiResponse),
313− FinishReason: finishReason,
314− }, nil
315− }
316−}
317−
318−func (o *openaiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
319− params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
320− params.StreamOptions = openai.ChatCompletionStreamOptionsParam{
321− IncludeUsage: openai.Bool(true),
322− }
323−
324− attempts := 0
325− eventChan := make(chan ProviderEvent)
326−
327− go func() {
328− for {
329− attempts++
330− // Kujtim: fixes an issue with anthropig models on openrouter
331− if len(params.Tools) == 0 {
332− params.Tools = nil
333− }
334− openaiStream := o.client.Chat.Completions.NewStreaming(
335− ctx,
336− params,
337− )
338−
339− acc := openai.ChatCompletionAccumulator{}
340− currentContent := ""
341− toolCalls := make([]message.ToolCall, 0)
342− msgToolCalls := make(map[int64]openai.ChatCompletionMessageToolCall)
343− toolMap := make(map[string]openai.ChatCompletionMessageToolCall)
344− toolCallIDMap := make(map[string]string)
345− for openaiStream.Next() {
346− chunk := openaiStream.Current()
347− // Kujtim: this is an issue with openrouter qwen, its sending -1 for the tool index
348− if len(chunk.Choices) != 0 && len(chunk.Choices[0].Delta.ToolCalls) > 0 && chunk.Choices[0].Delta.ToolCalls[0].Index == -1 {
349− chunk.Choices[0].Delta.ToolCalls[0].Index = 0
350− }
351− acc.AddChunk(chunk)
352− for i, choice := range chunk.Choices {
353− reasoning, ok := choice.Delta.JSON.ExtraFields["reasoning"]
354− if ok && reasoning.Raw() != "" {
355− reasoningStr := ""
356− json.Unmarshal([]byte(reasoning.Raw()), &reasoningStr)
357− if reasoningStr != "" {
358− eventChan <- ProviderEvent{
359− Type: EventThinkingDelta,
360− Thinking: reasoningStr,
361− }
362− }
363− }
364− if choice.Delta.Content != "" {
365− eventChan <- ProviderEvent{
366− Type: EventContentDelta,
367− Content: choice.Delta.Content,
368− }
369− currentContent += choice.Delta.Content
370− } else if len(choice.Delta.ToolCalls) > 0 {
371− toolCall := choice.Delta.ToolCalls[0]
372− if strings.HasPrefix(toolCall.ID, "functions.") {
373− exID, ok := toolCallIDMap[toolCall.ID]
374− if !ok {
375− newID := uuid.NewString()
376− toolCallIDMap[toolCall.ID] = newID
377− toolCall.ID = newID
378− } else {
379− toolCall.ID = exID
380− }
381− }
382− newToolCall := false
383− if existingToolCall, ok := msgToolCalls[toolCall.Index]; ok { // tool call exists
384− if toolCall.ID != "" && toolCall.ID != existingToolCall.ID {
385− found := false
386− // try to find the tool based on the ID
387− for _, tool := range msgToolCalls {
388− if tool.ID == toolCall.ID {
389− existingToolCall.Function.Arguments += toolCall.Function.Arguments
390− msgToolCalls[toolCall.Index] = existingToolCall
391− toolMap[existingToolCall.ID] = existingToolCall
392− found = true
393− }
394− }
395− if !found {
396− newToolCall = true
397− }
398− } else {
399− existingToolCall.Function.Arguments += toolCall.Function.Arguments
400− msgToolCalls[toolCall.Index] = existingToolCall
401− toolMap[existingToolCall.ID] = existingToolCall
402− }
403− } else {
404− newToolCall = true
405− }
406− if newToolCall { // new tool call
407− if toolCall.ID == "" {
408− toolCall.ID = uuid.NewString()
409− }
410− eventChan <- ProviderEvent{
411− Type: EventToolUseStart,
412− ToolCall: &message.ToolCall{
413− ID: toolCall.ID,
414− Name: toolCall.Function.Name,
415− Finished: false,
416− },
417− }
418− msgToolCalls[toolCall.Index] = openai.ChatCompletionMessageToolCall{
419− ID: toolCall.ID,
420− Type: "function",
421− Function: openai.ChatCompletionMessageToolCallFunction{
422− Name: toolCall.Function.Name,
423− Arguments: toolCall.Function.Arguments,
424− },
425− }
426− toolMap[toolCall.ID] = msgToolCalls[toolCall.Index]
427− }
428− toolCalls := []openai.ChatCompletionMessageToolCall{}
429− for _, tc := range toolMap {
430− toolCalls = append(toolCalls, tc)
431− }
432− acc.Choices[i].Message.ToolCalls = toolCalls
433− }
434− }
435− }
436−
437− err := openaiStream.Err()
438− if err == nil || errors.Is(err, io.EOF) {
439− if len(acc.Choices) == 0 {
440− eventChan <- ProviderEvent{
441− Type: EventError,
442− Error: fmt.Errorf("received empty streaming response from OpenAI API - check endpoint configuration"),
443− }
444− return
445− }
446−
447− resultFinishReason := acc.Choices[0].FinishReason
448− if resultFinishReason == "" {
449− // If the finish reason is empty, we assume it was a successful completion
450− // INFO: this is happening for openrouter for some reason
451− resultFinishReason = "stop"
452− }
453− // Stream completed successfully
454− finishReason := o.finishReason(resultFinishReason)
455− if len(acc.Choices[0].Message.ToolCalls) > 0 {
456− toolCalls = append(toolCalls, o.toolCalls(acc.ChatCompletion)...)
457− }
458− if len(toolCalls) > 0 {
459− finishReason = message.FinishReasonToolUse
460− }
461−
462− eventChan <- ProviderEvent{
463− Type: EventComplete,
464− Response: &ProviderResponse{
465− Content: currentContent,
466− ToolCalls: toolCalls,
467− Usage: o.usage(acc.ChatCompletion),
468− FinishReason: finishReason,
469− },
470− }
471− close(eventChan)
472− return
473− }
474−
475− // If there is an error we are going to see if we can retry the call
476− retry, after, retryErr := o.shouldRetry(attempts, err)
477− if retryErr != nil {
478− eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
479− close(eventChan)
480− return
481− }
482− if retry {
483− slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries, "error", err)
484− select {
485− case <-ctx.Done():
486− // context cancelled
487− if ctx.Err() != nil {
488− eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
489− }
490− close(eventChan)
491− return
492− case <-time.After(time.Duration(after) * time.Millisecond):
493− continue
494− }
495− }
496− eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
497− close(eventChan)
498− return
499− }
500− }()
501−
502− return eventChan
503−}
504−
505−func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
506− if attempts > maxRetries {
507− return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
508− }
509− if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
510− return false, 0, err
511− }
512− var apiErr *openai.Error
513− retryMs := 0
514− retryAfterValues := []string{}
515− if errors.As(err, &apiErr) {
516− // Check for token expiration (401 Unauthorized)
517− if apiErr.StatusCode == http.StatusUnauthorized {
518− prev := o.providerOptions.apiKey
519− // in case the key comes from a script, we try to re-evaluate it.
520− o.providerOptions.apiKey, err = config.Get().Resolve(o.providerOptions.config.APIKey)
521− if err != nil {
522− return false, 0, fmt.Errorf("failed to resolve API key: %w", err)
523− }
524− // if it didn't change, do not retry.
525− if prev == o.providerOptions.apiKey {
526− return false, 0, err
527− }
528− o.client = createOpenAIClient(o.providerOptions)
529− return true, 0, nil
530− }
531−
532− if apiErr.StatusCode == http.StatusTooManyRequests {
533− // Check if this is an insufficient quota error (permanent)
534− if apiErr.Type == "insufficient_quota" || apiErr.Code == "insufficient_quota" {
535− return false, 0, fmt.Errorf("OpenAI quota exceeded: %s. Please check your plan and billing details", apiErr.Message)
536− }
537− // Other 429 errors (rate limiting) can be retried
538− } else if apiErr.StatusCode != http.StatusInternalServerError {
539− return false, 0, err
540− }
541−
542− if apiErr.Response != nil {
543− retryAfterValues = apiErr.Response.Header.Values("Retry-After")
544− }
545− }
546−
547− if apiErr != nil {
548− slog.Warn("OpenAI API error", "status_code", apiErr.StatusCode, "message", apiErr.Message, "type", apiErr.Type)
549− if len(retryAfterValues) > 0 {
550− slog.Warn("Retry-After header", "values", retryAfterValues)
551− }
552− } else {
553− slog.Error("OpenAI API error", "error", err.Error(), "attempt", attempts, "max_retries", maxRetries)
554− }
555−
556− backoffMs := 2000 * (1 << (attempts - 1))
557− jitterMs := int(float64(backoffMs) * 0.2)
558− retryMs = backoffMs + jitterMs
559− if len(retryAfterValues) > 0 {
560− if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil {
561− retryMs = retryMs * 1000
562− }
563− }
564− return true, int64(retryMs), nil
565−}
566−
567−func (o *openaiClient) toolCalls(completion openai.ChatCompletion) []message.ToolCall {
568− var toolCalls []message.ToolCall
569−
570− if len(completion.Choices) > 0 && len(completion.Choices[0].Message.ToolCalls) > 0 {
571− for _, call := range completion.Choices[0].Message.ToolCalls {
572− // accumulator for some reason does this.
573− if call.Function.Name == "" {
574− continue
575− }
576− toolCall := message.ToolCall{
577− ID: call.ID,
578− Name: call.Function.Name,
579− Input: call.Function.Arguments,
580− Type: "function",
581− Finished: true,
582− }
583− toolCalls = append(toolCalls, toolCall)
584− }
585− }
586−
587− return toolCalls
588−}
589−
590−func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage {
591− cachedTokens := completion.Usage.PromptTokensDetails.CachedTokens
592− inputTokens := completion.Usage.PromptTokens - cachedTokens
593−
594− return TokenUsage{
595− InputTokens: inputTokens,
596− OutputTokens: completion.Usage.CompletionTokens,
597− CacheCreationTokens: 0, // OpenAI doesn't provide this directly
598− CacheReadTokens: cachedTokens,
599− }
600−}
601−
602−func (o *openaiClient) Model() catwalk.Model {
603− return o.providerOptions.model(o.providerOptions.modelType)
604−}
internal/llm/provider/openai_test.godeleted−166
@@ -1,166 +0,0 @@
1−package provider
2−
3−import (
4− "context"
5− "encoding/json"
6− "net/http"
7− "net/http/httptest"
8− "os"
9− "strings"
10− "testing"
11− "time"
12−
13− "github.com/charmbracelet/catwalk/pkg/catwalk"
14− "github.com/charmbracelet/crush/internal/config"
15− "github.com/charmbracelet/crush/internal/message"
16− "github.com/openai/openai-go"
17− "github.com/openai/openai-go/option"
18−)
19−
20−func TestMain(m *testing.M) {
21− _, err := config.Init(".", "", true)
22− if err != nil {
23− panic("Failed to initialize config: " + err.Error())
24− }
25−
26− os.Exit(m.Run())
27−}
28−
29−func TestOpenAIClientStreamChoices(t *testing.T) {
30− // Create a mock server that returns Server-Sent Events with empty choices
31− // This simulates the 🤡 behavior when a server returns 200 instead of 404
32− server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33− w.Header().Set("Content-Type", "text/event-stream")
34− w.Header().Set("Cache-Control", "no-cache")
35− w.Header().Set("Connection", "keep-alive")
36− w.WriteHeader(http.StatusOK)
37−
38− emptyChoicesChunk := map[string]any{
39− "id": "chat-completion-test",
40− "object": "chat.completion.chunk",
41− "created": time.Now().Unix(),
42− "model": "test-model",
43− "choices": []any{}, // Empty choices array that causes panic
44− }
45−
46− jsonData, _ := json.Marshal(emptyChoicesChunk)
47− w.Write([]byte("data: " + string(jsonData) + "\n\n"))
48− w.Write([]byte("data: [DONE]\n\n"))
49− }))
50− defer server.Close()
51−
52− // Create OpenAI client pointing to our mock server
53− client := &openaiClient{
54− providerOptions: providerClientOptions{
55− modelType: config.SelectedModelTypeLarge,
56− apiKey: "test-key",
57− systemMessage: "test",
58− model: func(config.SelectedModelType) catwalk.Model {
59− return catwalk.Model{
60− ID: "test-model",
61− Name: "test-model",
62− }
63− },
64− },
65− client: openai.NewClient(
66− option.WithAPIKey("test-key"),
67− option.WithBaseURL(server.URL),
68− ),
69− }
70−
71− // Create test messages
72− messages := []message.Message{
73− {
74− Role: message.User,
75− Parts: []message.ContentPart{message.TextContent{Text: "Hello"}},
76− },
77− }
78−
79− ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
80− defer cancel()
81−
82− eventsChan := client.stream(ctx, messages, nil)
83−
84− // Collect events - this will panic without the bounds check
85− for event := range eventsChan {
86− t.Logf("Received event: %+v", event)
87− if event.Type == EventError || event.Type == EventComplete {
88− break
89− }
90− }
91−}
92−
93−func TestOpenAIClient429InsufficientQuotaError(t *testing.T) {
94− client := &openaiClient{
95− providerOptions: providerClientOptions{
96− modelType: config.SelectedModelTypeLarge,
97− apiKey: "test-key",
98− systemMessage: "test",
99− config: config.ProviderConfig{
100− ID: "test-openai",
101− APIKey: "test-key",
102− },
103− model: func(config.SelectedModelType) catwalk.Model {
104− return catwalk.Model{
105− ID: "test-model",
106− Name: "test-model",
107− }
108− },
109− },
110− }
111−
112− // Test insufficient_quota error should not retry
113− apiErr := &openai.Error{
114− StatusCode: 429,
115− Message: "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.",
116− Type: "insufficient_quota",
117− Code: "insufficient_quota",
118− }
119−
120− retry, _, err := client.shouldRetry(1, apiErr)
121− if retry {
122− t.Error("Expected shouldRetry to return false for insufficient_quota error, but got true")
123− }
124− if err == nil {
125− t.Error("Expected shouldRetry to return an error for insufficient_quota, but got nil")
126− }
127− if err != nil && !strings.Contains(err.Error(), "quota") {
128− t.Errorf("Expected error message to mention quota, got: %v", err)
129− }
130−}
131−
132−func TestOpenAIClient429RateLimitError(t *testing.T) {
133− client := &openaiClient{
134− providerOptions: providerClientOptions{
135− modelType: config.SelectedModelTypeLarge,
136− apiKey: "test-key",
137− systemMessage: "test",
138− config: config.ProviderConfig{
139− ID: "test-openai",
140− APIKey: "test-key",
141− },
142− model: func(config.SelectedModelType) catwalk.Model {
143− return catwalk.Model{
144− ID: "test-model",
145− Name: "test-model",
146− }
147− },
148− },
149− }
150−
151− // Test regular rate limit error should retry
152− apiErr := &openai.Error{
153− StatusCode: 429,
154− Message: "Rate limit reached for requests",
155− Type: "rate_limit_exceeded",
156− Code: "rate_limit_exceeded",
157− }
158−
159− retry, _, err := client.shouldRetry(1, apiErr)
160− if !retry {
161− t.Error("Expected shouldRetry to return true for rate_limit_exceeded error, but got false")
162− }
163− if err != nil {
164− t.Errorf("Expected shouldRetry to return nil error for rate_limit_exceeded, but got: %v", err)
165− }
166−}
internal/llm/provider/provider.godeleted−208
@@ -1,208 +0,0 @@
1−package provider
2−
3−import (
4− "context"
5− "fmt"
6−
7− "github.com/charmbracelet/catwalk/pkg/catwalk"
8−
9− "github.com/charmbracelet/crush/internal/config"
10− "github.com/charmbracelet/crush/internal/llm/tools"
11− "github.com/charmbracelet/crush/internal/message"
12−)
13−
14−type EventType string
15−
16−const maxRetries = 3
17−
18−const (
19− EventContentStart EventType = "content_start"
20− EventToolUseStart EventType = "tool_use_start"
21− EventToolUseDelta EventType = "tool_use_delta"
22− EventToolUseStop EventType = "tool_use_stop"
23− EventContentDelta EventType = "content_delta"
24− EventThinkingDelta EventType = "thinking_delta"
25− EventSignatureDelta EventType = "signature_delta"
26− EventContentStop EventType = "content_stop"
27− EventComplete EventType = "complete"
28− EventError EventType = "error"
29− EventWarning EventType = "warning"
30−)
31−
32−type TokenUsage struct {
33− InputTokens int64
34− OutputTokens int64
35− CacheCreationTokens int64
36− CacheReadTokens int64
37−}
38−
39−type ProviderResponse struct {
40− Content string
41− ToolCalls []message.ToolCall
42− Usage TokenUsage
43− FinishReason message.FinishReason
44−}
45−
46−type ProviderEvent struct {
47− Type EventType
48−
49− Content string
50− Thinking string
51− Signature string
52− Response *ProviderResponse
53− ToolCall *message.ToolCall
54− Error error
55−}
56−type Provider interface {
57− SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
58−
59− StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
60−
61− Model() catwalk.Model
62−}
63−
64−type providerClientOptions struct {
65− baseURL string
66− config config.ProviderConfig
67− apiKey string
68− modelType config.SelectedModelType
69− model func(config.SelectedModelType) catwalk.Model
70− disableCache bool
71− systemMessage string
72− systemPromptPrefix string
73− maxTokens int64
74− extraHeaders map[string]string
75− extraBody map[string]any
76− extraParams map[string]string
77−}
78−
79−type ProviderClientOption func(*providerClientOptions)
80−
81−type ProviderClient interface {
82− send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
83− stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
84−
85− Model() catwalk.Model
86−}
87−
88−type baseProvider[C ProviderClient] struct {
89− options providerClientOptions
90− client C
91−}
92−
93−func (p *baseProvider[C]) cleanMessages(messages []message.Message) (cleaned []message.Message) {
94− for _, msg := range messages {
95− // The message has no content
96− if len(msg.Parts) == 0 {
97− continue
98− }
99− cleaned = append(cleaned, msg)
100− }
101− return cleaned
102−}
103−
104−func (p *baseProvider[C]) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
105− messages = p.cleanMessages(messages)
106− return p.client.send(ctx, messages, tools)
107−}
108−
109−func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
110− messages = p.cleanMessages(messages)
111− return p.client.stream(ctx, messages, tools)
112−}
113−
114−func (p *baseProvider[C]) Model() catwalk.Model {
115− return p.client.Model()
116−}
117−
118−func WithModel(model config.SelectedModelType) ProviderClientOption {
119− return func(options *providerClientOptions) {
120− options.modelType = model
121− }
122−}
123−
124−func WithDisableCache(disableCache bool) ProviderClientOption {
125− return func(options *providerClientOptions) {
126− options.disableCache = disableCache
127− }
128−}
129−
130−func WithSystemMessage(systemMessage string) ProviderClientOption {
131− return func(options *providerClientOptions) {
132− options.systemMessage = systemMessage
133− }
134−}
135−
136−func WithMaxTokens(maxTokens int64) ProviderClientOption {
137− return func(options *providerClientOptions) {
138− options.maxTokens = maxTokens
139− }
140−}
141−
142−func NewProvider(cfg config.ProviderConfig, opts ...ProviderClientOption) (Provider, error) {
143− restore := config.PushPopCrushEnv()
144− defer restore()
145− resolvedAPIKey, err := config.Get().Resolve(cfg.APIKey)
146− if err != nil {
147− return nil, fmt.Errorf("failed to resolve API key for provider %s: %w", cfg.ID, err)
148− }
149−
150− // Resolve extra headers
151− resolvedExtraHeaders := make(map[string]string)
152− for key, value := range cfg.ExtraHeaders {
153− resolvedValue, err := config.Get().Resolve(value)
154− if err != nil {
155− return nil, fmt.Errorf("failed to resolve extra header %s for provider %s: %w", key, cfg.ID, err)
156− }
157− resolvedExtraHeaders[key] = resolvedValue
158− }
159−
160− clientOptions := providerClientOptions{
161− baseURL: cfg.BaseURL,
162− config: cfg,
163− apiKey: resolvedAPIKey,
164− extraHeaders: resolvedExtraHeaders,
165− extraBody: cfg.ExtraBody,
166− extraParams: cfg.ExtraParams,
167− systemPromptPrefix: cfg.SystemPromptPrefix,
168− model: func(tp config.SelectedModelType) catwalk.Model {
169− return *config.Get().GetModelByType(tp)
170− },
171− }
172− for _, o := range opts {
173− o(&clientOptions)
174− }
175− switch cfg.Type {
176− case catwalk.TypeAnthropic:
177− return &baseProvider[AnthropicClient]{
178− options: clientOptions,
179− client: newAnthropicClient(clientOptions, AnthropicClientTypeNormal),
180− }, nil
181− case catwalk.TypeOpenAI:
182− return &baseProvider[OpenAIClient]{
183− options: clientOptions,
184− client: newOpenAIClient(clientOptions),
185− }, nil
186− case catwalk.TypeGemini:
187− return &baseProvider[GeminiClient]{
188− options: clientOptions,
189− client: newGeminiClient(clientOptions),
190− }, nil
191− case catwalk.TypeBedrock:
192− return &baseProvider[BedrockClient]{
193− options: clientOptions,
194− client: newBedrockClient(clientOptions),
195− }, nil
196− case catwalk.TypeAzure:
197− return &baseProvider[AzureClient]{
198− options: clientOptions,
199− client: newAzureClient(clientOptions),
200− }, nil
201− case catwalk.TypeVertexAI:
202− return &baseProvider[VertexAIClient]{
203− options: clientOptions,
204− client: newVertexAIClient(clientOptions),
205− }, nil
206− }
207− return nil, fmt.Errorf("provider not supported: %s", cfg.Type)
208−}
internal/llm/provider/vertexai.godeleted−40
@@ -1,40 +0,0 @@
1−package provider
2−
3−import (
4− "context"
5− "log/slog"
6− "strings"
7−
8− "github.com/charmbracelet/crush/internal/config"
9− "github.com/charmbracelet/crush/internal/log"
10− "google.golang.org/genai"
11−)
12−
13−type VertexAIClient ProviderClient
14−
15−func newVertexAIClient(opts providerClientOptions) VertexAIClient {
16− project := opts.extraParams["project"]
17− location := opts.extraParams["location"]
18− cc := &genai.ClientConfig{
19− Project: project,
20− Location: location,
21− Backend: genai.BackendVertexAI,
22− }
23− if config.Get().Options.Debug {
24− cc.HTTPClient = log.NewHTTPClient()
25− }
26− client, err := genai.NewClient(context.Background(), cc)
27− if err != nil {
28− slog.Error("Failed to create VertexAI client", "error", err)
29− return nil
30− }
31−
32− model := opts.model(opts.modelType)
33− if strings.Contains(model.ID, "anthropic") || strings.Contains(model.ID, "claude-sonnet") {
34− return newAnthropicClient(opts, AnthropicClientTypeVertex)
35− }
36− return &geminiClient{
37− providerOptions: opts,
38− client: client,
39− }
40−}
internal/llm/tools/bash.godeleted−395
@@ -1,395 +0,0 @@
1−package tools
2−
3−import (
4− "bytes"
5− "context"
6− _ "embed"
7− "encoding/json"
8− "fmt"
9− "html/template"
10− "strings"
11− "time"
12−
13− "github.com/charmbracelet/crush/internal/config"
14− "github.com/charmbracelet/crush/internal/permission"
15− "github.com/charmbracelet/crush/internal/shell"
16−)
17−
18−type BashParams struct {
19− Command string `json:"command"`
20− Timeout int `json:"timeout"`
21−}
22−
23−type BashPermissionsParams struct {
24− Command string `json:"command"`
25− Timeout int `json:"timeout"`
26−}
27−
28−type BashResponseMetadata struct {
29− StartTime int64 `json:"start_time"`
30− EndTime int64 `json:"end_time"`
31− Output string `json:"output"`
32− WorkingDirectory string `json:"working_directory"`
33−}
34−type bashTool struct {
35− permissions permission.Service
36− workingDir string
37− attribution *config.Attribution
38−}
39−
40−const (
41− BashToolName = "bash"
42−
43− DefaultTimeout = 1 * 60 * 1000 // 1 minutes in milliseconds
44− MaxTimeout = 10 * 60 * 1000 // 10 minutes in milliseconds
45− MaxOutputLength = 30000
46− BashNoOutput = "no output"
47−)
48−
49−//go:embed bash.md
50−var bashDescription []byte
51−
52−var bashDescriptionTpl = template.Must(
53− template.New("bashDescription").
54− Parse(string(bashDescription)),
55−)
56−
57−type bashDescriptionData struct {
58− BannedCommands string
59− MaxOutputLength int
60− AttributionStep string
61− AttributionExample string
62− PRAttribution string
63−}
64−
65−var bannedCommands = []string{
66− // Network/Download tools
67− "alias",
68− "aria2c",
69− "axel",
70− "chrome",
71− "curl",
72− "curlie",
73− "firefox",
74− "http-prompt",
75− "httpie",
76− "links",
77− "lynx",
78− "nc",
79− "safari",
80− "scp",
81− "ssh",
82− "telnet",
83− "w3m",
84− "wget",
85− "xh",
86−
87− // System administration
88− "doas",
89− "su",
90− "sudo",
91−
92− // Package managers
93− "apk",
94− "apt",
95− "apt-cache",
96− "apt-get",
97− "dnf",
98− "dpkg",
99− "emerge",
100− "home-manager",
101− "makepkg",
102− "opkg",
103− "pacman",
104− "paru",
105− "pkg",
106− "pkg_add",
107− "pkg_delete",
108− "portage",
109− "rpm",
110− "yay",
111− "yum",
112− "zypper",
113−
114− // System modification
115− "at",
116− "batch",
117− "chkconfig",
118− "crontab",
119− "fdisk",
120− "mkfs",
121− "mount",
122− "parted",
123− "service",
124− "systemctl",
125− "umount",
126−
127− // Network configuration
128− "firewall-cmd",
129− "ifconfig",
130− "ip",
131− "iptables",
132− "netstat",
133− "pfctl",
134− "route",
135− "ufw",
136−}
137−
138−func (b *bashTool) bashDescription() string {
139− bannedCommandsStr := strings.Join(bannedCommands, ", ")
140−
141− // Build attribution text based on settings
142− var attributionStep, attributionExample, prAttribution string
143−
144− // Default to true if attribution is nil (backward compatibility)
145− generatedWith := b.attribution == nil || b.attribution.GeneratedWith
146− coAuthoredBy := b.attribution == nil || b.attribution.CoAuthoredBy
147−
148− // Build PR attribution
149− if generatedWith {
150− prAttribution = "💘 Generated with Crush"
151− }
152−
153− if generatedWith || coAuthoredBy {
154− var attributionParts []string
155− if generatedWith {
156− attributionParts = append(attributionParts, "💘 Generated with Crush")
157− }
158− if coAuthoredBy {
159− attributionParts = append(attributionParts, "Co-Authored-By: Crush <crush@charm.land>")
160− }
161−
162− if len(attributionParts) > 0 {
163− attributionStep = fmt.Sprintf("4. Create the commit with a message ending with:\n%s", strings.Join(attributionParts, "\n"))
164−
165− attributionText := strings.Join(attributionParts, "\n ")
166− attributionExample = fmt.Sprintf(`<example>
167−git commit -m "$(cat <<'EOF'
168− Commit message here.
169−
170− %s
171− EOF
172−)"</example>`, attributionText)
173− }
174− }
175−
176− if attributionStep == "" {
177− attributionStep = "4. Create the commit with your commit message."
178− attributionExample = `<example>
179−git commit -m "$(cat <<'EOF'
180− Commit message here.
181− EOF
182−)"</example>`
183− }
184−
185− var out bytes.Buffer
186− if err := bashDescriptionTpl.Execute(&out, bashDescriptionData{
187− BannedCommands: bannedCommandsStr,
188− MaxOutputLength: MaxOutputLength,
189− AttributionStep: attributionStep,
190− AttributionExample: attributionExample,
191− PRAttribution: prAttribution,
192− }); err != nil {
193− // this should never happen.
194− panic("failed to execute bash description template: " + err.Error())
195− }
196− return out.String()
197−}
198−
199−func blockFuncs() []shell.BlockFunc {
200− return []shell.BlockFunc{
201− shell.CommandsBlocker(bannedCommands),
202−
203− // System package managers
204− shell.ArgumentsBlocker("apk", []string{"add"}, nil),
205− shell.ArgumentsBlocker("apt", []string{"install"}, nil),
206− shell.ArgumentsBlocker("apt-get", []string{"install"}, nil),
207− shell.ArgumentsBlocker("dnf", []string{"install"}, nil),
208− shell.ArgumentsBlocker("pacman", nil, []string{"-S"}),
209− shell.ArgumentsBlocker("pkg", []string{"install"}, nil),
210− shell.ArgumentsBlocker("yum", []string{"install"}, nil),
211− shell.ArgumentsBlocker("zypper", []string{"install"}, nil),
212−
213− // Language-specific package managers
214− shell.ArgumentsBlocker("brew", []string{"install"}, nil),
215− shell.ArgumentsBlocker("cargo", []string{"install"}, nil),
216− shell.ArgumentsBlocker("gem", []string{"install"}, nil),
217− shell.ArgumentsBlocker("go", []string{"install"}, nil),
218− shell.ArgumentsBlocker("npm", []string{"install"}, []string{"--global"}),
219− shell.ArgumentsBlocker("npm", []string{"install"}, []string{"-g"}),
220− shell.ArgumentsBlocker("pip", []string{"install"}, []string{"--user"}),
221− shell.ArgumentsBlocker("pip3", []string{"install"}, []string{"--user"}),
222− shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"--global"}),
223− shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"-g"}),
224− shell.ArgumentsBlocker("yarn", []string{"global", "add"}, nil),
225−
226− // `go test -exec` can run arbitrary commands
227− shell.ArgumentsBlocker("go", []string{"test"}, []string{"-exec"}),
228− }
229−}
230−
231−func NewBashTool(permission permission.Service, workingDir string, attribution *config.Attribution) BaseTool {
232− // Set up command blocking on the persistent shell
233− persistentShell := shell.GetPersistentShell(workingDir)
234− persistentShell.SetBlockFuncs(blockFuncs())
235−
236− return &bashTool{
237− permissions: permission,
238− workingDir: workingDir,
239− attribution: attribution,
240− }
241−}
242−
243−func (b *bashTool) Name() string {
244− return BashToolName
245−}
246−
247−func (b *bashTool) Info() ToolInfo {
248− return ToolInfo{
249− Name: BashToolName,
250− Description: b.bashDescription(),
251− Parameters: map[string]any{
252− "command": map[string]any{
253− "type": "string",
254− "description": "The command to execute",
255− },
256− "timeout": map[string]any{
257− "type": "number",
258− "description": "Optional timeout in milliseconds (max 600000)",
259− },
260− },
261− Required: []string{"command"},
262− }
263−}
264−
265−func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
266− var params BashParams
267− if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
268− return NewTextErrorResponse("invalid parameters"), nil
269− }
270−
271− if params.Timeout > MaxTimeout {
272− params.Timeout = MaxTimeout
273− } else if params.Timeout <= 0 {
274− params.Timeout = DefaultTimeout
275− }
276−
277− if params.Command == "" {
278− return NewTextErrorResponse("missing command"), nil
279− }
280−
281− isSafeReadOnly := false
282− cmdLower := strings.ToLower(params.Command)
283−
284− for _, safe := range safeCommands {
285− if strings.HasPrefix(cmdLower, safe) {
286− if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
287− isSafeReadOnly = true
288− break
289− }
290− }
291− }
292−
293− sessionID, messageID := GetContextValues(ctx)
294− if sessionID == "" || messageID == "" {
295− return ToolResponse{}, fmt.Errorf("session ID and message ID are required for executing shell command")
296− }
297− if !isSafeReadOnly {
298− shell := shell.GetPersistentShell(b.workingDir)
299− p := b.permissions.Request(
300− permission.CreatePermissionRequest{
301− SessionID: sessionID,
302− Path: shell.GetWorkingDir(),
303− ToolCallID: call.ID,
304− ToolName: BashToolName,
305− Action: "execute",
306− Description: fmt.Sprintf("Execute command: %s", params.Command),
307− Params: BashPermissionsParams{
308− Command: params.Command,
309− },
310− },
311− )
312− if !p {
313− return ToolResponse{}, permission.ErrorPermissionDenied
314− }
315− }
316− startTime := time.Now()
317− if params.Timeout > 0 {
318− var cancel context.CancelFunc
319− ctx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Millisecond)
320− defer cancel()
321− }
322−
323− persistentShell := shell.GetPersistentShell(b.workingDir)
324− stdout, stderr, err := persistentShell.Exec(ctx, params.Command)
325−
326− // Get the current working directory after command execution
327− currentWorkingDir := persistentShell.GetWorkingDir()
328− interrupted := shell.IsInterrupt(err)
329− exitCode := shell.ExitCode(err)
330− if exitCode == 0 && !interrupted && err != nil {
331− return ToolResponse{}, fmt.Errorf("error executing command: %w", err)
332− }
333−
334− stdout = truncateOutput(stdout)
335− stderr = truncateOutput(stderr)
336−
337− errorMessage := stderr
338− if errorMessage == "" && err != nil {
339− errorMessage = err.Error()
340− }
341−
342− if interrupted {
343− if errorMessage != "" {
344− errorMessage += "\n"
345− }
346− errorMessage += "Command was aborted before completion"
347− } else if exitCode != 0 {
348− if errorMessage != "" {
349− errorMessage += "\n"
350− }
351− errorMessage += fmt.Sprintf("Exit code %d", exitCode)
352− }
353−
354− hasBothOutputs := stdout != "" && stderr != ""
355−
356− if hasBothOutputs {
357− stdout += "\n"
358− }
359−
360− if errorMessage != "" {
361− stdout += "\n" + errorMessage
362− }
363−
364− metadata := BashResponseMetadata{
365− StartTime: startTime.UnixMilli(),
366− EndTime: time.Now().UnixMilli(),
367− Output: stdout,
368− WorkingDirectory: currentWorkingDir,
369− }
370− if stdout == "" {
371− return WithResponseMetadata(NewTextResponse(BashNoOutput), metadata), nil
372− }
373− stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", currentWorkingDir)
374− return WithResponseMetadata(NewTextResponse(stdout), metadata), nil
375−}
376−
377−func truncateOutput(content string) string {
378− if len(content) <= MaxOutputLength {
379− return content
380− }
381−
382− halfLength := MaxOutputLength / 2
383− start := content[:halfLength]
384− end := content[len(content)-halfLength:]
385−
386− truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
387− return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
388−}
389−
390−func countLines(s string) int {
391− if s == "" {
392− return 0
393− }
394− return len(strings.Split(s, "\n"))
395−}
internal/llm/tools/bash.mddeleted−161
@@ -1,161 +0,0 @@
1−Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
2−
3−CROSS-PLATFORM SHELL SUPPORT:
4−
5−- This tool uses a shell interpreter (mvdan/sh) that mimics the Bash language,
6− so you should use Bash syntax in all platforms, including Windows.
7− The most common shell builtins and core utils are available in Windows as
8− well.
9−- Make sure to use forward slashes (/) as path separators in commands, even on
10− Windows. Example: "ls C:/foo/bar" instead of "ls C:\foo\bar".
11−
12−Before executing the command, please follow these steps:
13−
14−1. Directory Verification:
15−
16−- If the command will create new directories or files, first use the LS tool to verify the parent directory exists and is the correct location
17−- For example, before running "mkdir foo/bar", first use LS to check that "foo" exists and is the intended parent directory
18−
19−2. Security Check:
20−
21−- For security and to limit the threat of a prompt injection attack, some commands are limited or banned. If you use a disallowed command, you will receive an error message explaining the restriction. Explain the error to the User.
22−- Verify that the command is not one of the banned commands: {{ .BannedCommands }}.
23−
24−3. Command Execution:
25−
26−- After ensuring proper quoting, execute the command.
27−- Capture the output of the command.
28−
29−4. Output Processing:
30−
31−- If the output exceeds {{ .MaxOutputLength }} characters, output will be truncated before being returned to you.
32−- Prepare the output for display to the user.
33−
34−5. Return Result:
35−
36−- Provide the processed output of the command.
37−- If any errors occurred during execution, include those in the output.
38−- The result will also have metadata like the cwd (current working directory) at the end, included with <cwd></cwd> tags.
39−
40−Usage notes:
41−
42−- The command argument is required.
43−- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 30 minutes.
44−- VERY IMPORTANT: You MUST avoid using search commands like 'find' and 'grep'. Instead use Grep, Glob, or Agent tools to search. You MUST avoid read tools like 'cat', 'head', 'tail', and 'ls', and use FileRead and LS tools to read files.
45−- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
46−- IMPORTANT: All commands share the same shell session. Shell state (environment variables, virtual environments, current directory, etc.) persist between commands. For example, if you set an environment variable as part of a command, the environment variable will persist for subsequent commands.
47−- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of 'cd'. You may use 'cd' if the User explicitly requests it.
48− <good-example>
49− pytest /foo/bar/tests
50− </good-example>
51− <bad-example>
52− cd /foo/bar && pytest tests
53− </bad-example>
54−
55−# Committing changes with git
56−
57−When the user asks you to create a new git commit, follow these steps carefully:
58−
59−1. Start with a single message that contains exactly three tool_use blocks that do the following (it is VERY IMPORTANT that you send these tool_use blocks in a single message, otherwise it will feel slow to the user!):
60−
61−- Run a git status command to see all untracked files.
62−- Run a git diff command to see both staged and unstaged changes that will be committed.
63−- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
64−
65−2. Use the git context at the start of this conversation to determine which files are relevant to your commit. Add relevant untracked files to the staging area. Do not commit files that were already modified at the start of this conversation, if they are not relevant to your commit.
66−
67−3. Analyze all staged changes (both previously staged and newly added) and draft a commit message. Wrap your analysis process in <commit_analysis> tags:
68−
69−<commit_analysis>
70−
71−- List the files that have been changed or added
72−- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
73−- Brainstorm the purpose or motivation behind these changes
74−- Do not use tools to explore code, beyond what is available in the git context
75−- Assess the impact of these changes on the overall project
76−- Check for any sensitive information that shouldn't be committed
77−- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
78−- Ensure your language is clear, concise, and to the point
79−- Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
80−- Ensure the message is not generic (avoid words like "Update" or "Fix" without context)
81−- Review the draft message to ensure it accurately reflects the changes and their purpose
82− </commit_analysis>
83−
84−{{ .AttributionStep }}
85−
86−- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
87− {{ .AttributionExample }}
88−
89−5. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.
90−
91−6. Finally, run git status to make sure the commit succeeded.
92−
93−Important notes:
94−
95−- When possible, combine the "git add" and "git commit" commands into a single "git commit -am" command, to speed things up
96−- However, be careful not to stage files (e.g. with 'git add .') for commits that aren't part of the change, they may have untracked files they want to keep around, but not commit.
97−- NEVER update the git config
98−- DO NOT push to the remote repository
99−- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
100−- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
101−- Ensure your commit message is meaningful and concise. It should explain the purpose of the changes, not just describe them.
102−- Return an empty response - the user will see the git output directly
103−
104−# Creating pull requests
105−
106−Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
107−
108−IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
109−
110−1. Understand the current state of the branch. Remember to send a single message that contains multiple tool_use blocks (it is VERY IMPORTANT that you do this in a single message, otherwise it will feel slow to the user!):
111−
112−- Run a git status command to see all untracked files.
113−- Run a git diff command to see both staged and unstaged changes that will be committed.
114−- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
115−- Run a git log command and 'git diff main...HEAD' to understand the full commit history for the current branch (from the time it diverged from the 'main' branch.)
116−
117−2. Create new branch if needed
118−
119−3. Commit changes if needed
120−
121−4. Push to remote with -u flag if needed
122−
123−5. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (not just the latest commit, but all commits that will be included in the pull request!), and draft a pull request summary. Wrap your analysis process in <pr_analysis> tags:
124−
125−<pr_analysis>
126−
127−- List the commits since diverging from the main branch
128−- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
129−- Brainstorm the purpose or motivation behind these changes
130−- Assess the impact of these changes on the overall project
131−- Do not use tools to explore code, beyond what is available in the git context
132−- Check for any sensitive information that shouldn't be committed
133−- Draft a concise (1-2 bullet points) pull request summary that focuses on the "why" rather than the "what"
134−- Ensure the summary accurately reflects all changes since diverging from the main branch
135−- Ensure your language is clear, concise, and to the point
136−- Ensure the summary accurately reflects the changes and their purpose (ie. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
137−- Ensure the summary is not generic (avoid words like "Update" or "Fix" without context)
138−- Review the draft summary to ensure it accurately reflects the changes and their purpose
139− </pr_analysis>
140−
141−6. Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
142− <example>
143− gh pr create --title "the pr title" --body "$(cat <<'EOF'
144−
145−## Summary
146−
147−<1-3 bullet points>
148−
149−## Test plan
150−
151−[Checklist of TODOs for testing the pull request...]
152−
153−{{ .PRAttribution }}
154−EOF
155−)"
156−</example>
157−
158−Important:
159−
160−- Return an empty response - the user will see the gh output directly
161−- Never update git config
internal/llm/tools/diagnostics.godeleted−213
@@ -1,213 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− _ "embed"
6− "encoding/json"
7− "fmt"
8− "log/slog"
9− "sort"
10− "strings"
11− "time"
12−
13− "github.com/charmbracelet/crush/internal/csync"
14− "github.com/charmbracelet/crush/internal/lsp"
15− "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
16−)
17−
18−type DiagnosticsParams struct {
19− FilePath string `json:"file_path"`
20−}
21−
22−type diagnosticsTool struct {
23− lspClients *csync.Map[string, *lsp.Client]
24−}
25−
26−const DiagnosticsToolName = "diagnostics"
27−
28−//go:embed diagnostics.md
29−var diagnosticsDescription []byte
30−
31−func NewDiagnosticsTool(lspClients *csync.Map[string, *lsp.Client]) BaseTool {
32− return &diagnosticsTool{
33− lspClients,
34− }
35−}
36−
37−func (b *diagnosticsTool) Name() string {
38− return DiagnosticsToolName
39−}
40−
41−func (b *diagnosticsTool) Info() ToolInfo {
42− return ToolInfo{
43− Name: DiagnosticsToolName,
44− Description: string(diagnosticsDescription),
45− Parameters: map[string]any{
46− "file_path": map[string]any{
47− "type": "string",
48− "description": "The path to the file to get diagnostics for (leave w empty for project diagnostics)",
49− },
50− },
51− Required: []string{},
52− }
53−}
54−
55−func (b *diagnosticsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
56− var params DiagnosticsParams
57− if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
58− return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
59− }
60−
61− if b.lspClients.Len() == 0 {
62− return NewTextErrorResponse("no LSP clients available"), nil
63− }
64− notifyLSPs(ctx, b.lspClients, params.FilePath)
65− output := getDiagnostics(params.FilePath, b.lspClients)
66− return NewTextResponse(output), nil
67−}
68−
69−func notifyLSPs(ctx context.Context, lsps *csync.Map[string, *lsp.Client], filepath string) {
70− if filepath == "" {
71− return
72− }
73− for client := range lsps.Seq() {
74− if !client.HandlesFile(filepath) {
75− continue
76− }
77− _ = client.OpenFileOnDemand(ctx, filepath)
78− _ = client.NotifyChange(ctx, filepath)
79− client.WaitForDiagnostics(ctx, 5*time.Second)
80− }
81−}
82−
83−func getDiagnostics(filePath string, lsps *csync.Map[string, *lsp.Client]) string {
84− fileDiagnostics := []string{}
85− projectDiagnostics := []string{}
86−
87− for lspName, client := range lsps.Seq2() {
88− for location, diags := range client.GetDiagnostics() {
89− path, err := location.Path()
90− if err != nil {
91− slog.Error("Failed to convert diagnostic location URI to path", "uri", location, "error", err)
92− continue
93− }
94− isCurrentFile := path == filePath
95− for _, diag := range diags {
96− formattedDiag := formatDiagnostic(path, diag, lspName)
97− if isCurrentFile {
98− fileDiagnostics = append(fileDiagnostics, formattedDiag)
99− } else {
100− projectDiagnostics = append(projectDiagnostics, formattedDiag)
101− }
102− }
103− }
104− }
105−
106− sortDiagnostics(fileDiagnostics)
107− sortDiagnostics(projectDiagnostics)
108−
109− var output strings.Builder
110− writeDiagnostics(&output, "file_diagnostics", fileDiagnostics)
111− writeDiagnostics(&output, "project_diagnostics", projectDiagnostics)
112−
113− if len(fileDiagnostics) > 0 || len(projectDiagnostics) > 0 {
114− fileErrors := countSeverity(fileDiagnostics, "Error")
115− fileWarnings := countSeverity(fileDiagnostics, "Warn")
116− projectErrors := countSeverity(projectDiagnostics, "Error")
117− projectWarnings := countSeverity(projectDiagnostics, "Warn")
118− output.WriteString("\n<diagnostic_summary>\n")
119− fmt.Fprintf(&output, "Current file: %d errors, %d warnings\n", fileErrors, fileWarnings)
120− fmt.Fprintf(&output, "Project: %d errors, %d warnings\n", projectErrors, projectWarnings)
121− output.WriteString("</diagnostic_summary>\n")
122− }
123−
124− out := output.String()
125− slog.Info("Diagnostics", "output", fmt.Sprintf("%q", out))
126− return out
127−}
128−
129−func writeDiagnostics(output *strings.Builder, tag string, in []string) {
130− if len(in) == 0 {
131− return
132− }
133− output.WriteString("\n<" + tag + ">\n")
134− if len(in) > 10 {
135− output.WriteString(strings.Join(in[:10], "\n"))
136− fmt.Fprintf(output, "\n... and %d more diagnostics", len(in)-10)
137− } else {
138− output.WriteString(strings.Join(in, "\n"))
139− }
140− output.WriteString("\n</" + tag + ">\n")
141−}
142−
143−func sortDiagnostics(in []string) []string {
144− sort.Slice(in, func(i, j int) bool {
145− iIsError := strings.HasPrefix(in[i], "Error")
146− jIsError := strings.HasPrefix(in[j], "Error")
147− if iIsError != jIsError {
148− return iIsError // Errors come first
149− }
150− return in[i] < in[j] // Then alphabetically
151− })
152− return in
153−}
154−
155−func formatDiagnostic(pth string, diagnostic protocol.Diagnostic, source string) string {
156− severity := "Info"
157− switch diagnostic.Severity {
158− case protocol.SeverityError:
159− severity = "Error"
160− case protocol.SeverityWarning:
161− severity = "Warn"
162− case protocol.SeverityHint:
163− severity = "Hint"
164− }
165−
166− location := fmt.Sprintf("%s:%d:%d", pth, diagnostic.Range.Start.Line+1, diagnostic.Range.Start.Character+1)
167−
168− sourceInfo := ""
169− if diagnostic.Source != "" {
170− sourceInfo = diagnostic.Source
171− } else if source != "" {
172− sourceInfo = source
173− }
174−
175− codeInfo := ""
176− if diagnostic.Code != nil {
177− codeInfo = fmt.Sprintf("[%v]", diagnostic.Code)
178− }
179−
180− tagsInfo := ""
181− if len(diagnostic.Tags) > 0 {
182− tags := []string{}
183− for _, tag := range diagnostic.Tags {
184− switch tag {
185− case protocol.Unnecessary:
186− tags = append(tags, "unnecessary")
187− case protocol.Deprecated:
188− tags = append(tags, "deprecated")
189− }
190− }
191− if len(tags) > 0 {
192− tagsInfo = fmt.Sprintf(" (%s)", strings.Join(tags, ", "))
193− }
194− }
195−
196− return fmt.Sprintf("%s: %s [%s]%s%s %s",
197− severity,
198− location,
199− sourceInfo,
200− codeInfo,
201− tagsInfo,
202− diagnostic.Message)
203−}
204−
205−func countSeverity(diagnostics []string, severity string) int {
206− count := 0
207− for _, diag := range diagnostics {
208− if strings.HasPrefix(diag, severity) {
209− count++
210− }
211− }
212− return count
213−}
internal/llm/tools/diagnostics.mddeleted−21
@@ -1,21 +0,0 @@
1−Get diagnostics for a file and/or project.
2−WHEN TO USE THIS TOOL:
3−
4−- Use when you need to check for errors or warnings in your code
5−- Helpful for debugging and ensuring code quality
6−- Good for getting a quick overview of issues in a file or project
7− HOW TO USE:
8−- Provide a path to a file to get diagnostics for that file
9−- Leave the path empty to get diagnostics for the entire project
10−- Results are displayed in a structured format with severity levels
11− FEATURES:
12−- Displays errors, warnings, and hints
13−- Groups diagnostics by severity
14−- Provides detailed information about each diagnostic
15− LIMITATIONS:
16−- Results are limited to the diagnostics provided by the LSP clients
17−- May not cover all possible issues in the code
18−- Does not provide suggestions for fixing issues
19− TIPS:
20−- Use in conjunction with other tools for a comprehensive code review
21−- Combine with the LSP client for real-time diagnostics
internal/llm/tools/download.godeleted−196
@@ -1,196 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− _ "embed"
6− "encoding/json"
7− "fmt"
8− "io"
9− "net/http"
10− "os"
11− "path/filepath"
12− "strings"
13− "time"
14−
15− "github.com/charmbracelet/crush/internal/permission"
16−)
17−
18−type DownloadParams struct {
19− URL string `json:"url"`
20− FilePath string `json:"file_path"`
21− Timeout int `json:"timeout,omitempty"`
22−}
23−
24−type DownloadPermissionsParams struct {
25− URL string `json:"url"`
26− FilePath string `json:"file_path"`
27− Timeout int `json:"timeout,omitempty"`
28−}
29−
30−type downloadTool struct {
31− client *http.Client
32− permissions permission.Service
33− workingDir string
34−}
35−
36−const DownloadToolName = "download"
37−
38−//go:embed download.md
39−var downloadDescription []byte
40−
41−func NewDownloadTool(permissions permission.Service, workingDir string) BaseTool {
42− return &downloadTool{
43− client: &http.Client{
44− Timeout: 5 * time.Minute, // Default 5 minute timeout for downloads
45− Transport: &http.Transport{
46− MaxIdleConns: 100,
47− MaxIdleConnsPerHost: 10,
48− IdleConnTimeout: 90 * time.Second,
49− },
50− },
51− permissions: permissions,
52− workingDir: workingDir,
53− }
54−}
55−
56−func (t *downloadTool) Name() string {
57− return DownloadToolName
58−}
59−
60−func (t *downloadTool) Info() ToolInfo {
61− return ToolInfo{
62− Name: DownloadToolName,
63− Description: string(downloadDescription),
64− Parameters: map[string]any{
65− "url": map[string]any{
66− "type": "string",
67− "description": "The URL to download from",
68− },
69− "file_path": map[string]any{
70− "type": "string",
71− "description": "The local file path where the downloaded content should be saved",
72− },
73− "timeout": map[string]any{
74− "type": "number",
75− "description": "Optional timeout in seconds (max 600)",
76− },
77− },
78− Required: []string{"url", "file_path"},
79− }
80−}
81−
82−func (t *downloadTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
83− var params DownloadParams
84− if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
85− return NewTextErrorResponse("Failed to parse download parameters: " + err.Error()), nil
86− }
87−
88− if params.URL == "" {
89− return NewTextErrorResponse("URL parameter is required"), nil
90− }
91−
92− if params.FilePath == "" {
93− return NewTextErrorResponse("file_path parameter is required"), nil
94− }
95−
96− if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
97− return NewTextErrorResponse("URL must start with http:// or https://"), nil
98− }
99−
100− // Convert relative path to absolute path
101− var filePath string
102− if filepath.IsAbs(params.FilePath) {
103− filePath = params.FilePath
104− } else {
105− filePath = filepath.Join(t.workingDir, params.FilePath)
106− }
107−
108− sessionID, messageID := GetContextValues(ctx)
109− if sessionID == "" || messageID == "" {
110− return ToolResponse{}, fmt.Errorf("session ID and message ID are required for downloading files")
111− }
112−
113− p := t.permissions.Request(
114− permission.CreatePermissionRequest{
115− SessionID: sessionID,
116− Path: filePath,
117− ToolName: DownloadToolName,
118− Action: "download",
119− Description: fmt.Sprintf("Download file from URL: %s to %s", params.URL, filePath),
120− Params: DownloadPermissionsParams(params),
121− },
122− )
123−
124− if !p {
125− return ToolResponse{}, permission.ErrorPermissionDenied
126− }
127−
128− // Handle timeout with context
129− requestCtx := ctx
130− if params.Timeout > 0 {
131− maxTimeout := 600 // 10 minutes
132− if params.Timeout > maxTimeout {
133− params.Timeout = maxTimeout
134− }
135− var cancel context.CancelFunc
136− requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
137− defer cancel()
138− }
139−
140− req, err := http.NewRequestWithContext(requestCtx, "GET", params.URL, nil)
141− if err != nil {
142− return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
143− }
144−
145− req.Header.Set("User-Agent", "crush/1.0")
146−
147− resp, err := t.client.Do(req)
148− if err != nil {
149− return ToolResponse{}, fmt.Errorf("failed to download from URL: %w", err)
150− }
151− defer resp.Body.Close()
152−
153− if resp.StatusCode != http.StatusOK {
154− return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
155− }
156−
157− // Check content length if available
158− maxSize := int64(100 * 1024 * 1024) // 100MB
159− if resp.ContentLength > maxSize {
160− return NewTextErrorResponse(fmt.Sprintf("File too large: %d bytes (max %d bytes)", resp.ContentLength, maxSize)), nil
161− }
162−
163− // Create parent directories if they don't exist
164− if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
165− return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
166− }
167−
168− // Create the output file
169− outFile, err := os.Create(filePath)
170− if err != nil {
171− return ToolResponse{}, fmt.Errorf("failed to create output file: %w", err)
172− }
173− defer outFile.Close()
174−
175− // Copy data with size limit
176− limitedReader := io.LimitReader(resp.Body, maxSize)
177− bytesWritten, err := io.Copy(outFile, limitedReader)
178− if err != nil {
179− return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
180− }
181−
182− // Check if we hit the size limit
183− if bytesWritten == maxSize {
184− // Clean up the file since it might be incomplete
185− os.Remove(filePath)
186− return NewTextErrorResponse(fmt.Sprintf("File too large: exceeded %d bytes limit", maxSize)), nil
187− }
188−
189− contentType := resp.Header.Get("Content-Type")
190− responseMsg := fmt.Sprintf("Successfully downloaded %d bytes to %s", bytesWritten, filePath)
191− if contentType != "" {
192− responseMsg += fmt.Sprintf(" (Content-Type: %s)", contentType)
193− }
194−
195− return NewTextResponse(responseMsg), nil
196−}
internal/llm/tools/download.mddeleted−34
@@ -1,34 +0,0 @@
1−Downloads binary data from a URL and saves it to a local file.
2−
3−WHEN TO USE THIS TOOL:
4−
5−- Use when you need to download files, images, or other binary data from URLs
6−- Helpful for downloading assets, documents, or any file type
7−- Useful for saving remote content locally for processing or storage
8−
9−HOW TO USE:
10−
11−- Provide the URL to download from
12−- Specify the local file path where the content should be saved
13−- Optionally set a timeout for the request
14−
15−FEATURES:
16−
17−- Downloads any file type (binary or text)
18−- Automatically creates parent directories if they don't exist
19−- Handles large files efficiently with streaming
20−- Sets reasonable timeouts to prevent hanging
21−- Validates input parameters before making requests
22−
23−LIMITATIONS:
24−
25−- Maximum file size is 100MB
26−- Only supports HTTP and HTTPS protocols
27−- Cannot handle authentication or cookies
28−- Some websites may block automated requests
29−- Will overwrite existing files without warning
30−
31−TIPS:
32−
33−- Use absolute paths or paths relative to the working directory
34−- Set appropriate timeouts for large files or slow connections
internal/llm/tools/edit.godeleted−486
@@ -1,486 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− _ "embed"
6− "encoding/json"
7− "fmt"
8− "log/slog"
9− "os"
10− "path/filepath"
11− "strings"
12− "time"
13−
14− "github.com/charmbracelet/crush/internal/csync"
15− "github.com/charmbracelet/crush/internal/diff"
16− "github.com/charmbracelet/crush/internal/fsext"
17− "github.com/charmbracelet/crush/internal/history"
18−
19− "github.com/charmbracelet/crush/internal/lsp"
20− "github.com/charmbracelet/crush/internal/permission"
21−)
22−
23−type EditParams struct {
24− FilePath string `json:"file_path"`
25− OldString string `json:"old_string"`
26− NewString string `json:"new_string"`
27− ReplaceAll bool `json:"replace_all,omitempty"`
28−}
29−
30−type EditPermissionsParams struct {
31− FilePath string `json:"file_path"`
32− OldContent string `json:"old_content,omitempty"`
33− NewContent string `json:"new_content,omitempty"`
34−}
35−
36−type EditResponseMetadata struct {
37− Additions int `json:"additions"`
38− Removals int `json:"removals"`
39− OldContent string `json:"old_content,omitempty"`
40− NewContent string `json:"new_content,omitempty"`
41−}
42−
43−type editTool struct {
44− lspClients *csync.Map[string, *lsp.Client]
45− permissions permission.Service
46− files history.Service
47− workingDir string
48−}
49−
50−const EditToolName = "edit"
51−
52−//go:embed edit.md
53−var editDescription []byte
54−
55−func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) BaseTool {
56− return &editTool{
57− lspClients: lspClients,
58− permissions: permissions,
59− files: files,
60− workingDir: workingDir,
61− }
62−}
63−
64−func (e *editTool) Name() string {
65− return EditToolName
66−}
67−
68−func (e *editTool) Info() ToolInfo {
69− return ToolInfo{
70− Name: EditToolName,
71− Description: string(editDescription),
72− Parameters: map[string]any{
73− "file_path": map[string]any{
74− "type": "string",
75− "description": "The absolute path to the file to modify",
76− },
77− "old_string": map[string]any{
78− "type": "string",
79− "description": "The text to replace",
80− },
81− "new_string": map[string]any{
82− "type": "string",
83− "description": "The text to replace it with",
84− },
85− "replace_all": map[string]any{
86− "type": "boolean",
87− "description": "Replace all occurrences of old_string (default false)",
88− },
89− },
90− Required: []string{"file_path", "old_string", "new_string"},
91− }
92−}
93−
94−func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
95− var params EditParams
96− if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
97− return NewTextErrorResponse("invalid parameters"), nil
98− }
99−
100− if params.FilePath == "" {
101− return NewTextErrorResponse("file_path is required"), nil
102− }
103−
104− if !filepath.IsAbs(params.FilePath) {
105− params.FilePath = filepath.Join(e.workingDir, params.FilePath)
106− }
107−
108− var response ToolResponse
109− var err error
110−
111− if params.OldString == "" {
112− response, err = e.createNewFile(ctx, params.FilePath, params.NewString, call)
113− if err != nil {
114− return response, err
115− }
116− }
117−
118− if params.NewString == "" {
119− response, err = e.deleteContent(ctx, params.FilePath, params.OldString, params.ReplaceAll, call)
120− if err != nil {
121− return response, err
122− }
123− }
124−
125− response, err = e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
126− if err != nil {
127− return response, err
128− }
129− if response.IsError {
130− // Return early if there was an error during content replacement
131− // This prevents unnecessary LSP diagnostics processing
132− return response, nil
133− }
134−
135− notifyLSPs(ctx, e.lspClients, params.FilePath)
136−
137− text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
138− text += getDiagnostics(params.FilePath, e.lspClients)
139− response.Content = text
140− return response, nil
141−}
142−
143−func (e *editTool) createNewFile(ctx context.Context, filePath, content string, call ToolCall) (ToolResponse, error) {
144− fileInfo, err := os.Stat(filePath)
145− if err == nil {
146− if fileInfo.IsDir() {
147− return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
148− }
149− return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
150− } else if !os.IsNotExist(err) {
151− return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
152− }
153−
154− dir := filepath.Dir(filePath)
155− if err = os.MkdirAll(dir, 0o755); err != nil {
156− return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
157− }
158−
159− sessionID, messageID := GetContextValues(ctx)
160− if sessionID == "" || messageID == "" {
161− return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
162− }
163−
164− _, additions, removals := diff.GenerateDiff(
165− "",
166− content,
167− strings.TrimPrefix(filePath, e.workingDir),
168− )
169− p := e.permissions.Request(
170− permission.CreatePermissionRequest{
171− SessionID: sessionID,
172− Path: fsext.PathOrPrefix(filePath, e.workingDir),
173− ToolCallID: call.ID,
174− ToolName: EditToolName,
175− Action: "write",
176− Description: fmt.Sprintf("Create file %s", filePath),
177− Params: EditPermissionsParams{
178− FilePath: filePath,
179− OldContent: "",
180− NewContent: content,
181− },
182− },
183− )
184− if !p {
185− return ToolResponse{}, permission.ErrorPermissionDenied
186− }
187−
188− err = os.WriteFile(filePath, []byte(content), 0o644)
189− if err != nil {
190− return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
191− }
192−
193− // File can't be in the history so we create a new file history
194− _, err = e.files.Create(ctx, sessionID, filePath, "")
195− if err != nil {
196− // Log error but don't fail the operation
197− return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
198− }
199−
200− // Add the new content to the file history
201− _, err = e.files.CreateVersion(ctx, sessionID, filePath, content)
202− if err != nil {
203− // Log error but don't fail the operation
204− slog.Debug("Error creating file history version", "error", err)
205− }
206−
207− recordFileWrite(filePath)
208− recordFileRead(filePath)
209−
210− return WithResponseMetadata(
211− NewTextResponse("File created: "+filePath),
212− EditResponseMetadata{
213− OldContent: "",
214− NewContent: content,
215− Additions: additions,
216− Removals: removals,
217− },
218− ), nil
219−}
220−
221−func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string, replaceAll bool, call ToolCall) (ToolResponse, error) {
222− fileInfo, err := os.Stat(filePath)
223− if err != nil {
224− if os.IsNotExist(err) {
225− return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
226− }
227− return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
228− }
229−
230− if fileInfo.IsDir() {
231− return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
232− }
233−
234− if getLastReadTime(filePath).IsZero() {
235− return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
236− }
237−
238− modTime := fileInfo.ModTime()
239− lastRead := getLastReadTime(filePath)
240− if modTime.After(lastRead) {
241− return NewTextErrorResponse(
242− fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
243− filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
244− )), nil
245− }
246−
247− content, err := os.ReadFile(filePath)
248− if err != nil {
249− return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
250− }
251−
252− oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
253−
254− var newContent string
255− var deletionCount int
256−
257− if replaceAll {
258− newContent = strings.ReplaceAll(oldContent, oldString, "")
259− deletionCount = strings.Count(oldContent, oldString)
260− if deletionCount == 0 {
261− return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
262− }
263− } else {
264− index := strings.Index(oldContent, oldString)
265− if index == -1 {
266− return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
267− }
268−
269− lastIndex := strings.LastIndex(oldContent, oldString)
270− if index != lastIndex {
271− return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
272− }
273−
274− newContent = oldContent[:index] + oldContent[index+len(oldString):]
275− deletionCount = 1
276− }
277−
278− sessionID, messageID := GetContextValues(ctx)
279−
280− if sessionID == "" || messageID == "" {
281− return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
282− }
283−
284− _, additions, removals := diff.GenerateDiff(
285− oldContent,
286− newContent,
287− strings.TrimPrefix(filePath, e.workingDir),
288− )
289−
290− p := e.permissions.Request(
291− permission.CreatePermissionRequest{
292− SessionID: sessionID,
293− Path: fsext.PathOrPrefix(filePath, e.workingDir),
294− ToolCallID: call.ID,
295− ToolName: EditToolName,
296− Action: "write",
297− Description: fmt.Sprintf("Delete content from file %s", filePath),
298− Params: EditPermissionsParams{
299− FilePath: filePath,
300− OldContent: oldContent,
301− NewContent: newContent,
302− },
303− },
304− )
305− if !p {
306− return ToolResponse{}, permission.ErrorPermissionDenied
307− }
308−
309− if isCrlf {
310− newContent, _ = fsext.ToWindowsLineEndings(newContent)
311− }
312−
313− err = os.WriteFile(filePath, []byte(newContent), 0o644)
314− if err != nil {
315− return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
316− }
317−
318− // Check if file exists in history
319− file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
320− if err != nil {
321− _, err = e.files.Create(ctx, sessionID, filePath, oldContent)
322− if err != nil {
323− // Log error but don't fail the operation
324− return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
325− }
326− }
327− if file.Content != oldContent {
328− // User Manually changed the content store an intermediate version
329− _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
330− if err != nil {
331− slog.Debug("Error creating file history version", "error", err)
332− }
333− }
334− // Store the new version
335− _, err = e.files.CreateVersion(ctx, sessionID, filePath, "")
336− if err != nil {
337− slog.Debug("Error creating file history version", "error", err)
338− }
339−
340− recordFileWrite(filePath)
341− recordFileRead(filePath)
342−
343− return WithResponseMetadata(
344− NewTextResponse("Content deleted from file: "+filePath),
345− EditResponseMetadata{
346− OldContent: oldContent,
347− NewContent: newContent,
348− Additions: additions,
349− Removals: removals,
350− },
351− ), nil
352−}
353−
354−func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string, replaceAll bool, call ToolCall) (ToolResponse, error) {
355− fileInfo, err := os.Stat(filePath)
356− if err != nil {
357− if os.IsNotExist(err) {
358− return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
359− }
360− return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
361− }
362−
363− if fileInfo.IsDir() {
364− return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
365− }
366−
367− if getLastReadTime(filePath).IsZero() {
368− return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
369− }
370−
371− modTime := fileInfo.ModTime()
372− lastRead := getLastReadTime(filePath)
373− if modTime.After(lastRead) {
374− return NewTextErrorResponse(
375− fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
376− filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
377− )), nil
378− }
379−
380− content, err := os.ReadFile(filePath)
381− if err != nil {
382− return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
383− }
384−
385− oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
386−
387− var newContent string
388− var replacementCount int
389−
390− if replaceAll {
391− newContent = strings.ReplaceAll(oldContent, oldString, newString)
392− replacementCount = strings.Count(oldContent, oldString)
393− if replacementCount == 0 {
394− return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
395− }
396− } else {
397− index := strings.Index(oldContent, oldString)
398− if index == -1 {
399− return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
400− }
401−
402− lastIndex := strings.LastIndex(oldContent, oldString)
403− if index != lastIndex {
404− return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
405− }
406−
407− newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
408− replacementCount = 1
409− }
410−
411− if oldContent == newContent {
412− return NewTextErrorResponse("new content is the same as old content. No changes made."), nil
413− }
414− sessionID, messageID := GetContextValues(ctx)
415−
416− if sessionID == "" || messageID == "" {
417− return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
418− }
419− _, additions, removals := diff.GenerateDiff(
420− oldContent,
421− newContent,
422− strings.TrimPrefix(filePath, e.workingDir),
423− )
424−
425− p := e.permissions.Request(
426− permission.CreatePermissionRequest{
427− SessionID: sessionID,
428− Path: fsext.PathOrPrefix(filePath, e.workingDir),
429− ToolCallID: call.ID,
430− ToolName: EditToolName,
431− Action: "write",
432− Description: fmt.Sprintf("Replace content in file %s", filePath),
433− Params: EditPermissionsParams{
434− FilePath: filePath,
435− OldContent: oldContent,
436− NewContent: newContent,
437− },
438− },
439− )
440− if !p {
441− return ToolResponse{}, permission.ErrorPermissionDenied
442− }
443−
444− if isCrlf {
445− newContent, _ = fsext.ToWindowsLineEndings(newContent)
446− }
447−
448− err = os.WriteFile(filePath, []byte(newContent), 0o644)
449− if err != nil {
450− return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
451− }
452−
453− // Check if file exists in history
454− file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
455− if err != nil {
456− _, err = e.files.Create(ctx, sessionID, filePath, oldContent)
457− if err != nil {
458− // Log error but don't fail the operation
459− return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
460− }
461− }
462− if file.Content != oldContent {
463− // User Manually changed the content store an intermediate version
464− _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
465− if err != nil {
466− slog.Debug("Error creating file history version", "error", err)
467− }
468− }
469− // Store the new version
470− _, err = e.files.CreateVersion(ctx, sessionID, filePath, newContent)
471− if err != nil {
472− slog.Debug("Error creating file history version", "error", err)
473− }
474−
475− recordFileWrite(filePath)
476− recordFileRead(filePath)
477−
478− return WithResponseMetadata(
479− NewTextResponse("Content replaced in file: "+filePath),
480− EditResponseMetadata{
481− OldContent: oldContent,
482− NewContent: newContent,
483− Additions: additions,
484− Removals: removals,
485− }), nil
486−}
internal/llm/tools/edit.mddeleted−60
@@ -1,60 +0,0 @@
1−Edits files by replacing text, creating new files, or deleting content. For moving or renaming files, use the Bash tool with the 'mv' command instead. For larger file edits, use the FileWrite tool to overwrite files.
2−
3−Before using this tool:
4−
5−1. Use the FileRead tool to understand the file's contents and context
6−
7−2. Verify the directory path is correct (only applicable when creating new files):
8− - Use the LS tool to verify the parent directory exists and is the correct location
9−
10−To make a file edit, provide the following:
11−
12−1. file_path: The absolute path to the file to modify (must be absolute, not relative)
13−2. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
14−3. new_string: The edited text to replace the old_string
15−4. replace_all: Replace all occurrences of old_string (default false)
16−
17−Special cases:
18−
19−- To create a new file: provide file_path and new_string, leave old_string empty
20−- To delete content: provide file_path and old_string, leave new_string empty
21−
22−The tool will replace ONE occurrence of old_string with new_string in the specified file by default. Set replace_all to true to replace all occurrences.
23−
24−CRITICAL REQUIREMENTS FOR USING THIS TOOL:
25−
26−1. UNIQUENESS: When replace_all is false (default), the old_string MUST uniquely identify the specific instance you want to change. This means:
27− - Include AT LEAST 3-5 lines of context BEFORE the change point
28− - Include AT LEAST 3-5 lines of context AFTER the change point
29− - Include all whitespace, indentation, and surrounding code exactly as it appears in the file
30−
31−2. SINGLE INSTANCE: When replace_all is false, this tool can only change ONE instance at a time. If you need to change multiple instances:
32− - Set replace_all to true to replace all occurrences at once
33− - Or make separate calls to this tool for each instance
34− - Each call must uniquely identify its specific instance using extensive context
35−
36−3. VERIFICATION: Before using this tool:
37− - Check how many instances of the target text exist in the file
38− - If multiple instances exist and replace_all is false, gather enough context to uniquely identify each one
39− - Plan separate tool calls for each instance or use replace_all
40−
41−WARNING: If you do not follow these requirements:
42−
43−- The tool will fail if old_string matches multiple locations and replace_all is false
44−- The tool will fail if old_string doesn't match exactly (including whitespace)
45−- You may change the wrong instance if you don't include enough context
46−
47−When making edits:
48−
49−- Ensure the edit results in idiomatic, correct code
50−- Do not leave the code in a broken state
51−- Always use absolute file paths (starting with /)
52−
53−WINDOWS NOTES:
54−
55−- File paths should use forward slashes (/) for cross-platform compatibility
56−- On Windows, absolute paths start with drive letters (C:/) but forward slashes work throughout
57−- File permissions are handled automatically by the Go runtime
58−- Always assumes \n for line endings. The tool will handle \r\n conversion automatically if needed.
59−
60−Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.
internal/llm/tools/fetch.godeleted−236
@@ -1,236 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− _ "embed"
6− "encoding/json"
7− "fmt"
8− "io"
9− "net/http"
10− "strings"
11− "time"
12− "unicode/utf8"
13−
14− md "github.com/JohannesKaufmann/html-to-markdown"
15− "github.com/PuerkitoBio/goquery"
16− "github.com/charmbracelet/crush/internal/permission"
17−)
18−
19−type FetchParams struct {
20− URL string `json:"url"`
21− Format string `json:"format"`
22− Timeout int `json:"timeout,omitempty"`
23−}
24−
25−type FetchPermissionsParams struct {
26− URL string `json:"url"`
27− Format string `json:"format"`
28− Timeout int `json:"timeout,omitempty"`
29−}
30−
31−type fetchTool struct {
32− client *http.Client
33− permissions permission.Service
34− workingDir string
35−}
36−
37−const FetchToolName = "fetch"
38−
39−//go:embed fetch.md
40−var fetchDescription []byte
41−
42−func NewFetchTool(permissions permission.Service, workingDir string) BaseTool {
43− return &fetchTool{
44− client: &http.Client{
45− Timeout: 30 * time.Second,
46− Transport: &http.Transport{
47− MaxIdleConns: 100,
48− MaxIdleConnsPerHost: 10,
49− IdleConnTimeout: 90 * time.Second,
50− },
51− },
52− permissions: permissions,
53− workingDir: workingDir,
54− }
55−}
56−
57−func (t *fetchTool) Name() string {
58− return FetchToolName
59−}
60−
61−func (t *fetchTool) Info() ToolInfo {
62− return ToolInfo{
63− Name: FetchToolName,
64− Description: string(fetchDescription),
65− Parameters: map[string]any{
66− "url": map[string]any{
67− "type": "string",
68− "description": "The URL to fetch content from",
69− },
70− "format": map[string]any{
71− "type": "string",
72− "description": "The format to return the content in (text, markdown, or html)",
73− "enum": []string{"text", "markdown", "html"},
74− },
75− "timeout": map[string]any{
76− "type": "number",
77− "description": "Optional timeout in seconds (max 120)",
78− },
79− },
80− Required: []string{"url", "format"},
81− }
82−}
83−
84−func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
85− var params FetchParams
86− if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
87− return NewTextErrorResponse("Failed to parse fetch parameters: " + err.Error()), nil
88− }
89−
90− if params.URL == "" {
91− return NewTextErrorResponse("URL parameter is required"), nil
92− }
93−
94− format := strings.ToLower(params.Format)
95− if format != "text" && format != "markdown" && format != "html" {
96− return NewTextErrorResponse("Format must be one of: text, markdown, html"), nil
97− }
98−
99− if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
100− return NewTextErrorResponse("URL must start with http:// or https://"), nil
101− }
102−
103− sessionID, messageID := GetContextValues(ctx)
104− if sessionID == "" || messageID == "" {
105− return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
106− }
107−
108− p := t.permissions.Request(
109− permission.CreatePermissionRequest{
110− SessionID: sessionID,
111− Path: t.workingDir,
112− ToolCallID: call.ID,
113− ToolName: FetchToolName,
114− Action: "fetch",
115− Description: fmt.Sprintf("Fetch content from URL: %s", params.URL),
116− Params: FetchPermissionsParams(params),
117− },
118− )
119−
120− if !p {
121− return ToolResponse{}, permission.ErrorPermissionDenied
122− }
123−
124− // Handle timeout with context
125− requestCtx := ctx
126− if params.Timeout > 0 {
127− maxTimeout := 120 // 2 minutes
128− if params.Timeout > maxTimeout {
129− params.Timeout = maxTimeout
130− }
131− var cancel context.CancelFunc
132− requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
133− defer cancel()
134− }
135−
136− req, err := http.NewRequestWithContext(requestCtx, "GET", params.URL, nil)
137− if err != nil {
138− return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
139− }
140−
141− req.Header.Set("User-Agent", "crush/1.0")
142−
143− resp, err := t.client.Do(req)
144− if err != nil {
145− return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
146− }
147− defer resp.Body.Close()
148−
149− if resp.StatusCode != http.StatusOK {
150− return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
151− }
152−
153− maxSize := int64(5 * 1024 * 1024) // 5MB
154− body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize))
155− if err != nil {
156− return NewTextErrorResponse("Failed to read response body: " + err.Error()), nil
157− }
158−
159− content := string(body)
160−
161− isValidUt8 := utf8.ValidString(content)
162− if !isValidUt8 {
163− return NewTextErrorResponse("Response content is not valid UTF-8"), nil
164− }
165− contentType := resp.Header.Get("Content-Type")
166−
167− switch format {
168− case "text":
169− if strings.Contains(contentType, "text/html") {
170− text, err := extractTextFromHTML(content)
171− if err != nil {
172− return NewTextErrorResponse("Failed to extract text from HTML: " + err.Error()), nil
173− }
174− content = text
175− }
176−
177− case "markdown":
178− if strings.Contains(contentType, "text/html") {
179− markdown, err := convertHTMLToMarkdown(content)
180− if err != nil {
181− return NewTextErrorResponse("Failed to convert HTML to Markdown: " + err.Error()), nil
182− }
183− content = markdown
184− }
185−
186− content = "```\n" + content + "\n```"
187−
188− case "html":
189− // return only the body of the HTML document
190− if strings.Contains(contentType, "text/html") {
191− doc, err := goquery.NewDocumentFromReader(strings.NewReader(content))
192− if err != nil {
193− return NewTextErrorResponse("Failed to parse HTML: " + err.Error()), nil
194− }
195− body, err := doc.Find("body").Html()
196− if err != nil {
197− return NewTextErrorResponse("Failed to extract body from HTML: " + err.Error()), nil
198− }
199− if body == "" {
200− return NewTextErrorResponse("No body content found in HTML"), nil
201− }
202− content = "<html>\n<body>\n" + body + "\n</body>\n</html>"
203− }
204− }
205− // calculate byte size of content
206− contentSize := int64(len(content))
207− if contentSize > MaxReadSize {
208− content = content[:MaxReadSize]
209− content += fmt.Sprintf("\n\n[Content truncated to %d bytes]", MaxReadSize)
210− }
211−
212− return NewTextResponse(content), nil
213−}
214−
215−func extractTextFromHTML(html string) (string, error) {
216− doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
217− if err != nil {
218− return "", err
219− }
220−
221− text := doc.Find("body").Text()
222− text = strings.Join(strings.Fields(text), " ")
223−
224− return text, nil
225−}
226−
227−func convertHTMLToMarkdown(html string) (string, error) {
228− converter := md.NewConverter("", true, nil)
229−
230− markdown, err := converter.ConvertString(html)
231− if err != nil {
232− return "", err
233− }
234−
235− return markdown, nil
236−}
internal/llm/tools/fetch.mddeleted−34
@@ -1,34 +0,0 @@
1−Fetches content from a URL and returns it in the specified format.
2−
3−WHEN TO USE THIS TOOL:
4−
5−- Use when you need to download content from a URL
6−- Helpful for retrieving documentation, API responses, or web content
7−- Useful for getting external information to assist with tasks
8−
9−HOW TO USE:
10−
11−- Provide the URL to fetch content from
12−- Specify the desired output format (text, markdown, or html)
13−- Optionally set a timeout for the request
14−
15−FEATURES:
16−
17−- Supports three output formats: text, markdown, and html
18−- Automatically handles HTTP redirects
19−- Sets reasonable timeouts to prevent hanging
20−- Validates input parameters before making requests
21−
22−LIMITATIONS:
23−
24−- Maximum response size is 5MB
25−- Only supports HTTP and HTTPS protocols
26−- Cannot handle authentication or cookies
27−- Some websites may block automated requests
28−
29−TIPS:
30−
31−- Use text format for plain text content or simple API responses
32−- Use markdown format for content that should be rendered with formatting
33−- Use html format when you need the raw HTML structure
34−- Set appropriate timeouts for potentially slow websites
internal/llm/tools/file.godeleted−53
@@ -1,53 +0,0 @@
1−package tools
2−
3−import (
4− "sync"
5− "time"
6−)
7−
8−// File record to track when files were read/written
9−type fileRecord struct {
10− path string
11− readTime time.Time
12− writeTime time.Time
13−}
14−
15−var (
16− fileRecords = make(map[string]fileRecord)
17− fileRecordMutex sync.RWMutex
18−)
19−
20−func recordFileRead(path string) {
21− fileRecordMutex.Lock()
22− defer fileRecordMutex.Unlock()
23−
24− record, exists := fileRecords[path]
25− if !exists {
26− record = fileRecord{path: path}
27− }
28− record.readTime = time.Now()
29− fileRecords[path] = record
30−}
31−
32−func getLastReadTime(path string) time.Time {
33− fileRecordMutex.RLock()
34− defer fileRecordMutex.RUnlock()
35−
36− record, exists := fileRecords[path]
37− if !exists {
38− return time.Time{}
39− }
40− return record.readTime
41−}
42−
43−func recordFileWrite(path string) {
44− fileRecordMutex.Lock()
45− defer fileRecordMutex.Unlock()
46−
47− record, exists := fileRecords[path]
48− if !exists {
49− record = fileRecord{path: path}
50− }
51− record.writeTime = time.Now()
52− fileRecords[path] = record
53−}
internal/llm/tools/glob.godeleted−150
@@ -1,150 +0,0 @@
1−package tools
2−
3−import (
4− "bytes"
5− "context"
6− _ "embed"
7− "encoding/json"
8− "fmt"
9− "log/slog"
10− "os/exec"
11− "path/filepath"
12− "sort"
13− "strings"
14−
15− "github.com/charmbracelet/crush/internal/fsext"
16−)
17−
18−const GlobToolName = "glob"
19−
20−//go:embed glob.md
21−var globDescription []byte
22−
23−type GlobParams struct {
24− Pattern string `json:"pattern"`
25− Path string `json:"path"`
26−}
27−
28−type GlobResponseMetadata struct {
29− NumberOfFiles int `json:"number_of_files"`
30− Truncated bool `json:"truncated"`
31−}
32−
33−type globTool struct {
34− workingDir string
35−}
36−
37−func NewGlobTool(workingDir string) BaseTool {
38− return &globTool{
39− workingDir: workingDir,
40− }
41−}
42−
43−func (g *globTool) Name() string {
44− return GlobToolName
45−}
46−
47−func (g *globTool) Info() ToolInfo {
48− return ToolInfo{
49− Name: GlobToolName,
50− Description: string(globDescription),
51− Parameters: map[string]any{
52− "pattern": map[string]any{
53− "type": "string",
54− "description": "The glob pattern to match files against",
55− },
56− "path": map[string]any{
57− "type": "string",
58− "description": "The directory to search in. Defaults to the current working directory.",
59− },
60− },
61− Required: []string{"pattern"},
62− }
63−}
64−
65−func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
66− var params GlobParams
67− if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
68− return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
69− }
70−
71− if params.Pattern == "" {
72− return NewTextErrorResponse("pattern is required"), nil
73− }
74−
75− searchPath := params.Path
76− if searchPath == "" {
77− searchPath = g.workingDir
78− }
79−
80− files, truncated, err := globFiles(ctx, params.Pattern, searchPath, 100)
81− if err != nil {
82− return ToolResponse{}, fmt.Errorf("error finding files: %w", err)
83− }
84−
85− var output string
86− if len(files) == 0 {
87− output = "No files found"
88− } else {
89− output = strings.Join(files, "\n")
90− if truncated {
91− output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)"
92− }
93− }
94−
95− return WithResponseMetadata(
96− NewTextResponse(output),
97− GlobResponseMetadata{
98− NumberOfFiles: len(files),
99− Truncated: truncated,
100− },
101− ), nil
102−}
103−
104−func globFiles(ctx context.Context, pattern, searchPath string, limit int) ([]string, bool, error) {
105− cmdRg := getRgCmd(ctx, pattern)
106− if cmdRg != nil {
107− cmdRg.Dir = searchPath
108− matches, err := runRipgrep(cmdRg, searchPath, limit)
109− if err == nil {
110− return matches, len(matches) >= limit && limit > 0, nil
111− }
112− slog.Warn("Ripgrep execution failed, falling back to doublestar", "error", err)
113− }
114−
115− return fsext.GlobWithDoubleStar(pattern, searchPath, limit)
116−}
117−
118−func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
119− out, err := cmd.CombinedOutput()
120− if err != nil {
121− if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
122− return nil, nil
123− }
124− return nil, fmt.Errorf("ripgrep: %w\n%s", err, out)
125− }
126−
127− var matches []string
128− for p := range bytes.SplitSeq(out, []byte{0}) {
129− if len(p) == 0 {
130− continue
131− }
132− absPath := string(p)
133− if !filepath.IsAbs(absPath) {
134− absPath = filepath.Join(searchRoot, absPath)
135− }
136− if fsext.SkipHidden(absPath) {
137− continue
138− }
139− matches = append(matches, absPath)
140− }
141−
142− sort.SliceStable(matches, func(i, j int) bool {
143− return len(matches[i]) < len(matches[j])
144− })
145−
146− if limit > 0 && len(matches) > limit {
147− matches = matches[:limit]
148− }
149− return matches, nil
150−}
internal/llm/tools/glob.mddeleted−46
@@ -1,46 +0,0 @@
1−Fast file pattern matching tool that finds files by name and pattern, returning matching paths sorted by modification time (newest first).
2−
3−WHEN TO USE THIS TOOL:
4−
5−- Use when you need to find files by name patterns or extensions
6−- Great for finding specific file types across a directory structure
7−- Useful for discovering files that match certain naming conventions
8−
9−HOW TO USE:
10−
11−- Provide a glob pattern to match against file paths
12−- Optionally specify a starting directory (defaults to current working directory)
13−- Results are sorted with most recently modified files first
14−
15−GLOB PATTERN SYNTAX:
16−
17−- '\*' matches any sequence of non-separator characters
18−- '\*\*' matches any sequence of characters, including separators
19−- '?' matches any single non-separator character
20−- '[...]' matches any character in the brackets
21−- '[!...]' matches any character not in the brackets
22−
23−COMMON PATTERN EXAMPLES:
24−
25−- '\*.js' - Find all JavaScript files in the current directory
26−- '\*_/_.js' - Find all JavaScript files in any subdirectory
27−- 'src/\*_/_.{ts,tsx}' - Find all TypeScript files in the src directory
28−- '\*.{html,css,js}' - Find all HTML, CSS, and JS files
29−
30−LIMITATIONS:
31−
32−- Results are limited to 100 files (newest first)
33−- Does not search file contents (use Grep tool for that)
34−- Hidden files (starting with '.') are skipped
35−
36−WINDOWS NOTES:
37−
38−- Path separators are handled automatically (both / and \ work)
39−- Uses ripgrep (rg) command if available, otherwise falls back to built-in Go implementation
40−
41−TIPS:
42−
43−- Patterns should use forward slashes (/) for cross-platform compatibility
44−- For the most useful results, combine with the Grep tool: first find files with Glob, then search their contents with Grep
45−- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
46−- Always check if results are truncated and refine your search pattern if needed
internal/llm/tools/grep.godeleted−470
@@ -1,470 +0,0 @@
1−package tools
2−
3−import (
4− "bufio"
5− "context"
6− _ "embed"
7− "encoding/json"
8− "fmt"
9− "io"
10− "os"
11− "os/exec"
12− "path/filepath"
13− "regexp"
14− "sort"
15− "strconv"
16− "strings"
17− "sync"
18− "time"
19−
20− "github.com/charmbracelet/crush/internal/fsext"
21−)
22−
23−// regexCache provides thread-safe caching of compiled regex patterns
24−type regexCache struct {
25− cache map[string]*regexp.Regexp
26− mu sync.RWMutex
27−}
28−
29−// newRegexCache creates a new regex cache
30−func newRegexCache() *regexCache {
31− return &regexCache{
32− cache: make(map[string]*regexp.Regexp),
33− }
34−}
35−
36−// get retrieves a compiled regex from cache or compiles and caches it
37−func (rc *regexCache) get(pattern string) (*regexp.Regexp, error) {
38− // Try to get from cache first (read lock)
39− rc.mu.RLock()
40− if regex, exists := rc.cache[pattern]; exists {
41− rc.mu.RUnlock()
42− return regex, nil
43− }
44− rc.mu.RUnlock()
45−
46− // Compile the regex (write lock)
47− rc.mu.Lock()
48− defer rc.mu.Unlock()
49−
50− // Double-check in case another goroutine compiled it while we waited
51− if regex, exists := rc.cache[pattern]; exists {
52− return regex, nil
53− }
54−
55− // Compile and cache the regex
56− regex, err := regexp.Compile(pattern)
57− if err != nil {
58− return nil, err
59− }
60−
61− rc.cache[pattern] = regex
62− return regex, nil
63−}
64−
65−// Global regex cache instances
66−var (
67− searchRegexCache = newRegexCache()
68− globRegexCache = newRegexCache()
69− // Pre-compiled regex for glob conversion (used frequently)
70− globBraceRegex = regexp.MustCompile(`\{([^}]+)\}`)
71−)
72−
73−type GrepParams struct {
74− Pattern string `json:"pattern"`
75− Path string `json:"path"`
76− Include string `json:"include"`
77− LiteralText bool `json:"literal_text"`
78−}
79−
80−type grepMatch struct {
81− path string
82− modTime time.Time
83− lineNum int
84− lineText string
85−}
86−
87−type GrepResponseMetadata struct {
88− NumberOfMatches int `json:"number_of_matches"`
89− Truncated bool `json:"truncated"`
90−}
91−
92−type grepTool struct {
93− workingDir string
94−}
95−
96−const GrepToolName = "grep"
97−
98−//go:embed grep.md
99−var grepDescription []byte
100−
101−func NewGrepTool(workingDir string) BaseTool {
102− return &grepTool{
103− workingDir: workingDir,
104− }
105−}
106−
107−func (g *grepTool) Name() string {
108− return GrepToolName
109−}
110−
111−func (g *grepTool) Info() ToolInfo {
112− return ToolInfo{
113− Name: GrepToolName,
114− Description: string(grepDescription),
115− Parameters: map[string]any{
116− "pattern": map[string]any{
117− "type": "string",
118− "description": "The regex pattern to search for in file contents",
119− },
120− "path": map[string]any{
121− "type": "string",
122− "description": "The directory to search in. Defaults to the current working directory.",
123− },
124− "include": map[string]any{
125− "type": "string",
126− "description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")",
127− },
128− "literal_text": map[string]any{
129− "type": "boolean",
130− "description": "If true, the pattern will be treated as literal text with special regex characters escaped. Default is false.",
131− },
132− },
133− Required: []string{"pattern"},
134− }
135−}
136−
137−// escapeRegexPattern escapes special regex characters so they're treated as literal characters
138−func escapeRegexPattern(pattern string) string {
139− specialChars := []string{"\\", ".", "+", "*", "?", "(", ")", "[", "]", "{", "}", "^", "$", "|"}
140− escaped := pattern
141−
142− for _, char := range specialChars {
143− escaped = strings.ReplaceAll(escaped, char, "\\"+char)
144− }
145−
146− return escaped
147−}
148−
149−func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
150− var params GrepParams
151− if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
152− return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
153− }
154−
155− if params.Pattern == "" {
156− return NewTextErrorResponse("pattern is required"), nil
157− }
158−
159− // If literal_text is true, escape the pattern
160− searchPattern := params.Pattern
161− if params.LiteralText {
162− searchPattern = escapeRegexPattern(params.Pattern)
163− }
164−
165− searchPath := params.Path
166− if searchPath == "" {
167− searchPath = g.workingDir
168− }
169−
170− matches, truncated, err := searchFiles(ctx, searchPattern, searchPath, params.Include, 100)
171− if err != nil {
172− return ToolResponse{}, fmt.Errorf("error searching files: %w", err)
173− }
174−
175− var output strings.Builder
176− if len(matches) == 0 {
177− output.WriteString("No files found")
178− } else {
179− fmt.Fprintf(&output, "Found %d matches\n", len(matches))
180−
181− currentFile := ""
182− for _, match := range matches {
183− if currentFile != match.path {
184− if currentFile != "" {
185− output.WriteString("\n")
186− }
187− currentFile = match.path
188− fmt.Fprintf(&output, "%s:\n", match.path)
189− }
190− if match.lineNum > 0 {
191− fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, match.lineText)
192− } else {
193− fmt.Fprintf(&output, " %s\n", match.path)
194− }
195− }
196−
197− if truncated {
198− output.WriteString("\n(Results are truncated. Consider using a more specific path or pattern.)")
199− }
200− }
201−
202− return WithResponseMetadata(
203− NewTextResponse(output.String()),
204− GrepResponseMetadata{
205− NumberOfMatches: len(matches),
206− Truncated: truncated,
207− },
208− ), nil
209−}
210−
211−func searchFiles(ctx context.Context, pattern, rootPath, include string, limit int) ([]grepMatch, bool, error) {
212− matches, err := searchWithRipgrep(ctx, pattern, rootPath, include)
213− if err != nil {
214− matches, err = searchFilesWithRegex(pattern, rootPath, include)
215− if err != nil {
216− return nil, false, err
217− }
218− }
219−
220− sort.Slice(matches, func(i, j int) bool {
221− return matches[i].modTime.After(matches[j].modTime)
222− })
223−
224− truncated := len(matches) > limit
225− if truncated {
226− matches = matches[:limit]
227− }
228−
229− return matches, truncated, nil
230−}
231−
232−func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]grepMatch, error) {
233− cmd := getRgSearchCmd(ctx, pattern, path, include)
234− if cmd == nil {
235− return nil, fmt.Errorf("ripgrep not found in $PATH")
236− }
237−
238− // Only add ignore files if they exist
239− for _, ignoreFile := range []string{".gitignore", ".crushignore"} {
240− ignorePath := filepath.Join(path, ignoreFile)
241− if _, err := os.Stat(ignorePath); err == nil {
242− cmd.Args = append(cmd.Args, "--ignore-file", ignorePath)
243− }
244− }
245−
246− output, err := cmd.Output()
247− if err != nil {
248− if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
249− return []grepMatch{}, nil
250− }
251− return nil, err
252− }
253−
254− lines := strings.Split(strings.TrimSpace(string(output)), "\n")
255− matches := make([]grepMatch, 0, len(lines))
256−
257− for _, line := range lines {
258− if line == "" {
259− continue
260− }
261−
262− // Parse ripgrep output using null separation
263− filePath, lineNumStr, lineText, ok := parseRipgrepLine(line)
264− if !ok {
265− continue
266− }
267−
268− lineNum, err := strconv.Atoi(lineNumStr)
269− if err != nil {
270− continue
271− }
272−
273− fileInfo, err := os.Stat(filePath)
274− if err != nil {
275− continue // Skip files we can't access
276− }
277−
278− matches = append(matches, grepMatch{
279− path: filePath,
280− modTime: fileInfo.ModTime(),
281− lineNum: lineNum,
282− lineText: lineText,
283− })
284− }
285−
286− return matches, nil
287−}
288−
289−// parseRipgrepLine parses ripgrep output with null separation to handle Windows paths
290−func parseRipgrepLine(line string) (filePath, lineNum, lineText string, ok bool) {
291− // Split on null byte first to separate filename from rest
292− parts := strings.SplitN(line, "\x00", 2)
293− if len(parts) != 2 {
294− return "", "", "", false
295− }
296−
297− filePath = parts[0]
298− remainder := parts[1]
299−
300− // Now split the remainder on first colon: "linenum:content"
301− colonIndex := strings.Index(remainder, ":")
302− if colonIndex == -1 {
303− return "", "", "", false
304− }
305−
306− lineNumStr := remainder[:colonIndex]
307− lineText = remainder[colonIndex+1:]
308−
309− if _, err := strconv.Atoi(lineNumStr); err != nil {
310− return "", "", "", false
311− }
312−
313− return filePath, lineNumStr, lineText, true
314−}
315−
316−func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
317− matches := []grepMatch{}
318−
319− // Use cached regex compilation
320− regex, err := searchRegexCache.get(pattern)
321− if err != nil {
322− return nil, fmt.Errorf("invalid regex pattern: %w", err)
323− }
324−
325− var includePattern *regexp.Regexp
326− if include != "" {
327− regexPattern := globToRegex(include)
328− includePattern, err = globRegexCache.get(regexPattern)
329− if err != nil {
330− return nil, fmt.Errorf("invalid include pattern: %w", err)
331− }
332− }
333−
334− // Create walker with gitignore and crushignore support
335− walker := fsext.NewFastGlobWalker(rootPath)
336−
337− err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
338− if err != nil {
339− return nil // Skip errors
340− }
341−
342− if info.IsDir() {
343− // Check if directory should be skipped
344− if walker.ShouldSkip(path) {
345− return filepath.SkipDir
346− }
347− return nil // Continue into directory
348− }
349−
350− // Use walker's shouldSkip method for files
351− if walker.ShouldSkip(path) {
352− return nil
353− }
354−
355− // Skip hidden files (starting with a dot) to match ripgrep's default behavior
356− base := filepath.Base(path)
357− if base != "." && strings.HasPrefix(base, ".") {
358− return nil
359− }
360−
361− if includePattern != nil && !includePattern.MatchString(path) {
362− return nil
363− }
364−
365− match, lineNum, lineText, err := fileContainsPattern(path, regex)
366− if err != nil {
367− return nil // Skip files we can't read
368− }
369−
370− if match {
371− matches = append(matches, grepMatch{
372− path: path,
373− modTime: info.ModTime(),
374− lineNum: lineNum,
375− lineText: lineText,
376− })
377−
378− if len(matches) >= 200 {
379− return filepath.SkipAll
380− }
381− }
382−
383− return nil
384− })
385− if err != nil {
386− return nil, err
387− }
388−
389− return matches, nil
390−}
391−
392−func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) {
393− // Quick binary file detection
394− if isBinaryFile(filePath) {
395− return false, 0, "", nil
396− }
397−
398− file, err := os.Open(filePath)
399− if err != nil {
400− return false, 0, "", err
401− }
402− defer file.Close()
403−
404− scanner := bufio.NewScanner(file)
405− lineNum := 0
406− for scanner.Scan() {
407− lineNum++
408− line := scanner.Text()
409− if pattern.MatchString(line) {
410− return true, lineNum, line, nil
411− }
412− }
413−
414− return false, 0, "", scanner.Err()
415−}
416−
417−var binaryExts = map[string]struct{}{
418− ".exe": {}, ".dll": {}, ".so": {}, ".dylib": {},
419− ".bin": {}, ".obj": {}, ".o": {}, ".a": {},
420− ".zip": {}, ".tar": {}, ".gz": {}, ".bz2": {},
421− ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {},
422− ".pdf": {}, ".doc": {}, ".docx": {}, ".xls": {},
423− ".mp3": {}, ".mp4": {}, ".avi": {}, ".mov": {},
424−}
425−
426−// isBinaryFile performs a quick check to determine if a file is binary
427−func isBinaryFile(filePath string) bool {
428− // Check file extension first (fastest)
429− ext := strings.ToLower(filepath.Ext(filePath))
430− if _, isBinary := binaryExts[ext]; isBinary {
431− return true
432− }
433−
434− // Quick content check for files without clear extensions
435− file, err := os.Open(filePath)
436− if err != nil {
437− return false // If we can't open it, let the caller handle the error
438− }
439− defer file.Close()
440−
441− // Read first 512 bytes to check for null bytes
442− buffer := make([]byte, 512)
443− n, err := file.Read(buffer)
444− if err != nil && err != io.EOF {
445− return false
446− }
447−
448− // Check for null bytes (common in binary files)
449− for i := range n {
450− if buffer[i] == 0 {
451− return true
452− }
453− }
454−
455− return false
456−}
457−
458−func globToRegex(glob string) string {
459− regexPattern := strings.ReplaceAll(glob, ".", "\\.")
460− regexPattern = strings.ReplaceAll(regexPattern, "*", ".*")
461− regexPattern = strings.ReplaceAll(regexPattern, "?", ".")
462−
463− // Use pre-compiled regex instead of compiling each time
464− regexPattern = globBraceRegex.ReplaceAllStringFunc(regexPattern, func(match string) string {
465− inner := match[1 : len(match)-1]
466− return "(" + strings.ReplaceAll(inner, ",", "|") + ")"
467− })
468−
469− return regexPattern
470−}
internal/llm/tools/grep.mddeleted−54
@@ -1,54 +0,0 @@
1−Fast content search tool that finds files containing specific text or patterns, returning matching file paths sorted by modification time (newest first).
2−
3−WHEN TO USE THIS TOOL:
4−
5−- Use when you need to find files containing specific text or patterns
6−- Great for searching code bases for function names, variable declarations, or error messages
7−- Useful for finding all files that use a particular API or pattern
8−
9−HOW TO USE:
10−
11−- Provide a regex pattern to search for within file contents
12−- Set literal_text=true if you want to search for the exact text with special characters (recommended for non-regex users)
13−- Optionally specify a starting directory (defaults to current working directory)
14−- Optionally provide an include pattern to filter which files to search
15−- Results are sorted with most recently modified files first
16−
17−REGEX PATTERN SYNTAX (when literal_text=false):
18−
19−- Supports standard regular expression syntax
20−- 'function' searches for the literal text "function"
21−- 'log\..\*Error' finds text starting with "log." and ending with "Error"
22−- 'import\s+.\*\s+from' finds import statements in JavaScript/TypeScript
23−
24−COMMON INCLUDE PATTERN EXAMPLES:
25−
26−- '\*.js' - Only search JavaScript files
27−- '\*.{ts,tsx}' - Only search TypeScript files
28−- '\*.go' - Only search Go files
29−
30−LIMITATIONS:
31−
32−- Results are limited to 100 files (newest first)
33−- Performance depends on the number of files being searched
34−- Very large binary files may be skipped
35−- Hidden files (starting with '.') are skipped
36−
37−IGNORE FILE SUPPORT:
38−
39−- Respects .gitignore patterns to skip ignored files and directories
40−- Respects .crushignore patterns for additional ignore rules
41−- Both ignore files are automatically detected in the search root directory
42−
43−CROSS-PLATFORM NOTES:
44−
45−- Uses ripgrep (rg) command if available for better performance
46−- Falls back to built-in Go implementation if ripgrep is not available
47−- File paths are normalized automatically for cross-platform compatibility
48−
49−TIPS:
50−
51−- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
52−- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
53−- Always check if results are truncated and refine your search pattern if needed
54−- Use literal_text=true when searching for exact text containing special characters like dots, parentheses, etc.
internal/llm/tools/grep_test.godeleted−200
@@ -1,200 +0,0 @@
1−package tools
2−
3−import (
4− "os"
5− "path/filepath"
6− "regexp"
7− "testing"
8−
9− "github.com/stretchr/testify/require"
10−)
11−
12−func TestRegexCache(t *testing.T) {
13− cache := newRegexCache()
14−
15− // Test basic caching
16− pattern := "test.*pattern"
17− regex1, err := cache.get(pattern)
18− if err != nil {
19− t.Fatalf("Failed to compile regex: %v", err)
20− }
21−
22− regex2, err := cache.get(pattern)
23− if err != nil {
24− t.Fatalf("Failed to get cached regex: %v", err)
25− }
26−
27− // Should be the same instance (cached)
28− if regex1 != regex2 {
29− t.Error("Expected cached regex to be the same instance")
30− }
31−
32− // Test that it actually works
33− if !regex1.MatchString("test123pattern") {
34− t.Error("Regex should match test string")
35− }
36−}
37−
38−func TestGlobToRegexCaching(t *testing.T) {
39− // Test that globToRegex uses pre-compiled regex
40− pattern1 := globToRegex("*.{js,ts}")
41−
42− // Should not panic and should work correctly
43− regex1, err := regexp.Compile(pattern1)
44− if err != nil {
45− t.Fatalf("Failed to compile glob regex: %v", err)
46− }
47−
48− if !regex1.MatchString("test.js") {
49− t.Error("Glob regex should match .js files")
50− }
51− if !regex1.MatchString("test.ts") {
52− t.Error("Glob regex should match .ts files")
53− }
54− if regex1.MatchString("test.go") {
55− t.Error("Glob regex should not match .go files")
56− }
57−}
58−
59−func TestGrepWithIgnoreFiles(t *testing.T) {
60− t.Parallel()
61− tempDir := t.TempDir()
62−
63− // Create test files
64− testFiles := map[string]string{
65− "file1.txt": "hello world",
66− "file2.txt": "hello world",
67− "ignored/file3.txt": "hello world",
68− "node_modules/lib.js": "hello world",
69− "secret.key": "hello world",
70− }
71−
72− for path, content := range testFiles {
73− fullPath := filepath.Join(tempDir, path)
74− require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755))
75− require.NoError(t, os.WriteFile(fullPath, []byte(content), 0o644))
76− }
77−
78− // Create .gitignore file
79− gitignoreContent := "ignored/\n*.key\n"
80− require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(gitignoreContent), 0o644))
81−
82− // Create .crushignore file
83− crushignoreContent := "node_modules/\n"
84− require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte(crushignoreContent), 0o644))
85−
86− // Test both implementations
87− for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){
88− "regex": searchFilesWithRegex,
89− "rg": func(pattern, path, include string) ([]grepMatch, error) {
90− return searchWithRipgrep(t.Context(), pattern, path, include)
91− },
92− } {
93− t.Run(name, func(t *testing.T) {
94− t.Parallel()
95−
96− if name == "rg" && getRg() == "" {
97− t.Skip("rg is not in $PATH")
98− }
99−
100− matches, err := fn("hello world", tempDir, "")
101− require.NoError(t, err)
102−
103− // Convert matches to a set of file paths for easier testing
104− foundFiles := make(map[string]bool)
105− for _, match := range matches {
106− foundFiles[filepath.Base(match.path)] = true
107− }
108−
109− // Should find file1.txt and file2.txt
110− require.True(t, foundFiles["file1.txt"], "Should find file1.txt")
111− require.True(t, foundFiles["file2.txt"], "Should find file2.txt")
112−
113− // Should NOT find ignored files
114− require.False(t, foundFiles["file3.txt"], "Should not find file3.txt (ignored by .gitignore)")
115− require.False(t, foundFiles["lib.js"], "Should not find lib.js (ignored by .crushignore)")
116− require.False(t, foundFiles["secret.key"], "Should not find secret.key (ignored by .gitignore)")
117−
118− // Should find exactly 2 matches
119− require.Equal(t, 2, len(matches), "Should find exactly 2 matches")
120− })
121− }
122−}
123−
124−func TestSearchImplementations(t *testing.T) {
125− t.Parallel()
126− tempDir := t.TempDir()
127−
128− for path, content := range map[string]string{
129− "file1.go": "package main\nfunc main() {\n\tfmt.Println(\"hello world\")\n}",
130− "file2.js": "console.log('hello world');",
131− "file3.txt": "hello world from text file",
132− "binary.exe": "\x00\x01\x02\x03",
133− "empty.txt": "",
134− "subdir/nested.go": "package nested\n// hello world comment",
135− ".hidden.txt": "hello world in hidden file",
136− "file4.txt": "hello world from a banana",
137− "file5.txt": "hello world from a grape",
138− } {
139− fullPath := filepath.Join(tempDir, path)
140− require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755))
141− require.NoError(t, os.WriteFile(fullPath, []byte(content), 0o644))
142− }
143−
144− require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte("file4.txt\n"), 0o644))
145− require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte("file5.txt\n"), 0o644))
146−
147− for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){
148− "regex": searchFilesWithRegex,
149− "rg": func(pattern, path, include string) ([]grepMatch, error) {
150− return searchWithRipgrep(t.Context(), pattern, path, include)
151− },
152− } {
153− t.Run(name, func(t *testing.T) {
154− t.Parallel()
155−
156− if name == "rg" && getRg() == "" {
157− t.Skip("rg is not in $PATH")
158− }
159−
160− matches, err := fn("hello world", tempDir, "")
161− require.NoError(t, err)
162−
163− require.Equal(t, len(matches), 4)
164− for _, match := range matches {
165− require.NotEmpty(t, match.path)
166− require.NotZero(t, match.lineNum)
167− require.NotEmpty(t, match.lineText)
168− require.NotZero(t, match.modTime)
169− require.NotContains(t, match.path, ".hidden.txt")
170− require.NotContains(t, match.path, "file4.txt")
171− require.NotContains(t, match.path, "file5.txt")
172− require.NotContains(t, match.path, "binary.exe")
173− }
174− })
175− }
176−}
177−
178−// Benchmark to show performance improvement
179−func BenchmarkRegexCacheVsCompile(b *testing.B) {
180− cache := newRegexCache()
181− pattern := "test.*pattern.*[0-9]+"
182−
183− b.Run("WithCache", func(b *testing.B) {
184− for b.Loop() {
185− _, err := cache.get(pattern)
186− if err != nil {
187− b.Fatal(err)
188− }
189− }
190− })
191−
192− b.Run("WithoutCache", func(b *testing.B) {
193− for b.Loop() {
194− _, err := regexp.Compile(pattern)
195− if err != nil {
196− b.Fatal(err)
197− }
198− }
199− })
200−}
internal/llm/tools/ls.godeleted−283
@@ -1,283 +0,0 @@
1−package tools
2−
3−import (
4− "cmp"
5− "context"
6− _ "embed"
7− "encoding/json"
8− "fmt"
9− "os"
10− "path/filepath"
11− "strings"
12−
13− "github.com/charmbracelet/crush/internal/config"
14− "github.com/charmbracelet/crush/internal/fsext"
15− "github.com/charmbracelet/crush/internal/permission"
16−)
17−
18−type LSParams struct {
19− Path string `json:"path"`
20− Ignore []string `json:"ignore"`
21− Depth int `json:"depth"`
22−}
23−
24−type LSPermissionsParams struct {
25− Path string `json:"path"`
26− Ignore []string `json:"ignore"`
27− Depth int `json:"depth"`
28−}
29−
30−type TreeNode struct {
31− Name string `json:"name"`
32− Path string `json:"path"`
33− Type string `json:"type"` // "file" or "directory"
34− Children []*TreeNode `json:"children,omitempty"`
35−}
36−
37−type LSResponseMetadata struct {
38− NumberOfFiles int `json:"number_of_files"`
39− Truncated bool `json:"truncated"`
40−}
41−
42−type lsTool struct {
43− workingDir string
44− permissions permission.Service
45−}
46−
47−const (
48− LSToolName = "ls"
49− maxLSFiles = 1000
50−)
51−
52−//go:embed ls.md
53−var lsDescription []byte
54−
55−func NewLsTool(permissions permission.Service, workingDir string) BaseTool {
56− return &lsTool{
57− workingDir: workingDir,
58− permissions: permissions,
59− }
60−}
61−
62−func (l *lsTool) Name() string {
63− return LSToolName
64−}
65−
66−func (l *lsTool) Info() ToolInfo {
67− return ToolInfo{
68− Name: LSToolName,
69− Description: string(lsDescription),
70− Parameters: map[string]any{
71− "path": map[string]any{
72− "type": "string",
73− "description": "The path to the directory to list (defaults to current working directory)",
74− },
75− "depth": map[string]any{
76− "type": "integer",
77− "description": "The maximum depth to traverse",
78− },
79− "ignore": map[string]any{
80− "type": "array",
81− "description": "List of glob patterns to ignore",
82− "items": map[string]any{
83− "type": "string",
84− },
85− },
86− },
87− Required: []string{"path"},
88− }
89−}
90−
91−func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
92− var params LSParams
93− if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
94− return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
95− }
96−
97− searchPath, err := fsext.Expand(cmp.Or(params.Path, l.workingDir))
98− if err != nil {
99− return ToolResponse{}, fmt.Errorf("error expanding path: %w", err)
100− }
101−
102− if !filepath.IsAbs(searchPath) {
103− searchPath = filepath.Join(l.workingDir, searchPath)
104− }
105−
106− // Check if directory is outside working directory and request permission if needed
107− absWorkingDir, err := filepath.Abs(l.workingDir)
108− if err != nil {
109− return ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
110− }
111−
112− absSearchPath, err := filepath.Abs(searchPath)
113− if err != nil {
114− return ToolResponse{}, fmt.Errorf("error resolving search path: %w", err)
115− }
116−
117− relPath, err := filepath.Rel(absWorkingDir, absSearchPath)
118− if err != nil || strings.HasPrefix(relPath, "..") {
119− // Directory is outside working directory, request permission
120− sessionID, messageID := GetContextValues(ctx)
121− if sessionID == "" || messageID == "" {
122− return ToolResponse{}, fmt.Errorf("session ID and message ID are required for accessing directories outside working directory")
123− }
124−
125− granted := l.permissions.Request(
126− permission.CreatePermissionRequest{
127− SessionID: sessionID,
128− Path: absSearchPath,
129− ToolCallID: call.ID,
130− ToolName: LSToolName,
131− Action: "list",
132− Description: fmt.Sprintf("List directory outside working directory: %s", absSearchPath),
133− Params: LSPermissionsParams(params),
134− },
135− )
136−
137− if !granted {
138− return ToolResponse{}, permission.ErrorPermissionDenied
139− }
140− }
141−
142− output, metadata, err := ListDirectoryTree(searchPath, params)
143− if err != nil {
144− return ToolResponse{}, err
145− }
146−
147− return WithResponseMetadata(
148− NewTextResponse(output),
149− metadata,
150− ), nil
151−}
152−
153−func ListDirectoryTree(searchPath string, params LSParams) (string, LSResponseMetadata, error) {
154− if _, err := os.Stat(searchPath); os.IsNotExist(err) {
155− return "", LSResponseMetadata{}, fmt.Errorf("path does not exist: %s", searchPath)
156− }
157−
158− ls := config.Get().Tools.Ls
159− depth, limit := ls.Limits()
160− maxFiles := min(limit, maxLSFiles)
161− files, truncated, err := fsext.ListDirectory(
162− searchPath,
163− params.Ignore,
164− cmp.Or(params.Depth, depth),
165− maxFiles,
166− )
167− if err != nil {
168− return "", LSResponseMetadata{}, fmt.Errorf("error listing directory: %w", err)
169− }
170−
171− metadata := LSResponseMetadata{
172− NumberOfFiles: len(files),
173− Truncated: truncated,
174− }
175− tree := createFileTree(files, searchPath)
176−
177− var output string
178− if truncated {
179− output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %[1]d files and directories are included below.\n", maxFiles)
180− }
181− if depth > 0 {
182− output = fmt.Sprintf("The directory tree is shown up to a depth of %d. Use a higher depth and a specific path to see more levels.\n", cmp.Or(params.Depth, depth))
183− }
184− return output + "\n" + printTree(tree, searchPath), metadata, nil
185−}
186−
187−func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {
188− root := []*TreeNode{}
189− pathMap := make(map[string]*TreeNode)
190−
191− for _, path := range sortedPaths {
192− relativePath := strings.TrimPrefix(path, rootPath)
193− parts := strings.Split(relativePath, string(filepath.Separator))
194− currentPath := ""
195− var parentPath string
196−
197− var cleanParts []string
198− for _, part := range parts {
199− if part != "" {
200− cleanParts = append(cleanParts, part)
201− }
202− }
203− parts = cleanParts
204−
205− if len(parts) == 0 {
206− continue
207− }
208−
209− for i, part := range parts {
210− if currentPath == "" {
211− currentPath = part
212− } else {
213− currentPath = filepath.Join(currentPath, part)
214− }
215−
216− if _, exists := pathMap[currentPath]; exists {
217− parentPath = currentPath
218− continue
219− }
220−
221− isLastPart := i == len(parts)-1
222− isDir := !isLastPart || strings.HasSuffix(relativePath, string(filepath.Separator))
223− nodeType := "file"
224− if isDir {
225− nodeType = "directory"
226− }
227− newNode := &TreeNode{
228− Name: part,
229− Path: currentPath,
230− Type: nodeType,
231− Children: []*TreeNode{},
232− }
233−
234− pathMap[currentPath] = newNode
235−
236− if i > 0 && parentPath != "" {
237− if parent, ok := pathMap[parentPath]; ok {
238− parent.Children = append(parent.Children, newNode)
239− }
240− } else {
241− root = append(root, newNode)
242− }
243−
244− parentPath = currentPath
245− }
246− }
247−
248− return root
249−}
250−
251−func printTree(tree []*TreeNode, rootPath string) string {
252− var result strings.Builder
253−
254− result.WriteString("- ")
255− result.WriteString(rootPath)
256− if rootPath[len(rootPath)-1] != '/' {
257− result.WriteByte(filepath.Separator)
258− }
259− result.WriteByte('\n')
260−
261− for _, node := range tree {
262− printNode(&result, node, 1)
263− }
264−
265− return result.String()
266−}
267−
268−func printNode(builder *strings.Builder, node *TreeNode, level int) {
269− indent := strings.Repeat(" ", level)
270−
271− nodeName := node.Name
272− if node.Type == "directory" {
273− nodeName = nodeName + string(filepath.Separator)
274− }
275−
276− fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
277−
278− if node.Type == "directory" && len(node.Children) > 0 {
279− for _, child := range node.Children {
280− printNode(builder, child, level+1)
281− }
282− }
283−}
internal/llm/tools/ls.mddeleted−40
@@ -1,40 +0,0 @@
1−Directory listing tool that shows files and subdirectories in a tree structure, helping you explore and understand the project organization.
2−
3−WHEN TO USE THIS TOOL:
4−
5−- Use when you need to explore the structure of a directory
6−- Helpful for understanding the organization of a project
7−- Good first step when getting familiar with a new codebase
8−
9−HOW TO USE:
10−
11−- Provide a path to list (defaults to current working directory)
12−- Optionally specify glob patterns to ignore
13−- Results are displayed in a tree structure
14−
15−FEATURES:
16−
17−- Displays a hierarchical view of files and directories
18−- Automatically skips hidden files/directories (starting with '.')
19−- Skips common system directories like **pycache**
20−- Can filter out files matching specific patterns
21−
22−LIMITATIONS:
23−
24−- Results are limited to 1000 files
25−- Very large directories will be truncated
26−- Does not show file sizes or permissions
27−- Cannot recursively list all directories in a large project
28−
29−WINDOWS NOTES:
30−
31−- Hidden file detection uses Unix convention (files starting with '.')
32−- Windows-specific hidden files (with hidden attribute) are not automatically skipped
33−- Common Windows directories like System32, Program Files are not in default ignore list
34−- Path separators are handled automatically (both / and \ work)
35−
36−TIPS:
37−
38−- Use Glob tool for finding files by name patterns instead of browsing
39−- Use Grep tool for searching file contents
40−- Combine with other tools for more effective exploration
internal/llm/tools/multiedit.godeleted−424
@@ -1,424 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− _ "embed"
6− "encoding/json"
7− "fmt"
8− "log/slog"
9− "os"
10− "path/filepath"
11− "strings"
12− "time"
13−
14− "github.com/charmbracelet/crush/internal/csync"
15− "github.com/charmbracelet/crush/internal/diff"
16− "github.com/charmbracelet/crush/internal/fsext"
17− "github.com/charmbracelet/crush/internal/history"
18− "github.com/charmbracelet/crush/internal/lsp"
19− "github.com/charmbracelet/crush/internal/permission"
20−)
21−
22−type MultiEditOperation struct {
23− OldString string `json:"old_string"`
24− NewString string `json:"new_string"`
25− ReplaceAll bool `json:"replace_all,omitempty"`
26−}
27−
28−type MultiEditParams struct {
29− FilePath string `json:"file_path"`
30− Edits []MultiEditOperation `json:"edits"`
31−}
32−
33−type MultiEditPermissionsParams struct {
34− FilePath string `json:"file_path"`
35− OldContent string `json:"old_content,omitempty"`
36− NewContent string `json:"new_content,omitempty"`
37−}
38−
39−type MultiEditResponseMetadata struct {
40− Additions int `json:"additions"`
41− Removals int `json:"removals"`
42− OldContent string `json:"old_content,omitempty"`
43− NewContent string `json:"new_content,omitempty"`
44− EditsApplied int `json:"edits_applied"`
45−}
46−
47−type multiEditTool struct {
48− lspClients *csync.Map[string, *lsp.Client]
49− permissions permission.Service
50− files history.Service
51− workingDir string
52−}
53−
54−const MultiEditToolName = "multiedit"
55−
56−//go:embed multiedit.md
57−var multieditDescription []byte
58−
59−func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) BaseTool {
60− return &multiEditTool{
61− lspClients: lspClients,
62− permissions: permissions,
63− files: files,
64− workingDir: workingDir,
65− }
66−}
67−
68−func (m *multiEditTool) Name() string {
69− return MultiEditToolName
70−}
71−
72−func (m *multiEditTool) Info() ToolInfo {
73− return ToolInfo{
74− Name: MultiEditToolName,
75− Description: string(multieditDescription),
76− Parameters: map[string]any{
77− "file_path": map[string]any{
78− "type": "string",
79− "description": "The absolute path to the file to modify",
80− },
81− "edits": map[string]any{
82− "type": "array",
83− "items": map[string]any{
84− "type": "object",
85− "properties": map[string]any{
86− "old_string": map[string]any{
87− "type": "string",
88− "description": "The text to replace",
89− },
90− "new_string": map[string]any{
91− "type": "string",
92− "description": "The text to replace it with",
93− },
94− "replace_all": map[string]any{
95− "type": "boolean",
96− "default": false,
97− "description": "Replace all occurrences of old_string (default false).",
98− },
99− },
100− "required": []string{"old_string", "new_string"},
101− "additionalProperties": false,
102− },
103− "minItems": 1,
104− "description": "Array of edit operations to perform sequentially on the file",
105− },
106− },
107− Required: []string{"file_path", "edits"},
108− }
109−}
110−
111−func (m *multiEditTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
112− var params MultiEditParams
113− if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
114− return NewTextErrorResponse("invalid parameters"), nil
115− }
116−
117− if params.FilePath == "" {
118− return NewTextErrorResponse("file_path is required"), nil
119− }
120−
121− if len(params.Edits) == 0 {
122− return NewTextErrorResponse("at least one edit operation is required"), nil
123− }
124−
125− if !filepath.IsAbs(params.FilePath) {
126− params.FilePath = filepath.Join(m.workingDir, params.FilePath)
127− }
128−
129− // Validate all edits before applying any
130− if err := m.validateEdits(params.Edits); err != nil {
131− return NewTextErrorResponse(err.Error()), nil
132− }
133−
134− var response ToolResponse
135− var err error
136−
137− // Handle file creation case (first edit has empty old_string)
138− if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
139− response, err = m.processMultiEditWithCreation(ctx, params, call)
140− } else {
141− response, err = m.processMultiEditExistingFile(ctx, params, call)
142− }
143−
144− if err != nil {
145− return response, err
146− }
147−
148− if response.IsError {
149− return response, nil
150− }
151−
152− // Notify LSP clients about the change
153− notifyLSPs(ctx, m.lspClients, params.FilePath)
154−
155− // Wait for LSP diagnostics and add them to the response
156− text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
157− text += getDiagnostics(params.FilePath, m.lspClients)
158− response.Content = text
159− return response, nil
160−}
161−
162−func (m *multiEditTool) validateEdits(edits []MultiEditOperation) error {
163− for i, edit := range edits {
164− if edit.OldString == edit.NewString {
165− return fmt.Errorf("edit %d: old_string and new_string are identical", i+1)
166− }
167− // Only the first edit can have empty old_string (for file creation)
168− if i > 0 && edit.OldString == "" {
169− return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
170− }
171− }
172− return nil
173−}
174−
175−func (m *multiEditTool) processMultiEditWithCreation(ctx context.Context, params MultiEditParams, call ToolCall) (ToolResponse, error) {
176− // First edit creates the file
177− firstEdit := params.Edits[0]
178− if firstEdit.OldString != "" {
179− return NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
180− }
181−
182− // Check if file already exists
183− if _, err := os.Stat(params.FilePath); err == nil {
184− return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
185− } else if !os.IsNotExist(err) {
186− return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
187− }
188−
189− // Create parent directories
190− dir := filepath.Dir(params.FilePath)
191− if err := os.MkdirAll(dir, 0o755); err != nil {
192− return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
193− }
194−
195− // Start with the content from the first edit
196− currentContent := firstEdit.NewString
197−
198− // Apply remaining edits to the content
199− for i := 1; i < len(params.Edits); i++ {
200− edit := params.Edits[i]
201− newContent, err := m.applyEditToContent(currentContent, edit)
202− if err != nil {
203− return NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
204− }
205− currentContent = newContent
206− }
207−
208− // Get session and message IDs
209− sessionID, messageID := GetContextValues(ctx)
210− if sessionID == "" || messageID == "" {
211− return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
212− }
213−
214− // Check permissions
215− _, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, m.workingDir))
216−
217− p := m.permissions.Request(permission.CreatePermissionRequest{
218− SessionID: sessionID,
219− Path: fsext.PathOrPrefix(params.FilePath, m.workingDir),
220− ToolCallID: call.ID,
221− ToolName: MultiEditToolName,
222− Action: "write",
223− Description: fmt.Sprintf("Create file %s with %d edits", params.FilePath, len(params.Edits)),
224− Params: MultiEditPermissionsParams{
225− FilePath: params.FilePath,
226− OldContent: "",
227− NewContent: currentContent,
228− },
229− })
230− if !p {
231− return ToolResponse{}, permission.ErrorPermissionDenied
232− }
233−
234− // Write the file
235− err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
236− if err != nil {
237− return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
238− }
239−
240− // Update file history
241− _, err = m.files.Create(ctx, sessionID, params.FilePath, "")
242− if err != nil {
243− return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
244− }
245−
246− _, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, currentContent)
247− if err != nil {
248− slog.Debug("Error creating file history version", "error", err)
249− }
250−
251− recordFileWrite(params.FilePath)
252− recordFileRead(params.FilePath)
253−
254− return WithResponseMetadata(
255− NewTextResponse(fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)),
256− MultiEditResponseMetadata{
257− OldContent: "",
258− NewContent: currentContent,
259− Additions: additions,
260− Removals: removals,
261− EditsApplied: len(params.Edits),
262− },
263− ), nil
264−}
265−
266−func (m *multiEditTool) processMultiEditExistingFile(ctx context.Context, params MultiEditParams, call ToolCall) (ToolResponse, error) {
267− // Validate file exists and is readable
268− fileInfo, err := os.Stat(params.FilePath)
269− if err != nil {
270− if os.IsNotExist(err) {
271− return NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
272− }
273− return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
274− }
275−
276− if fileInfo.IsDir() {
277− return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
278− }
279−
280− // Check if file was read before editing
281− if getLastReadTime(params.FilePath).IsZero() {
282− return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
283− }
284−
285− // Check if file was modified since last read
286− modTime := fileInfo.ModTime()
287− lastRead := getLastReadTime(params.FilePath)
288− if modTime.After(lastRead) {
289− return NewTextErrorResponse(
290− fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
291− params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
292− )), nil
293− }
294−
295− // Read current file content
296− content, err := os.ReadFile(params.FilePath)
297− if err != nil {
298− return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
299− }
300−
301− oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
302− currentContent := oldContent
303−
304− // Apply all edits sequentially
305− for i, edit := range params.Edits {
306− newContent, err := m.applyEditToContent(currentContent, edit)
307− if err != nil {
308− return NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil
309− }
310− currentContent = newContent
311− }
312−
313− // Check if content actually changed
314− if oldContent == currentContent {
315− return NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
316− }
317−
318− // Get session and message IDs
319− sessionID, messageID := GetContextValues(ctx)
320− if sessionID == "" || messageID == "" {
321− return ToolResponse{}, fmt.Errorf("session ID and message ID are required for editing file")
322− }
323−
324− // Generate diff and check permissions
325− _, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, m.workingDir))
326− p := m.permissions.Request(permission.CreatePermissionRequest{
327− SessionID: sessionID,
328− Path: fsext.PathOrPrefix(params.FilePath, m.workingDir),
329− ToolCallID: call.ID,
330− ToolName: MultiEditToolName,
331− Action: "write",
332− Description: fmt.Sprintf("Apply %d edits to file %s", len(params.Edits), params.FilePath),
333− Params: MultiEditPermissionsParams{
334− FilePath: params.FilePath,
335− OldContent: oldContent,
336− NewContent: currentContent,
337− },
338− })
339− if !p {
340− return ToolResponse{}, permission.ErrorPermissionDenied
341− }
342−
343− if isCrlf {
344− currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
345− }
346−
347− // Write the updated content
348− err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
349− if err != nil {
350− return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
351− }
352−
353− // Update file history
354− file, err := m.files.GetByPathAndSession(ctx, params.FilePath, sessionID)
355− if err != nil {
356− _, err = m.files.Create(ctx, sessionID, params.FilePath, oldContent)
357− if err != nil {
358− return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
359− }
360− }
361− if file.Content != oldContent {
362− // User manually changed the content, store an intermediate version
363− _, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, oldContent)
364− if err != nil {
365− slog.Debug("Error creating file history version", "error", err)
366− }
367− }
368−
369− // Store the new version
370− _, err = m.files.CreateVersion(ctx, sessionID, params.FilePath, currentContent)
371− if err != nil {
372− slog.Debug("Error creating file history version", "error", err)
373− }
374−
375− recordFileWrite(params.FilePath)
376− recordFileRead(params.FilePath)
377−
378− return WithResponseMetadata(
379− NewTextResponse(fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)),
380− MultiEditResponseMetadata{
381− OldContent: oldContent,
382− NewContent: currentContent,
383− Additions: additions,
384− Removals: removals,
385− EditsApplied: len(params.Edits),
386− },
387− ), nil
388−}
389−
390−func (m *multiEditTool) applyEditToContent(content string, edit MultiEditOperation) (string, error) {
391− if edit.OldString == "" && edit.NewString == "" {
392− return content, nil
393− }
394−
395− if edit.OldString == "" {
396− return "", fmt.Errorf("old_string cannot be empty for content replacement")
397− }
398−
399− var newContent string
400− var replacementCount int
401−
402− if edit.ReplaceAll {
403− newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
404− replacementCount = strings.Count(content, edit.OldString)
405− if replacementCount == 0 {
406− return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
407− }
408− } else {
409− index := strings.Index(content, edit.OldString)
410− if index == -1 {
411− return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
412− }
413−
414− lastIndex := strings.LastIndex(content, edit.OldString)
415− if index != lastIndex {
416− return "", fmt.Errorf("old_string appears multiple times in the content. Please provide more context to ensure a unique match, or set replace_all to true")
417− }
418−
419− newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
420− replacementCount = 1
421− }
422−
423− return newContent, nil
424−}
internal/llm/tools/multiedit.mddeleted−48
@@ -1,48 +0,0 @@
1−This is a tool for making multiple edits to a single file in one operation. It is built on top of the Edit tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the Edit tool when you need to make multiple edits to the same file.
2−
3−Before using this tool:
4−
5−1. Use the Read tool to understand the file's contents and context
6−
7−2. Verify the directory path is correct
8−
9−To make multiple file edits, provide the following:
10−
11−1. file_path: The absolute path to the file to modify (must be absolute, not relative)
12−2. edits: An array of edit operations to perform, where each edit contains:
13− - old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
14− - new_string: The edited text to replace the old_string
15− - replace_all: Replace all occurrences of old_string. This parameter is optional and defaults to false.
16−
17−IMPORTANT:
18−
19−- All edits are applied in sequence, in the order they are provided
20−- Each edit operates on the result of the previous edit
21−- All edits must be valid for the operation to succeed - if any edit fails, none will be applied
22−- This tool is ideal when you need to make several changes to different parts of the same file
23−
24−CRITICAL REQUIREMENTS:
25−
26−1. All edits follow the same requirements as the single Edit tool
27−2. The edits are atomic - either all succeed or none are applied
28−3. Plan your edits carefully to avoid conflicts between sequential operations
29−
30−WARNING:
31−
32−- The tool will fail if edits.old_string doesn't match the file contents exactly (including whitespace)
33−- The tool will fail if edits.old_string and edits.new_string are the same
34−- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find
35−
36−When making edits:
37−
38−- Ensure all edits result in idiomatic, correct code
39−- Do not leave the code in a broken state
40−- Always use absolute file paths (starting with /)
41−- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
42−- Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
43−
44−If you want to create a new file, use:
45−
46−- A new file path, including dir name if needed
47−- First edit: empty old_string and the new file's contents as new_string
48−- Subsequent edits: normal edit operations on the created content
internal/llm/tools/rg.godeleted−53
@@ -1,53 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− "log/slog"
6− "os/exec"
7− "path/filepath"
8− "strings"
9− "sync"
10−
11− "github.com/charmbracelet/crush/internal/log"
12−)
13−
14−var getRg = sync.OnceValue(func() string {
15− path, err := exec.LookPath("rg")
16− if err != nil {
17− if log.Initialized() {
18− slog.Warn("Ripgrep (rg) not found in $PATH. Some grep features might be limited or slower.")
19− }
20− return ""
21− }
22− return path
23−})
24−
25−func getRgCmd(ctx context.Context, globPattern string) *exec.Cmd {
26− name := getRg()
27− if name == "" {
28− return nil
29− }
30− args := []string{"--files", "-L", "--null"}
31− if globPattern != "" {
32− if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") {
33− globPattern = "/" + globPattern
34− }
35− args = append(args, "--glob", globPattern)
36− }
37− return exec.CommandContext(ctx, name, args...)
38−}
39−
40−func getRgSearchCmd(ctx context.Context, pattern, path, include string) *exec.Cmd {
41− name := getRg()
42− if name == "" {
43− return nil
44− }
45− // Use -n to show line numbers, -0 for null separation to handle Windows paths
46− args := []string{"-H", "-n", "-0", pattern}
47− if include != "" {
48− args = append(args, "--glob", include)
49− }
50− args = append(args, path)
51−
52− return exec.CommandContext(ctx, name, args...)
53−}
internal/llm/tools/safe.godeleted−70
@@ -1,70 +0,0 @@
1−package tools
2−
3−import "runtime"
4−
5−var safeCommands = []string{
6− // Bash builtins and core utils
7− "cal",
8− "date",
9− "df",
10− "du",
11− "echo",
12− "env",
13− "free",
14− "groups",
15− "hostname",
16− "id",
17− "kill",
18− "killall",
19− "ls",
20− "nice",
21− "nohup",
22− "printenv",
23− "ps",
24− "pwd",
25− "set",
26− "time",
27− "timeout",
28− "top",
29− "type",
30− "uname",
31− "unset",
32− "uptime",
33− "whatis",
34− "whereis",
35− "which",
36− "whoami",
37−
38− // Git
39− "git blame",
40− "git branch",
41− "git config --get",
42− "git config --list",
43− "git describe",
44− "git diff",
45− "git grep",
46− "git log",
47− "git ls-files",
48− "git ls-remote",
49− "git remote",
50− "git rev-parse",
51− "git shortlog",
52− "git show",
53− "git status",
54− "git tag",
55−}
56−
57−func init() {
58− if runtime.GOOS == "windows" {
59− safeCommands = append(
60− safeCommands,
61− // Windows-specific commands
62− "ipconfig",
63− "nslookup",
64− "ping",
65− "systeminfo",
66− "tasklist",
67− "where",
68− )
69− }
70−}
internal/llm/tools/sourcegraph.godeleted−302
@@ -1,302 +0,0 @@
1−package tools
2−
3−import (
4− "bytes"
5− "context"
6− _ "embed"
7− "encoding/json"
8− "fmt"
9− "io"
10− "net/http"
11− "strings"
12− "time"
13−)
14−
15−type SourcegraphParams struct {
16− Query string `json:"query"`
17− Count int `json:"count,omitempty"`
18− ContextWindow int `json:"context_window,omitempty"`
19− Timeout int `json:"timeout,omitempty"`
20−}
21−
22−type SourcegraphResponseMetadata struct {
23− NumberOfMatches int `json:"number_of_matches"`
24− Truncated bool `json:"truncated"`
25−}
26−
27−type sourcegraphTool struct {
28− client *http.Client
29−}
30−
31−const SourcegraphToolName = "sourcegraph"
32−
33−//go:embed sourcegraph.md
34−var sourcegraphDescription []byte
35−
36−func NewSourcegraphTool() BaseTool {
37− return &sourcegraphTool{
38− client: &http.Client{
39− Timeout: 30 * time.Second,
40− Transport: &http.Transport{
41− MaxIdleConns: 100,
42− MaxIdleConnsPerHost: 10,
43− IdleConnTimeout: 90 * time.Second,
44− },
45− },
46− }
47−}
48−
49−func (t *sourcegraphTool) Name() string {
50− return SourcegraphToolName
51−}
52−
53−func (t *sourcegraphTool) Info() ToolInfo {
54− return ToolInfo{
55− Name: SourcegraphToolName,
56− Description: string(sourcegraphDescription),
57− Parameters: map[string]any{
58− "query": map[string]any{
59− "type": "string",
60− "description": "The Sourcegraph search query",
61− },
62− "count": map[string]any{
63− "type": "number",
64− "description": "Optional number of results to return (default: 10, max: 20)",
65− },
66− "context_window": map[string]any{
67− "type": "number",
68− "description": "The context around the match to return (default: 10 lines)",
69− },
70− "timeout": map[string]any{
71− "type": "number",
72− "description": "Optional timeout in seconds (max 120)",
73− },
74− },
75− Required: []string{"query"},
76− }
77−}
78−
79−func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
80− var params SourcegraphParams
81− if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
82− return NewTextErrorResponse("Failed to parse sourcegraph parameters: " + err.Error()), nil
83− }
84−
85− if params.Query == "" {
86− return NewTextErrorResponse("Query parameter is required"), nil
87− }
88−
89− if params.Count <= 0 {
90− params.Count = 10
91− } else if params.Count > 20 {
92− params.Count = 20 // Limit to 20 results
93− }
94−
95− if params.ContextWindow <= 0 {
96− params.ContextWindow = 10 // Default context window
97− }
98−
99− // Handle timeout with context
100− requestCtx := ctx
101− if params.Timeout > 0 {
102− maxTimeout := 120 // 2 minutes
103− if params.Timeout > maxTimeout {
104− params.Timeout = maxTimeout
105− }
106− var cancel context.CancelFunc
107− requestCtx, cancel = context.WithTimeout(ctx, time.Duration(params.Timeout)*time.Second)
108− defer cancel()
109− }
110−
111− type graphqlRequest struct {
112− Query string `json:"query"`
113− Variables struct {
114− Query string `json:"query"`
115− } `json:"variables"`
116− }
117−
118− request := graphqlRequest{
119− Query: "query Search($query: String!) { search(query: $query, version: V2, patternType: keyword ) { results { matchCount, limitHit, resultCount, approximateResultCount, missing { name }, timedout { name }, indexUnavailable, results { __typename, ... on FileMatch { repository { name }, file { path, url, content }, lineMatches { preview, lineNumber, offsetAndLengths } } } } } }",
120− }
121− request.Variables.Query = params.Query
122−
123− graphqlQueryBytes, err := json.Marshal(request)
124− if err != nil {
125− return ToolResponse{}, fmt.Errorf("failed to marshal GraphQL request: %w", err)
126− }
127− graphqlQuery := string(graphqlQueryBytes)
128−
129− req, err := http.NewRequestWithContext(
130− requestCtx,
131− "POST",
132− "https://sourcegraph.com/.api/graphql",
133− bytes.NewBuffer([]byte(graphqlQuery)),
134− )
135− if err != nil {
136− return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
137− }
138−
139− req.Header.Set("Content-Type", "application/json")
140− req.Header.Set("User-Agent", "crush/1.0")
141−
142− resp, err := t.client.Do(req)
143− if err != nil {
144− return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
145− }
146− defer resp.Body.Close()
147−
148− if resp.StatusCode != http.StatusOK {
149− body, _ := io.ReadAll(resp.Body)
150− if len(body) > 0 {
151− return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d, response: %s", resp.StatusCode, string(body))), nil
152− }
153−
154− return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
155− }
156− body, err := io.ReadAll(resp.Body)
157− if err != nil {
158− return ToolResponse{}, fmt.Errorf("failed to read response body: %w", err)
159− }
160−
161− var result map[string]any
162− if err = json.Unmarshal(body, &result); err != nil {
163− return ToolResponse{}, fmt.Errorf("failed to unmarshal response: %w", err)
164− }
165−
166− formattedResults, err := formatSourcegraphResults(result, params.ContextWindow)
167− if err != nil {
168− return NewTextErrorResponse("Failed to format results: " + err.Error()), nil
169− }
170−
171− return NewTextResponse(formattedResults), nil
172−}
173−
174−func formatSourcegraphResults(result map[string]any, contextWindow int) (string, error) {
175− var buffer strings.Builder
176−
177− if errors, ok := result["errors"].([]any); ok && len(errors) > 0 {
178− buffer.WriteString("## Sourcegraph API Error\n\n")
179− for _, err := range errors {
180− if errMap, ok := err.(map[string]any); ok {
181− if message, ok := errMap["message"].(string); ok {
182− buffer.WriteString(fmt.Sprintf("- %s\n", message))
183− }
184− }
185− }
186− return buffer.String(), nil
187− }
188−
189− data, ok := result["data"].(map[string]any)
190− if !ok {
191− return "", fmt.Errorf("invalid response format: missing data field")
192− }
193−
194− search, ok := data["search"].(map[string]any)
195− if !ok {
196− return "", fmt.Errorf("invalid response format: missing search field")
197− }
198−
199− searchResults, ok := search["results"].(map[string]any)
200− if !ok {
201− return "", fmt.Errorf("invalid response format: missing results field")
202− }
203−
204− matchCount, _ := searchResults["matchCount"].(float64)
205− resultCount, _ := searchResults["resultCount"].(float64)
206− limitHit, _ := searchResults["limitHit"].(bool)
207−
208− buffer.WriteString("# Sourcegraph Search Results\n\n")
209− buffer.WriteString(fmt.Sprintf("Found %d matches across %d results\n", int(matchCount), int(resultCount)))
210−
211− if limitHit {
212− buffer.WriteString("(Result limit reached, try a more specific query)\n")
213− }
214−
215− buffer.WriteString("\n")
216−
217− results, ok := searchResults["results"].([]any)
218− if !ok || len(results) == 0 {
219− buffer.WriteString("No results found. Try a different query.\n")
220− return buffer.String(), nil
221− }
222−
223− maxResults := 10
224− if len(results) > maxResults {
225− results = results[:maxResults]
226− }
227−
228− for i, res := range results {
229− fileMatch, ok := res.(map[string]any)
230− if !ok {
231− continue
232− }
233−
234− typeName, _ := fileMatch["__typename"].(string)
235− if typeName != "FileMatch" {
236− continue
237− }
238−
239− repo, _ := fileMatch["repository"].(map[string]any)
240− file, _ := fileMatch["file"].(map[string]any)
241− lineMatches, _ := fileMatch["lineMatches"].([]any)
242−
243− if repo == nil || file == nil {
244− continue
245− }
246−
247− repoName, _ := repo["name"].(string)
248− filePath, _ := file["path"].(string)
249− fileURL, _ := file["url"].(string)
250− fileContent, _ := file["content"].(string)
251−
252− buffer.WriteString(fmt.Sprintf("## Result %d: %s/%s\n\n", i+1, repoName, filePath))
253−
254− if fileURL != "" {
255− buffer.WriteString(fmt.Sprintf("URL: %s\n\n", fileURL))
256− }
257−
258− if len(lineMatches) > 0 {
259− for _, lm := range lineMatches {
260− lineMatch, ok := lm.(map[string]any)
261− if !ok {
262− continue
263− }
264−
265− lineNumber, _ := lineMatch["lineNumber"].(float64)
266− preview, _ := lineMatch["preview"].(string)
267−
268− if fileContent != "" {
269− lines := strings.Split(fileContent, "\n")
270−
271− buffer.WriteString("```\n")
272−
273− startLine := max(1, int(lineNumber)-contextWindow)
274−
275− for j := startLine - 1; j < int(lineNumber)-1 && j < len(lines); j++ {
276− if j >= 0 {
277− buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j]))
278− }
279− }
280−
281− buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
282−
283− endLine := int(lineNumber) + contextWindow
284−
285− for j := int(lineNumber); j < endLine && j < len(lines); j++ {
286− if j < len(lines) {
287− buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j]))
288− }
289− }
290−
291− buffer.WriteString("```\n\n")
292− } else {
293− buffer.WriteString("```\n")
294− buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
295− buffer.WriteString("```\n\n")
296− }
297− }
298− }
299− }
300−
301− return buffer.String(), nil
302−}
internal/llm/tools/sourcegraph.mddeleted−102
@@ -1,102 +0,0 @@
1−Search code across public repositories using Sourcegraph's GraphQL API.
2−
3−WHEN TO USE THIS TOOL:
4−
5−- Use when you need to find code examples or implementations across public repositories
6−- Helpful for researching how others have solved similar problems
7−- Useful for discovering patterns and best practices in open source code
8−
9−HOW TO USE:
10−
11−- Provide a search query using Sourcegraph's query syntax
12−- Optionally specify the number of results to return (default: 10)
13−- Optionally set a timeout for the request
14−
15−QUERY SYNTAX:
16−
17−- Basic search: "fmt.Println" searches for exact matches
18−- File filters: "file:.go fmt.Println" limits to Go files
19−- Repository filters: "repo:^github\.com/golang/go$ fmt.Println" limits to specific repos
20−- Language filters: "lang:go fmt.Println" limits to Go code
21−- Boolean operators: "fmt.Println AND log.Fatal" for combined terms
22−- Regular expressions: "fmt\.(Print|Printf|Println)" for pattern matching
23−- Quoted strings: "\"exact phrase\"" for exact phrase matching
24−- Exclude filters: "-file:test" or "-repo:forks" to exclude matches
25−
26−ADVANCED FILTERS:
27−
28−- Repository filters:
29− - "repo:name" - Match repositories with name containing "name"
30− - "repo:^github\.com/org/repo$" - Exact repository match
31− - "repo:org/repo@branch" - Search specific branch
32− - "repo:org/repo rev:branch" - Alternative branch syntax
33− - "-repo:name" - Exclude repositories
34− - "fork:yes" or "fork:only" - Include or only show forks
35− - "archived:yes" or "archived:only" - Include or only show archived repos
36− - "visibility:public" or "visibility:private" - Filter by visibility
37−
38−- File filters:
39− - "file:\.js$" - Files with .js extension
40− - "file:internal/" - Files in internal directory
41− - "-file:test" - Exclude test files
42− - "file:has.content(Copyright)" - Files containing "Copyright"
43− - "file:has.contributor([email protected])" - Files with specific contributor
44−
45−- Content filters:
46− - "content:\"exact string\"" - Search for exact string
47− - "-content:\"unwanted\"" - Exclude files with unwanted content
48− - "case:yes" - Case-sensitive search
49−
50−- Type filters:
51− - "type:symbol" - Search for symbols (functions, classes, etc.)
52− - "type:file" - Search file content only
53− - "type:path" - Search filenames only
54− - "type:diff" - Search code changes
55− - "type:commit" - Search commit messages
56−
57−- Commit/diff search:
58− - "after:\"1 month ago\"" - Commits after date
59− - "before:\"2023-01-01\"" - Commits before date
60− - "author:name" - Commits by author
61− - "message:\"fix bug\"" - Commits with message
62−
63−- Result selection:
64− - "select:repo" - Show only repository names
65− - "select:file" - Show only file paths
66− - "select:content" - Show only matching content
67− - "select:symbol" - Show only matching symbols
68−
69−- Result control:
70− - "count:100" - Return up to 100 results
71− - "count:all" - Return all results
72− - "timeout:30s" - Set search timeout
73−
74−EXAMPLES:
75−
76−- "file:.go context.WithTimeout" - Find Go code using context.WithTimeout
77−- "lang:typescript useState type:symbol" - Find TypeScript React useState hooks
78−- "repo:^github\.com/kubernetes/kubernetes$ pod list type:file" - Find Kubernetes files related to pod listing
79−- "repo:sourcegraph/sourcegraph$ after:\"3 months ago\" type:diff database" - Recent changes to database code
80−- "file:Dockerfile (alpine OR ubuntu) -content:alpine:latest" - Dockerfiles with specific base images
81−- "repo:has.path(\.py) file:requirements.txt tensorflow" - Python projects using TensorFlow
82−
83−BOOLEAN OPERATORS:
84−
85−- "term1 AND term2" - Results containing both terms
86−- "term1 OR term2" - Results containing either term
87−- "term1 NOT term2" - Results with term1 but not term2
88−- "term1 and (term2 or term3)" - Grouping with parentheses
89−
90−LIMITATIONS:
91−
92−- Only searches public repositories
93−- Rate limits may apply
94−- Complex queries may take longer to execute
95−- Maximum of 20 results per query
96−
97−TIPS:
98−
99−- Use specific file extensions to narrow results
100−- Add repo: filters for more targeted searches
101−- Use type:symbol to find function/method definitions
102−- Use type:file to find relevant files
internal/llm/tools/tools.godeleted−85
@@ -1,85 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− "encoding/json"
6−)
7−
8−type ToolInfo struct {
9− Name string
10− Description string
11− Parameters map[string]any
12− Required []string
13−}
14−
15−type toolResponseType string
16−
17−type (
18− sessionIDContextKey string
19− messageIDContextKey string
20−)
21−
22−const (
23− ToolResponseTypeText toolResponseType = "text"
24− ToolResponseTypeImage toolResponseType = "image"
25−
26− SessionIDContextKey sessionIDContextKey = "session_id"
27− MessageIDContextKey messageIDContextKey = "message_id"
28−)
29−
30−type ToolResponse struct {
31− Type toolResponseType `json:"type"`
32− Content string `json:"content"`
33− Metadata string `json:"metadata,omitempty"`
34− IsError bool `json:"is_error"`
35−}
36−
37−func NewTextResponse(content string) ToolResponse {
38− return ToolResponse{
39− Type: ToolResponseTypeText,
40− Content: content,
41− }
42−}
43−
44−func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse {
45− if metadata != nil {
46− metadataBytes, err := json.Marshal(metadata)
47− if err != nil {
48− return response
49− }
50− response.Metadata = string(metadataBytes)
51− }
52− return response
53−}
54−
55−func NewTextErrorResponse(content string) ToolResponse {
56− return ToolResponse{
57− Type: ToolResponseTypeText,
58− Content: content,
59− IsError: true,
60− }
61−}
62−
63−type ToolCall struct {
64− ID string `json:"id"`
65− Name string `json:"name"`
66− Input string `json:"input"`
67−}
68−
69−type BaseTool interface {
70− Info() ToolInfo
71− Name() string
72− Run(ctx context.Context, params ToolCall) (ToolResponse, error)
73−}
74−
75−func GetContextValues(ctx context.Context) (string, string) {
76− sessionID := ctx.Value(SessionIDContextKey)
77− messageID := ctx.Value(MessageIDContextKey)
78− if sessionID == nil {
79− return "", ""
80− }
81− if messageID == nil {
82− return sessionID.(string), ""
83− }
84− return sessionID.(string), messageID.(string)
85−}
internal/llm/tools/view.godeleted−343
@@ -1,343 +0,0 @@
1−package tools
2−
3−import (
4− "bufio"
5− "context"
6− _ "embed"
7− "encoding/json"
8− "fmt"
9− "io"
10− "os"
11− "path/filepath"
12− "strings"
13− "unicode/utf8"
14−
15− "github.com/charmbracelet/crush/internal/csync"
16− "github.com/charmbracelet/crush/internal/lsp"
17− "github.com/charmbracelet/crush/internal/permission"
18−)
19−
20−//go:embed view.md
21−var viewDescription []byte
22−
23−type ViewParams struct {
24− FilePath string `json:"file_path"`
25− Offset int `json:"offset"`
26− Limit int `json:"limit"`
27−}
28−
29−type ViewPermissionsParams struct {
30− FilePath string `json:"file_path"`
31− Offset int `json:"offset"`
32− Limit int `json:"limit"`
33−}
34−
35−type viewTool struct {
36− lspClients *csync.Map[string, *lsp.Client]
37− workingDir string
38− permissions permission.Service
39−}
40−
41−type ViewResponseMetadata struct {
42− FilePath string `json:"file_path"`
43− Content string `json:"content"`
44−}
45−
46−const (
47− ViewToolName = "view"
48− MaxReadSize = 250 * 1024
49− DefaultReadLimit = 2000
50− MaxLineLength = 2000
51−)
52−
53−func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) BaseTool {
54− return &viewTool{
55− lspClients: lspClients,
56− workingDir: workingDir,
57− permissions: permissions,
58− }
59−}
60−
61−func (v *viewTool) Name() string {
62− return ViewToolName
63−}
64−
65−func (v *viewTool) Info() ToolInfo {
66− return ToolInfo{
67− Name: ViewToolName,
68− Description: string(viewDescription),
69− Parameters: map[string]any{
70− "file_path": map[string]any{
71− "type": "string",
72− "description": "The path to the file to read",
73− },
74− "offset": map[string]any{
75− "type": "integer",
76− "description": "The line number to start reading from (0-based)",
77− },
78− "limit": map[string]any{
79− "type": "integer",
80− "description": "The number of lines to read (defaults to 2000)",
81− },
82− },
83− Required: []string{"file_path"},
84− }
85−}
86−
87−// Run implements Tool.
88−func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
89− var params ViewParams
90− if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
91− return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
92− }
93−
94− if params.FilePath == "" {
95− return NewTextErrorResponse("file_path is required"), nil
96− }
97−
98− // Handle relative paths
99− filePath := params.FilePath
100− if !filepath.IsAbs(filePath) {
101− filePath = filepath.Join(v.workingDir, filePath)
102− }
103−
104− // Check if file is outside working directory and request permission if needed
105− absWorkingDir, err := filepath.Abs(v.workingDir)
106− if err != nil {
107− return ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
108− }
109−
110− absFilePath, err := filepath.Abs(filePath)
111− if err != nil {
112− return ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
113− }
114−
115− relPath, err := filepath.Rel(absWorkingDir, absFilePath)
116− if err != nil || strings.HasPrefix(relPath, "..") {
117− // File is outside working directory, request permission
118− sessionID, messageID := GetContextValues(ctx)
119− if sessionID == "" || messageID == "" {
120− return ToolResponse{}, fmt.Errorf("session ID and message ID are required for accessing files outside working directory")
121− }
122−
123− granted := v.permissions.Request(
124− permission.CreatePermissionRequest{
125− SessionID: sessionID,
126− Path: absFilePath,
127− ToolCallID: call.ID,
128− ToolName: ViewToolName,
129− Action: "read",
130− Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
131− Params: ViewPermissionsParams(params),
132− },
133− )
134−
135− if !granted {
136− return ToolResponse{}, permission.ErrorPermissionDenied
137− }
138− }
139−
140− // Check if file exists
141− fileInfo, err := os.Stat(filePath)
142− if err != nil {
143− if os.IsNotExist(err) {
144− // Try to offer suggestions for similarly named files
145− dir := filepath.Dir(filePath)
146− base := filepath.Base(filePath)
147−
148− dirEntries, dirErr := os.ReadDir(dir)
149− if dirErr == nil {
150− var suggestions []string
151− for _, entry := range dirEntries {
152− if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
153− strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
154− suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
155− if len(suggestions) >= 3 {
156− break
157− }
158− }
159− }
160−
161− if len(suggestions) > 0 {
162− return NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
163− filePath, strings.Join(suggestions, "\n"))), nil
164− }
165− }
166−
167− return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
168− }
169− return ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
170− }
171−
172− // Check if it's a directory
173− if fileInfo.IsDir() {
174− return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
175− }
176−
177− // Check file size
178− if fileInfo.Size() > MaxReadSize {
179− return NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
180− fileInfo.Size(), MaxReadSize)), nil
181− }
182−
183− // Set default limit if not provided
184− if params.Limit <= 0 {
185− params.Limit = DefaultReadLimit
186− }
187−
188− // Check if it's an image file
189− isImage, imageType := isImageFile(filePath)
190− // TODO: handle images
191− if isImage {
192− return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\n", imageType)), nil
193− }
194−
195− // Read the file content
196− content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
197− isValidUt8 := utf8.ValidString(content)
198− if !isValidUt8 {
199− return NewTextErrorResponse("File content is not valid UTF-8"), nil
200− }
201− if err != nil {
202− return ToolResponse{}, fmt.Errorf("error reading file: %w", err)
203− }
204−
205− notifyLSPs(ctx, v.lspClients, filePath)
206− output := "<file>\n"
207− // Format the output with line numbers
208− output += addLineNumbers(content, params.Offset+1)
209−
210− // Add a note if the content was truncated
211− if lineCount > params.Offset+len(strings.Split(content, "\n")) {
212− output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
213− params.Offset+len(strings.Split(content, "\n")))
214− }
215− output += "\n</file>\n"
216− output += getDiagnostics(filePath, v.lspClients)
217− recordFileRead(filePath)
218− return WithResponseMetadata(
219− NewTextResponse(output),
220− ViewResponseMetadata{
221− FilePath: filePath,
222− Content: content,
223− },
224− ), nil
225−}
226−
227−func addLineNumbers(content string, startLine int) string {
228− if content == "" {
229− return ""
230− }
231−
232− lines := strings.Split(content, "\n")
233−
234− var result []string
235− for i, line := range lines {
236− line = strings.TrimSuffix(line, "\r")
237−
238− lineNum := i + startLine
239− numStr := fmt.Sprintf("%d", lineNum)
240−
241− if len(numStr) >= 6 {
242− result = append(result, fmt.Sprintf("%s|%s", numStr, line))
243− } else {
244− paddedNum := fmt.Sprintf("%6s", numStr)
245− result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
246− }
247− }
248−
249− return strings.Join(result, "\n")
250−}
251−
252−func readTextFile(filePath string, offset, limit int) (string, int, error) {
253− file, err := os.Open(filePath)
254− if err != nil {
255− return "", 0, err
256− }
257− defer file.Close()
258−
259− lineCount := 0
260−
261− scanner := NewLineScanner(file)
262− if offset > 0 {
263− for lineCount < offset && scanner.Scan() {
264− lineCount++
265− }
266− if err = scanner.Err(); err != nil {
267− return "", 0, err
268− }
269− }
270−
271− if offset == 0 {
272− _, err = file.Seek(0, io.SeekStart)
273− if err != nil {
274− return "", 0, err
275− }
276− }
277−
278− // Pre-allocate slice with expected capacity
279− lines := make([]string, 0, limit)
280− lineCount = offset
281−
282− for scanner.Scan() && len(lines) < limit {
283− lineCount++
284− lineText := scanner.Text()
285− if len(lineText) > MaxLineLength {
286− lineText = lineText[:MaxLineLength] + "..."
287− }
288− lines = append(lines, lineText)
289− }
290−
291− // Continue scanning to get total line count
292− for scanner.Scan() {
293− lineCount++
294− }
295−
296− if err := scanner.Err(); err != nil {
297− return "", 0, err
298− }
299−
300− return strings.Join(lines, "\n"), lineCount, nil
301−}
302−
303−func isImageFile(filePath string) (bool, string) {
304− ext := strings.ToLower(filepath.Ext(filePath))
305− switch ext {
306− case ".jpg", ".jpeg":
307− return true, "JPEG"
308− case ".png":
309− return true, "PNG"
310− case ".gif":
311− return true, "GIF"
312− case ".bmp":
313− return true, "BMP"
314− case ".svg":
315− return true, "SVG"
316− case ".webp":
317− return true, "WebP"
318− default:
319− return false, ""
320− }
321−}
322−
323−type LineScanner struct {
324− scanner *bufio.Scanner
325−}
326−
327−func NewLineScanner(r io.Reader) *LineScanner {
328− return &LineScanner{
329− scanner: bufio.NewScanner(r),
330− }
331−}
332−
333−func (s *LineScanner) Scan() bool {
334− return s.scanner.Scan()
335−}
336−
337−func (s *LineScanner) Text() string {
338− return s.scanner.Text()
339−}
340−
341−func (s *LineScanner) Err() error {
342− return s.scanner.Err()
343−}
internal/llm/tools/view.mddeleted−42
@@ -1,42 +0,0 @@
1−File viewing tool that reads and displays the contents of files with line numbers, allowing you to examine code, logs, or text data.
2−
3−WHEN TO USE THIS TOOL:
4−
5−- Use when you need to read the contents of a specific file
6−- Helpful for examining source code, configuration files, or log files
7−- Perfect for looking at text-based file formats
8−
9−HOW TO USE:
10−
11−- Provide the path to the file you want to view
12−- Optionally specify an offset to start reading from a specific line
13−- Optionally specify a limit to control how many lines are read
14−- Do not use this for directories use the ls tool instead
15−
16−FEATURES:
17−
18−- Displays file contents with line numbers for easy reference
19−- Can read from any position in a file using the offset parameter
20−- Handles large files by limiting the number of lines read
21−- Automatically truncates very long lines for better display
22−- Suggests similar file names when the requested file isn't found
23−
24−LIMITATIONS:
25−
26−- Maximum file size is 250KB
27−- Default reading limit is 2000 lines
28−- Lines longer than 2000 characters are truncated
29−- Cannot display binary files or images
30−- Images can be identified but not displayed
31−
32−WINDOWS NOTES:
33−
34−- Handles both Windows (CRLF) and Unix (LF) line endings automatically
35−- File paths work with both forward slashes (/) and backslashes (\)
36−- Text encoding is detected automatically for most common formats
37−
38−TIPS:
39−
40−- Use with Glob tool to first find files you want to view
41−- For code exploration, first use Grep to find relevant files, then View to examine them
42−- When viewing large files, use the offset parameter to read specific sections
internal/llm/tools/write.godeleted−208
@@ -1,208 +0,0 @@
1−package tools
2−
3−import (
4− "context"
5− _ "embed"
6− "encoding/json"
7− "fmt"
8− "log/slog"
9− "os"
10− "path/filepath"
11− "strings"
12− "time"
13−
14− "github.com/charmbracelet/crush/internal/csync"
15− "github.com/charmbracelet/crush/internal/diff"
16− "github.com/charmbracelet/crush/internal/fsext"
17− "github.com/charmbracelet/crush/internal/history"
18−
19− "github.com/charmbracelet/crush/internal/lsp"
20− "github.com/charmbracelet/crush/internal/permission"
21−)
22−
23−//go:embed write.md
24−var writeDescription []byte
25−
26−type WriteParams struct {
27− FilePath string `json:"file_path"`
28− Content string `json:"content"`
29−}
30−
31−type WritePermissionsParams struct {
32− FilePath string `json:"file_path"`
33− OldContent string `json:"old_content,omitempty"`
34− NewContent string `json:"new_content,omitempty"`
35−}
36−
37−type writeTool struct {
38− lspClients *csync.Map[string, *lsp.Client]
39− permissions permission.Service
40− files history.Service
41− workingDir string
42−}
43−
44−type WriteResponseMetadata struct {
45− Diff string `json:"diff"`
46− Additions int `json:"additions"`
47− Removals int `json:"removals"`
48−}
49−
50−const WriteToolName = "write"
51−
52−func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) BaseTool {
53− return &writeTool{
54− lspClients: lspClients,
55− permissions: permissions,
56− files: files,
57− workingDir: workingDir,
58− }
59−}
60−
61−func (w *writeTool) Name() string {
62− return WriteToolName
63−}
64−
65−func (w *writeTool) Info() ToolInfo {
66− return ToolInfo{
67− Name: WriteToolName,
68− Description: string(writeDescription),
69− Parameters: map[string]any{
70− "file_path": map[string]any{
71− "type": "string",
72− "description": "The path to the file to write",
73− },
74− "content": map[string]any{
75− "type": "string",
76− "description": "The content to write to the file",
77− },
78− },
79− Required: []string{"file_path", "content"},
80− }
81−}
82−
83−func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
84− var params WriteParams
85− if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
86− return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
87− }
88−
89− if params.FilePath == "" {
90− return NewTextErrorResponse("file_path is required"), nil
91− }
92−
93− if params.Content == "" {
94− return NewTextErrorResponse("content is required"), nil
95− }
96−
97− filePath := params.FilePath
98− if !filepath.IsAbs(filePath) {
99− filePath = filepath.Join(w.workingDir, filePath)
100− }
101−
102− fileInfo, err := os.Stat(filePath)
103− if err == nil {
104− if fileInfo.IsDir() {
105− return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
106− }
107−
108− modTime := fileInfo.ModTime()
109− lastRead := getLastReadTime(filePath)
110− if modTime.After(lastRead) {
111− return NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
112− filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
113− }
114−
115− oldContent, readErr := os.ReadFile(filePath)
116− if readErr == nil && string(oldContent) == params.Content {
117− return NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
118− }
119− } else if !os.IsNotExist(err) {
120− return ToolResponse{}, fmt.Errorf("error checking file: %w", err)
121− }
122−
123− dir := filepath.Dir(filePath)
124− if err = os.MkdirAll(dir, 0o755); err != nil {
125− return ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
126− }
127−
128− oldContent := ""
129− if fileInfo != nil && !fileInfo.IsDir() {
130− oldBytes, readErr := os.ReadFile(filePath)
131− if readErr == nil {
132− oldContent = string(oldBytes)
133− }
134− }
135−
136− sessionID, messageID := GetContextValues(ctx)
137− if sessionID == "" || messageID == "" {
138− return ToolResponse{}, fmt.Errorf("session_id and message_id are required")
139− }
140−
141− diff, additions, removals := diff.GenerateDiff(
142− oldContent,
143− params.Content,
144− strings.TrimPrefix(filePath, w.workingDir),
145− )
146−
147− p := w.permissions.Request(
148− permission.CreatePermissionRequest{
149− SessionID: sessionID,
150− Path: fsext.PathOrPrefix(filePath, w.workingDir),
151− ToolCallID: call.ID,
152− ToolName: WriteToolName,
153− Action: "write",
154− Description: fmt.Sprintf("Create file %s", filePath),
155− Params: WritePermissionsParams{
156− FilePath: filePath,
157− OldContent: oldContent,
158− NewContent: params.Content,
159− },
160− },
161− )
162− if !p {
163− return ToolResponse{}, permission.ErrorPermissionDenied
164− }
165−
166− err = os.WriteFile(filePath, []byte(params.Content), 0o644)
167− if err != nil {
168− return ToolResponse{}, fmt.Errorf("error writing file: %w", err)
169− }
170−
171− // Check if file exists in history
172− file, err := w.files.GetByPathAndSession(ctx, filePath, sessionID)
173− if err != nil {
174− _, err = w.files.Create(ctx, sessionID, filePath, oldContent)
175− if err != nil {
176− // Log error but don't fail the operation
177− return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
178− }
179− }
180− if file.Content != oldContent {
181− // User Manually changed the content store an intermediate version
182− _, err = w.files.CreateVersion(ctx, sessionID, filePath, oldContent)
183− if err != nil {
184− slog.Debug("Error creating file history version", "error", err)
185− }
186− }
187− // Store the new version
188− _, err = w.files.CreateVersion(ctx, sessionID, filePath, params.Content)
189− if err != nil {
190− slog.Debug("Error creating file history version", "error", err)
191− }
192−
193− recordFileWrite(filePath)
194− recordFileRead(filePath)
195−
196− notifyLSPs(ctx, w.lspClients, params.FilePath)
197−
198− result := fmt.Sprintf("File successfully written: %s", filePath)
199− result = fmt.Sprintf("<result>\n%s\n</result>", result)
200− result += getDiagnostics(filePath, w.lspClients)
201− return WithResponseMetadata(NewTextResponse(result),
202− WriteResponseMetadata{
203− Diff: diff,
204− Additions: additions,
205− Removals: removals,
206− },
207− ), nil
208−}
internal/llm/tools/write.mddeleted−38
@@ -1,38 +0,0 @@
1−File writing tool that creates or updates files in the filesystem, allowing you to save or modify text content.
2−
3−WHEN TO USE THIS TOOL:
4−
5−- Use when you need to create a new file
6−- Helpful for updating existing files with modified content
7−- Perfect for saving generated code, configurations, or text data
8−
9−HOW TO USE:
10−
11−- Provide the path to the file you want to write
12−- Include the content to be written to the file
13−- The tool will create any necessary parent directories
14−
15−FEATURES:
16−
17−- Can create new files or overwrite existing ones
18−- Creates parent directories automatically if they don't exist
19−- Checks if the file has been modified since last read for safety
20−- Avoids unnecessary writes when content hasn't changed
21−
22−LIMITATIONS:
23−
24−- You should read a file before writing to it to avoid conflicts
25−- Cannot append to files (rewrites the entire file)
26−
27−WINDOWS NOTES:
28−
29−- File permissions (0o755, 0o644) are Unix-style but work on Windows with appropriate translations
30−- Use forward slashes (/) in paths for cross-platform compatibility
31−- Windows file attributes and permissions are handled automatically by the Go runtime
32−
33−TIPS:
34−
35−- Use the View tool first to examine existing files before modifying them
36−- Use the LS tool to verify the correct location when creating new files
37−- Combine with Glob and Grep tools to find and modify multiple files
38−- Always include descriptive comments when making changes to existing code
internal/tui/components/chat/chat.go+4−4
@@ -8,8 +8,8 @@ import (
88 "github.com/atotto/clipboard"
99 "github.com/charmbracelet/bubbles/v2/key"
1010 tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/crush/internal/agent"
1112 "github.com/charmbracelet/crush/internal/app"
12− "github.com/charmbracelet/crush/internal/llm/agent"
1313 "github.com/charmbracelet/crush/internal/message"
1414 "github.com/charmbracelet/crush/internal/permission"
1515 "github.com/charmbracelet/crush/internal/pubsub"
@@ -103,8 +103,8 @@ func (m *messageListCmp) Init() tea.Cmd {
103103// Update handles incoming messages and updates the component state.
104104func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
105105 var cmds []tea.Cmd
106− if m.session.ID != "" && m.app.CoderAgent != nil {
107− queueSize := m.app.CoderAgent.QueuedPrompts(m.session.ID)
106+ if m.session.ID != "" && m.app.AgentCoordinator != nil {
107+ queueSize := m.app.AgentCoordinator.QueuedPrompts(m.session.ID)
108108 if queueSize != m.promptQueue {
109109 m.promptQueue = queueSize
110110 cmds = append(cmds, m.SetSize(m.width, m.height))
@@ -235,7 +235,7 @@ func (m *messageListCmp) View() string {
235235 m.listCmp.View(),
236236 ),
237237 }
238− if m.app.CoderAgent != nil && m.promptQueue > 0 {
238+ if m.app.AgentCoordinator != nil && m.promptQueue > 0 {
239239 queuePill := queuePill(m.promptQueue, t)
240240 view = append(view, t.S().Base.PaddingLeft(4).PaddingTop(1).Render(queuePill))
241241 }
internal/tui/components/chat/editor/editor.go+3−3
@@ -211,7 +211,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
211211 }
212212
213213 case commands.OpenExternalEditorMsg:
214− if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
214+ if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
215215 return m, util.ReportWarn("Agent is working, please wait...")
216216 }
217217 return m, m.openEditor(m.textarea.Value())
@@ -297,7 +297,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
297297 }
298298 }
299299 if key.Matches(msg, m.keyMap.OpenEditor) {
300− if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
300+ if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
301301 return m, util.ReportWarn("Agent is working, please wait...")
302302 }
303303 return m, m.openEditor(m.textarea.Value())
@@ -415,7 +415,7 @@ func (m *editorCmp) randomizePlaceholders() {
415415func (m *editorCmp) View() string {
416416 t := styles.CurrentTheme()
417417 // Update placeholder
418− if m.app.CoderAgent != nil && m.app.CoderAgent.IsBusy() {
418+ if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() {
419419 m.textarea.Placeholder = m.workingPlaceholder
420420 } else {
421421 m.textarea.Placeholder = m.readyPlaceholder
internal/tui/components/chat/header/header.go+1−1
@@ -119,7 +119,7 @@ func (h *header) details(availWidth int) string {
119119 parts = append(parts, s.Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
120120 }
121121
122− agentCfg := config.Get().Agents["coder"]
122+ agentCfg := config.Get().Agents[config.AgentCoder]
123123 model := config.Get().GetModelByType(agentCfg.Model)
124124 percentage := (float64(h.session.CompletionTokens+h.session.PromptTokens) / float64(model.ContextWindow)) * 100
125125 formattedPercentage := s.Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
internal/tui/components/chat/messages/renderer.go+2−2
@@ -6,10 +6,10 @@ import (
66 "strings"
77 "time"
88
9+ "github.com/charmbracelet/crush/internal/agent"
10+ "github.com/charmbracelet/crush/internal/agent/tools"
911 "github.com/charmbracelet/crush/internal/ansiext"
1012 "github.com/charmbracelet/crush/internal/fsext"
11− "github.com/charmbracelet/crush/internal/llm/agent"
12− "github.com/charmbracelet/crush/internal/llm/tools"
1313 "github.com/charmbracelet/crush/internal/tui/components/core"
1414 "github.com/charmbracelet/crush/internal/tui/highlight"
1515 "github.com/charmbracelet/crush/internal/tui/styles"
internal/tui/components/chat/messages/tool.go+2−2
@@ -10,10 +10,10 @@ import (
1010 "github.com/atotto/clipboard"
1111 "github.com/charmbracelet/bubbles/v2/key"
1212 tea "github.com/charmbracelet/bubbletea/v2"
13+ "github.com/charmbracelet/crush/internal/agent"
14+ "github.com/charmbracelet/crush/internal/agent/tools"
1315 "github.com/charmbracelet/crush/internal/diff"
1416 "github.com/charmbracelet/crush/internal/fsext"
15− "github.com/charmbracelet/crush/internal/llm/agent"
16− "github.com/charmbracelet/crush/internal/llm/tools"
1717 "github.com/charmbracelet/crush/internal/message"
1818 "github.com/charmbracelet/crush/internal/permission"
1919 "github.com/charmbracelet/crush/internal/tui/components/anim"
internal/tui/components/chat/sidebar/sidebar.go+1−1
@@ -545,7 +545,7 @@ func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
545545
546546func (s *sidebarCmp) currentModelBlock() string {
547547 cfg := config.Get()
548− agentCfg := cfg.Agents["coder"]
548+ agentCfg := cfg.Agents[config.AgentCoder]
549549
550550 selectedModel := cfg.Models[agentCfg.Model]
551551
internal/tui/components/chat/splash/splash.go+3−3
@@ -9,9 +9,9 @@ import (
99 "github.com/charmbracelet/bubbles/v2/spinner"
1010 tea "github.com/charmbracelet/bubbletea/v2"
1111 "github.com/charmbracelet/catwalk/pkg/catwalk"
12+ "github.com/charmbracelet/crush/internal/agent"
1213 "github.com/charmbracelet/crush/internal/config"
1314 "github.com/charmbracelet/crush/internal/home"
14− "github.com/charmbracelet/crush/internal/llm/prompt"
1515 "github.com/charmbracelet/crush/internal/tui/components/chat"
1616 "github.com/charmbracelet/crush/internal/tui/components/core"
1717 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
@@ -334,7 +334,7 @@ func (s *splashCmp) initializeProject() tea.Cmd {
334334 cmds = append(cmds,
335335 util.CmdHandler(chat.SessionClearedMsg{}),
336336 util.CmdHandler(chat.SendMsg{
337− Text: prompt.Initialize(),
337+ Text: agent.InitializePrompt(),
338338 }),
339339 )
340340 }
@@ -695,7 +695,7 @@ func (s *splashCmp) mcpBlock() string {
695695
696696func (s *splashCmp) currentModelBlock() string {
697697 cfg := config.Get()
698− agentCfg := cfg.Agents["coder"]
698+ agentCfg := cfg.Agents[config.AgentCoder]
699699 model := config.Get().GetModelByType(agentCfg.Model)
700700 if model == nil {
701701 return ""
internal/tui/components/dialogs/commands/commands.go+4−4
@@ -9,8 +9,8 @@ import (
99 "github.com/charmbracelet/catwalk/pkg/catwalk"
1010 "github.com/charmbracelet/lipgloss/v2"
1111
12+ "github.com/charmbracelet/crush/internal/agent"
1213 "github.com/charmbracelet/crush/internal/config"
13− "github.com/charmbracelet/crush/internal/llm/prompt"
1414 "github.com/charmbracelet/crush/internal/tui/components/chat"
1515 "github.com/charmbracelet/crush/internal/tui/components/core"
1616 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
@@ -303,7 +303,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
303303
304304 // Add reasoning toggle for models that support it
305305 cfg := config.Get()
306− if agentCfg, ok := cfg.Agents["coder"]; ok {
306+ if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
307307 providerCfg := cfg.GetProviderForModel(agentCfg.Model)
308308 model := cfg.GetModelByType(agentCfg.Model)
309309 if providerCfg != nil && model != nil && model.CanReason {
@@ -350,7 +350,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
350350 })
351351 }
352352 if c.sessionID != "" {
353− agentCfg := config.Get().Agents["coder"]
353+ agentCfg := config.Get().Agents[config.AgentCoder]
354354 model := config.Get().GetModelByType(agentCfg.Model)
355355 if model.SupportsImages {
356356 commands = append(commands, Command{
@@ -402,7 +402,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
402402 Description: "Create/Update the CRUSH.md memory file",
403403 Handler: func(cmd Command) tea.Cmd {
404404 return util.CmdHandler(chat.SendMsg{
405− Text: prompt.Initialize(),
405+ Text: agent.InitializePrompt(),
406406 })
407407 },
408408 },
internal/tui/components/dialogs/compact/compact.go+46−150
@@ -7,7 +7,7 @@ import (
77 tea "github.com/charmbracelet/bubbletea/v2"
88 "github.com/charmbracelet/lipgloss/v2"
99
10− "github.com/charmbracelet/crush/internal/llm/agent"
10+ "github.com/charmbracelet/crush/internal/agent"
1111 "github.com/charmbracelet/crush/internal/tui/components/core"
1212 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
1313 "github.com/charmbracelet/crush/internal/tui/styles"
@@ -27,26 +27,16 @@ type compactDialogCmp struct {
2727 selected int
2828 keyMap KeyMap
2929 sessionID string
30− state compactState
3130 progress string
32− agent agent.Service
31+ agent agent.Coordinator
3332 noAsk bool // If true, skip confirmation dialog
3433}
3534
36−type compactState int
37−
38−const (
39− stateConfirm compactState = iota
40− stateCompacting
41− stateError
42−)
43−
4435// NewCompactDialogCmp creates a new session compact dialog
45−func NewCompactDialogCmp(agent agent.Service, sessionID string, noAsk bool) CompactDialog {
36+func NewCompactDialogCmp(agent agent.Coordinator, sessionID string, noAsk bool) CompactDialog {
4637 return &compactDialogCmp{
4738 sessionID: sessionID,
4839 keyMap: DefaultKeyMap(),
49− state: stateConfirm,
5040 selected: 0,
5141 agent: agent,
5242 noAsk: noAsk,
@@ -56,7 +46,8 @@ func NewCompactDialogCmp(agent agent.Service, sessionID string, noAsk bool) Comp
5646func (c *compactDialogCmp) Init() tea.Cmd {
5747 if c.noAsk {
5848 // If noAsk is true, skip confirmation and start compaction immediately
59− return c.startCompaction()
49+ c.agent.Summarize(context.Background(), c.sessionID)
50+ return util.CmdHandler(dialogs.CloseDialogMsg{})
6051 }
6152 return nil
6253}
@@ -68,80 +59,30 @@ func (c *compactDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6859 c.wHeight = msg.Height
6960 cmd := c.SetSize()
7061 return c, cmd
71−
7262 case tea.KeyPressMsg:
73− switch c.state {
74− case stateConfirm:
75− switch {
76− case key.Matches(msg, c.keyMap.ChangeSelection):
77− c.selected = (c.selected + 1) % 2
78− return c, nil
79− case key.Matches(msg, c.keyMap.Select):
80− if c.selected == 0 {
81− return c, c.startCompaction()
82− } else {
83− return c, util.CmdHandler(dialogs.CloseDialogMsg{})
84− }
85− case key.Matches(msg, c.keyMap.Y):
86− return c, c.startCompaction()
87− case key.Matches(msg, c.keyMap.N):
88− return c, util.CmdHandler(dialogs.CloseDialogMsg{})
89− case key.Matches(msg, c.keyMap.Close):
90− return c, util.CmdHandler(dialogs.CloseDialogMsg{})
91− }
92− case stateCompacting:
93− switch {
94− case key.Matches(msg, c.keyMap.Close):
95− return c, util.CmdHandler(dialogs.CloseDialogMsg{})
96− }
97− case stateError:
98− switch {
99− case key.Matches(msg, c.keyMap.Select):
63+ switch {
64+ case key.Matches(msg, c.keyMap.ChangeSelection):
65+ c.selected = (c.selected + 1) % 2
66+ return c, nil
67+ case key.Matches(msg, c.keyMap.Select):
68+ if c.selected == 0 {
69+ c.agent.Summarize(context.Background(), c.sessionID)
10070 return c, util.CmdHandler(dialogs.CloseDialogMsg{})
101− case key.Matches(msg, c.keyMap.Close):
102− return c, util.CmdHandler(dialogs.CloseDialogMsg{})
103− }
104− }
105−
106− case agent.AgentEvent:
107− switch msg.Type {
108− case agent.AgentEventTypeSummarize:
109− if msg.Error != nil {
110− c.state = stateError
111− c.progress = "Error: " + msg.Error.Error()
112− } else if msg.Done {
113− return c, util.CmdHandler(dialogs.CloseDialogMsg{})
114− } else {
115− c.progress = msg.Progress
116− }
117− case agent.AgentEventTypeError:
118− // Handle errors that occur during summarization but are sent as separate error events.
119− c.state = stateError
120− if msg.Error != nil {
121− c.progress = "Error: " + msg.Error.Error()
12271 } else {
123− c.progress = "An unknown error occurred"
72+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
12473 }
74+ case key.Matches(msg, c.keyMap.Y):
75+ c.agent.Summarize(context.Background(), c.sessionID)
76+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
77+ case key.Matches(msg, c.keyMap.N):
78+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
79+ case key.Matches(msg, c.keyMap.Close):
80+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
12581 }
126− return c, nil
12782 }
128−
12983 return c, nil
13084}
13185
132−func (c *compactDialogCmp) startCompaction() tea.Cmd {
133− c.state = stateCompacting
134− c.progress = "Starting summarization..."
135− return func() tea.Msg {
136− err := c.agent.Summarize(context.Background(), c.sessionID)
137− if err != nil {
138− c.state = stateError
139− c.progress = "Error: " + err.Error()
140− }
141− return nil
142− }
143−}
144−
14586func (c *compactDialogCmp) renderButtons() string {
14687 t := styles.CurrentTheme()
14788 baseStyle := t.S().Base
@@ -164,82 +105,37 @@ func (c *compactDialogCmp) renderButtons() string {
164105 return baseStyle.AlignHorizontal(lipgloss.Right).Width(c.width - 4).Render(content)
165106}
166107
167−func (c *compactDialogCmp) renderContent() string {
168− t := styles.CurrentTheme()
169− baseStyle := t.S().Base
170−
171− switch c.state {
172− case stateConfirm:
173− explanation := t.S().Text.
174− Width(c.width - 4).
175− Render("This will summarize the current session and reset the context. The conversation history will be condensed into a summary to free up context space while preserving important information.")
176−
177− question := t.S().Text.
178− Width(c.width - 4).
179− Render("Do you want to continue?")
180−
181− return baseStyle.Render(lipgloss.JoinVertical(
182− lipgloss.Left,
183− explanation,
184− "",
185− question,
186− ))
187− case stateCompacting:
188− return baseStyle.Render(lipgloss.JoinVertical(
189− lipgloss.Left,
190− c.progress,
191− "",
192− "Please wait...",
193− ))
194− case stateError:
195− return baseStyle.Render(lipgloss.JoinVertical(
196− lipgloss.Left,
197− c.progress,
198− "",
199− "Press Enter to close",
200− ))
201− }
202− return ""
203−}
204−
205108func (c *compactDialogCmp) render() string {
206109 t := styles.CurrentTheme()
207110 baseStyle := t.S().Base
208111
209− var title string
210− switch c.state {
211− case stateConfirm:
212− title = "Compact Session"
213− case stateCompacting:
214− title = "Compacting Session"
215− case stateError:
216− title = "Compact Failed"
217− }
218−
112+ title := "Compact Session"
219113 titleView := core.Title(title, c.width-4)
220− content := c.renderContent()
221−
222− var dialogContent string
223− if c.state == stateConfirm {
224− buttons := c.renderButtons()
225− dialogContent = lipgloss.JoinVertical(
226− lipgloss.Top,
227− titleView,
228− "",
229− content,
230− "",
231− buttons,
232− "",
233− )
234− } else {
235− dialogContent = lipgloss.JoinVertical(
236− lipgloss.Top,
237− titleView,
238− "",
239− content,
240− "",
241− )
242− }
114+ explanation := t.S().Text.
115+ Width(c.width - 4).
116+ Render("This will summarize the current session and reset the context. The conversation history will be condensed into a summary to free up context space while preserving important information.")
117+
118+ question := t.S().Text.
119+ Width(c.width - 4).
120+ Render("Do you want to continue?")
121+
122+ content := baseStyle.Render(lipgloss.JoinVertical(
123+ lipgloss.Left,
124+ explanation,
125+ "",
126+ question,
127+ ))
128+
129+ buttons := c.renderButtons()
130+ dialogContent := lipgloss.JoinVertical(
131+ lipgloss.Top,
132+ titleView,
133+ "",
134+ content,
135+ "",
136+ buttons,
137+ "",
138+ )
243139
244140 return baseStyle.
245141 Padding(0, 1).
internal/tui/components/dialogs/permissions/permissions.go+1−1
@@ -9,8 +9,8 @@ import (
99 "github.com/charmbracelet/bubbles/v2/key"
1010 "github.com/charmbracelet/bubbles/v2/viewport"
1111 tea "github.com/charmbracelet/bubbletea/v2"
12+ "github.com/charmbracelet/crush/internal/agent/tools"
1213 "github.com/charmbracelet/crush/internal/fsext"
13− "github.com/charmbracelet/crush/internal/llm/tools"
1414 "github.com/charmbracelet/crush/internal/permission"
1515 "github.com/charmbracelet/crush/internal/tui/components/core"
1616 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
internal/tui/components/dialogs/reasoning/reasoning.go+1−1
@@ -120,7 +120,7 @@ func (r *reasoningDialogCmp) Init() tea.Cmd {
120120
121121func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd {
122122 cfg := config.Get()
123− if agentCfg, ok := cfg.Agents["coder"]; ok {
123+ if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
124124 selectedModel := cfg.Models[agentCfg.Model]
125125 model := cfg.GetModelByType(agentCfg.Model)
126126
internal/tui/components/mcp/mcp.go+6−6
@@ -5,8 +5,8 @@ import (
55
66 "github.com/charmbracelet/lipgloss/v2"
77
8+ "github.com/charmbracelet/crush/internal/agent/tools"
89 "github.com/charmbracelet/crush/internal/config"
9− "github.com/charmbracelet/crush/internal/llm/agent"
1010 "github.com/charmbracelet/crush/internal/tui/components/core"
1111 "github.com/charmbracelet/crush/internal/tui/styles"
1212)
@@ -40,7 +40,7 @@ func RenderMCPList(opts RenderOptions) []string {
4040 }
4141
4242 // Get MCP states
43− mcpStates := agent.GetMCPStates()
43+ mcpStates := tools.GetMCPStates()
4444
4545 // Determine how many items to show
4646 maxItems := len(mcps)
@@ -60,17 +60,17 @@ func RenderMCPList(opts RenderOptions) []string {
6060
6161 if state, exists := mcpStates[l.Name]; exists {
6262 switch state.State {
63− case agent.MCPStateDisabled:
63+ case tools.MCPStateDisabled:
6464 description = t.S().Subtle.Render("disabled")
65− case agent.MCPStateStarting:
65+ case tools.MCPStateStarting:
6666 icon = t.ItemBusyIcon
6767 description = t.S().Subtle.Render("starting...")
68− case agent.MCPStateConnected:
68+ case tools.MCPStateConnected:
6969 icon = t.ItemOnlineIcon
7070 if state.ToolCount > 0 {
7171 extraContent = t.S().Subtle.Render(fmt.Sprintf("%d tools", state.ToolCount))
7272 }
73− case agent.MCPStateError:
73+ case tools.MCPStateError:
7474 icon = t.ItemErrorIcon
7575 if state.Error != nil {
7676 description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
internal/tui/page/chat/chat.go+27−21
@@ -331,7 +331,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
331331 return p, tea.Batch(cmds...)
332332
333333 case commands.CommandRunCustomMsg:
334− if p.app.CoderAgent.IsBusy() {
334+ if p.app.AgentCoordinator.IsBusy() {
335335 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
336336 }
337337
@@ -355,7 +355,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
355355 p.focusedPane = PanelTypeEditor
356356 return p, p.SetSize(p.width, p.height)
357357 case commands.NewSessionsMsg:
358− if p.app.CoderAgent.IsBusy() {
358+ if p.app.AgentCoordinator.IsBusy() {
359359 return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
360360 }
361361 return p, p.newSession()
@@ -363,15 +363,15 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
363363 switch {
364364 case key.Matches(msg, p.keyMap.NewSession):
365365 // if we have no agent do nothing
366− if p.app.CoderAgent == nil {
366+ if p.app.AgentCoordinator == nil {
367367 return p, nil
368368 }
369− if p.app.CoderAgent.IsBusy() {
369+ if p.app.AgentCoordinator.IsBusy() {
370370 return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
371371 }
372372 return p, p.newSession()
373373 case key.Matches(msg, p.keyMap.AddAttachment):
374− agentCfg := config.Get().Agents["coder"]
374+ agentCfg := config.Get().Agents[config.AgentCoder]
375375 model := config.Get().GetModelByType(agentCfg.Model)
376376 if model.SupportsImages {
377377 return p, util.CmdHandler(commands.OpenFilePickerMsg{})
@@ -387,7 +387,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
387387 p.changeFocus()
388388 return p, nil
389389 case key.Matches(msg, p.keyMap.Cancel):
390− if p.session.ID != "" && p.app.CoderAgent.IsBusy() {
390+ if p.session.ID != "" && p.app.AgentCoordinator.IsBusy() {
391391 return p, p.cancel()
392392 }
393393 case key.Matches(msg, p.keyMap.Details):
@@ -530,7 +530,7 @@ func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
530530func (p *chatPage) toggleThinking() tea.Cmd {
531531 return func() tea.Msg {
532532 cfg := config.Get()
533− agentCfg := cfg.Agents["coder"]
533+ agentCfg := cfg.Agents[config.AgentCoder]
534534 currentModel := cfg.Models[agentCfg.Model]
535535
536536 // Toggle the thinking mode
@@ -559,7 +559,7 @@ func (p *chatPage) toggleThinking() tea.Cmd {
559559func (p *chatPage) openReasoningDialog() tea.Cmd {
560560 return func() tea.Msg {
561561 cfg := config.Get()
562− agentCfg := cfg.Agents["coder"]
562+ agentCfg := cfg.Agents[config.AgentCoder]
563563 model := cfg.GetModelByType(agentCfg.Model)
564564 providerCfg := cfg.GetProviderForModel(agentCfg.Model)
565565
@@ -577,7 +577,7 @@ func (p *chatPage) openReasoningDialog() tea.Cmd {
577577func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
578578 return func() tea.Msg {
579579 cfg := config.Get()
580− agentCfg := cfg.Agents["coder"]
580+ agentCfg := cfg.Agents[config.AgentCoder]
581581 currentModel := cfg.Models[agentCfg.Model]
582582
583583 // Update the model configuration
@@ -706,14 +706,14 @@ func (p *chatPage) changeFocus() {
706706func (p *chatPage) cancel() tea.Cmd {
707707 if p.isCanceling {
708708 p.isCanceling = false
709− if p.app.CoderAgent != nil {
710− p.app.CoderAgent.Cancel(p.session.ID)
709+ if p.app.AgentCoordinator != nil {
710+ p.app.AgentCoordinator.Cancel(p.session.ID)
711711 }
712712 return nil
713713 }
714714
715− if p.app.CoderAgent != nil && p.app.CoderAgent.QueuedPrompts(p.session.ID) > 0 {
716− p.app.CoderAgent.ClearQueue(p.session.ID)
715+ if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
716+ p.app.AgentCoordinator.ClearQueue(p.session.ID)
717717 return nil
718718 }
719719 p.isCanceling = true
@@ -746,14 +746,20 @@ func (p *chatPage) sendMessage(text string, attachments []message.Attachment) te
746746 session = newSession
747747 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
748748 }
749− if p.app.CoderAgent == nil {
749+ if p.app.AgentCoordinator == nil {
750750 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
751751 }
752− _, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
753− if err != nil {
754− return util.ReportError(err)
755− }
756752 cmds = append(cmds, p.chat.GoToBottom())
753+ cmds = append(cmds, func() tea.Msg {
754+ _, err := p.app.AgentCoordinator.Run(context.Background(), session.ID, text, attachments...)
755+ if err != nil {
756+ return util.InfoMsg{
757+ Type: util.InfoTypeError,
758+ Msg: err.Error(),
759+ }
760+ }
761+ return nil
762+ })
757763 return tea.Batch(cmds...)
758764}
759765
@@ -762,7 +768,7 @@ func (p *chatPage) Bindings() []key.Binding {
762768 p.keyMap.NewSession,
763769 p.keyMap.AddAttachment,
764770 }
765− if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
771+ if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
766772 cancelBinding := p.keyMap.Cancel
767773 if p.isCanceling {
768774 cancelBinding = key.NewBinding(
@@ -883,7 +889,7 @@ func (p *chatPage) Help() help.KeyMap {
883889 }
884890 return core.NewSimpleHelp(shortList, fullList)
885891 }
886− if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
892+ if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
887893 cancelBinding := key.NewBinding(
888894 key.WithKeys("esc", "alt+esc"),
889895 key.WithHelp("esc", "cancel"),
@@ -894,7 +900,7 @@ func (p *chatPage) Help() help.KeyMap {
894900 key.WithHelp("esc", "press again to cancel"),
895901 )
896902 }
897− if p.app.CoderAgent != nil && p.app.CoderAgent.QueuedPrompts(p.session.ID) > 0 {
903+ if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
898904 cancelBinding = key.NewBinding(
899905 key.WithKeys("esc", "alt+esc"),
900906 key.WithHelp("esc", "clear queue"),
internal/tui/tui.go+35−35
@@ -11,7 +11,6 @@ import (
1111 "github.com/charmbracelet/crush/internal/app"
1212 "github.com/charmbracelet/crush/internal/config"
1313 "github.com/charmbracelet/crush/internal/event"
14− "github.com/charmbracelet/crush/internal/llm/agent"
1514 "github.com/charmbracelet/crush/internal/permission"
1615 "github.com/charmbracelet/crush/internal/pubsub"
1716 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
@@ -180,7 +179,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
180179 // Compact
181180 case commands.CompactMsg:
182181 return a, util.CmdHandler(dialogs.OpenDialogMsg{
183− Model: compact.NewCompactDialogCmp(a.app.CoderAgent, msg.SessionID, true),
182+ Model: compact.NewCompactDialogCmp(a.app.AgentCoordinator, msg.SessionID, true),
184183 })
185184 case commands.QuitMsg:
186185 return a, util.CmdHandler(dialogs.OpenDialogMsg{
@@ -194,7 +193,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
194193 return a, a.handleWindowResize(a.wWidth, a.wHeight)
195194 // Model Switch
196195 case models.ModelSelectedMsg:
197− if a.app.CoderAgent.IsBusy() {
196+ if a.app.AgentCoordinator.IsBusy() {
198197 return a, util.ReportWarn("Agent is busy, please wait...")
199198 }
200199
@@ -253,36 +252,37 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
253252 }
254253 return a, nil
255254 // Agent Events
256− case pubsub.Event[agent.AgentEvent]:
257− payload := msg.Payload
258−
259− // Forward agent events to dialogs
260− if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() == compact.CompactDialogID {
261− u, dialogCmd := a.dialog.Update(payload)
262− if model, ok := u.(dialogs.DialogCmp); ok {
263− a.dialog = model
264− }
265−
266− cmds = append(cmds, dialogCmd)
267− }
268−
269− // Handle auto-compact logic
270− if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSessionID != "" {
271− // Get current session to check token usage
272− session, err := a.app.Sessions.Get(context.Background(), a.selectedSessionID)
273− if err == nil {
274− model := a.app.CoderAgent.Model()
275− contextWindow := model.ContextWindow
276− tokens := session.CompletionTokens + session.PromptTokens
277− if (tokens >= int64(float64(contextWindow)*0.95)) && !config.Get().Options.DisableAutoSummarize { // Show compact confirmation dialog
278− cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
279− Model: compact.NewCompactDialogCmp(a.app.CoderAgent, a.selectedSessionID, false),
280− }))
281− }
282− }
283− }
284−
285− return a, tea.Batch(cmds...)
255+ // TODO: HANDLE AUTO COMPACT
256+ // case pubsub.Event[agent.AgentEvent]:
257+ // payload := msg.Payload
258+ //
259+ // // Forward agent events to dialogs
260+ // if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() == compact.CompactDialogID {
261+ // u, dialogCmd := a.dialog.Update(payload)
262+ // if model, ok := u.(dialogs.DialogCmp); ok {
263+ // a.dialog = model
264+ // }
265+ //
266+ // cmds = append(cmds, dialogCmd)
267+ // }
268+ //
269+ // // Handle auto-compact logic
270+ // if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSessionID != "" {
271+ // // Get current session to check token usage
272+ // session, err := a.app.Sessions.Get(context.Background(), a.selectedSessionID)
273+ // if err == nil {
274+ // model := a.app.AgentCoordinator.Model()
275+ // contextWindow := model.CatwalkCfg.ContextWindow
276+ // tokens := session.CompletionTokens + session.PromptTokens
277+ // if (tokens >= int64(float64(contextWindow)*0.95)) && !config.Get().Options.DisableAutoSummarize { // Show compact confirmation dialog
278+ // cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
279+ // Model: compact.NewCompactDialogCmp(a.app.AgentCoordinator, a.selectedSessionID, false),
280+ // }))
281+ // }
282+ // }
283+ // }
284+ //
285+ // return a, tea.Batch(cmds...)
286286 case splash.OnboardingCompleteMsg:
287287 item, ok := a.pages[a.currentPage]
288288 if !ok {
@@ -484,7 +484,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
484484 )
485485 return tea.Sequence(cmds...)
486486 case key.Matches(msg, a.keyMap.Suspend):
487− if a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
487+ if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
488488 return util.ReportWarn("Agent is busy, please wait...")
489489 }
490490 return tea.Suspend
@@ -504,7 +504,7 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
504504
505505// moveToPage handles navigation between different pages in the application.
506506func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
507− if a.app.CoderAgent.IsBusy() {
507+ if a.app.AgentCoordinator.IsBusy() {
508508 // TODO: maybe remove this : For now we don't move to any page if the agent is busy
509509 return util.ReportWarn("Agent is busy, please wait...")
510510 }