diff --git a/go.mod b/go.mod index 020e5eb1f0768b842b773e57255e72755e3c7351..1e3fdb41d6a219ff617c4cb7888af0c0f4ddf7c4 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/charmbracelet/crush go 1.25.0 require ( + charm.land/fantasy v0.0.0 github.com/JohannesKaufmann/html-to-markdown v1.6.0 github.com/MakeNowJust/heredoc v1.0.0 github.com/PuerkitoBio/goquery v1.10.3 @@ -15,7 +16,6 @@ require ( github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20251011205917-3b687ffc1619 github.com/charmbracelet/catwalk v0.6.5-0.20251015124117-3bfe9eb6d20f github.com/charmbracelet/fang v0.4.3 - github.com/charmbracelet/fantasy v0.0.0-20251015132833-5bc4cc524d70 github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 @@ -47,6 +47,8 @@ require ( mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 ) +replace charm.land/fantasy => ../../fantasy/main/ + require ( cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.12.1 // indirect @@ -56,6 +58,20 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/anthropics/anthropic-sdk-go v1.12.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect + github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.27 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect + github.com/aws/smithy-go v1.20.3 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect diff --git a/go.sum b/go.sum index 6dcdae5849b1ddc7d5edfe4184dcb2793dbfda07..99cf9b5f3dd573835f198edb9182fef4612f494c 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,34 @@ github.com/anthropics/anthropic-sdk-go v1.12.0 h1:xPqlGnq7rWrTiHazIvCiumA0u7mGQn github.com/anthropics/anthropic-sdk-go v1.12.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= +github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM= +github.com/aws/aws-sdk-go-v2/config v1.27.27 h1:HdqgGt1OAP0HkEDDShEl0oSYa9ZZBSOmKpdpsDMdO90= +github.com/aws/aws-sdk-go-v2/config v1.27.27/go.mod h1:MVYamCg76dFNINkZFu4n4RjDixhVr51HLj4ErWzrVwg= +github.com/aws/aws-sdk-go-v2/credentials v1.17.27 h1:2raNba6gr2IfA0eqqiP2XiQ0UVOpGPgDSi0I9iAP+UI= +github.com/aws/aws-sdk-go-v2/credentials v1.17.27/go.mod h1:gniiwbGahQByxan6YjQUMcW4Aov6bLC3m+evgcoN4r4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 h1:KreluoV8FZDEtI6Co2xuNk/UqI9iwMrOx/87PBNIKqw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 h1:HGErhhrxZlQ044RiM+WdoZxp0p+EGM62y3L6pwA4olE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 h1:BXx0ZIxvrJdSgSvKTZ+yRBeSqqgPM89VPlulEcl37tM= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.4/go.mod h1:ooyCOXjvJEsUw7x+ZDHeISPMhtwI3ZCB7ggFMcFfWLU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 h1:yiwVzJW2ZxZTurVbYWA7QOrAaCYQR72t0wrSBfoesUE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudrvuKpDKgMVRlepGE= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ= +github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE= +github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= @@ -61,8 +89,6 @@ github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqI github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= github.com/charmbracelet/fang v0.4.3 h1:qXeMxnL4H6mSKBUhDefHu8NfikFbP/MBNTfqTrXvzmY= github.com/charmbracelet/fang v0.4.3/go.mod h1:wHJKQYO5ReYsxx+yZl+skDtrlKO/4LLEQ6EXsdHhRhg= -github.com/charmbracelet/fantasy v0.0.0-20251015132833-5bc4cc524d70 h1:zVfEkMs1ncdUVQic9cGHHoZYMR0QNsmQ+gz8+LBo4cw= -github.com/charmbracelet/fantasy v0.0.0-20251015132833-5bc4cc524d70/go.mod h1:FugQt16qw07BjrmjHzu+l6BtlUnZgTcX/8qHyg4D/yQ= github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 h1:PU4Zvpagsk5sgaDxn5W4sxHuLp9QRMBZB3bFSk40A4w= github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018/go.mod h1:Z/GLmp9fzaqX4ze3nXG7StgWez5uBM5XtlLHK8V/qSk= github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea h1:g1HfUgSMvye8mgecMD1mPscpt+pzJoDEiSA+p2QXzdQ= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index a90e3c6038de9d677d84ecc90bafae3ff2e756c9..9a237954179f83e9a60295162f3225b98a0cdbe3 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -10,6 +10,8 @@ import ( "sync" "time" + "charm.land/fantasy" + "charm.land/fantasy/providers/anthropic" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/config" @@ -17,8 +19,6 @@ import ( "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/fantasy/ai" - "github.com/charmbracelet/fantasy/anthropic" ) //go:embed templates/title.md @@ -30,7 +30,7 @@ var summaryPrompt []byte type SessionAgentCall struct { SessionID string Prompt string - ProviderOptions ai.ProviderOptions + ProviderOptions fantasy.ProviderOptions Attachments []message.Attachment MaxOutputTokens int64 Temperature *float64 @@ -41,9 +41,9 @@ type SessionAgentCall struct { } type SessionAgent interface { - Run(context.Context, SessionAgentCall) (*ai.AgentResult, error) + Run(context.Context, SessionAgentCall) (*fantasy.AgentResult, error) SetModels(large Model, small Model) - SetTools(tools []ai.AgentTool) + SetTools(tools []fantasy.AgentTool) Cancel(sessionID string) CancelAll() IsSessionBusy(sessionID string) bool @@ -55,7 +55,7 @@ type SessionAgent interface { } type Model struct { - Model ai.LanguageModel + Model fantasy.LanguageModel CatwalkCfg catwalk.Model ModelCfg config.SelectedModel } @@ -64,7 +64,7 @@ type sessionAgent struct { largeModel Model smallModel Model systemPrompt string - tools []ai.AgentTool + tools []fantasy.AgentTool sessions session.Service messages message.Service disableAutoSummarize bool @@ -80,7 +80,7 @@ type SessionAgentOptions struct { DisableAutoSummarize bool Sessions session.Service Messages message.Service - Tools []ai.AgentTool + Tools []fantasy.AgentTool } func NewSessionAgent( @@ -99,7 +99,7 @@ func NewSessionAgent( } } -func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*ai.AgentResult, error) { +func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) { if call.Prompt == "" { return nil, ErrEmptyPrompt } @@ -123,10 +123,10 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*ai.Agen a.tools[len(a.tools)-1].SetProviderOptions(a.getCacheControlOptions()) } - agent := ai.NewAgent( + agent := fantasy.NewAgent( a.largeModel.Model, - ai.WithSystemPrompt(a.systemPrompt), - ai.WithTools(a.tools...), + fantasy.WithSystemPrompt(a.systemPrompt), + fantasy.WithTools(a.tools...), ) sessionLock := sync.Mutex{} @@ -169,7 +169,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*ai.Agen var currentAssistant *message.Message var shouldSummarize bool - result, err := agent.Stream(genCtx, ai.AgentStreamCall{ + result, err := agent.Stream(genCtx, fantasy.AgentStreamCall{ Prompt: call.Prompt, Files: files, Messages: history, @@ -181,7 +181,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*ai.Agen TopK: call.TopK, FrequencyPenalty: call.FrequencyPenalty, // Before each step create the new assistant message - PrepareStep: func(callContext context.Context, options ai.PrepareStepFunctionOptions) (_ context.Context, prepared ai.PrepareStepResult, err error) { + PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) { var assistantMsg message.Message assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{ Role: message.Assistant, @@ -217,7 +217,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*ai.Agen systemMessageUpdated := false for i, msg := range prepared.Messages { // only add cache control to the last message - if msg.Role == ai.MessageRoleSystem { + if msg.Role == fantasy.MessageRoleSystem { lastSystemRoleInx = i } else if !systemMessageUpdated { prepared.Messages[lastSystemRoleInx].ProviderOptions = a.getCacheControlOptions() @@ -234,7 +234,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*ai.Agen currentAssistant.AppendReasoningContent(text) return a.messages.Update(genCtx, *currentAssistant) }, - OnReasoningEnd: func(id string, reasoning ai.ReasoningContent) error { + OnReasoningEnd: func(id string, reasoning fantasy.ReasoningContent) error { // handle anthropic signature if anthropicData, ok := reasoning.ProviderMetadata[anthropic.Name]; ok { if reasoning, ok := anthropicData.(*anthropic.ReasoningOptionMetadata); ok { @@ -258,10 +258,10 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*ai.Agen currentAssistant.AddToolCall(toolCall) return a.messages.Update(genCtx, *currentAssistant) }, - OnRetry: func(err *ai.APICallError, delay time.Duration) { + OnRetry: func(err *fantasy.APICallError, delay time.Duration) { // TODO: implement }, - OnToolCall: func(tc ai.ToolCallContent) error { + OnToolCall: func(tc fantasy.ToolCallContent) error { toolCall := message.ToolCall{ ID: tc.ToolCallID, Name: tc.ToolName, @@ -272,22 +272,22 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*ai.Agen currentAssistant.AddToolCall(toolCall) return a.messages.Update(genCtx, *currentAssistant) }, - OnToolResult: func(result ai.ToolResultContent) error { + OnToolResult: func(result fantasy.ToolResultContent) error { var resultContent string isError := false switch result.Result.GetType() { - case ai.ToolResultContentTypeText: - r, ok := ai.AsToolResultOutputType[ai.ToolResultOutputContentText](result.Result) + case fantasy.ToolResultContentTypeText: + r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](result.Result) if ok { resultContent = r.Text } - case ai.ToolResultContentTypeError: - r, ok := ai.AsToolResultOutputType[ai.ToolResultOutputContentError](result.Result) + case fantasy.ToolResultContentTypeError: + r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](result.Result) if ok { isError = true resultContent = r.Error.Error() } - case ai.ToolResultContentTypeMedia: + case fantasy.ToolResultContentTypeMedia: // TODO: handle this message type } toolResult := message.ToolResult{ @@ -308,14 +308,14 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*ai.Agen } return nil }, - OnStepFinish: func(stepResult ai.StepResult) error { + OnStepFinish: func(stepResult fantasy.StepResult) error { finishReason := message.FinishReasonUnknown switch stepResult.FinishReason { - case ai.FinishReasonLength: + case fantasy.FinishReasonLength: finishReason = message.FinishReasonMaxTokens - case ai.FinishReasonStop: + case fantasy.FinishReasonStop: finishReason = message.FinishReasonEndTurn - case ai.FinishReasonToolCalls: + case fantasy.FinishReasonToolCalls: finishReason = message.FinishReasonToolUse } currentAssistant.AddFinish(finishReason, "", "") @@ -328,8 +328,8 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*ai.Agen } return a.messages.Update(genCtx, *currentAssistant) }, - StopWhen: []ai.StopCondition{ - func(_ []ai.StepResult) bool { + StopWhen: []fantasy.StopCondition{ + func(_ []fantasy.StepResult) bool { contextWindow := a.largeModel.CatwalkCfg.ContextWindow tokens := currentSession.CompletionTokens + currentSession.PromptTokens percentage := (float64(tokens) / float64(contextWindow)) * 100 @@ -461,8 +461,8 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string) error { defer a.activeRequests.Del(sessionID) defer cancel() - agent := ai.NewAgent(a.largeModel.Model, - ai.WithSystemPrompt(string(summaryPrompt)), + agent := fantasy.NewAgent(a.largeModel.Model, + fantasy.WithSystemPrompt(string(summaryPrompt)), ) summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{ Role: message.Assistant, @@ -474,14 +474,14 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string) error { return err } - resp, err := agent.Stream(genCtx, ai.AgentStreamCall{ + resp, err := agent.Stream(genCtx, fantasy.AgentStreamCall{ Prompt: "Provide a detailed summary of our conversation above.", Messages: aiMsgs, OnReasoningDelta: func(id string, text string) error { summaryMessage.AppendReasoningContent(text) return a.messages.Update(genCtx, summaryMessage) }, - OnReasoningEnd: func(id string, reasoning ai.ReasoningContent) error { + OnReasoningEnd: func(id string, reasoning fantasy.ReasoningContent) error { // handle anthropic signature if anthropicData, ok := reasoning.ProviderMetadata["anthropic"]; ok { if signature, ok := anthropicData.(*anthropic.ReasoningOptionMetadata); ok && signature.Signature != "" { @@ -523,8 +523,8 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string) error { return err } -func (a *sessionAgent) getCacheControlOptions() ai.ProviderOptions { - return ai.ProviderOptions{ +func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions { + return fantasy.ProviderOptions{ anthropic.Name: &anthropic.ProviderCacheControlOptions{ CacheControl: anthropic.CacheControl{Type: "ephemeral"}, }, @@ -548,8 +548,8 @@ func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentC return msg, nil } -func (a *sessionAgent) preparePrompt(msgs []message.Message, attachments ...message.Attachment) ([]ai.Message, []ai.FilePart) { - var history []ai.Message +func (a *sessionAgent) preparePrompt(msgs []message.Message, attachments ...message.Attachment) ([]fantasy.Message, []fantasy.FilePart) { + var history []fantasy.Message for _, m := range msgs { if len(m.Parts) == 0 { continue @@ -561,9 +561,9 @@ func (a *sessionAgent) preparePrompt(msgs []message.Message, attachments ...mess history = append(history, m.ToAIMessage()...) } - var files []ai.FilePart + var files []fantasy.FilePart for _, attachment := range attachments { - files = append(files, ai.FilePart{ + files = append(files, fantasy.FilePart{ Filename: attachment.FileName, Data: attachment.Content, MediaType: attachment.MimeType, @@ -605,12 +605,12 @@ func (a *sessionAgent) generateTitle(ctx context.Context, session *session.Sessi maxOutput = a.smallModel.CatwalkCfg.DefaultMaxTokens } - agent := ai.NewAgent(a.smallModel.Model, - ai.WithSystemPrompt(string(titlePrompt)+"\n /no_think"), - ai.WithMaxOutputTokens(maxOutput), + agent := fantasy.NewAgent(a.smallModel.Model, + fantasy.WithSystemPrompt(string(titlePrompt)+"\n /no_think"), + fantasy.WithMaxOutputTokens(maxOutput), ) - resp, err := agent.Stream(ctx, ai.AgentStreamCall{ + resp, err := agent.Stream(ctx, fantasy.AgentStreamCall{ Prompt: fmt.Sprintf("Generate a concise title for the following content:\n\n%s\n \n\n", prompt), }) if err != nil { @@ -642,7 +642,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, session *session.Sessi } } -func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage ai.Usage) { +func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage) { modelConfig := model.CatwalkCfg cost := modelConfig.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) + modelConfig.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) + @@ -727,7 +727,7 @@ func (a *sessionAgent) SetModels(large Model, small Model) { a.smallModel = small } -func (a *sessionAgent) SetTools(tools []ai.AgentTool) { +func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) { a.tools = tools } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 9eedec274c1fa86181476bed95276de661acb8fe..db691c9c7d5761d84fddc6a2a2cfcb7039c1707d 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -6,10 +6,10 @@ import ( "strings" "testing" + "charm.land/fantasy" "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/shell" - "github.com/charmbracelet/fantasy/ai" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/dnaeon/go-vcr.v4/pkg/recorder" @@ -24,7 +24,7 @@ var modelPairs = []modelPair{ {"zai-glm4.6", zAIBuilder("glm-4.6"), zAIBuilder("glm-4.5-air")}, } -func getModels(t *testing.T, r *recorder.Recorder, pair modelPair) (ai.LanguageModel, ai.LanguageModel) { +func getModels(t *testing.T, r *recorder.Recorder, pair modelPair) (fantasy.LanguageModel, fantasy.LanguageModel) { large, err := pair.largeModel(t, r) require.NoError(t, err) small, err := pair.smallModel(t, r) diff --git a/internal/agent/agent_tool.go b/internal/agent/agent_tool.go index 5ffe3d6f4c3b5e56330bff8739d815191ec2dd8c..03a2f0c8c8cfa53eafae47c2fcffc2b1fc36886e 100644 --- a/internal/agent/agent_tool.go +++ b/internal/agent/agent_tool.go @@ -7,7 +7,7 @@ import ( "errors" "fmt" - "github.com/charmbracelet/fantasy/ai" + "charm.land/fantasy" "github.com/charmbracelet/crush/internal/agent/prompt" "github.com/charmbracelet/crush/internal/agent/tools" @@ -25,7 +25,7 @@ const ( AgentToolName = "agent" ) -func (c *coordinator) agentTool() (ai.AgentTool, error) { +func (c *coordinator) agentTool() (fantasy.AgentTool, error) { agentCfg, ok := c.cfg.Agents[config.AgentTask] if !ok { return nil, errors.New("task agent not configured") @@ -39,31 +39,31 @@ func (c *coordinator) agentTool() (ai.AgentTool, error) { if err != nil { return nil, err } - return ai.NewAgentTool( + return fantasy.NewAgentTool( AgentToolName, string(agentToolDescription), - func(ctx context.Context, params AgentParams, call ai.ToolCall) (ai.ToolResponse, error) { + func(ctx context.Context, params AgentParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil { - return ai.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil } if params.Prompt == "" { - return ai.NewTextErrorResponse("prompt is required"), nil + return fantasy.NewTextErrorResponse("prompt is required"), nil } sessionID := tools.GetSessionFromContext(ctx) if sessionID == "" { - return ai.ToolResponse{}, errors.New("session id missing from context") + return fantasy.ToolResponse{}, errors.New("session id missing from context") } agentMessageID := tools.GetMessageFromContext(ctx) if agentMessageID == "" { - return ai.ToolResponse{}, errors.New("agent message id missing from context") + return fantasy.ToolResponse{}, errors.New("agent message id missing from context") } agentToolSessionID := c.sessions.CreateAgentToolSessionID(agentMessageID, call.ID) session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, sessionID, "New Agent Session") if err != nil { - return ai.ToolResponse{}, fmt.Errorf("error creating session: %s", err) + return fantasy.ToolResponse{}, fmt.Errorf("error creating session: %s", err) } model := agent.Model() maxTokens := model.CatwalkCfg.DefaultMaxTokens @@ -73,7 +73,7 @@ func (c *coordinator) agentTool() (ai.AgentTool, error) { providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider) if !ok { - return ai.ToolResponse{}, errors.New("model provider not configured") + return fantasy.ToolResponse{}, errors.New("model provider not configured") } result, err := agent.Run(ctx, SessionAgentCall{ SessionID: session.ID, @@ -87,23 +87,23 @@ func (c *coordinator) agentTool() (ai.AgentTool, error) { PresencePenalty: model.ModelCfg.PresencePenalty, }) if err != nil { - return ai.NewTextErrorResponse("error generating response"), nil + return fantasy.NewTextErrorResponse("error generating response"), nil } updatedSession, err := c.sessions.Get(ctx, session.ID) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("error getting session: %s", err) + return fantasy.ToolResponse{}, fmt.Errorf("error getting session: %s", err) } parentSession, err := c.sessions.Get(ctx, sessionID) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err) + return fantasy.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err) } parentSession.Cost += updatedSession.Cost _, err = c.sessions.Save(ctx, parentSession) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err) + return fantasy.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err) } - return ai.NewTextResponse(result.Response.Content.Text()), nil + return fantasy.NewTextResponse(result.Response.Content.Text()), nil }), nil } diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index b6769acb458bd17641a7a8a7df20bfb6b56af4f7..0c1b34a6c88543862bea6550198cfb6b05b5c6c9 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -7,6 +7,11 @@ import ( "testing" "time" + "charm.land/fantasy" + "charm.land/fantasy/providers/anthropic" + "charm.land/fantasy/providers/openai" + "charm.land/fantasy/providers/openaicompat" + "charm.land/fantasy/providers/openrouter" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent/prompt" "github.com/charmbracelet/crush/internal/agent/tools" @@ -18,11 +23,6 @@ import ( "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/fantasy/ai" - "github.com/charmbracelet/fantasy/anthropic" - "github.com/charmbracelet/fantasy/openai" - "github.com/charmbracelet/fantasy/openaicompat" - "github.com/charmbracelet/fantasy/openrouter" "github.com/stretchr/testify/require" "gopkg.in/dnaeon/go-vcr.v4/pkg/recorder" @@ -38,7 +38,7 @@ type env struct { lspClients *csync.Map[string, *lsp.Client] } -type builderFunc func(t *testing.T, r *recorder.Recorder) (ai.LanguageModel, error) +type builderFunc func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) type modelPair struct { name string @@ -47,7 +47,7 @@ type modelPair struct { } func anthropicBuilder(model string) builderFunc { - return func(_ *testing.T, r *recorder.Recorder) (ai.LanguageModel, error) { + return func(_ *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) { provider := anthropic.New( anthropic.WithAPIKey(os.Getenv("CRUSH_ANTHROPIC_API_KEY")), anthropic.WithHTTPClient(&http.Client{Transport: r}), @@ -57,7 +57,7 @@ func anthropicBuilder(model string) builderFunc { } func openaiBuilder(model string) builderFunc { - return func(_ *testing.T, r *recorder.Recorder) (ai.LanguageModel, error) { + return func(_ *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) { provider := openai.New( openai.WithAPIKey(os.Getenv("CRUSH_OPENAI_API_KEY")), openai.WithHTTPClient(&http.Client{Transport: r}), @@ -67,7 +67,7 @@ func openaiBuilder(model string) builderFunc { } func openRouterBuilder(model string) builderFunc { - return func(t *testing.T, r *recorder.Recorder) (ai.LanguageModel, error) { + return func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) { provider := openrouter.New( openrouter.WithAPIKey(os.Getenv("CRUSH_OPENROUTER_API_KEY")), openrouter.WithHTTPClient(&http.Client{Transport: r}), @@ -77,7 +77,7 @@ func openRouterBuilder(model string) builderFunc { } func zAIBuilder(model string) builderFunc { - return func(t *testing.T, r *recorder.Recorder) (ai.LanguageModel, error) { + return func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) { provider := openaicompat.New( openaicompat.WithBaseURL("https://api.z.ai/api/coding/paas/v4"), openaicompat.WithAPIKey(os.Getenv("CRUSH_ZAI_API_KEY")), @@ -114,7 +114,7 @@ func testEnv(t *testing.T) env { } } -func testSessionAgent(env env, large, small ai.LanguageModel, systemPrompt string, tools ...ai.AgentTool) SessionAgent { +func testSessionAgent(env env, large, small fantasy.LanguageModel, systemPrompt string, tools ...fantasy.AgentTool) SessionAgent { largeModel := Model{ Model: large, CatwalkCfg: catwalk.Model{ @@ -133,7 +133,7 @@ func testSessionAgent(env env, large, small ai.LanguageModel, systemPrompt strin return agent } -func coderAgent(r *recorder.Recorder, env env, large, small ai.LanguageModel) (SessionAgent, error) { +func coderAgent(r *recorder.Recorder, env env, large, small fantasy.LanguageModel) (SessionAgent, error) { fixedTime := func() time.Time { t, _ := time.Parse("1/2/2006", "1/1/2025") return t @@ -155,7 +155,7 @@ func coderAgent(r *recorder.Recorder, env env, large, small ai.LanguageModel) (S if err != nil { return nil, err } - allTools := []ai.AgentTool{ + allTools := []fantasy.AgentTool{ tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution), tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()), tools.NewEditTool(env.lspClients, env.permissions, env.history, env.workingDir), diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 54ba4f44bb38265f7c5d5c7f1fd60a73441717fc..241d90d813d150cc3632e8731b83ed6a4ab6fa9b 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -11,6 +11,7 @@ import ( "slices" "strings" + "charm.land/fantasy" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent/prompt" "github.com/charmbracelet/crush/internal/agent/tools" @@ -22,20 +23,20 @@ import ( "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/fantasy/ai" - "github.com/charmbracelet/fantasy/anthropic" - "github.com/charmbracelet/fantasy/azure" - "github.com/charmbracelet/fantasy/google" - "github.com/charmbracelet/fantasy/openai" - "github.com/charmbracelet/fantasy/openaicompat" - "github.com/charmbracelet/fantasy/openrouter" + + "charm.land/fantasy/providers/anthropic" + "charm.land/fantasy/providers/azure" + "charm.land/fantasy/providers/google" + "charm.land/fantasy/providers/openai" + "charm.land/fantasy/providers/openaicompat" + "charm.land/fantasy/providers/openrouter" "github.com/qjebbs/go-jsons" ) type Coordinator interface { // INFO: (kujtim) this is not used yet we will use this when we have multiple agents // SetMainAgent(string) - Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*ai.AgentResult, error) + Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) Cancel(sessionID string) CancelAll() IsSessionBusy(sessionID string) bool @@ -98,7 +99,7 @@ func NewCoordinator( } // Run implements Coordinator. -func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*ai.AgentResult, error) { +func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) { model := c.currentAgent.Model() maxTokens := model.CatwalkCfg.DefaultMaxTokens if model.ModelCfg.MaxTokens != 0 { @@ -130,8 +131,8 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, }) } -func getProviderOptions(model Model, tp catwalk.Type) ai.ProviderOptions { - options := ai.ProviderOptions{} +func getProviderOptions(model Model, tp catwalk.Type) fantasy.ProviderOptions { + options := fantasy.ProviderOptions{} cfgOpts := []byte("{}") catwalkOpts := []byte("{}") @@ -233,7 +234,7 @@ func getProviderOptions(model Model, tp catwalk.Type) ai.ProviderOptions { return options } -func mergeCallOptions(model Model, tp catwalk.Type) (ai.ProviderOptions, *float64, *float64, *int64, *float64, *float64) { +func mergeCallOptions(model Model, tp catwalk.Type) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) { modelOptions := getProviderOptions(model, tp) temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature) topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP) @@ -261,8 +262,8 @@ func (c *coordinator) buildAgent(prompt *prompt.Prompt, agent config.Agent) (Ses return NewSessionAgent(SessionAgentOptions{large, small, systemPrompt, c.cfg.Options.DisableAutoSummarize, c.sessions, c.messages, tools}), nil } -func (c *coordinator) buildTools(agent config.Agent) ([]ai.AgentTool, error) { - var allTools []ai.AgentTool +func (c *coordinator) buildTools(agent config.Agent) ([]fantasy.AgentTool, error) { + var allTools []fantasy.AgentTool if slices.Contains(agent.AllowedTools, AgentToolName) { agentTool, err := c.agentTool() if err != nil { @@ -285,7 +286,7 @@ func (c *coordinator) buildTools(agent config.Agent) ([]ai.AgentTool, error) { tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()), ) - var filteredTools []ai.AgentTool + var filteredTools []fantasy.AgentTool for _, tool := range allTools { if slices.Contains(agent.AllowedTools, tool.Info().Name) { filteredTools = append(filteredTools, tool) @@ -394,7 +395,7 @@ func (c *coordinator) buildAgentModels() (Model, Model, error) { }, nil } -func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) ai.Provider { +func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string) fantasy.Provider { hasBearerAuth := false for key := range headers { if strings.ToLower(key) == "authorization" { @@ -429,7 +430,7 @@ func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map return anthropic.New(opts...) } -func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) ai.Provider { +func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string) fantasy.Provider { opts := []openai.Option{ openai.WithAPIKey(apiKey), } @@ -446,7 +447,7 @@ func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[st return openai.New(opts...) } -func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) ai.Provider { +func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) fantasy.Provider { opts := []openrouter.Option{ openrouter.WithAPIKey(apiKey), } @@ -460,7 +461,7 @@ func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[stri return openrouter.New(opts...) } -func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string) ai.Provider { +func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string) fantasy.Provider { opts := []openaicompat.Option{ openaicompat.WithBaseURL(baseURL), openaicompat.WithAPIKey(apiKey), @@ -476,7 +477,7 @@ func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers return openaicompat.New(opts...) } -func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) ai.Provider { +func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) fantasy.Provider { opts := []azure.Option{ azure.WithBaseURL(baseURL), azure.WithAPIKey(apiKey), @@ -498,7 +499,7 @@ func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[str return azure.New(opts...) } -func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) ai.Provider { +func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) fantasy.Provider { opts := []google.Option{ google.WithBaseURL(baseURL), google.WithGeminiAPIKey(apiKey), @@ -513,7 +514,7 @@ func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[st return google.New(opts...) } -func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) ai.Provider { +func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) fantasy.Provider { opts := []google.Option{} if c.cfg.Options.Debug { httpClient := log.NewHTTPClient() @@ -550,7 +551,7 @@ func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool { return false } -func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (ai.Provider, error) { +func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) { headers := providerCfg.ExtraHeaders // handle special headers for anthropic @@ -561,7 +562,7 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con // TODO: make sure we have apiKey, _ := c.cfg.Resolve(providerCfg.APIKey) baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL) - var provider ai.Provider + var provider fantasy.Provider switch providerCfg.Type { case openai.Name: provider = c.buildOpenaiProvider(baseURL, apiKey, headers) diff --git a/internal/agent/tools/bash.go b/internal/agent/tools/bash.go index f125d5ff1ca8262db99a10e5e895f04dc8fceef9..8b01887f4de2051aeeca5034c747a1adf744a039 100644 --- a/internal/agent/tools/bash.go +++ b/internal/agent/tools/bash.go @@ -9,10 +9,10 @@ import ( "strings" "time" + "charm.land/fantasy" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/shell" - "github.com/charmbracelet/fantasy/ai" ) type BashParams struct { @@ -177,14 +177,14 @@ func blockFuncs() []shell.BlockFunc { } } -func NewBashTool(permissions permission.Service, workingDir string, attribution *config.Attribution) ai.AgentTool { +func NewBashTool(permissions permission.Service, workingDir string, attribution *config.Attribution) fantasy.AgentTool { // Set up command blocking on the persistent shell persistentShell := shell.GetPersistentShell(workingDir) persistentShell.SetBlockFuncs(blockFuncs()) - return ai.NewAgentTool( + return fantasy.NewAgentTool( BashToolName, string(bashDescription(attribution)), - func(ctx context.Context, params BashParams, call ai.ToolCall) (ai.ToolResponse, error) { + func(ctx context.Context, params BashParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { if params.Timeout > MaxTimeout { params.Timeout = MaxTimeout } else if params.Timeout <= 0 { @@ -192,7 +192,7 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution } if params.Command == "" { - return ai.NewTextErrorResponse("missing command"), nil + return fantasy.NewTextErrorResponse("missing command"), nil } isSafeReadOnly := false @@ -209,7 +209,7 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution sessionID := GetSessionFromContext(ctx) if sessionID == "" { - return ai.ToolResponse{}, fmt.Errorf("session ID is required for executing shell command") + return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for executing shell command") } if !isSafeReadOnly { shell := shell.GetPersistentShell(workingDir) @@ -228,7 +228,7 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution }, ) if !p { - return ai.ToolResponse{}, permission.ErrorPermissionDenied + return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } } startTime := time.Now() @@ -246,7 +246,7 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution interrupted := shell.IsInterrupt(err) exitCode := shell.ExitCode(err) if exitCode == 0 && !interrupted && err != nil { - return ai.ToolResponse{}, fmt.Errorf("error executing command: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error executing command: %w", err) } stdout = truncateOutput(stdout) @@ -287,10 +287,10 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution WorkingDirectory: currentWorkingDir, } if stdout == "" { - return ai.WithResponseMetadata(ai.NewTextResponse(BashNoOutput), metadata), nil + return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil } stdout += fmt.Sprintf("\n\n%s", currentWorkingDir) - return ai.WithResponseMetadata(ai.NewTextResponse(stdout), metadata), nil + return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil }) } diff --git a/internal/agent/tools/diagnostics.go b/internal/agent/tools/diagnostics.go index e2d27450f2e6d23e63cdf2359719c17df622221d..5eceaf0245444341c2c5589c8c14d56869da2a58 100644 --- a/internal/agent/tools/diagnostics.go +++ b/internal/agent/tools/diagnostics.go @@ -9,9 +9,9 @@ import ( "strings" "time" + "charm.land/fantasy" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/lsp" - "github.com/charmbracelet/fantasy/ai" "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" ) @@ -24,17 +24,17 @@ const DiagnosticsToolName = "diagnostics" //go:embed diagnostics.md var diagnosticsDescription []byte -func NewDiagnosticsTool(lspClients *csync.Map[string, *lsp.Client]) ai.AgentTool { - return ai.NewAgentTool( +func NewDiagnosticsTool(lspClients *csync.Map[string, *lsp.Client]) fantasy.AgentTool { + return fantasy.NewAgentTool( DiagnosticsToolName, string(diagnosticsDescription), - func(ctx context.Context, params DiagnosticsParams, call ai.ToolCall) (ai.ToolResponse, error) { + func(ctx context.Context, params DiagnosticsParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { if lspClients.Len() == 0 { - return ai.NewTextErrorResponse("no LSP clients available"), nil + return fantasy.NewTextErrorResponse("no LSP clients available"), nil } notifyLSPs(ctx, lspClients, params.FilePath) output := getDiagnostics(params.FilePath, lspClients) - return ai.NewTextResponse(output), nil + return fantasy.NewTextResponse(output), nil }) } diff --git a/internal/agent/tools/download.go b/internal/agent/tools/download.go index 24ada06fa409a1b72983ccaff13c00bb830db567..cae5e5d6ab2f64f5e62521bf1f9379411dfbe0ad 100644 --- a/internal/agent/tools/download.go +++ b/internal/agent/tools/download.go @@ -11,8 +11,8 @@ import ( "strings" "time" + "charm.land/fantasy" "github.com/charmbracelet/crush/internal/permission" - "github.com/charmbracelet/fantasy/ai" ) type DownloadParams struct { @@ -32,7 +32,7 @@ const DownloadToolName = "download" //go:embed download.md var downloadDescription []byte -func NewDownloadTool(permissions permission.Service, workingDir string, client *http.Client) ai.AgentTool { +func NewDownloadTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool { if client == nil { client = &http.Client{ Timeout: 5 * time.Minute, // Default 5 minute timeout for downloads @@ -43,20 +43,20 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client * }, } } - return ai.NewAgentTool( + return fantasy.NewAgentTool( DownloadToolName, string(downloadDescription), - func(ctx context.Context, params DownloadParams, call ai.ToolCall) (ai.ToolResponse, error) { + func(ctx context.Context, params DownloadParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { if params.URL == "" { - return ai.NewTextErrorResponse("URL parameter is required"), nil + return fantasy.NewTextErrorResponse("URL parameter is required"), nil } if params.FilePath == "" { - return ai.NewTextErrorResponse("file_path parameter is required"), nil + return fantasy.NewTextErrorResponse("file_path parameter is required"), nil } if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") { - return ai.NewTextErrorResponse("URL must start with http:// or https://"), nil + return fantasy.NewTextErrorResponse("URL must start with http:// or https://"), nil } // Convert relative path to absolute path @@ -69,7 +69,7 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client * sessionID := GetSessionFromContext(ctx) if sessionID == "" { - return ai.ToolResponse{}, fmt.Errorf("session ID is required for downloading files") + return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for downloading files") } p := permissions.Request( @@ -84,7 +84,7 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client * ) if !p { - return ai.ToolResponse{}, permission.ErrorPermissionDenied + return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } // Handle timeout with context @@ -101,36 +101,36 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client * req, err := http.NewRequestWithContext(requestCtx, "GET", params.URL, nil) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to create request: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("User-Agent", "crush/1.0") resp, err := client.Do(req) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to download from URL: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to download from URL: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return ai.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil } // Check content length if available maxSize := int64(100 * 1024 * 1024) // 100MB if resp.ContentLength > maxSize { - return ai.NewTextErrorResponse(fmt.Sprintf("File too large: %d bytes (max %d bytes)", resp.ContentLength, maxSize)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("File too large: %d bytes (max %d bytes)", resp.ContentLength, maxSize)), nil } // Create parent directories if they don't exist if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err) } // Create the output file outFile, err := os.Create(filePath) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to create output file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to create output file: %w", err) } defer outFile.Close() @@ -138,14 +138,14 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client * limitedReader := io.LimitReader(resp.Body, maxSize) bytesWritten, err := io.Copy(outFile, limitedReader) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to write file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err) } // Check if we hit the size limit if bytesWritten == maxSize { // Clean up the file since it might be incomplete os.Remove(filePath) - return ai.NewTextErrorResponse(fmt.Sprintf("File too large: exceeded %d bytes limit", maxSize)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("File too large: exceeded %d bytes limit", maxSize)), nil } contentType := resp.Header.Get("Content-Type") @@ -154,6 +154,6 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client * responseMsg += fmt.Sprintf(" (Content-Type: %s)", contentType) } - return ai.NewTextResponse(responseMsg), nil + return fantasy.NewTextResponse(responseMsg), nil }) } diff --git a/internal/agent/tools/edit.go b/internal/agent/tools/edit.go index 6b3246fda7ab4733060dba425b4196557cb56a5d..bcd6592f7c0693433bebcb4a76432269df1e2191 100644 --- a/internal/agent/tools/edit.go +++ b/internal/agent/tools/edit.go @@ -10,11 +10,11 @@ import ( "strings" "time" + "charm.land/fantasy" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/diff" "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/history" - "github.com/charmbracelet/fantasy/ai" "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/crush/internal/permission" @@ -52,20 +52,20 @@ type editContext struct { workingDir string } -func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) ai.AgentTool { - return ai.NewAgentTool( +func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool { + return fantasy.NewAgentTool( EditToolName, string(editDescription), - func(ctx context.Context, params EditParams, call ai.ToolCall) (ai.ToolResponse, error) { + func(ctx context.Context, params EditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { if params.FilePath == "" { - return ai.NewTextErrorResponse("file_path is required"), nil + return fantasy.NewTextErrorResponse("file_path is required"), nil } if !filepath.IsAbs(params.FilePath) { params.FilePath = filepath.Join(workingDir, params.FilePath) } - var response ai.ToolResponse + var response fantasy.ToolResponse var err error editCtx := editContext{ctx, permissions, files, workingDir} @@ -103,25 +103,25 @@ func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss }) } -func createNewFile(edit editContext, filePath, content string, call ai.ToolCall) (ai.ToolResponse, error) { +func createNewFile(edit editContext, filePath, content string, call fantasy.ToolCall) (fantasy.ToolResponse, error) { fileInfo, err := os.Stat(filePath) if err == nil { if fileInfo.IsDir() { - return ai.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil } - return ai.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil } else if !os.IsNotExist(err) { - return ai.ToolResponse{}, fmt.Errorf("failed to access file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err) } dir := filepath.Dir(filePath) if err = os.MkdirAll(dir, 0o755); err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err) } sessionID := GetSessionFromContext(edit.ctx) if sessionID == "" { - return ai.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file") + return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file") } _, additions, removals := diff.GenerateDiff( @@ -145,19 +145,19 @@ func createNewFile(edit editContext, filePath, content string, call ai.ToolCall) }, ) if !p { - return ai.ToolResponse{}, permission.ErrorPermissionDenied + return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } err = os.WriteFile(filePath, []byte(content), 0o644) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to write file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err) } // File can't be in the history so we create a new file history _, err = edit.files.Create(edit.ctx, sessionID, filePath, "") if err != nil { // Log error but don't fail the operation - return ai.ToolResponse{}, fmt.Errorf("error creating file history: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err) } // Add the new content to the file history @@ -170,8 +170,8 @@ func createNewFile(edit editContext, filePath, content string, call ai.ToolCall) recordFileWrite(filePath) recordFileRead(filePath) - return ai.WithResponseMetadata( - ai.NewTextResponse("File created: "+filePath), + return fantasy.WithResponseMetadata( + fantasy.NewTextResponse("File created: "+filePath), EditResponseMetadata{ OldContent: "", NewContent: content, @@ -181,27 +181,27 @@ func createNewFile(edit editContext, filePath, content string, call ai.ToolCall) ), nil } -func deleteContent(edit editContext, filePath, oldString string, replaceAll bool, call ai.ToolCall) (ai.ToolResponse, error) { +func deleteContent(edit editContext, filePath, oldString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) { fileInfo, err := os.Stat(filePath) if err != nil { if os.IsNotExist(err) { - return ai.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil } - return ai.ToolResponse{}, fmt.Errorf("failed to access file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err) } if fileInfo.IsDir() { - return ai.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil } if getLastReadTime(filePath).IsZero() { - return ai.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil + return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil } modTime := fileInfo.ModTime() lastRead := getLastReadTime(filePath) if modTime.After(lastRead) { - return ai.NewTextErrorResponse( + return fantasy.NewTextErrorResponse( fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)", filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339), )), nil @@ -209,7 +209,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool content, err := os.ReadFile(filePath) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to read file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err) } oldContent, isCrlf := fsext.ToUnixLineEndings(string(content)) @@ -221,17 +221,17 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool newContent = strings.ReplaceAll(oldContent, oldString, "") deletionCount = strings.Count(oldContent, oldString) if deletionCount == 0 { - return ai.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil + return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil } } else { index := strings.Index(oldContent, oldString) if index == -1 { - return ai.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil + return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil } lastIndex := strings.LastIndex(oldContent, oldString) if index != lastIndex { - return ai.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 + return fantasy.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 } newContent = oldContent[:index] + oldContent[index+len(oldString):] @@ -241,7 +241,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool sessionID := GetSessionFromContext(edit.ctx) if sessionID == "" { - return ai.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file") + return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file") } _, additions, removals := diff.GenerateDiff( @@ -266,7 +266,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool }, ) if !p { - return ai.ToolResponse{}, permission.ErrorPermissionDenied + return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } if isCrlf { @@ -275,7 +275,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool err = os.WriteFile(filePath, []byte(newContent), 0o644) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to write file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err) } // Check if file exists in history @@ -284,7 +284,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool _, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent) if err != nil { // Log error but don't fail the operation - return ai.ToolResponse{}, fmt.Errorf("error creating file history: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err) } } if file.Content != oldContent { @@ -303,8 +303,8 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool recordFileWrite(filePath) recordFileRead(filePath) - return ai.WithResponseMetadata( - ai.NewTextResponse("Content deleted from file: "+filePath), + return fantasy.WithResponseMetadata( + fantasy.NewTextResponse("Content deleted from file: "+filePath), EditResponseMetadata{ OldContent: oldContent, NewContent: newContent, @@ -314,27 +314,27 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool ), nil } -func replaceContent(edit editContext, filePath, oldString, newString string, replaceAll bool, call ai.ToolCall) (ai.ToolResponse, error) { +func replaceContent(edit editContext, filePath, oldString, newString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) { fileInfo, err := os.Stat(filePath) if err != nil { if os.IsNotExist(err) { - return ai.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil } - return ai.ToolResponse{}, fmt.Errorf("failed to access file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err) } if fileInfo.IsDir() { - return ai.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil } if getLastReadTime(filePath).IsZero() { - return ai.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil + return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil } modTime := fileInfo.ModTime() lastRead := getLastReadTime(filePath) if modTime.After(lastRead) { - return ai.NewTextErrorResponse( + return fantasy.NewTextErrorResponse( fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)", filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339), )), nil @@ -342,7 +342,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep content, err := os.ReadFile(filePath) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to read file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err) } oldContent, isCrlf := fsext.ToUnixLineEndings(string(content)) @@ -354,17 +354,17 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep newContent = strings.ReplaceAll(oldContent, oldString, newString) replacementCount = strings.Count(oldContent, oldString) if replacementCount == 0 { - return ai.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil + return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil } } else { index := strings.Index(oldContent, oldString) if index == -1 { - return ai.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil + return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil } lastIndex := strings.LastIndex(oldContent, oldString) if index != lastIndex { - return ai.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 + return fantasy.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 } newContent = oldContent[:index] + newString + oldContent[index+len(oldString):] @@ -372,12 +372,12 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep } if oldContent == newContent { - return ai.NewTextErrorResponse("new content is the same as old content. No changes made."), nil + return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil } sessionID := GetSessionFromContext(edit.ctx) if sessionID == "" { - return ai.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file") + return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file") } _, additions, removals := diff.GenerateDiff( oldContent, @@ -401,7 +401,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep }, ) if !p { - return ai.ToolResponse{}, permission.ErrorPermissionDenied + return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } if isCrlf { @@ -410,7 +410,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep err = os.WriteFile(filePath, []byte(newContent), 0o644) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to write file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err) } // Check if file exists in history @@ -419,7 +419,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep _, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent) if err != nil { // Log error but don't fail the operation - return ai.ToolResponse{}, fmt.Errorf("error creating file history: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err) } } if file.Content != oldContent { @@ -438,8 +438,8 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep recordFileWrite(filePath) recordFileRead(filePath) - return ai.WithResponseMetadata( - ai.NewTextResponse("Content replaced in file: "+filePath), + return fantasy.WithResponseMetadata( + fantasy.NewTextResponse("Content replaced in file: "+filePath), EditResponseMetadata{ OldContent: oldContent, NewContent: newContent, diff --git a/internal/agent/tools/fetch.go b/internal/agent/tools/fetch.go index 2079b7f4f765e0c18ac266bd1b65f19627c2a342..0701bd2be151213f2fd0e313726d03a5c35833a3 100644 --- a/internal/agent/tools/fetch.go +++ b/internal/agent/tools/fetch.go @@ -10,10 +10,10 @@ import ( "time" "unicode/utf8" + "charm.land/fantasy" md "github.com/JohannesKaufmann/html-to-markdown" "github.com/PuerkitoBio/goquery" "github.com/charmbracelet/crush/internal/permission" - "github.com/charmbracelet/fantasy/ai" ) type FetchParams struct { @@ -39,7 +39,7 @@ const FetchToolName = "fetch" //go:embed fetch.md var fetchDescription []byte -func NewFetchTool(permissions permission.Service, workingDir string, client *http.Client) ai.AgentTool { +func NewFetchTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool { if client == nil { client = &http.Client{ Timeout: 30 * time.Second, @@ -51,26 +51,26 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt } } - return ai.NewAgentTool( + return fantasy.NewAgentTool( FetchToolName, string(fetchDescription), - func(ctx context.Context, params FetchParams, call ai.ToolCall) (ai.ToolResponse, error) { + func(ctx context.Context, params FetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { if params.URL == "" { - return ai.NewTextErrorResponse("URL parameter is required"), nil + return fantasy.NewTextErrorResponse("URL parameter is required"), nil } format := strings.ToLower(params.Format) if format != "text" && format != "markdown" && format != "html" { - return ai.NewTextErrorResponse("Format must be one of: text, markdown, html"), nil + return fantasy.NewTextErrorResponse("Format must be one of: text, markdown, html"), nil } if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") { - return ai.NewTextErrorResponse("URL must start with http:// or https://"), nil + return fantasy.NewTextErrorResponse("URL must start with http:// or https://"), nil } sessionID := GetSessionFromContext(ctx) if sessionID == "" { - return ai.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file") + return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file") } p := permissions.Request( @@ -86,7 +86,7 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt ) if !p { - return ai.ToolResponse{}, permission.ErrorPermissionDenied + return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } // Handle timeout with context @@ -103,32 +103,32 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt req, err := http.NewRequestWithContext(requestCtx, "GET", params.URL, nil) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to create request: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("User-Agent", "crush/1.0") resp, err := client.Do(req) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return ai.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil } maxSize := int64(5 * 1024 * 1024) // 5MB body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize)) if err != nil { - return ai.NewTextErrorResponse("Failed to read response body: " + err.Error()), nil + return fantasy.NewTextErrorResponse("Failed to read response body: " + err.Error()), nil } content := string(body) isValidUt8 := utf8.ValidString(content) if !isValidUt8 { - return ai.NewTextErrorResponse("Response content is not valid UTF-8"), nil + return fantasy.NewTextErrorResponse("Response content is not valid UTF-8"), nil } contentType := resp.Header.Get("Content-Type") @@ -137,7 +137,7 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt if strings.Contains(contentType, "text/html") { text, err := extractTextFromHTML(content) if err != nil { - return ai.NewTextErrorResponse("Failed to extract text from HTML: " + err.Error()), nil + return fantasy.NewTextErrorResponse("Failed to extract text from HTML: " + err.Error()), nil } content = text } @@ -146,7 +146,7 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt if strings.Contains(contentType, "text/html") { markdown, err := convertHTMLToMarkdown(content) if err != nil { - return ai.NewTextErrorResponse("Failed to convert HTML to Markdown: " + err.Error()), nil + return fantasy.NewTextErrorResponse("Failed to convert HTML to Markdown: " + err.Error()), nil } content = markdown } @@ -158,14 +158,14 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt if strings.Contains(contentType, "text/html") { doc, err := goquery.NewDocumentFromReader(strings.NewReader(content)) if err != nil { - return ai.NewTextErrorResponse("Failed to parse HTML: " + err.Error()), nil + return fantasy.NewTextErrorResponse("Failed to parse HTML: " + err.Error()), nil } body, err := doc.Find("body").Html() if err != nil { - return ai.NewTextErrorResponse("Failed to extract body from HTML: " + err.Error()), nil + return fantasy.NewTextErrorResponse("Failed to extract body from HTML: " + err.Error()), nil } if body == "" { - return ai.NewTextErrorResponse("No body content found in HTML"), nil + return fantasy.NewTextErrorResponse("No body content found in HTML"), nil } content = "\n\n" + body + "\n\n" } @@ -177,7 +177,7 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt content += fmt.Sprintf("\n\n[Content truncated to %d bytes]", MaxReadSize) } - return ai.NewTextResponse(content), nil + return fantasy.NewTextResponse(content), nil }) } diff --git a/internal/agent/tools/glob.go b/internal/agent/tools/glob.go index fd264a2aa01f46e266c0c3cc99ab20a91caeb63e..29396dbd969787889a44a013f75a4f8fcc781354 100644 --- a/internal/agent/tools/glob.go +++ b/internal/agent/tools/glob.go @@ -11,8 +11,8 @@ import ( "sort" "strings" + "charm.land/fantasy" "github.com/charmbracelet/crush/internal/fsext" - "github.com/charmbracelet/fantasy/ai" ) const GlobToolName = "glob" @@ -30,13 +30,13 @@ type GlobResponseMetadata struct { Truncated bool `json:"truncated"` } -func NewGlobTool(workingDir string) ai.AgentTool { - return ai.NewAgentTool( +func NewGlobTool(workingDir string) fantasy.AgentTool { + return fantasy.NewAgentTool( GlobToolName, string(globDescription), - func(ctx context.Context, params GlobParams, call ai.ToolCall) (ai.ToolResponse, error) { + func(ctx context.Context, params GlobParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { if params.Pattern == "" { - return ai.NewTextErrorResponse("pattern is required"), nil + return fantasy.NewTextErrorResponse("pattern is required"), nil } searchPath := params.Path @@ -46,7 +46,7 @@ func NewGlobTool(workingDir string) ai.AgentTool { files, truncated, err := globFiles(ctx, params.Pattern, searchPath, 100) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("error finding files: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error finding files: %w", err) } var output string @@ -59,8 +59,8 @@ func NewGlobTool(workingDir string) ai.AgentTool { } } - return ai.WithResponseMetadata( - ai.NewTextResponse(output), + return fantasy.WithResponseMetadata( + fantasy.NewTextResponse(output), GlobResponseMetadata{ NumberOfFiles: len(files), Truncated: truncated, diff --git a/internal/agent/tools/grep.go b/internal/agent/tools/grep.go index e299d7d71a3046f33fb27d14aa7870d20ef9670c..e10a24a0685bcd37f39cfa74ec9837cbecf69507 100644 --- a/internal/agent/tools/grep.go +++ b/internal/agent/tools/grep.go @@ -16,8 +16,8 @@ import ( "sync" "time" + "charm.land/fantasy" "github.com/charmbracelet/crush/internal/fsext" - "github.com/charmbracelet/fantasy/ai" ) // regexCache provides thread-safe caching of compiled regex patterns @@ -97,13 +97,13 @@ const ( //go:embed grep.md var grepDescription []byte -func NewGrepTool(workingDir string) ai.AgentTool { - return ai.NewAgentTool( +func NewGrepTool(workingDir string) fantasy.AgentTool { + return fantasy.NewAgentTool( GrepToolName, string(grepDescription), - func(ctx context.Context, params GrepParams, call ai.ToolCall) (ai.ToolResponse, error) { + func(ctx context.Context, params GrepParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { if params.Pattern == "" { - return ai.NewTextErrorResponse("pattern is required"), nil + return fantasy.NewTextErrorResponse("pattern is required"), nil } // If literal_text is true, escape the pattern @@ -119,7 +119,7 @@ func NewGrepTool(workingDir string) ai.AgentTool { matches, truncated, err := searchFiles(ctx, searchPattern, searchPath, params.Include, 100) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("error searching files: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error searching files: %w", err) } var output strings.Builder @@ -153,8 +153,8 @@ func NewGrepTool(workingDir string) ai.AgentTool { } } - return ai.WithResponseMetadata( - ai.NewTextResponse(output.String()), + return fantasy.WithResponseMetadata( + fantasy.NewTextResponse(output.String()), GrepResponseMetadata{ NumberOfMatches: len(matches), Truncated: truncated, diff --git a/internal/agent/tools/ls.go b/internal/agent/tools/ls.go index 20a68cf89b761f4cdf736318e40dd880baabefdd..6e8bab1ec5ebb025a0efad870fb31afca1ee9e7d 100644 --- a/internal/agent/tools/ls.go +++ b/internal/agent/tools/ls.go @@ -9,10 +9,10 @@ import ( "path/filepath" "strings" + "charm.land/fantasy" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/permission" - "github.com/charmbracelet/fantasy/ai" ) type LSParams struct { @@ -47,14 +47,14 @@ const ( //go:embed ls.md var lsDescription []byte -func NewLsTool(permissions permission.Service, workingDir string, lsConfig config.ToolLs) ai.AgentTool { - return ai.NewAgentTool( +func NewLsTool(permissions permission.Service, workingDir string, lsConfig config.ToolLs) fantasy.AgentTool { + return fantasy.NewAgentTool( LSToolName, string(lsDescription), - func(ctx context.Context, params LSParams, call ai.ToolCall) (ai.ToolResponse, error) { + func(ctx context.Context, params LSParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { searchPath, err := fsext.Expand(cmp.Or(params.Path, workingDir)) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("error expanding path: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error expanding path: %w", err) } if !filepath.IsAbs(searchPath) { @@ -64,12 +64,12 @@ func NewLsTool(permissions permission.Service, workingDir string, lsConfig confi // Check if directory is outside working directory and request permission if needed absWorkingDir, err := filepath.Abs(workingDir) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err) } absSearchPath, err := filepath.Abs(searchPath) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("error resolving search path: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error resolving search path: %w", err) } relPath, err := filepath.Rel(absWorkingDir, absSearchPath) @@ -77,7 +77,7 @@ func NewLsTool(permissions permission.Service, workingDir string, lsConfig confi // Directory is outside working directory, request permission sessionID := GetSessionFromContext(ctx) if sessionID == "" { - return ai.ToolResponse{}, fmt.Errorf("session ID is required for accessing directories outside working directory") + return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing directories outside working directory") } granted := permissions.Request( @@ -93,17 +93,17 @@ func NewLsTool(permissions permission.Service, workingDir string, lsConfig confi ) if !granted { - return ai.ToolResponse{}, permission.ErrorPermissionDenied + return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } } output, metadata, err := ListDirectoryTree(searchPath, params, lsConfig) if err != nil { - return ai.ToolResponse{}, err + return fantasy.ToolResponse{}, err } - return ai.WithResponseMetadata( - ai.NewTextResponse(output), + return fantasy.WithResponseMetadata( + fantasy.NewTextResponse(output), metadata, ), nil }) diff --git a/internal/agent/tools/mcp-tools.go b/internal/agent/tools/mcp-tools.go index 9c34c0f25033b365d382b1a14f92dfedb8c141ea..c6ffda4d75137a1acfc69cd176c699a087e00b06 100644 --- a/internal/agent/tools/mcp-tools.go +++ b/internal/agent/tools/mcp-tools.go @@ -16,13 +16,13 @@ import ( "sync" "time" + "charm.land/fantasy" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/version" - "github.com/charmbracelet/fantasy/ai" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -92,14 +92,14 @@ type McpTool struct { tool *mcp.Tool permissions permission.Service workingDir string - providerOptions ai.ProviderOptions + providerOptions fantasy.ProviderOptions } -func (m *McpTool) SetProviderOptions(opts ai.ProviderOptions) { +func (m *McpTool) SetProviderOptions(opts fantasy.ProviderOptions) { m.providerOptions = opts } -func (m *McpTool) ProviderOptions() ai.ProviderOptions { +func (m *McpTool) ProviderOptions() fantasy.ProviderOptions { return m.providerOptions } @@ -115,7 +115,7 @@ func (m *McpTool) MCPToolName() string { return m.tool.Name } -func (b *McpTool) Info() ai.ToolInfo { +func (b *McpTool) Info() fantasy.ToolInfo { input := b.tool.InputSchema.(map[string]any) required, _ := input["required"].([]string) if required == nil { @@ -125,7 +125,7 @@ func (b *McpTool) Info() ai.ToolInfo { if parameters == nil { parameters = make(map[string]any) } - return ai.ToolInfo{ + return fantasy.ToolInfo{ Name: fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name), Description: b.tool.Description, Parameters: parameters, @@ -133,22 +133,22 @@ func (b *McpTool) Info() ai.ToolInfo { } } -func runTool(ctx context.Context, name, toolName string, input string) (ai.ToolResponse, error) { +func runTool(ctx context.Context, name, toolName string, input string) (fantasy.ToolResponse, error) { var args map[string]any if err := json.Unmarshal([]byte(input), &args); err != nil { - return ai.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil } c, err := getOrRenewClient(ctx, name) if err != nil { - return ai.NewTextErrorResponse(err.Error()), nil + return fantasy.NewTextErrorResponse(err.Error()), nil } result, err := c.CallTool(ctx, &mcp.CallToolParams{ Name: toolName, Arguments: args, }) if err != nil { - return ai.NewTextErrorResponse(err.Error()), nil + return fantasy.NewTextErrorResponse(err.Error()), nil } output := make([]string, 0, len(result.Content)) @@ -159,7 +159,7 @@ func runTool(ctx context.Context, name, toolName string, input string) (ai.ToolR output = append(output, fmt.Sprintf("%v", v)) } } - return ai.NewTextResponse(strings.Join(output, "\n")), nil + return fantasy.NewTextResponse(strings.Join(output, "\n")), nil } func getOrRenewClient(ctx context.Context, name string) (*mcp.ClientSession, error) { @@ -191,10 +191,10 @@ func getOrRenewClient(ctx context.Context, name string) (*mcp.ClientSession, err return sess, nil } -func (m *McpTool) Run(ctx context.Context, params ai.ToolCall) (ai.ToolResponse, error) { +func (m *McpTool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolResponse, error) { sessionID := GetSessionFromContext(ctx) if sessionID == "" { - return ai.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file") + return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file") } permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name) p := m.permissions.Request( @@ -209,7 +209,7 @@ func (m *McpTool) Run(ctx context.Context, params ai.ToolCall) (ai.ToolResponse, }, ) if !p { - return ai.ToolResponse{}, permission.ErrorPermissionDenied + return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } return runTool(ctx, m.mcpName, m.tool.Name, params.Input) diff --git a/internal/agent/tools/multiedit.go b/internal/agent/tools/multiedit.go index 0dffcf6e819ec4cdb35863986bc93729760ef672..a0f6d3c83decde013a829b4c4fe13902545bc9db 100644 --- a/internal/agent/tools/multiedit.go +++ b/internal/agent/tools/multiedit.go @@ -10,13 +10,13 @@ import ( "strings" "time" + "charm.land/fantasy" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/diff" "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/crush/internal/permission" - "github.com/charmbracelet/fantasy/ai" ) type MultiEditOperation struct { @@ -49,17 +49,17 @@ const MultiEditToolName = "multiedit" //go:embed multiedit.md var multieditDescription []byte -func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) ai.AgentTool { - return ai.NewAgentTool( +func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool { + return fantasy.NewAgentTool( MultiEditToolName, string(multieditDescription), - func(ctx context.Context, params MultiEditParams, call ai.ToolCall) (ai.ToolResponse, error) { + func(ctx context.Context, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { if params.FilePath == "" { - return ai.NewTextErrorResponse("file_path is required"), nil + return fantasy.NewTextErrorResponse("file_path is required"), nil } if len(params.Edits) == 0 { - return ai.NewTextErrorResponse("at least one edit operation is required"), nil + return fantasy.NewTextErrorResponse("at least one edit operation is required"), nil } if !filepath.IsAbs(params.FilePath) { @@ -68,10 +68,10 @@ func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions pe // Validate all edits before applying any if err := validateEdits(params.Edits); err != nil { - return ai.NewTextErrorResponse(err.Error()), nil + return fantasy.NewTextErrorResponse(err.Error()), nil } - var response ai.ToolResponse + var response fantasy.ToolResponse var err error editCtx := editContext{ctx, permissions, files, workingDir} @@ -114,24 +114,24 @@ func validateEdits(edits []MultiEditOperation) error { return nil } -func processMultiEditWithCreation(edit editContext, params MultiEditParams, call ai.ToolCall) (ai.ToolResponse, error) { +func processMultiEditWithCreation(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { // First edit creates the file firstEdit := params.Edits[0] if firstEdit.OldString != "" { - return ai.NewTextErrorResponse("first edit must have empty old_string for file creation"), nil + return fantasy.NewTextErrorResponse("first edit must have empty old_string for file creation"), nil } // Check if file already exists if _, err := os.Stat(params.FilePath); err == nil { - return ai.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil } else if !os.IsNotExist(err) { - return ai.ToolResponse{}, fmt.Errorf("failed to access file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err) } // Create parent directories dir := filepath.Dir(params.FilePath) if err := os.MkdirAll(dir, 0o755); err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err) } // Start with the content from the first edit @@ -142,7 +142,7 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call edit := params.Edits[i] newContent, err := applyEditToContent(currentContent, edit) if err != nil { - return ai.NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil } currentContent = newContent } @@ -150,7 +150,7 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call // Get session and message IDs sessionID := GetSessionFromContext(edit.ctx) if sessionID == "" { - return ai.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file") + return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file") } // Check permissions @@ -170,19 +170,19 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call }, }) if !p { - return ai.ToolResponse{}, permission.ErrorPermissionDenied + return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } // Write the file err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to write file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err) } // Update file history _, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, "") if err != nil { - return ai.ToolResponse{}, fmt.Errorf("error creating file history: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err) } _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent) @@ -193,8 +193,8 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call recordFileWrite(params.FilePath) recordFileRead(params.FilePath) - return ai.WithResponseMetadata( - ai.NewTextResponse(fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)), + return fantasy.WithResponseMetadata( + fantasy.NewTextResponse(fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)), MultiEditResponseMetadata{ OldContent: "", NewContent: currentContent, @@ -205,30 +205,30 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call ), nil } -func processMultiEditExistingFile(edit editContext, params MultiEditParams, call ai.ToolCall) (ai.ToolResponse, error) { +func processMultiEditExistingFile(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { // Validate file exists and is readable fileInfo, err := os.Stat(params.FilePath) if err != nil { if os.IsNotExist(err) { - return ai.NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil } - return ai.ToolResponse{}, fmt.Errorf("failed to access file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err) } if fileInfo.IsDir() { - return ai.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil } // Check if file was read before editing if getLastReadTime(params.FilePath).IsZero() { - return ai.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil + return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil } // Check if file was modified since last read modTime := fileInfo.ModTime() lastRead := getLastReadTime(params.FilePath) if modTime.After(lastRead) { - return ai.NewTextErrorResponse( + return fantasy.NewTextErrorResponse( fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)", params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339), )), nil @@ -237,7 +237,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call // Read current file content content, err := os.ReadFile(params.FilePath) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to read file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err) } oldContent, isCrlf := fsext.ToUnixLineEndings(string(content)) @@ -247,20 +247,20 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call for i, edit := range params.Edits { newContent, err := applyEditToContent(currentContent, edit) if err != nil { - return ai.NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("edit %d failed: %s", i+1, err.Error())), nil } currentContent = newContent } // Check if content actually changed if oldContent == currentContent { - return ai.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil + return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil } // Get session and message IDs sessionID := GetSessionFromContext(edit.ctx) if sessionID == "" { - return ai.ToolResponse{}, fmt.Errorf("session ID is required for editing file") + return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file") } // Generate diff and check permissions @@ -279,7 +279,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call }, }) if !p { - return ai.ToolResponse{}, permission.ErrorPermissionDenied + return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } if isCrlf { @@ -289,7 +289,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call // Write the updated content err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to write file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err) } // Update file history @@ -297,7 +297,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call if err != nil { _, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, oldContent) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("error creating file history: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err) } } if file.Content != oldContent { @@ -317,8 +317,8 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call recordFileWrite(params.FilePath) recordFileRead(params.FilePath) - return ai.WithResponseMetadata( - ai.NewTextResponse(fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)), + return fantasy.WithResponseMetadata( + fantasy.NewTextResponse(fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)), MultiEditResponseMetadata{ OldContent: oldContent, NewContent: currentContent, diff --git a/internal/agent/tools/sourcegraph.go b/internal/agent/tools/sourcegraph.go index f44f1530be4c4528d7c5e5500c04d2f7c6ce93a4..5216405e5538edbcd10386c2640f48ced69eb22b 100644 --- a/internal/agent/tools/sourcegraph.go +++ b/internal/agent/tools/sourcegraph.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/charmbracelet/fantasy/ai" + "charm.land/fantasy" ) type SourcegraphParams struct { @@ -31,7 +31,7 @@ const SourcegraphToolName = "sourcegraph" //go:embed sourcegraph.md var sourcegraphDescription []byte -func NewSourcegraphTool(client *http.Client) ai.AgentTool { +func NewSourcegraphTool(client *http.Client) fantasy.AgentTool { if client == nil { client = &http.Client{ Timeout: 30 * time.Second, @@ -42,12 +42,12 @@ func NewSourcegraphTool(client *http.Client) ai.AgentTool { }, } } - return ai.NewAgentTool( + return fantasy.NewAgentTool( SourcegraphToolName, string(sourcegraphDescription), - func(ctx context.Context, params SourcegraphParams, call ai.ToolCall) (ai.ToolResponse, error) { + func(ctx context.Context, params SourcegraphParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { if params.Query == "" { - return ai.NewTextErrorResponse("Query parameter is required"), nil + return fantasy.NewTextErrorResponse("Query parameter is required"), nil } if params.Count <= 0 { @@ -86,7 +86,7 @@ func NewSourcegraphTool(client *http.Client) ai.AgentTool { graphqlQueryBytes, err := json.Marshal(request) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to marshal GraphQL request: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to marshal GraphQL request: %w", err) } graphqlQuery := string(graphqlQueryBytes) @@ -97,7 +97,7 @@ func NewSourcegraphTool(client *http.Client) ai.AgentTool { bytes.NewBuffer([]byte(graphqlQuery)), ) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to create request: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") @@ -105,34 +105,34 @@ func NewSourcegraphTool(client *http.Client) ai.AgentTool { resp, err := client.Do(req) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) if len(body) > 0 { - return ai.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d, response: %s", resp.StatusCode, string(body))), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d, response: %s", resp.StatusCode, string(body))), nil } - return ai.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil } body, err := io.ReadAll(resp.Body) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to read response body: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to read response body: %w", err) } var result map[string]any if err = json.Unmarshal(body, &result); err != nil { - return ai.ToolResponse{}, fmt.Errorf("failed to unmarshal response: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("failed to unmarshal response: %w", err) } formattedResults, err := formatSourcegraphResults(result, params.ContextWindow) if err != nil { - return ai.NewTextErrorResponse("Failed to format results: " + err.Error()), nil + return fantasy.NewTextErrorResponse("Failed to format results: " + err.Error()), nil } - return ai.NewTextResponse(formattedResults), nil + return fantasy.NewTextResponse(formattedResults), nil }) } diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index 545f5a348c6a02cea8728f56f0b6590a4969ca69..af9212acd9f9207a8e1b82b0f7e11aa2a1618994 100644 --- a/internal/agent/tools/view.go +++ b/internal/agent/tools/view.go @@ -11,10 +11,10 @@ import ( "strings" "unicode/utf8" + "charm.land/fantasy" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/crush/internal/permission" - "github.com/charmbracelet/fantasy/ai" ) //go:embed view.md @@ -50,13 +50,13 @@ const ( MaxLineLength = 2000 ) -func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) ai.AgentTool { - return ai.NewAgentTool( +func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) fantasy.AgentTool { + return fantasy.NewAgentTool( ViewToolName, string(viewDescription), - func(ctx context.Context, params ViewParams, call ai.ToolCall) (ai.ToolResponse, error) { + func(ctx context.Context, params ViewParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { if params.FilePath == "" { - return ai.NewTextErrorResponse("file_path is required"), nil + return fantasy.NewTextErrorResponse("file_path is required"), nil } // Handle relative paths @@ -68,12 +68,12 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss // Check if file is outside working directory and request permission if needed absWorkingDir, err := filepath.Abs(workingDir) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err) } absFilePath, err := filepath.Abs(filePath) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("error resolving file path: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error resolving file path: %w", err) } relPath, err := filepath.Rel(absWorkingDir, absFilePath) @@ -81,7 +81,7 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss // File is outside working directory, request permission sessionID := GetSessionFromContext(ctx) if sessionID == "" { - return ai.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory") + return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory") } granted := permissions.Request( @@ -97,7 +97,7 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss ) if !granted { - return ai.ToolResponse{}, permission.ErrorPermissionDenied + return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } } @@ -123,24 +123,24 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss } if len(suggestions) > 0 { - return ai.NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s", + return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s", filePath, strings.Join(suggestions, "\n"))), nil } } - return ai.NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil } - return ai.ToolResponse{}, fmt.Errorf("error accessing file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error accessing file: %w", err) } // Check if it's a directory if fileInfo.IsDir() { - return ai.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil } // Check file size if fileInfo.Size() > MaxReadSize { - return ai.NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes", + return fantasy.NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes", fileInfo.Size(), MaxReadSize)), nil } @@ -153,17 +153,17 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss isImage, imageType := isImageFile(filePath) // TODO: handle images if isImage { - return ai.NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\n", imageType)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\n", imageType)), nil } // Read the file content content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit) isValidUt8 := utf8.ValidString(content) if !isValidUt8 { - return ai.NewTextErrorResponse("File content is not valid UTF-8"), nil + return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil } if err != nil { - return ai.ToolResponse{}, fmt.Errorf("error reading file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err) } notifyLSPs(ctx, lspClients, filePath) @@ -179,8 +179,8 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss output += "\n\n" output += getDiagnostics(filePath, lspClients) recordFileRead(filePath) - return ai.WithResponseMetadata( - ai.NewTextResponse(output), + return fantasy.WithResponseMetadata( + fantasy.NewTextResponse(output), ViewResponseMetadata{ FilePath: filePath, Content: content, diff --git a/internal/agent/tools/write.go b/internal/agent/tools/write.go index 571360fe93b861c86bdf4cfd8e62eedd4cc6f424..6c62fb0d5c2a9454a02300c22b69a7a20574c470 100644 --- a/internal/agent/tools/write.go +++ b/internal/agent/tools/write.go @@ -10,11 +10,11 @@ import ( "strings" "time" + "charm.land/fantasy" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/diff" "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/history" - "github.com/charmbracelet/fantasy/ai" "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/crush/internal/permission" @@ -49,17 +49,17 @@ type WriteResponseMetadata struct { const WriteToolName = "write" -func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) ai.AgentTool { - return ai.NewAgentTool( +func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool { + return fantasy.NewAgentTool( WriteToolName, string(writeDescription), - func(ctx context.Context, params WriteParams, call ai.ToolCall) (ai.ToolResponse, error) { + func(ctx context.Context, params WriteParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { if params.FilePath == "" { - return ai.NewTextErrorResponse("file_path is required"), nil + return fantasy.NewTextErrorResponse("file_path is required"), nil } if params.Content == "" { - return ai.NewTextErrorResponse("content is required"), nil + return fantasy.NewTextErrorResponse("content is required"), nil } filePath := params.FilePath @@ -70,27 +70,27 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis fileInfo, err := os.Stat(filePath) if err == nil { if fileInfo.IsDir() { - return ai.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil } modTime := fileInfo.ModTime() lastRead := getLastReadTime(filePath) if modTime.After(lastRead) { - return ai.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.", + return fantasy.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.", filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil } oldContent, readErr := os.ReadFile(filePath) if readErr == nil && string(oldContent) == params.Content { - return ai.NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil + return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil } } else if !os.IsNotExist(err) { - return ai.ToolResponse{}, fmt.Errorf("error checking file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error checking file: %w", err) } dir := filepath.Dir(filePath) if err = os.MkdirAll(dir, 0o755); err != nil { - return ai.ToolResponse{}, fmt.Errorf("error creating directory: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error creating directory: %w", err) } oldContent := "" @@ -103,7 +103,7 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis sessionID := GetSessionFromContext(ctx) if sessionID == "" { - return ai.ToolResponse{}, fmt.Errorf("session_id is required") + return fantasy.ToolResponse{}, fmt.Errorf("session_id is required") } diff, additions, removals := diff.GenerateDiff( @@ -128,12 +128,12 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis }, ) if !p { - return ai.ToolResponse{}, permission.ErrorPermissionDenied + return fantasy.ToolResponse{}, permission.ErrorPermissionDenied } err = os.WriteFile(filePath, []byte(params.Content), 0o644) if err != nil { - return ai.ToolResponse{}, fmt.Errorf("error writing file: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error writing file: %w", err) } // Check if file exists in history @@ -142,7 +142,7 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis _, err = files.Create(ctx, sessionID, filePath, oldContent) if err != nil { // Log error but don't fail the operation - return ai.ToolResponse{}, fmt.Errorf("error creating file history: %w", err) + return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err) } } if file.Content != oldContent { @@ -166,7 +166,7 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis result := fmt.Sprintf("File successfully written: %s", filePath) result = fmt.Sprintf("\n%s\n", result) result += getDiagnostics(filePath, lspClients) - return ai.WithResponseMetadata(ai.NewTextResponse(result), + return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result), WriteResponseMetadata{ Diff: diff, Additions: additions, diff --git a/internal/app/app.go b/internal/app/app.go index 7d1904096931fe80ecee6292309305afc05994bc..8309d21057a4c00ef18e2094a611d59b597ccf27 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "charm.land/fantasy" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/agent" "github.com/charmbracelet/crush/internal/agent/tools" @@ -23,7 +24,6 @@ import ( "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/fantasy/ai" "github.com/charmbracelet/x/ansi" ) @@ -145,7 +145,7 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool app.Permissions.AutoApproveSession(sess.ID) type response struct { - result *ai.AgentResult + result *fantasy.AgentResult err error } done := make(chan response, 1) diff --git a/internal/message/content.go b/internal/message/content.go index e397946ab6a04439a55639d2934b6da761796e0d..6c95aea9e94b5730858361f77e36d35ec7cd1a2e 100644 --- a/internal/message/content.go +++ b/internal/message/content.go @@ -7,9 +7,9 @@ import ( "strings" "time" + "charm.land/fantasy" + "charm.land/fantasy/providers/anthropic" "github.com/charmbracelet/catwalk/pkg/catwalk" - "github.com/charmbracelet/fantasy/ai" - "github.com/charmbracelet/fantasy/anthropic" ) type MessageRole string @@ -390,35 +390,35 @@ func (m *Message) AddBinary(mimeType string, data []byte) { m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data}) } -func (m *Message) ToAIMessage() []ai.Message { - var messages []ai.Message +func (m *Message) ToAIMessage() []fantasy.Message { + var messages []fantasy.Message switch m.Role { case User: - var parts []ai.MessagePart + var parts []fantasy.MessagePart text := strings.TrimSpace(m.Content().Text) if text != "" { - parts = append(parts, ai.TextPart{Text: text}) + parts = append(parts, fantasy.TextPart{Text: text}) } for _, content := range m.BinaryContent() { - parts = append(parts, ai.FilePart{ + parts = append(parts, fantasy.FilePart{ Filename: content.Path, Data: content.Data, MediaType: content.MIMEType, }) } - messages = append(messages, ai.Message{ - Role: ai.MessageRoleUser, + messages = append(messages, fantasy.Message{ + Role: fantasy.MessageRoleUser, Content: parts, }) case Assistant: - var parts []ai.MessagePart + var parts []fantasy.MessagePart text := strings.TrimSpace(m.Content().Text) if text != "" { - parts = append(parts, ai.TextPart{Text: text}) + parts = append(parts, fantasy.TextPart{Text: text}) } reasoning := m.ReasoningContent() if reasoning.Thinking != "" { - reasoningPart := ai.ReasoningPart{Text: reasoning.Thinking, ProviderOptions: ai.ProviderOptions{}} + reasoningPart := fantasy.ReasoningPart{Text: reasoning.Thinking, ProviderOptions: fantasy.ProviderOptions{}} if reasoning.Signature != "" { reasoningPart.ProviderOptions["anthropic"] = &anthropic.ReasoningOptionMetadata{ Signature: reasoning.Signature, @@ -427,42 +427,42 @@ func (m *Message) ToAIMessage() []ai.Message { parts = append(parts, reasoningPart) } for _, call := range m.ToolCalls() { - parts = append(parts, ai.ToolCallPart{ + parts = append(parts, fantasy.ToolCallPart{ ToolCallID: call.ID, ToolName: call.Name, Input: call.Input, ProviderExecuted: call.ProviderExecuted, }) } - messages = append(messages, ai.Message{ - Role: ai.MessageRoleAssistant, + messages = append(messages, fantasy.Message{ + Role: fantasy.MessageRoleAssistant, Content: parts, }) case Tool: - var parts []ai.MessagePart + var parts []fantasy.MessagePart for _, result := range m.ToolResults() { - var content ai.ToolResultOutputContent + var content fantasy.ToolResultOutputContent if result.IsError { - content = ai.ToolResultOutputContentError{ + content = fantasy.ToolResultOutputContentError{ Error: errors.New(result.Content), } } else if result.Data != "" { - content = ai.ToolResultOutputContentMedia{ + content = fantasy.ToolResultOutputContentMedia{ Data: result.Data, MediaType: result.MIMEType, } } else { - content = ai.ToolResultOutputContentText{ + content = fantasy.ToolResultOutputContentText{ Text: result.Content, } } - parts = append(parts, ai.ToolResultPart{ + parts = append(parts, fantasy.ToolResultPart{ ToolCallID: result.ToolCallID, Output: content, }) } - messages = append(messages, ai.Message{ - Role: ai.MessageRoleTool, + messages = append(messages, fantasy.Message{ + Role: fantasy.MessageRoleTool, Content: parts, }) }