diff options
Diffstat (limited to 'internal/llm')
36 files changed, 3081 insertions, 2943 deletions
diff --git a/internal/llm/agent/agent-tool.go b/internal/llm/agent/agent-tool.go index deb6aed60..be6e09a9b 100644 --- a/internal/llm/agent/agent-tool.go +++ b/internal/llm/agent/agent-tool.go @@ -5,14 +5,17 @@ import ( "encoding/json" "fmt" - "github.com/kujtimiihoxha/termai/internal/app" - "github.com/kujtimiihoxha/termai/internal/llm/tools" - "github.com/kujtimiihoxha/termai/internal/message" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/llm/tools" + "github.com/kujtimiihoxha/opencode/internal/lsp" + "github.com/kujtimiihoxha/opencode/internal/message" + "github.com/kujtimiihoxha/opencode/internal/session" ) type agentTool struct { - parentSessionID string - app *app.App + sessions session.Service + messages message.Service + lspClients map[string]*lsp.Client } const ( @@ -46,57 +49,63 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes return tools.NewTextErrorResponse("prompt is required"), nil } - agent, err := NewTaskAgent(b.app) - if err != nil { - return tools.NewTextErrorResponse(fmt.Sprintf("error creating agent: %s", err)), nil + sessionID, messageID := tools.GetContextValues(ctx) + if sessionID == "" || messageID == "" { + return tools.ToolResponse{}, fmt.Errorf("session_id and message_id are required") } - session, err := b.app.Sessions.CreateTaskSession(call.ID, b.parentSessionID, "New Agent Session") + agent, err := NewAgent(config.AgentTask, b.sessions, b.messages, TaskAgentTools(b.lspClients)) if err != nil { - return tools.NewTextErrorResponse(fmt.Sprintf("error creating session: %s", err)), nil + return tools.ToolResponse{}, fmt.Errorf("error creating agent: %s", err) } - err = agent.Generate(ctx, session.ID, params.Prompt) + session, err := b.sessions.CreateTaskSession(ctx, call.ID, sessionID, "New Agent Session") if err != nil { - return tools.NewTextErrorResponse(fmt.Sprintf("error generating agent: %s", err)), nil + return tools.ToolResponse{}, fmt.Errorf("error creating session: %s", err) } - messages, err := b.app.Messages.List(session.ID) + done, err := agent.Run(ctx, session.ID, params.Prompt) if err != nil { - return tools.NewTextErrorResponse(fmt.Sprintf("error listing messages: %s", err)), nil + return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err) } - if len(messages) == 0 { - return tools.NewTextErrorResponse("no messages found"), nil + result := <-done + if result.Err() != nil { + return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Err()) } - response := messages[len(messages)-1] + response := result.Response() if response.Role != message.Assistant { - return tools.NewTextErrorResponse("no assistant message found"), nil + return tools.NewTextErrorResponse("no response"), nil } - updatedSession, err := b.app.Sessions.Get(session.ID) + updatedSession, err := b.sessions.Get(ctx, session.ID) if err != nil { - return tools.NewTextErrorResponse(fmt.Sprintf("error: %s", err)), nil + return tools.ToolResponse{}, fmt.Errorf("error getting session: %s", err) } - parentSession, err := b.app.Sessions.Get(b.parentSessionID) + parentSession, err := b.sessions.Get(ctx, sessionID) if err != nil { - return tools.NewTextErrorResponse(fmt.Sprintf("error: %s", err)), nil + return tools.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err) } parentSession.Cost += updatedSession.Cost parentSession.PromptTokens += updatedSession.PromptTokens parentSession.CompletionTokens += updatedSession.CompletionTokens - _, err = b.app.Sessions.Save(parentSession) + _, err = b.sessions.Save(ctx, parentSession) if err != nil { - return tools.NewTextErrorResponse(fmt.Sprintf("error: %s", err)), nil + return tools.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err) } return tools.NewTextResponse(response.Content().String()), nil } -func NewAgentTool(parentSessionID string, app *app.App) tools.BaseTool { +func NewAgentTool( + Sessions session.Service, + Messages message.Service, + LspClients map[string]*lsp.Client, +) tools.BaseTool { return &agentTool{ - parentSessionID: parentSessionID, - app: app, + sessions: Sessions, + messages: Messages, + lspClients: LspClients, } } diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 998dc1551..6c5808eab 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -7,30 +7,123 @@ import ( "strings" "sync" - "github.com/kujtimiihoxha/termai/internal/app" - "github.com/kujtimiihoxha/termai/internal/config" - "github.com/kujtimiihoxha/termai/internal/llm/models" - "github.com/kujtimiihoxha/termai/internal/llm/prompt" - "github.com/kujtimiihoxha/termai/internal/llm/provider" - "github.com/kujtimiihoxha/termai/internal/llm/tools" - "github.com/kujtimiihoxha/termai/internal/logging" - "github.com/kujtimiihoxha/termai/internal/message" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/llm/models" + "github.com/kujtimiihoxha/opencode/internal/llm/prompt" + "github.com/kujtimiihoxha/opencode/internal/llm/provider" + "github.com/kujtimiihoxha/opencode/internal/llm/tools" + "github.com/kujtimiihoxha/opencode/internal/logging" + "github.com/kujtimiihoxha/opencode/internal/message" + "github.com/kujtimiihoxha/opencode/internal/permission" + "github.com/kujtimiihoxha/opencode/internal/session" ) -type Agent interface { - Generate(ctx context.Context, sessionID string, content string) error +// Common errors +var ( + ErrRequestCancelled = errors.New("request cancelled by user") + ErrSessionBusy = errors.New("session is currently processing another request") +) + +type AgentEvent struct { + message message.Message + err error +} + +func (e *AgentEvent) Err() error { + return e.err +} + +func (e *AgentEvent) Response() message.Message { + return e.message +} + +type Service interface { + Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error) + Cancel(sessionID string) + IsSessionBusy(sessionID string) bool + IsBusy() bool } type agent struct { - *app.App - model models.Model - tools []tools.BaseTool - agent provider.Provider - titleGenerator provider.Provider + sessions session.Service + messages message.Service + + tools []tools.BaseTool + provider provider.Provider + + titleProvider provider.Provider + + activeRequests sync.Map +} + +func NewAgent( + agentName config.AgentName, + sessions session.Service, + messages message.Service, + agentTools []tools.BaseTool, +) (Service, error) { + agentProvider, err := createAgentProvider(agentName) + if err != nil { + return nil, err + } + var titleProvider provider.Provider + // Only generate titles for the coder agent + if agentName == config.AgentCoder { + titleProvider, err = createAgentProvider(config.AgentTitle) + if err != nil { + return nil, err + } + } + + agent := &agent{ + provider: agentProvider, + messages: messages, + sessions: sessions, + tools: agentTools, + titleProvider: titleProvider, + activeRequests: sync.Map{}, + } + + return agent, nil +} + +func (a *agent) Cancel(sessionID string) { + if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists { + if cancel, ok := cancelFunc.(context.CancelFunc); ok { + logging.InfoPersist(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID)) + cancel() + } + } +} + +func (a *agent) IsBusy() bool { + busy := false + a.activeRequests.Range(func(key, value interface{}) bool { + if cancelFunc, ok := value.(context.CancelFunc); ok { + if cancelFunc != nil { + busy = true + return false // Stop iterating + } + } + return true // Continue iterating + }) + return busy +} + +func (a *agent) IsSessionBusy(sessionID string) bool { + _, busy := a.activeRequests.Load(sessionID) + return busy } -func (c *agent) handleTitleGeneration(ctx context.Context, sessionID, content string) { - response, err := c.titleGenerator.SendMessages( +func (a *agent) generateTitle(ctx context.Context, sessionID string, content string) error { + if a.titleProvider == nil { + return nil + } + session, err := a.sessions.Get(ctx, sessionID) + if err != nil { + return err + } + response, err := a.titleProvider.SendMessages( ctx, []message.Message{ { @@ -42,476 +135,357 @@ func (c *agent) handleTitleGeneration(ctx context.Context, sessionID, content st }, }, }, - nil, + make([]tools.BaseTool, 0), ) if err != nil { - return - } - - session, err := c.Sessions.Get(sessionID) - if err != nil { - return - } - if response.Content != "" { - session.Title = response.Content - session.Title = strings.TrimSpace(session.Title) - session.Title = strings.ReplaceAll(session.Title, "\n", " ") - c.Sessions.Save(session) - } -} - -func (c *agent) TrackUsage(sessionID string, model models.Model, usage provider.TokenUsage) error { - session, err := c.Sessions.Get(sessionID) - if err != nil { return err } - cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) + - model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) + - model.CostPer1MIn/1e6*float64(usage.InputTokens) + - model.CostPer1MOut/1e6*float64(usage.OutputTokens) - - session.Cost += cost - session.CompletionTokens += usage.OutputTokens - session.PromptTokens += usage.InputTokens + title := strings.TrimSpace(strings.ReplaceAll(response.Content, "\n", " ")) + if title == "" { + return nil + } - _, err = c.Sessions.Save(session) + session.Title = title + _, err = a.sessions.Save(ctx, session) return err } -func (c *agent) processEvent( - sessionID string, - assistantMsg *message.Message, - event provider.ProviderEvent, -) error { - switch event.Type { - case provider.EventThinkingDelta: - assistantMsg.AppendReasoningContent(event.Content) - return c.Messages.Update(*assistantMsg) - case provider.EventContentDelta: - assistantMsg.AppendContent(event.Content) - return c.Messages.Update(*assistantMsg) - case provider.EventError: - if errors.Is(event.Error, context.Canceled) { - return nil - } - logging.ErrorPersist(event.Error.Error()) - return event.Error - case provider.EventWarning: - logging.WarnPersist(event.Info) - return nil - case provider.EventInfo: - logging.InfoPersist(event.Info) - case provider.EventComplete: - assistantMsg.SetToolCalls(event.Response.ToolCalls) - assistantMsg.AddFinish(event.Response.FinishReason) - err := c.Messages.Update(*assistantMsg) - if err != nil { - return err - } - return c.TrackUsage(sessionID, c.model, event.Response.Usage) +func (a *agent) err(err error) AgentEvent { + return AgentEvent{ + err: err, } - - return nil } -func (c *agent) ExecuteTools(ctx context.Context, toolCalls []message.ToolCall, tls []tools.BaseTool) ([]message.ToolResult, error) { - var wg sync.WaitGroup - toolResults := make([]message.ToolResult, len(toolCalls)) - mutex := &sync.Mutex{} - errChan := make(chan error, 1) - - // Create a child context that can be canceled - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - for i, tc := range toolCalls { - wg.Add(1) - go func(index int, toolCall message.ToolCall) { - defer wg.Done() - - // Check if context is already canceled - select { - case <-ctx.Done(): - mutex.Lock() - toolResults[index] = message.ToolResult{ - ToolCallID: toolCall.ID, - Content: "Tool execution canceled", - IsError: true, - } - mutex.Unlock() - - // Send cancellation error to error channel if it's empty - select { - case errChan <- ctx.Err(): - default: - } - return - default: - } - - response := "" - isError := false - found := false - - for _, tool := range tls { - if tool.Info().Name == toolCall.Name { - found = true - toolResult, toolErr := tool.Run(ctx, tools.ToolCall{ - ID: toolCall.ID, - Name: toolCall.Name, - Input: toolCall.Input, - }) - - if toolErr != nil { - if errors.Is(toolErr, context.Canceled) { - response = "Tool execution canceled" - - // Send cancellation error to error channel if it's empty - select { - case errChan <- ctx.Err(): - default: - } - } else { - response = fmt.Sprintf("error running tool: %s", toolErr) - } - isError = true - } else { - response = toolResult.Content - isError = toolResult.IsError - } - break - } - } - - if !found { - response = fmt.Sprintf("tool not found: %s", toolCall.Name) - isError = true - } - - mutex.Lock() - defer mutex.Unlock() - - toolResults[index] = message.ToolResult{ - ToolCallID: toolCall.ID, - Content: response, - IsError: isError, - } - }(i, tc) +func (a *agent) Run(ctx context.Context, sessionID string, content string) (<-chan AgentEvent, error) { + events := make(chan AgentEvent) + if a.IsSessionBusy(sessionID) { + return nil, ErrSessionBusy } - // Wait for all goroutines to finish or context to be canceled - done := make(chan struct{}) - go func() { - wg.Wait() - close(done) - }() + genCtx, cancel := context.WithCancel(ctx) - select { - case <-done: - // All tools completed successfully - case err := <-errChan: - // One of the tools encountered a cancellation - return toolResults, err - case <-ctx.Done(): - // Context was canceled externally - return toolResults, ctx.Err() - } + a.activeRequests.Store(sessionID, cancel) + go func() { + logging.Debug("Request started", "sessionID", sessionID) + defer logging.RecoverPanic("agent.Run", func() { + events <- a.err(fmt.Errorf("panic while running the agent")) + }) - return toolResults, nil + result := a.processGeneration(genCtx, sessionID, content) + if result.Err() != nil && !errors.Is(result.Err(), ErrRequestCancelled) && !errors.Is(result.Err(), context.Canceled) { + logging.ErrorPersist(fmt.Sprintf("Generation error for session %s: %v", sessionID, result)) + } + logging.Debug("Request completed", "sessionID", sessionID) + a.activeRequests.Delete(sessionID) + cancel() + events <- result + close(events) + }() + return events, nil } -func (c *agent) handleToolExecution( - ctx context.Context, - assistantMsg message.Message, -) (*message.Message, error) { - if len(assistantMsg.ToolCalls()) == 0 { - return nil, nil - } - - toolResults, err := c.ExecuteTools(ctx, assistantMsg.ToolCalls(), c.tools) +func (a *agent) processGeneration(ctx context.Context, sessionID, content string) AgentEvent { + // List existing messages; if none, start title generation asynchronously. + msgs, err := a.messages.List(ctx, sessionID) if err != nil { - return nil, err + return a.err(fmt.Errorf("failed to list messages: %w", err)) } - parts := make([]message.ContentPart, 0) - for _, toolResult := range toolResults { - parts = append(parts, toolResult) + if len(msgs) == 0 { + go func() { + defer logging.RecoverPanic("agent.Run", func() { + logging.ErrorPersist("panic while generating title") + }) + titleErr := a.generateTitle(context.Background(), sessionID, content) + if titleErr != nil { + logging.ErrorPersist(fmt.Sprintf("failed to generate title: %v", titleErr)) + } + }() } - msg, err := c.Messages.Create(assistantMsg.SessionID, message.CreateMessageParams{ - Role: message.Tool, - Parts: parts, - }) - - return &msg, err -} -func (c *agent) generate(ctx context.Context, sessionID string, content string) error { - messages, err := c.Messages.List(sessionID) + userMsg, err := a.createUserMessage(ctx, sessionID, content) if err != nil { - return err + return a.err(fmt.Errorf("failed to create user message: %w", err)) } - if len(messages) == 0 { - go c.handleTitleGeneration(ctx, sessionID, content) + // Append the new user message to the conversation history. + msgHistory := append(msgs, userMsg) + for { + // Check for cancellation before each iteration + select { + case <-ctx.Done(): + return a.err(ctx.Err()) + default: + // Continue processing + } + agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, msgHistory) + if err != nil { + if errors.Is(err, context.Canceled) { + agentMessage.AddFinish(message.FinishReasonCanceled) + a.messages.Update(context.Background(), agentMessage) + return a.err(ErrRequestCancelled) + } + return a.err(fmt.Errorf("failed to process events: %w", err)) + } + logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults) + if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil { + // We are not done, we need to respond with the tool response + msgHistory = append(msgHistory, agentMessage, *toolResults) + continue + } + return AgentEvent{ + message: agentMessage, + } } +} - userMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{ +func (a *agent) createUserMessage(ctx context.Context, sessionID, content string) (message.Message, error) { + return a.messages.Create(ctx, sessionID, message.CreateMessageParams{ Role: message.User, Parts: []message.ContentPart{ - message.TextContent{ - Text: content, - }, + message.TextContent{Text: content}, }, }) +} + +func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) { + eventChan := a.provider.StreamResponse(ctx, msgHistory, a.tools) + + assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{ + Role: message.Assistant, + Parts: []message.ContentPart{}, + Model: a.provider.Model().ID, + }) if err != nil { - return err + return assistantMsg, nil, fmt.Errorf("failed to create assistant message: %w", err) } - messages = append(messages, userMsg) - for { + // Add the session and message ID into the context if needed by tools. + ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID) + ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID) + + // Process each event in the stream. + for event := range eventChan { + if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil { + a.finishMessage(ctx, &assistantMsg, message.FinishReasonCanceled) + return assistantMsg, nil, processErr + } + if ctx.Err() != nil { + a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled) + return assistantMsg, nil, ctx.Err() + } + } + + toolResults := make([]message.ToolResult, len(assistantMsg.ToolCalls())) + toolCalls := assistantMsg.ToolCalls() + for i, toolCall := range toolCalls { select { case <-ctx.Done(): - assistantMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{ - Role: message.Assistant, - Parts: []message.ContentPart{}, - }) - if err != nil { - return err + a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled) + // Make all future tool calls cancelled + for j := i; j < len(toolCalls); j++ { + toolResults[j] = message.ToolResult{ + ToolCallID: toolCalls[j].ID, + Content: "Tool execution canceled by user", + IsError: true, + } } - assistantMsg.AddFinish("canceled") - c.Messages.Update(assistantMsg) - return context.Canceled + goto out default: // Continue processing - } - - eventChan, err := c.agent.StreamResponse(ctx, messages, c.tools) - if err != nil { - if errors.Is(err, context.Canceled) { - assistantMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{ - Role: message.Assistant, - Parts: []message.ContentPart{}, - }) - if err != nil { - return err + var tool tools.BaseTool + for _, availableTools := range a.tools { + if availableTools.Info().Name == toolCall.Name { + tool = availableTools } - assistantMsg.AddFinish("canceled") - c.Messages.Update(assistantMsg) - return context.Canceled } - return err - } - assistantMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{ - Role: message.Assistant, - Parts: []message.ContentPart{}, - }) - if err != nil { - return err - } - for event := range eventChan { - err = c.processEvent(sessionID, &assistantMsg, event) - if err != nil { - if errors.Is(err, context.Canceled) { - assistantMsg.AddFinish("canceled") - c.Messages.Update(assistantMsg) - return context.Canceled + // Tool not found + if tool == nil { + toolResults[i] = message.ToolResult{ + ToolCallID: toolCall.ID, + Content: fmt.Sprintf("Tool not found: %s", toolCall.Name), + IsError: true, } - assistantMsg.AddFinish("error:" + err.Error()) - c.Messages.Update(assistantMsg) - return err + continue } - select { - case <-ctx.Done(): - assistantMsg.AddFinish("canceled") - c.Messages.Update(assistantMsg) - return context.Canceled - default: + toolResult, toolErr := tool.Run(ctx, tools.ToolCall{ + ID: toolCall.ID, + Name: toolCall.Name, + Input: toolCall.Input, + }) + if toolErr != nil { + if errors.Is(toolErr, permission.ErrorPermissionDenied) { + toolResults[i] = message.ToolResult{ + ToolCallID: toolCall.ID, + Content: "Permission denied", + IsError: true, + } + for j := i + 1; j < len(toolCalls); j++ { + toolResults[j] = message.ToolResult{ + ToolCallID: toolCalls[j].ID, + Content: "Tool execution canceled by user", + IsError: true, + } + } + a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied) + break + } } - } - - // Check for context cancellation before tool execution - select { - case <-ctx.Done(): - assistantMsg.AddFinish("canceled") - c.Messages.Update(assistantMsg) - return context.Canceled - default: - // Continue processing - } - - msg, err := c.handleToolExecution(ctx, assistantMsg) - if err != nil { - if errors.Is(err, context.Canceled) { - assistantMsg.AddFinish("canceled") - c.Messages.Update(assistantMsg) - return context.Canceled + toolResults[i] = message.ToolResult{ + ToolCallID: toolCall.ID, + Content: toolResult.Content, + Metadata: toolResult.Metadata, + IsError: toolResult.IsError, } - return err } + } +out: + if len(toolResults) == 0 { + return assistantMsg, nil, nil + } + parts := make([]message.ContentPart, 0) + for _, tr := range toolResults { + parts = append(parts, tr) + } + msg, err := a.messages.Create(context.Background(), assistantMsg.SessionID, message.CreateMessageParams{ + Role: message.Tool, + Parts: parts, + }) + if err != nil { + return assistantMsg, nil, fmt.Errorf("failed to create cancelled tool message: %w", err) + } - c.Messages.Update(assistantMsg) + return assistantMsg, &msg, err +} - if len(assistantMsg.ToolCalls()) == 0 { - break - } +func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReson message.FinishReason) { + msg.AddFinish(finishReson) + _ = a.messages.Update(ctx, *msg) +} - messages = append(messages, assistantMsg) - if msg != nil { - messages = append(messages, *msg) - } +func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg *message.Message, event provider.ProviderEvent) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + // Continue processing. + } - // Check for context cancellation after tool execution - select { - case <-ctx.Done(): - assistantMsg.AddFinish("canceled") - c.Messages.Update(assistantMsg) + switch event.Type { + case provider.EventThinkingDelta: + assistantMsg.AppendReasoningContent(event.Content) + return a.messages.Update(ctx, *assistantMsg) + case provider.EventContentDelta: + assistantMsg.AppendContent(event.Content) + return a.messages.Update(ctx, *assistantMsg) + case provider.EventToolUseStart: + assistantMsg.AddToolCall(*event.ToolCall) + return a.messages.Update(ctx, *assistantMsg) + // TODO: see how to handle this + // case provider.EventToolUseDelta: + // tm := time.Unix(assistantMsg.UpdatedAt, 0) + // assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input) + // if time.Since(tm) > 1000*time.Millisecond { + // err := a.messages.Update(ctx, *assistantMsg) + // assistantMsg.UpdatedAt = time.Now().Unix() + // return err + // } + case provider.EventToolUseStop: + assistantMsg.FinishToolCall(event.ToolCall.ID) + return a.messages.Update(ctx, *assistantMsg) + case provider.EventError: + if errors.Is(event.Error, context.Canceled) { + logging.InfoPersist(fmt.Sprintf("Event processing canceled for session: %s", sessionID)) return context.Canceled - default: - // Continue processing } + logging.ErrorPersist(event.Error.Error()) + return event.Error + case provider.EventComplete: + assistantMsg.SetToolCalls(event.Response.ToolCalls) + assistantMsg.AddFinish(event.Response.FinishReason) + if err := a.messages.Update(ctx, *assistantMsg); err != nil { + return fmt.Errorf("failed to update message: %w", err) + } + return a.TrackUsage(ctx, sessionID, a.provider.Model(), event.Response.Usage) } + return nil } -func getAgentProviders(ctx context.Context, model models.Model) (provider.Provider, provider.Provider, error) { - maxTokens := config.Get().Model.CoderMaxTokens - - providerConfig, ok := config.Get().Providers[model.Provider] - if !ok || !providerConfig.Enabled { - return nil, nil, errors.New("provider is not enabled") +func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.Model, usage provider.TokenUsage) error { + sess, err := a.sessions.Get(ctx, sessionID) + if err != nil { + return fmt.Errorf("failed to get session: %w", err) } - var agentProvider provider.Provider - var titleGenerator provider.Provider - switch model.Provider { - case models.ProviderOpenAI: - var err error - agentProvider, err = provider.NewOpenAIProvider( - provider.WithOpenAISystemMessage( - prompt.CoderOpenAISystemPrompt(), - ), - provider.WithOpenAIMaxTokens(maxTokens), - provider.WithOpenAIModel(model), - provider.WithOpenAIKey(providerConfig.APIKey), - ) - if err != nil { - return nil, nil, err - } - titleGenerator, err = provider.NewOpenAIProvider( - provider.WithOpenAISystemMessage( - prompt.TitlePrompt(), - ), - provider.WithOpenAIMaxTokens(80), - provider.WithOpenAIModel(model), - provider.WithOpenAIKey(providerConfig.APIKey), - ) - if err != nil { - return nil, nil, err - } - case models.ProviderAnthropic: - var err error - agentProvider, err = provider.NewAnthropicProvider( - provider.WithAnthropicSystemMessage( - prompt.CoderAnthropicSystemPrompt(), - ), - provider.WithAnthropicMaxTokens(maxTokens), - provider.WithAnthropicKey(providerConfig.APIKey), - provider.WithAnthropicModel(model), - ) - if err != nil { - return nil, nil, err - } - titleGenerator, err = provider.NewAnthropicProvider( - provider.WithAnthropicSystemMessage( - prompt.TitlePrompt(), - ), - provider.WithAnthropicMaxTokens(80), - provider.WithAnthropicKey(providerConfig.APIKey), - provider.WithAnthropicModel(model), - ) - if err != nil { - return nil, nil, err - } + cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) + + model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) + + model.CostPer1MIn/1e6*float64(usage.InputTokens) + + model.CostPer1MOut/1e6*float64(usage.OutputTokens) - case models.ProviderGemini: - var err error - agentProvider, err = provider.NewGeminiProvider( - ctx, - provider.WithGeminiSystemMessage( - prompt.CoderOpenAISystemPrompt(), - ), - provider.WithGeminiMaxTokens(int32(maxTokens)), - provider.WithGeminiKey(providerConfig.APIKey), - provider.WithGeminiModel(model), - ) - if err != nil { - return nil, nil, err - } - titleGenerator, err = provider.NewGeminiProvider( - ctx, - provider.WithGeminiSystemMessage( - prompt.TitlePrompt(), - ), - provider.WithGeminiMaxTokens(80), - provider.WithGeminiKey(providerConfig.APIKey), - provider.WithGeminiModel(model), - ) - if err != nil { - return nil, nil, err - } - case models.ProviderGROQ: - var err error - agentProvider, err = provider.NewOpenAIProvider( - provider.WithOpenAISystemMessage( - prompt.CoderAnthropicSystemPrompt(), - ), - provider.WithOpenAIMaxTokens(maxTokens), - provider.WithOpenAIModel(model), - provider.WithOpenAIKey(providerConfig.APIKey), - provider.WithOpenAIBaseURL("https://api.groq.com/openai/v1"), - ) - if err != nil { - return nil, nil, err - } - titleGenerator, err = provider.NewOpenAIProvider( - provider.WithOpenAISystemMessage( - prompt.TitlePrompt(), - ), - provider.WithOpenAIMaxTokens(80), - provider.WithOpenAIModel(model), - provider.WithOpenAIKey(providerConfig.APIKey), - provider.WithOpenAIBaseURL("https://api.groq.com/openai/v1"), - ) - if err != nil { - return nil, nil, err - } + sess.Cost += cost + sess.CompletionTokens += usage.OutputTokens + sess.PromptTokens += usage.InputTokens + + _, err = a.sessions.Save(ctx, sess) + if err != nil { + return fmt.Errorf("failed to save session: %w", err) + } + return nil +} + +func createAgentProvider(agentName config.AgentName) (provider.Provider, error) { + cfg := config.Get() + agentConfig, ok := cfg.Agents[agentName] + if !ok { + return nil, fmt.Errorf("agent %s not found", agentName) + } + model, ok := models.SupportedModels[agentConfig.Model] + if !ok { + return nil, fmt.Errorf("model %s not supported", agentConfig.Model) + } - case models.ProviderBedrock: - var err error - agentProvider, err = provider.NewBedrockProvider( - provider.WithBedrockSystemMessage( - prompt.CoderAnthropicSystemPrompt(), + providerCfg, ok := cfg.Providers[model.Provider] + if !ok { + return nil, fmt.Errorf("provider %s not supported", model.Provider) + } + if providerCfg.Disabled { + return nil, fmt.Errorf("provider %s is not enabled", model.Provider) + } + maxTokens := model.DefaultMaxTokens + if agentConfig.MaxTokens > 0 { + maxTokens = agentConfig.MaxTokens + } + opts := []provider.ProviderClientOption{ + provider.WithAPIKey(providerCfg.APIKey), + provider.WithModel(model), + provider.WithSystemMessage(prompt.GetAgentPrompt(agentName, model.Provider)), + provider.WithMaxTokens(maxTokens), + } + if model.Provider == models.ProviderOpenAI && model.CanReason { + opts = append( + opts, + provider.WithOpenAIOptions( + provider.WithReasoningEffort(agentConfig.ReasoningEffort), ), - provider.WithBedrockMaxTokens(maxTokens), - provider.WithBedrockModel(model), ) - if err != nil { - return nil, nil, err - } - titleGenerator, err = provider.NewBedrockProvider( - provider.WithBedrockSystemMessage( - prompt.TitlePrompt(), + } else if model.Provider == models.ProviderAnthropic && model.CanReason && agentName == config.AgentCoder { + opts = append( + opts, + provider.WithAnthropicOptions( + provider.WithAnthropicShouldThinkFn(provider.DefaultShouldThinkFn), ), - provider.WithBedrockMaxTokens(maxTokens), - provider.WithBedrockModel(model), ) - if err != nil { - return nil, nil, err - } - + } + agentProvider, err := provider.NewProvider( + model.Provider, + opts..., + ) + if err != nil { + return nil, fmt.Errorf("could not create provider: %v", err) } - return agentProvider, titleGenerator, nil + return agentProvider, nil } diff --git a/internal/llm/agent/coder.go b/internal/llm/agent/coder.go deleted file mode 100644 index 5deff05a8..000000000 --- a/internal/llm/agent/coder.go +++ /dev/null @@ -1,73 +0,0 @@ -package agent - -import ( - "context" - "errors" - - "github.com/kujtimiihoxha/termai/internal/app" - "github.com/kujtimiihoxha/termai/internal/config" - "github.com/kujtimiihoxha/termai/internal/llm/models" - "github.com/kujtimiihoxha/termai/internal/llm/tools" -) - -type coderAgent struct { - *agent -} - -func (c *coderAgent) setAgentTool(sessionID string) { - inx := -1 - for i, tool := range c.tools { - if tool.Info().Name == AgentToolName { - inx = i - break - } - } - if inx == -1 { - c.tools = append(c.tools, NewAgentTool(sessionID, c.App)) - } else { - c.tools[inx] = NewAgentTool(sessionID, c.App) - } -} - -func (c *coderAgent) Generate(ctx context.Context, sessionID string, content string) error { - c.setAgentTool(sessionID) - return c.generate(ctx, sessionID, content) -} - -func NewCoderAgent(app *app.App) (Agent, error) { - model, ok := models.SupportedModels[config.Get().Model.Coder] - if !ok { - return nil, errors.New("model not supported") - } - - agentProvider, titleGenerator, err := getAgentProviders(app.Context, model) - if err != nil { - return nil, err - } - - otherTools := GetMcpTools(app.Context, app.Permissions) - if len(app.LSPClients) > 0 { - otherTools = append(otherTools, tools.NewDiagnosticsTool(app.LSPClients)) - } - return &coderAgent{ - agent: &agent{ - App: app, - tools: append( - []tools.BaseTool{ - tools.NewBashTool(app.Permissions), - tools.NewEditTool(app.LSPClients, app.Permissions), - tools.NewFetchTool(app.Permissions), - tools.NewGlobTool(), - tools.NewGrepTool(), - tools.NewLsTool(), - tools.NewSourcegraphTool(), - tools.NewViewTool(app.LSPClients), - tools.NewWriteTool(app.LSPClients, app.Permissions), - }, otherTools..., - ), - model: model, - agent: agentProvider, - titleGenerator: titleGenerator, - }, - }, nil -} diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index b1c97b512..53aada33f 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -5,11 +5,11 @@ import ( "encoding/json" "fmt" - "github.com/kujtimiihoxha/termai/internal/config" - "github.com/kujtimiihoxha/termai/internal/llm/tools" - "github.com/kujtimiihoxha/termai/internal/logging" - "github.com/kujtimiihoxha/termai/internal/permission" - "github.com/kujtimiihoxha/termai/internal/version" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/llm/tools" + "github.com/kujtimiihoxha/opencode/internal/logging" + "github.com/kujtimiihoxha/opencode/internal/permission" + "github.com/kujtimiihoxha/opencode/internal/version" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" @@ -46,7 +46,7 @@ func runTool(ctx context.Context, c MCPClient, toolName string, input string) (t initRequest := mcp.InitializeRequest{} initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION initRequest.Params.ClientInfo = mcp.Implementation{ - Name: "termai", + Name: "OpenCode", Version: version.Version, } @@ -80,9 +80,14 @@ func runTool(ctx context.Context, c MCPClient, toolName string, input string) (t } func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolResponse, error) { + sessionID, messageID := tools.GetContextValues(ctx) + if sessionID == "" || messageID == "" { + return tools.ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file") + } permissionDescription := fmt.Sprintf("execute %s with the following parameters: %s", b.Info().Name, params.Input) p := b.permissions.Request( permission.CreatePermissionRequest{ + SessionID: sessionID, Path: config.WorkingDirectory(), ToolName: b.Info().Name, Action: "execute", @@ -135,7 +140,7 @@ func getTools(ctx context.Context, name string, m config.MCPServer, permissions initRequest := mcp.InitializeRequest{} initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION initRequest.Params.ClientInfo = mcp.Implementation{ - Name: "termai", + Name: "OpenCode", Version: version.Version, } diff --git a/internal/llm/agent/task.go b/internal/llm/agent/task.go deleted file mode 100644 index 034e93460..000000000 --- a/internal/llm/agent/task.go +++ /dev/null @@ -1,46 +0,0 @@ -package agent - -import ( - "context" - "errors" - - "github.com/kujtimiihoxha/termai/internal/app" - "github.com/kujtimiihoxha/termai/internal/config" - "github.com/kujtimiihoxha/termai/internal/llm/models" - "github.com/kujtimiihoxha/termai/internal/llm/tools" -) - -type taskAgent struct { - *agent -} - -func (c *taskAgent) Generate(ctx context.Context, sessionID string, content string) error { - return c.generate(ctx, sessionID, content) -} - -func NewTaskAgent(app *app.App) (Agent, error) { - model, ok := models.SupportedModels[config.Get().Model.Coder] - if !ok { - return nil, errors.New("model not supported") - } - - agentProvider, titleGenerator, err := getAgentProviders(app.Context, model) - if err != nil { - return nil, err - } - return &taskAgent{ - agent: &agent{ - App: app, - tools: []tools.BaseTool{ - tools.NewGlobTool(), - tools.NewGrepTool(), - tools.NewLsTool(), - tools.NewSourcegraphTool(), - tools.NewViewTool(app.LSPClients), - }, - model: model, - agent: agentProvider, - titleGenerator: titleGenerator, - }, - }, nil -} diff --git a/internal/llm/agent/tools.go b/internal/llm/agent/tools.go new file mode 100644 index 000000000..b2e6816d5 --- /dev/null +++ b/internal/llm/agent/tools.go @@ -0,0 +1,51 @@ +package agent + +import ( + "context" + + "github.com/kujtimiihoxha/opencode/internal/history" + "github.com/kujtimiihoxha/opencode/internal/llm/tools" + "github.com/kujtimiihoxha/opencode/internal/lsp" + "github.com/kujtimiihoxha/opencode/internal/message" + "github.com/kujtimiihoxha/opencode/internal/permission" + "github.com/kujtimiihoxha/opencode/internal/session" +) + +func CoderAgentTools( + permissions permission.Service, + sessions session.Service, + messages message.Service, + history history.Service, + lspClients map[string]*lsp.Client, +) []tools.BaseTool { + ctx := context.Background() + otherTools := GetMcpTools(ctx, permissions) + if len(lspClients) > 0 { + otherTools = append(otherTools, tools.NewDiagnosticsTool(lspClients)) + } + return append( + []tools.BaseTool{ + tools.NewBashTool(permissions), + tools.NewEditTool(lspClients, permissions, history), + tools.NewFetchTool(permissions), + tools.NewGlobTool(), + tools.NewGrepTool(), + tools.NewLsTool(), + tools.NewSourcegraphTool(), + tools.NewViewTool(lspClients), + tools.NewPatchTool(lspClients, permissions, history), + tools.NewWriteTool(lspClients, permissions, history), + NewAgentTool(sessions, messages, lspClients), + }, otherTools..., + ) +} + +func TaskAgentTools(lspClients map[string]*lsp.Client) []tools.BaseTool { + return []tools.BaseTool{ + tools.NewGlobTool(), + tools.NewGrepTool(), + tools.NewLsTool(), + tools.NewSourcegraphTool(), + tools.NewViewTool(lspClients), + } +} diff --git a/internal/llm/models/anthropic.go b/internal/llm/models/anthropic.go new file mode 100644 index 000000000..87e9b4c89 --- /dev/null +++ b/internal/llm/models/anthropic.go @@ -0,0 +1,77 @@ +package models + +const ( + ProviderAnthropic ModelProvider = "anthropic" + + // Models + Claude35Sonnet ModelID = "claude-3.5-sonnet" + Claude3Haiku ModelID = "claude-3-haiku" + Claude37Sonnet ModelID = "claude-3.7-sonnet" + Claude35Haiku ModelID = "claude-3.5-haiku" + Claude3Opus ModelID = "claude-3-opus" +) + +var AnthropicModels = map[ModelID]Model{ + // Anthropic + Claude35Sonnet: { + ID: Claude35Sonnet, + Name: "Claude 3.5 Sonnet", + Provider: ProviderAnthropic, + APIModel: "claude-3-5-sonnet-latest", + CostPer1MIn: 3.0, + CostPer1MInCached: 3.75, + CostPer1MOutCached: 0.30, + CostPer1MOut: 15.0, + ContextWindow: 200000, + DefaultMaxTokens: 5000, + }, + Claude3Haiku: { + ID: Claude3Haiku, + Name: "Claude 3 Haiku", + Provider: ProviderAnthropic, + APIModel: "claude-3-haiku-latest", + CostPer1MIn: 0.25, + CostPer1MInCached: 0.30, + CostPer1MOutCached: 0.03, + CostPer1MOut: 1.25, + ContextWindow: 200000, + DefaultMaxTokens: 5000, + }, + Claude37Sonnet: { + ID: Claude37Sonnet, + Name: "Claude 3.7 Sonnet", + Provider: ProviderAnthropic, + APIModel: "claude-3-7-sonnet-latest", + CostPer1MIn: 3.0, + CostPer1MInCached: 3.75, + CostPer1MOutCached: 0.30, + CostPer1MOut: 15.0, + ContextWindow: 200000, + DefaultMaxTokens: 50000, + CanReason: true, + }, + Claude35Haiku: { + ID: Claude35Haiku, + Name: "Claude 3.5 Haiku", + Provider: ProviderAnthropic, + APIModel: "claude-3-5-haiku-latest", + CostPer1MIn: 0.80, + CostPer1MInCached: 1.0, + CostPer1MOutCached: 0.08, + CostPer1MOut: 4.0, + ContextWindow: 200000, + DefaultMaxTokens: 4096, + }, + Claude3Opus: { + ID: Claude3Opus, + Name: "Claude 3 Opus", + Provider: ProviderAnthropic, + APIModel: "claude-3-opus-latest", + CostPer1MIn: 15.0, + CostPer1MInCached: 18.75, + CostPer1MOutCached: 1.50, + CostPer1MOut: 75.0, + ContextWindow: 200000, + DefaultMaxTokens: 4096, + }, +} diff --git a/internal/llm/models/gemini.go b/internal/llm/models/gemini.go new file mode 100644 index 000000000..00bf7387f --- /dev/null +++ b/internal/llm/models/gemini.go @@ -0,0 +1,63 @@ +package models + +const ( + ProviderGemini ModelProvider = "gemini" + + // Models + Gemini25Flash ModelID = "gemini-2.5-flash" + Gemini25 ModelID = "gemini-2.5" + Gemini20Flash ModelID = "gemini-2.0-flash" + Gemini20FlashLite ModelID = "gemini-2.0-flash-lite" +) + +var GeminiModels = map[ModelID]Model{ + Gemini25Flash: { + ID: Gemini25Flash, + Name: "Gemini 2.5 Flash", + Provider: ProviderGemini, + APIModel: "gemini-2.5-flash-preview-04-17", + CostPer1MIn: 0.15, + CostPer1MInCached: 0, + CostPer1MOutCached: 0, + CostPer1MOut: 0.60, + ContextWindow: 1000000, + DefaultMaxTokens: 50000, + }, + Gemini25: { + ID: Gemini25, + Name: "Gemini 2.5 Pro", + Provider: ProviderGemini, + APIModel: "gemini-2.5-pro-preview-03-25", + CostPer1MIn: 1.25, + CostPer1MInCached: 0, + CostPer1MOutCached: 0, + CostPer1MOut: 10, + ContextWindow: 1000000, + DefaultMaxTokens: 50000, + }, + + Gemini20Flash: { + ID: Gemini20Flash, + Name: "Gemini 2.0 Flash", + Provider: ProviderGemini, + APIModel: "gemini-2.0-flash", + CostPer1MIn: 0.10, + CostPer1MInCached: 0, + CostPer1MOutCached: 0, + CostPer1MOut: 0.40, + ContextWindow: 1000000, + DefaultMaxTokens: 6000, + }, + Gemini20FlashLite: { + ID: Gemini20FlashLite, + Name: "Gemini 2.0 Flash Lite", + Provider: ProviderGemini, + APIModel: "gemini-2.0-flash-lite", + CostPer1MIn: 0.05, + CostPer1MInCached: 0, + CostPer1MOutCached: 0, + CostPer1MOut: 0.30, + ContextWindow: 1000000, + DefaultMaxTokens: 6000, + }, +} diff --git a/internal/llm/models/models.go b/internal/llm/models/models.go index 140693237..cccbd2765 100644 --- a/internal/llm/models/models.go +++ b/internal/llm/models/models.go @@ -1,5 +1,7 @@ package models +import "maps" + type ( ModelID string ModelProvider string @@ -14,21 +16,13 @@ type Model struct { CostPer1MOut float64 `json:"cost_per_1m_out"` CostPer1MInCached float64 `json:"cost_per_1m_in_cached"` CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"` + ContextWindow int64 `json:"context_window"` + DefaultMaxTokens int64 `json:"default_max_tokens"` + CanReason bool `json:"can_reason"` } // Model IDs -const ( - // Anthropic - Claude35Sonnet ModelID = "claude-3.5-sonnet" - Claude3Haiku ModelID = "claude-3-haiku" - Claude37Sonnet ModelID = "claude-3.7-sonnet" - // OpenAI - GPT41 ModelID = "gpt-4.1" - - // GEMINI - GEMINI25 ModelID = "gemini-2.5" - GRMINI20Flash ModelID = "gemini-2.0-flash" - +const ( // GEMINI // GROQ QWENQwq ModelID = "qwen-qwq" @@ -37,94 +31,51 @@ const ( ) const ( - ProviderOpenAI ModelProvider = "openai" - ProviderAnthropic ModelProvider = "anthropic" - ProviderBedrock ModelProvider = "bedrock" - ProviderGemini ModelProvider = "gemini" - ProviderGROQ ModelProvider = "groq" + ProviderBedrock ModelProvider = "bedrock" + ProviderGROQ ModelProvider = "groq" + + // ForTests + ProviderMock ModelProvider = "__mock" ) var SupportedModels = map[ModelID]Model{ - // Anthropic - Claude35Sonnet: { - ID: Claude35Sonnet, - Name: "Claude 3.5 Sonnet", - Provider: ProviderAnthropic, - APIModel: "claude-3-5-sonnet-latest", - CostPer1MIn: 3.0, - CostPer1MInCached: 3.75, - CostPer1MOutCached: 0.30, - CostPer1MOut: 15.0, - }, - Claude3Haiku: { - ID: Claude3Haiku, - Name: "Claude 3 Haiku", - Provider: ProviderAnthropic, - APIModel: "claude-3-haiku-latest", - CostPer1MIn: 0.80, - CostPer1MInCached: 1, - CostPer1MOutCached: 0.08, - CostPer1MOut: 4, - }, - Claude37Sonnet: { - ID: Claude37Sonnet, - Name: "Claude 3.7 Sonnet", - Provider: ProviderAnthropic, - APIModel: "claude-3-7-sonnet-latest", - CostPer1MIn: 3.0, - CostPer1MInCached: 3.75, - CostPer1MOutCached: 0.30, - CostPer1MOut: 15.0, - }, - - // OpenAI - GPT41: { - ID: GPT41, - Name: "GPT-4.1", - Provider: ProviderOpenAI, - APIModel: "gpt-4.1", - CostPer1MIn: 2.00, - CostPer1MInCached: 0.50, - CostPer1MOutCached: 0, - CostPer1MOut: 8.00, - }, - - // GEMINI - GEMINI25: { - ID: GEMINI25, - Name: "Gemini 2.5 Pro", - Provider: ProviderGemini, - APIModel: "gemini-2.5-pro-exp-03-25", - CostPer1MIn: 0, - CostPer1MInCached: 0, - CostPer1MOutCached: 0, - CostPer1MOut: 0, - }, - - GRMINI20Flash: { - ID: GRMINI20Flash, - Name: "Gemini 2.0 Flash", - Provider: ProviderGemini, - APIModel: "gemini-2.0-flash", - CostPer1MIn: 0.1, - CostPer1MInCached: 0, - CostPer1MOutCached: 0.025, - CostPer1MOut: 0.4, - }, - - // GROQ - QWENQwq: { - ID: QWENQwq, - Name: "Qwen Qwq", - Provider: ProviderGROQ, - APIModel: "qwen-qwq-32b", - CostPer1MIn: 0, - CostPer1MInCached: 0, - CostPer1MOutCached: 0, - CostPer1MOut: 0, - }, - - // Bedrock + // + // // GEMINI + // GEMINI25: { + // ID: GEMINI25, + // Name: "Gemini 2.5 Pro", + // Provider: ProviderGemini, + // APIModel: "gemini-2.5-pro-exp-03-25", + // CostPer1MIn: 0, + // CostPer1MInCached: 0, + // CostPer1MOutCached: 0, + // CostPer1MOut: 0, + // }, + // + // GRMINI20Flash: { + // ID: GRMINI20Flash, + // Name: "Gemini 2.0 Flash", + // Provider: ProviderGemini, + // APIModel: "gemini-2.0-flash", + // CostPer1MIn: 0.1, + // CostPer1MInCached: 0, + // CostPer1MOutCached: 0.025, + // CostPer1MOut: 0.4, + // }, + // + // // GROQ + // QWENQwq: { + // ID: QWENQwq, + // Name: "Qwen Qwq", + // Provider: ProviderGROQ, + // APIModel: "qwen-qwq-32b", + // CostPer1MIn: 0, + // CostPer1MInCached: 0, + // CostPer1MOutCached: 0, + // CostPer1MOut: 0, + // }, + // + // // Bedrock BedrockClaude37Sonnet: { ID: BedrockClaude37Sonnet, Name: "Bedrock: Claude 3.7 Sonnet", @@ -136,3 +87,9 @@ var SupportedModels = map[ModelID]Model{ CostPer1MOut: 15.0, }, } + +func init() { + maps.Copy(SupportedModels, AnthropicModels) + maps.Copy(SupportedModels, OpenAIModels) + maps.Copy(SupportedModels, GeminiModels) +} diff --git a/internal/llm/models/openai.go b/internal/llm/models/openai.go new file mode 100644 index 000000000..f0cbb298c --- /dev/null +++ b/internal/llm/models/openai.go @@ -0,0 +1,169 @@ +package models + +const ( + ProviderOpenAI ModelProvider = "openai" + + GPT41 ModelID = "gpt-4.1" + GPT41Mini ModelID = "gpt-4.1-mini" + GPT41Nano ModelID = "gpt-4.1-nano" + GPT45Preview ModelID = "gpt-4.5-preview" + GPT4o ModelID = "gpt-4o" + GPT4oMini ModelID = "gpt-4o-mini" + O1 ModelID = "o1" + O1Pro ModelID = "o1-pro" + O1Mini ModelID = "o1-mini" + O3 ModelID = "o3" + O3Mini ModelID = "o3-mini" + O4Mini ModelID = "o4-mini" +) + +var OpenAIModels = map[ModelID]Model{ + GPT41: { + ID: GPT41, + Name: "GPT 4.1", + Provider: ProviderOpenAI, + APIModel: "gpt-4.1", + CostPer1MIn: 2.00, + CostPer1MInCached: 0.50, + CostPer1MOutCached: 0.0, + CostPer1MOut: 8.00, + ContextWindow: 1_047_576, + DefaultMaxTokens: 20000, + }, + GPT41Mini: { + ID: GPT41Mini, + Name: "GPT 4.1 mini", + Provider: ProviderOpenAI, + APIModel: "gpt-4.1", + CostPer1MIn: 0.40, + CostPer1MInCached: 0.10, + CostPer1MOutCached: 0.0, + CostPer1MOut: 1.60, + ContextWindow: 200_000, + DefaultMaxTokens: 20000, + }, + GPT41Nano: { + ID: GPT41Nano, + Name: "GPT 4.1 nano", + Provider: ProviderOpenAI, + APIModel: "gpt-4.1-nano", + CostPer1MIn: 0.10, + CostPer1MInCached: 0.025, + CostPer1MOutCached: 0.0, + CostPer1MOut: 0.40, + ContextWindow: 1_047_576, + DefaultMaxTokens: 20000, + }, + GPT45Preview: { + ID: GPT45Preview, + Name: "GPT 4.5 preview", + Provider: ProviderOpenAI, + APIModel: "gpt-4.5-preview", + CostPer1MIn: 75.00, + CostPer1MInCached: 37.50, + CostPer1MOutCached: 0.0, + CostPer1MOut: 150.00, + ContextWindow: 128_000, + DefaultMaxTokens: 15000, + }, + GPT4o: { + ID: GPT4o, + Name: "GPT 4o", + Provider: ProviderOpenAI, + APIModel: "gpt-4o", + CostPer1MIn: 2.50, + CostPer1MInCached: 1.25, + CostPer1MOutCached: 0.0, + CostPer1MOut: 10.00, + ContextWindow: 128_000, + DefaultMaxTokens: 4096, + }, + GPT4oMini: { + ID: GPT4oMini, + Name: "GPT 4o mini", + Provider: ProviderOpenAI, + APIModel: "gpt-4o-mini", + CostPer1MIn: 0.15, + CostPer1MInCached: 0.075, + CostPer1MOutCached: 0.0, + CostPer1MOut: 0.60, + ContextWindow: 128_000, + }, + O1: { + ID: O1, + Name: "O1", + Provider: ProviderOpenAI, + APIModel: "o1", + CostPer1MIn: 15.00, + CostPer1MInCached: 7.50, + CostPer1MOutCached: 0.0, + CostPer1MOut: 60.00, + ContextWindow: 200_000, + DefaultMaxTokens: 50000, + CanReason: true, + }, + O1Pro: { + ID: O1Pro, + Name: "o1 pro", + Provider: ProviderOpenAI, + APIModel: "o1-pro", + CostPer1MIn: 150.00, + CostPer1MInCached: 0.0, + CostPer1MOutCached: 0.0, + CostPer1MOut: 600.00, + ContextWindow: 200_000, + DefaultMaxTokens: 50000, + CanReason: true, + }, + O1Mini: { + ID: O1Mini, + Name: "o1 mini", + Provider: ProviderOpenAI, + APIModel: "o1-mini", + CostPer1MIn: 1.10, + CostPer1MInCached: 0.55, + CostPer1MOutCached: 0.0, + CostPer1MOut: 4.40, + ContextWindow: 128_000, + DefaultMaxTokens: 50000, + CanReason: true, + }, + O3: { + ID: O3, + Name: "o3", + Provider: ProviderOpenAI, + APIModel: "o3", + CostPer1MIn: 10.00, + CostPer1MInCached: 2.50, + CostPer1MOutCached: 0.0, + CostPer1MOut: 40.00, + ContextWindow: 200_000, + CanReason: true, + }, + O3Mini: { + ID: O3Mini, + Name: "o3 mini", + Provider: ProviderOpenAI, + APIModel: "o3-mini", + CostPer1MIn: 1.10, + CostPer1MInCached: 0.55, + CostPer1MOutCached: 0.0, + CostPer1MOut: 4.40, + ContextWindow: 200_000, + DefaultMaxTokens: 50000, + CanReason: true, + }, + O4Mini: { + ID: O4Mini, + Name: "o4 mini", + Provider: ProviderOpenAI, + APIModel: "o4-mini", + CostPer1MIn: 1.10, + CostPer1MInCached: 0.275, + CostPer1MOutCached: 0.0, + CostPer1MOut: 4.40, + ContextWindow: 128_000, + DefaultMaxTokens: 50000, + CanReason: true, + }, +} diff --git a/internal/llm/prompt/coder.go b/internal/llm/prompt/coder.go index 47941f976..cc0da0313 100644 --- a/internal/llm/prompt/coder.go +++ b/internal/llm/prompt/coder.go @@ -8,80 +8,80 @@ import ( "runtime" "time" - "github.com/kujtimiihoxha/termai/internal/config" - "github.com/kujtimiihoxha/termai/internal/llm/tools" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/llm/models" + "github.com/kujtimiihoxha/opencode/internal/llm/tools" ) -func CoderOpenAISystemPrompt() string { - basePrompt := `You are termAI, an autonomous CLI-based software engineer. Your job is to reduce user effort by proactively reasoning, inferring context, and solving software engineering tasks end-to-end with minimal prompting. - -# Your mindset -Act like a competent, efficient software engineer who is familiar with large codebases. You should: -- Think critically about user requests. -- Proactively search the codebase for related information. -- Infer likely commands, tools, or conventions. -- Write and edit code with minimal user input. -- Anticipate next steps (tests, lints, etc.), but never commit unless explicitly told. - -# Context awareness -- Before acting, infer the purpose of a file from its name, directory, and neighboring files. -- If a file or function appears malicious, refuse to interact with it or discuss it. -- If a termai.md file exists, auto-load it as memory. Offer to update it only if new useful info appears (commands, preferences, structure). - -# CLI communication -- Use GitHub-flavored markdown in monospace font. -- Be concise. Never add preambles or postambles unless asked. Max 4 lines per response. -- Never explain your code unless asked. Do not narrate actions. -- Avoid unnecessary questions. Infer, search, act. - -# Behavior guidelines -- Follow project conventions: naming, formatting, libraries, frameworks. -- Before using any library or framework, confirm it’s already used. -- Always look at the surrounding code to match existing style. -- Do not add comments unless the code is complex or the user asks. - -# Autonomy rules -You are allowed and expected to: -- Search for commands, tools, or config files before asking the user. -- Run multiple search tool calls concurrently to gather relevant context. -- Choose test, lint, and typecheck commands based on package files or scripts. -- Offer to store these commands in termai.md if not already present. - -# Example behavior -user: write tests for new feature -assistant: [searches for existing test patterns, finds appropriate location, generates test code using existing style, optionally asks to add test command to termai.md] - -user: how do I typecheck this codebase? -assistant: [searches for known commands, infers package manager, checks for scripts or config files] -tsc --noEmit - -user: is X function used anywhere else? -assistant: [searches repo for references, returns file paths and lines] - -# Tool usage -- Use parallel calls when possible. -- Use file search and content tools before asking the user. -- Do not ask the user for information unless it cannot be determined via tools. - -Never commit changes unless the user explicitly asks you to.` - +func CoderPrompt(provider models.ModelProvider) string { + basePrompt := baseAnthropicCoderPrompt + switch provider { + case models.ProviderOpenAI: + basePrompt = baseOpenAICoderPrompt + } envInfo := getEnvironmentInfo() return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation()) } -func CoderAnthropicSystemPrompt() string { - basePrompt := `You are termAI, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. +const baseOpenAICoderPrompt = ` +You are operating as and within the OpenCode CLI, a terminal-based agentic coding assistant built by OpenAI. It wraps OpenAI models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful. + +You can: +- Receive user prompts, project context, and files. +- Stream responses and emit function calls (e.g., shell commands, code edits). +- Apply patches, run commands, and manage user approvals based on policy. +- Work inside a sandboxed, git-backed workspace with rollback support. +- Log telemetry so sessions can be replayed or inspected later. +- More details on your functionality are available at "opencode --help" + + +You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer. + +Please resolve the user's task by editing and testing the code files in your current code execution session. You are a deployed coding agent. Your session allows for you to modify and run code. The repo(s) are already cloned in your working directory, and you must fully solve the problem for your answer to be considered correct. + +You MUST adhere to the following criteria when executing the task: +- Working on the repo(s) in the current environment is allowed, even if they are proprietary. +- Analyzing code for vulnerabilities is allowed. +- Showing user code and tool call details is allowed. +- User instructions may overwrite the *CODING GUIDELINES* section in this developer message. +- If completing the user's task requires writing or modifying files: + - Your code and final answer should follow these *CODING GUIDELINES*: + - Fix the problem at the root cause rather than applying surface-level patches, when possible. + - Avoid unneeded complexity in your solution. + - Ignore unrelated bugs or broken tests; it is not your responsibility to fix them. + - Update documentation as necessary. + - Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task. + - Use "git log" and "git blame" to search the history of the codebase if additional context is required; internet access is disabled. + - NEVER add copyright or license headers unless specifically requested. + - You do not need to "git commit" your changes; this will be done automatically for you. + - Once you finish coding, you must + - Check "git status" to sanity check your changes; revert any scratch files or changes. + - Remove all inline comments you added as much as possible, even if they look normal. Check using "git diff". Inline comments must be generally avoided, unless active maintainers of the repo, after long careful study of the code and the issue, will still misinterpret the code without the comments. + - Check if you accidentally add copyright or license headers. If so, remove them. + - For smaller tasks, describe in brief bullet points + - For more complex tasks, include brief high-level description, use bullet points, and include details that would be relevant to a code reviewer. +- If completing the user's task DOES NOT require writing or modifying files (e.g., the user asks a question about the code base): + - Respond in a friendly tune as a remote teammate, who is knowledgeable, capable and eager to help with coding. +- When your task involves writing or modifying files: + - Do NOT tell the user to "save the file" or "copy the code into a file" if you already created or modified the file using "apply_patch". Instead, reference the file as already saved. + - Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them. +- When doing things with paths, always use use the full path, if the working directory is /abc/xyz and you want to edit the file abc.go in the working dir refer to it as /abc/xyz/abc.go. +- If you send a path not including the working dir, the working dir will be prepended to it. +- Remember the user does not see the full output of tools +` + +const baseAnthropicCoderPrompt = `You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. # Memory -If the current working directory contains a file called termai.md, it will be automatically added to your context. This file serves multiple purposes: +If the current working directory contains a file called OpenCode.md, it will be automatically added to your context. This file serves multiple purposes: 1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time 2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.) 3. Maintaining useful information about the codebase structure and organization -When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to termai.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to termai.md so you can remember it for next time. +When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to OpenCode.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to OpenCode.md so you can remember it for next time. # Tone and style You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). @@ -131,7 +131,7 @@ assistant: src/foo.c <example> user: write tests for new feature -assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests] +assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit/patch file tool to write new tests] </example> # Proactiveness @@ -156,21 +156,17 @@ The user will primarily request you perform software engineering tasks. This inc 1. Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially. 2. Implement the solution using all tools available to you 3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach. -4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to termai.md so that you will know to run it next time. +4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to opencode.md so that you will know to run it next time. NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. # Tool usage policy - When doing file search, prefer to use the Agent tool in order to reduce context usage. - If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in the same function_calls block. +- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user. You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.` - envInfo := getEnvironmentInfo() - - return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation()) -} - func getEnvironmentInfo() string { cwd := config.WorkingDirectory() isGit := isGitRepo(cwd) diff --git a/internal/llm/prompt/prompt.go b/internal/llm/prompt/prompt.go new file mode 100644 index 000000000..a6b4c03fb --- /dev/null +++ b/internal/llm/prompt/prompt.go @@ -0,0 +1,63 @@ +package prompt + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/llm/models" +) + +// contextFiles is a list of potential context files to check for +var contextFiles = []string{ + ".github/copilot-instructions.md", + ".cursorrules", + "CLAUDE.md", + "CLAUDE.local.md", + "opencode.md", + "opencode.local.md", + "OpenCode.md", + "OpenCode.local.md", + "OPENCODE.md", + "OPENCODE.local.md", +} + +func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string { + basePrompt := "" + switch agentName { + case config.AgentCoder: + basePrompt = CoderPrompt(provider) + case config.AgentTitle: + basePrompt = TitlePrompt(provider) + case config.AgentTask: + basePrompt = TaskPrompt(provider) + default: + basePrompt = "You are a helpful assistant" + } + + if agentName == config.AgentCoder || agentName == config.AgentTask { + // Add context from project-specific instruction files if they exist + contextContent := getContextFromFiles() + if contextContent != "" { + return fmt.Sprintf("%s\n\n# Project-Specific Context\n%s", basePrompt, contextContent) + } + } + return basePrompt +} + +// getContextFromFiles checks for the existence of context files and returns their content +func getContextFromFiles() string { + workDir := config.WorkingDirectory() + var contextContent string + + for _, file := range contextFiles { + filePath := filepath.Join(workDir, file) + content, err := os.ReadFile(filePath) + if err == nil { + contextContent += fmt.Sprintf("\n%s\n", string(content)) + } + } + + return contextContent +} diff --git a/internal/llm/prompt/task.go b/internal/llm/prompt/task.go index ee3c707fa..88cd1a0f4 100644 --- a/internal/llm/prompt/task.go +++ b/internal/llm/prompt/task.go @@ -2,11 +2,12 @@ package prompt import ( "fmt" -) -func TaskAgentSystemPrompt() string { - agentPrompt := `You are an agent for termAI. Given the user's prompt, you should use the tools available to you to answer the user's question. + "github.com/kujtimiihoxha/opencode/internal/llm/models" +) +func TaskPrompt(_ models.ModelProvider) string { + agentPrompt := `You are an agent for OpenCode. Given the user's prompt, you should use the tools available to you to answer the user's question. Notes: 1. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". 2. When relevant, share file names and code snippets relevant to the query diff --git a/internal/llm/prompt/title.go b/internal/llm/prompt/title.go index 5c47f4d64..5656360da 100644 --- a/internal/llm/prompt/title.go +++ b/internal/llm/prompt/title.go @@ -1,9 +1,12 @@ package prompt -func TitlePrompt() string { +import "github.com/kujtimiihoxha/opencode/internal/llm/models" + +func TitlePrompt(_ models.ModelProvider) string { return `you will generate a short title based on the first message a user begins a conversation with - ensure it is not more than 50 characters long - the title should be a summary of the user's message +- it should be one line long - do not use quotes or colons - the entire text you return will be used as the title` } diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go index 93c4308ad..03d96fb24 100644 --- a/internal/llm/provider/anthropic.go +++ b/internal/llm/provider/anthropic.go @@ -12,192 +12,275 @@ import ( "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/bedrock" "github.com/anthropics/anthropic-sdk-go/option" - "github.com/kujtimiihoxha/termai/internal/llm/models" - "github.com/kujtimiihoxha/termai/internal/llm/tools" - "github.com/kujtimiihoxha/termai/internal/message" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/llm/tools" + "github.com/kujtimiihoxha/opencode/internal/logging" + "github.com/kujtimiihoxha/opencode/internal/message" ) -type anthropicProvider struct { - client anthropic.Client - model models.Model - maxTokens int64 - apiKey string - systemMessage string - useBedrock bool - disableCache bool +type anthropicOptions struct { + useBedrock bool + disableCache bool + shouldThink func(userMessage string) bool } -type AnthropicOption func(*anthropicProvider) +type AnthropicOption func(*anthropicOptions) -func WithAnthropicSystemMessage(message string) AnthropicOption { - return func(a *anthropicProvider) { - a.systemMessage = message - } +type anthropicClient struct { + providerOptions providerClientOptions + options anthropicOptions + client anthropic.Client } -func WithAnthropicMaxTokens(maxTokens int64) AnthropicOption { - return func(a *anthropicProvider) { - a.maxTokens = maxTokens - } -} +type AnthropicClient ProviderClient -func WithAnthropicModel(model models.Model) AnthropicOption { - return func(a *anthropicProvider) { - a.model = model +func newAnthropicClient(opts providerClientOptions) AnthropicClient { + anthropicOpts := anthropicOptions{} + for _, o := range opts.anthropicOptions { + o(&anthropicOpts) } -} -func WithAnthropicKey(apiKey string) AnthropicOption { - return func(a *anthropicProvider) { - a.apiKey = apiKey + anthropicClientOptions := []option.RequestOption{} + if opts.apiKey != "" { + anthropicClientOptions = append(anthropicClientOptions, option.WithAPIKey(opts.apiKey)) } -} - -func WithAnthropicBedrock() AnthropicOption { - return func(a *anthropicProvider) { - a.useBedrock = true + if anthropicOpts.useBedrock { + anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background())) } -} -func WithAnthropicDisableCache() AnthropicOption { - return func(a *anthropicProvider) { - a.disableCache = true + client := anthropic.NewClient(anthropicClientOptions...) + return &anthropicClient{ + providerOptions: opts, + options: anthropicOpts, + client: client, } } -func NewAnthropicProvider(opts ...AnthropicOption) (Provider, error) { - provider := &anthropicProvider{ - maxTokens: 1024, - } +func (a *anthropicClient) convertMessages(messages []message.Message) (anthropicMessages []anthropic.MessageParam) { + for i, msg := range messages { + cache := false + if i > len(messages)-3 { + cache = true + } + switch msg.Role { + case message.User: + content := anthropic.NewTextBlock(msg.Content().String()) + if cache && !a.options.disableCache { + content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{ + Type: "ephemeral", + } + } + anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(content)) - for _, opt := range opts { - opt(provider) - } + case message.Assistant: + blocks := []anthropic.ContentBlockParamUnion{} + if msg.Content().String() != "" { + content := anthropic.NewTextBlock(msg.Content().String()) + if cache && !a.options.disableCache { + content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{ + Type: "ephemeral", + } + } + blocks = append(blocks, content) + } - if provider.systemMessage == "" { - return nil, errors.New("system message is required") - } + for _, toolCall := range msg.ToolCalls() { + var inputMap map[string]any + err := json.Unmarshal([]byte(toolCall.Input), &inputMap) + if err != nil { + continue + } + blocks = append(blocks, anthropic.ContentBlockParamOfRequestToolUseBlock(toolCall.ID, inputMap, toolCall.Name)) + } - anthropicOptions := []option.RequestOption{} + if len(blocks) == 0 { + logging.Warn("There is a message without content, investigate, this should not happen") + continue + } + anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...)) - if provider.apiKey != "" { - anthropicOptions = append(anthropicOptions, option.WithAPIKey(provider.apiKey)) - } - if provider.useBedrock { - anthropicOptions = append(anthropicOptions, bedrock.WithLoadDefaultConfig(context.Background())) + case message.Tool: + results := make([]anthropic.ContentBlockParamUnion, len(msg.ToolResults())) + for i, toolResult := range msg.ToolResults() { + results[i] = anthropic.NewToolResultBlock(toolResult.ToolCallID, toolResult.Content, toolResult.IsError) + } + anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(results...)) + } } - - provider.client = anthropic.NewClient(anthropicOptions...) - return provider, nil + return } -func (a *anthropicProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) { - messages = cleanupMessages(messages) - anthropicMessages := a.convertToAnthropicMessages(messages) - anthropicTools := a.convertToAnthropicTools(tools) - - response, err := a.client.Messages.New( - ctx, - anthropic.MessageNewParams{ - Model: anthropic.Model(a.model.APIModel), - MaxTokens: a.maxTokens, - Temperature: anthropic.Float(0), - Messages: anthropicMessages, - Tools: anthropicTools, - System: []anthropic.TextBlockParam{ - { - Text: a.systemMessage, - CacheControl: anthropic.CacheControlEphemeralParam{ - Type: "ephemeral", - }, - }, +func (a *anthropicClient) convertTools(tools []tools.BaseTool) []anthropic.ToolUnionParam { + anthropicTools := make([]anthropic.ToolUnionParam, len(tools)) + + for i, tool := range tools { + info := tool.Info() + toolParam := anthropic.ToolParam{ + Name: info.Name, + Description: anthropic.String(info.Description), + InputSchema: anthropic.ToolInputSchemaParam{ + Properties: info.Parameters, + // TODO: figure out how we can tell claude the required fields? }, - }, - ) - if err != nil { - return nil, err - } + } - content := "" - for _, block := range response.Content { - if text, ok := block.AsAny().(anthropic.TextBlock); ok { - content += text.Text + if i == len(tools)-1 && !a.options.disableCache { + toolParam.CacheControl = anthropic.CacheControlEphemeralParam{ + Type: "ephemeral", + } } - } - toolCalls := a.extractToolCalls(response.Content) - tokenUsage := a.extractTokenUsage(response.Usage) + anthropicTools[i] = anthropic.ToolUnionParam{OfTool: &toolParam} + } - return &ProviderResponse{ - Content: content, - ToolCalls: toolCalls, - Usage: tokenUsage, - }, nil + return anthropicTools } -func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) { - messages = cleanupMessages(messages) - anthropicMessages := a.convertToAnthropicMessages(messages) - anthropicTools := a.convertToAnthropicTools(tools) +func (a *anthropicClient) finishReason(reason string) message.FinishReason { + switch reason { + case "end_turn": + return message.FinishReasonEndTurn + case "max_tokens": + return message.FinishReasonMaxTokens + case "tool_use": + return message.FinishReasonToolUse + case "stop_sequence": + return message.FinishReasonEndTurn + default: + return message.FinishReasonUnknown + } +} +func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, tools []anthropic.ToolUnionParam) anthropic.MessageNewParams { var thinkingParam anthropic.ThinkingConfigParamUnion lastMessage := messages[len(messages)-1] + isUser := lastMessage.Role == anthropic.MessageParamRoleUser + messageContent := "" temperature := anthropic.Float(0) - if lastMessage.Role == message.User && strings.Contains(strings.ToLower(lastMessage.Content().String()), "think") { - thinkingParam = anthropic.ThinkingConfigParamUnion{ - OfThinkingConfigEnabled: &anthropic.ThinkingConfigEnabledParam{ - BudgetTokens: int64(float64(a.maxTokens) * 0.8), - Type: "enabled", - }, + if isUser { + for _, m := range lastMessage.Content { + if m.OfRequestTextBlock != nil && m.OfRequestTextBlock.Text != "" { + messageContent = m.OfRequestTextBlock.Text + } + } + if messageContent != "" && a.options.shouldThink != nil && a.options.shouldThink(messageContent) { + thinkingParam = anthropic.ThinkingConfigParamUnion{ + OfThinkingConfigEnabled: &anthropic.ThinkingConfigEnabledParam{ + BudgetTokens: int64(float64(a.providerOptions.maxTokens) * 0.8), + Type: "enabled", + }, + } + temperature = anthropic.Float(1) } - temperature = anthropic.Float(1) } - eventChan := make(chan ProviderEvent) + return anthropic.MessageNewParams{ + Model: anthropic.Model(a.providerOptions.model.APIModel), + MaxTokens: a.providerOptions.maxTokens, + Temperature: temperature, + Messages: messages, + Tools: tools, + Thinking: thinkingParam, + System: []anthropic.TextBlockParam{ + { + Text: a.providerOptions.systemMessage, + CacheControl: anthropic.CacheControlEphemeralParam{ + Type: "ephemeral", + }, + }, + }, + } +} - go func() { - defer close(eventChan) +func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (resposne *ProviderResponse, err error) { + preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools)) + cfg := config.Get() + if cfg.Debug { + // jsonData, _ := json.Marshal(preparedMessages) + // logging.Debug("Prepared messages", "messages", string(jsonData)) + } + attempts := 0 + for { + attempts++ + anthropicResponse, err := a.client.Messages.New( + ctx, + preparedMessages, + ) + // If there is an error we are going to see if we can retry the call + if err != nil { + retry, after, retryErr := a.shouldRetry(attempts, err) + if retryErr != nil { + return nil, retryErr + } + if retry { + logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100)) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(after) * time.Millisecond): + continue + } + } + return nil, retryErr + } - const maxRetries = 8 - attempts := 0 + content := "" + for _, block := range anthropicResponse.Content { + if text, ok := block.AsAny().(anthropic.TextBlock); ok { + content += text.Text + } + } - for { + return &ProviderResponse{ + Content: content, + ToolCalls: a.toolCalls(*anthropicResponse), + Usage: a.usage(*anthropicResponse), + }, nil + } +} +func (a *anthropicClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent { + preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools)) + cfg := config.Get() + if cfg.Debug { + // jsonData, _ := json.Marshal(preparedMessages) + // logging.Debug("Prepared messages", "messages", string(jsonData)) + } + attempts := 0 + eventChan := make(chan ProviderEvent) + go func() { + for { attempts++ - - stream := a.client.Messages.NewStreaming( + anthropicStream := a.client.Messages.NewStreaming( ctx, - anthropic.MessageNewParams{ - Model: anthropic.Model(a.model.APIModel), - MaxTokens: a.maxTokens, - Temperature: temperature, - Messages: anthropicMessages, - Tools: anthropicTools, - Thinking: thinkingParam, - System: []anthropic.TextBlockParam{ - { - Text: a.systemMessage, - CacheControl: anthropic.CacheControlEphemeralParam{ - Type: "ephemeral", - }, - }, - }, - }, + preparedMessages, ) - accumulatedMessage := anthropic.Message{} - for stream.Next() { - event := stream.Current() + currentToolCallID := "" + for anthropicStream.Next() { + event := anthropicStream.Current() err := accumulatedMessage.Accumulate(event) if err != nil { eventChan <- ProviderEvent{Type: EventError, Error: err} - return // Don't retry on accumulation errors + continue } switch event := event.AsAny().(type) { case anthropic.ContentBlockStartEvent: - eventChan <- ProviderEvent{Type: EventContentStart} + if event.ContentBlock.Type == "text" { + eventChan <- ProviderEvent{Type: EventContentStart} + } else if event.ContentBlock.Type == "tool_use" { + currentToolCallID = event.ContentBlock.ID + eventChan <- ProviderEvent{ + Type: EventToolUseStart, + ToolCall: &message.ToolCall{ + ID: event.ContentBlock.ID, + Name: event.ContentBlock.Name, + Finished: false, + }, + } + } case anthropic.ContentBlockDeltaEvent: if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" { @@ -210,10 +293,30 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa Type: EventContentDelta, Content: event.Delta.Text, } + } else if event.Delta.Type == "input_json_delta" { + if currentToolCallID != "" { + eventChan <- ProviderEvent{ + Type: EventToolUseDelta, + ToolCall: &message.ToolCall{ + ID: currentToolCallID, + Finished: false, + Input: event.Delta.JSON.PartialJSON.Raw(), + }, + } + } } - case anthropic.ContentBlockStopEvent: - eventChan <- ProviderEvent{Type: EventContentStop} + if currentToolCallID != "" { + eventChan <- ProviderEvent{ + Type: EventToolUseStop, + ToolCall: &message.ToolCall{ + ID: currentToolCallID, + }, + } + currentToolCallID = "" + } else { + eventChan <- ProviderEvent{Type: EventContentStop} + } case anthropic.MessageStopEvent: content := "" @@ -223,91 +326,95 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa } } - toolCalls := a.extractToolCalls(accumulatedMessage.Content) - tokenUsage := a.extractTokenUsage(accumulatedMessage.Usage) - eventChan <- ProviderEvent{ Type: EventComplete, Response: &ProviderResponse{ Content: content, - ToolCalls: toolCalls, - Usage: tokenUsage, - FinishReason: string(accumulatedMessage.StopReason), + ToolCalls: a.toolCalls(accumulatedMessage), + Usage: a.usage(accumulatedMessage), + FinishReason: a.finishReason(string(accumulatedMessage.StopReason)), }, } } } - err := stream.Err() + err := anthropicStream.Err() if err == nil || errors.Is(err, io.EOF) { + close(eventChan) return } - - var apierr *anthropic.Error - if !errors.As(err, &apierr) { - eventChan <- ProviderEvent{Type: EventError, Error: err} + // If there is an error we are going to see if we can retry the call + retry, after, retryErr := a.shouldRetry(attempts, err) + if retryErr != nil { + eventChan <- ProviderEvent{Type: EventError, Error: retryErr} + close(eventChan) return } - - if apierr.StatusCode != 429 && apierr.StatusCode != 529 { - eventChan <- ProviderEvent{Type: EventError, Error: err} - return - } - - if attempts > maxRetries { - eventChan <- ProviderEvent{ - Type: EventError, - Error: errors.New("maximum retry attempts reached for rate limit (429)"), - } - return - } - - retryMs := 0 - retryAfterValues := apierr.Response.Header.Values("Retry-After") - if len(retryAfterValues) > 0 { - var retryAfterSec int - if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryAfterSec); err == nil { - retryMs = retryAfterSec * 1000 - eventChan <- ProviderEvent{ - Type: EventWarning, - Info: fmt.Sprintf("[Rate limited: waiting %d seconds as specified by API]", retryAfterSec), + if retry { + logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100)) + select { + case <-ctx.Done(): + // context cancelled + if ctx.Err() != nil { + eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()} } + close(eventChan) + return + case <-time.After(time.Duration(after) * time.Millisecond): + continue } - } else { - eventChan <- ProviderEvent{ - Type: EventWarning, - Info: fmt.Sprintf("[Retrying due to rate limit... attempt %d of %d]", attempts, maxRetries), - } - - backoffMs := 2000 * (1 << (attempts - 1)) - jitterMs := int(float64(backoffMs) * 0.2) - retryMs = backoffMs + jitterMs } - select { - case <-ctx.Done(): + if ctx.Err() != nil { eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()} - return - case <-time.After(time.Duration(retryMs) * time.Millisecond): - continue } + close(eventChan) + return } }() + return eventChan +} + +func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, error) { + var apierr *anthropic.Error + if !errors.As(err, &apierr) { + return false, 0, err + } - return eventChan, nil + if apierr.StatusCode != 429 && apierr.StatusCode != 529 { + return false, 0, err + } + + if attempts > maxRetries { + return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries) + } + + retryMs := 0 + retryAfterValues := apierr.Response.Header.Values("Retry-After") + + backoffMs := 2000 * (1 << (attempts - 1)) + jitterMs := int(float64(backoffMs) * 0.2) + retryMs = backoffMs + jitterMs + if len(retryAfterValues) > 0 { + if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil { + retryMs = retryMs * 1000 + } + } + return true, int64(retryMs), nil } -func (a *anthropicProvider) extractToolCalls(content []anthropic.ContentBlockUnion) []message.ToolCall { +func (a *anthropicClient) toolCalls(msg anthropic.Message) []message.ToolCall { var toolCalls []message.ToolCall - for _, block := range content { + for _, block := range msg.Content { switch variant := block.AsAny().(type) { case anthropic.ToolUseBlock: toolCall := message.ToolCall{ - ID: variant.ID, - Name: variant.Name, - Input: string(variant.Input), - Type: string(variant.Type), + ID: variant.ID, + Name: variant.Name, + Input: string(variant.Input), + Type: string(variant.Type), + Finished: true, } toolCalls = append(toolCalls, toolCall) } @@ -316,90 +423,33 @@ func (a *anthropicProvider) extractToolCalls(content []anthropic.ContentBlockUni return toolCalls } -func (a *anthropicProvider) extractTokenUsage(usage anthropic.Usage) TokenUsage { +func (a *anthropicClient) usage(msg anthropic.Message) TokenUsage { return TokenUsage{ - InputTokens: usage.InputTokens, - OutputTokens: usage.OutputTokens, - CacheCreationTokens: usage.CacheCreationInputTokens, - CacheReadTokens: usage.CacheReadInputTokens, + InputTokens: msg.Usage.InputTokens, + OutputTokens: msg.Usage.OutputTokens, + CacheCreationTokens: msg.Usage.CacheCreationInputTokens, + CacheReadTokens: msg.Usage.CacheReadInputTokens, } } -func (a *anthropicProvider) convertToAnthropicTools(tools []tools.BaseTool) []anthropic.ToolUnionParam { - anthropicTools := make([]anthropic.ToolUnionParam, len(tools)) - - for i, tool := range tools { - info := tool.Info() - toolParam := anthropic.ToolParam{ - Name: info.Name, - Description: anthropic.String(info.Description), - InputSchema: anthropic.ToolInputSchemaParam{ - Properties: info.Parameters, - }, - } - - if i == len(tools)-1 && !a.disableCache { - toolParam.CacheControl = anthropic.CacheControlEphemeralParam{ - Type: "ephemeral", - } - } - - anthropicTools[i] = anthropic.ToolUnionParam{OfTool: &toolParam} +func WithAnthropicBedrock(useBedrock bool) AnthropicOption { + return func(options *anthropicOptions) { + options.useBedrock = useBedrock } - - return anthropicTools } -func (a *anthropicProvider) convertToAnthropicMessages(messages []message.Message) []anthropic.MessageParam { - anthropicMessages := make([]anthropic.MessageParam, 0, len(messages)) - cachedBlocks := 0 - - for _, msg := range messages { - switch msg.Role { - case message.User: - content := anthropic.NewTextBlock(msg.Content().String()) - if cachedBlocks < 2 && !a.disableCache { - content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{ - Type: "ephemeral", - } - cachedBlocks++ - } - anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(content)) - - case message.Assistant: - blocks := []anthropic.ContentBlockParamUnion{} - if msg.Content().String() != "" { - content := anthropic.NewTextBlock(msg.Content().String()) - if cachedBlocks < 2 && !a.disableCache { - content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{ - Type: "ephemeral", - } - cachedBlocks++ - } - blocks = append(blocks, content) - } - - for _, toolCall := range msg.ToolCalls() { - var inputMap map[string]any - err := json.Unmarshal([]byte(toolCall.Input), &inputMap) - if err != nil { - continue - } - blocks = append(blocks, anthropic.ContentBlockParamOfRequestToolUseBlock(toolCall.ID, inputMap, toolCall.Name)) - } +func WithAnthropicDisableCache() AnthropicOption { + return func(options *anthropicOptions) { + options.disableCache = true + } +} - if len(blocks) > 0 { - anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...)) - } +func DefaultShouldThinkFn(s string) bool { + return strings.Contains(strings.ToLower(s), "think") +} - case message.Tool: - results := make([]anthropic.ContentBlockParamUnion, len(msg.ToolResults())) - for i, toolResult := range msg.ToolResults() { - results[i] = anthropic.NewToolResultBlock(toolResult.ToolCallID, toolResult.Content, toolResult.IsError) - } - anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(results...)) - } +func WithAnthropicShouldThinkFn(fn func(string) bool) AnthropicOption { + return func(options *anthropicOptions) { + options.shouldThink = fn } - - return anthropicMessages } diff --git a/internal/llm/provider/bedrock.go b/internal/llm/provider/bedrock.go index 677f4676b..9415b30fe 100644 --- a/internal/llm/provider/bedrock.go +++ b/internal/llm/provider/bedrock.go @@ -7,33 +7,29 @@ import ( "os" "strings" - "github.com/kujtimiihoxha/termai/internal/llm/models" - "github.com/kujtimiihoxha/termai/internal/llm/tools" - "github.com/kujtimiihoxha/termai/internal/message" + "github.com/kujtimiihoxha/opencode/internal/llm/tools" + "github.com/kujtimiihoxha/opencode/internal/message" ) -type bedrockProvider struct { - childProvider Provider - model models.Model - maxTokens int64 - systemMessage string +type bedrockOptions struct { + // Bedrock specific options can be added here } -func (b *bedrockProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) { - return b.childProvider.SendMessages(ctx, messages, tools) -} +type BedrockOption func(*bedrockOptions) -func (b *bedrockProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) { - return b.childProvider.StreamResponse(ctx, messages, tools) +type bedrockClient struct { + providerOptions providerClientOptions + options bedrockOptions + childProvider ProviderClient } -func NewBedrockProvider(opts ...BedrockOption) (Provider, error) { - provider := &bedrockProvider{} - for _, opt := range opts { - opt(provider) - } +type BedrockClient ProviderClient + +func newBedrockClient(opts providerClientOptions) BedrockClient { + bedrockOpts := bedrockOptions{} + // Apply bedrock specific options if they are added in the future - // based on the AWS region prefix the model name with, us, eu, ap, sa, etc. + // Get AWS region from environment region := os.Getenv("AWS_REGION") if region == "" { region = os.Getenv("AWS_DEFAULT_REGION") @@ -43,45 +39,62 @@ func NewBedrockProvider(opts ...BedrockOption) (Provider, error) { region = "us-east-1" // default region } if len(region) < 2 { - return nil, errors.New("AWS_REGION or AWS_DEFAULT_REGION environment variable is invalid") + return &bedrockClient{ + providerOptions: opts, + options: bedrockOpts, + childProvider: nil, // Will cause an error when used + } } + + // Prefix the model name with region regionPrefix := region[:2] - provider.model.APIModel = fmt.Sprintf("%s.%s", regionPrefix, provider.model.APIModel) + modelName := opts.model.APIModel + opts.model.APIModel = fmt.Sprintf("%s.%s", regionPrefix, modelName) - if strings.Contains(string(provider.model.APIModel), "anthropic") { - anthropic, err := NewAnthropicProvider( - WithAnthropicModel(provider.model), - WithAnthropicMaxTokens(provider.maxTokens), - WithAnthropicSystemMessage(provider.systemMessage), - WithAnthropicBedrock(), + // Determine which provider to use based on the model + if strings.Contains(string(opts.model.APIModel), "anthropic") { + // Create Anthropic client with Bedrock configuration + anthropicOpts := opts + anthropicOpts.anthropicOptions = append(anthropicOpts.anthropicOptions, + WithAnthropicBedrock(true), WithAnthropicDisableCache(), ) - provider.childProvider = anthropic - if err != nil { - return nil, err + return &bedrockClient{ + providerOptions: opts, + options: bedrockOpts, + childProvider: newAnthropicClient(anthropicOpts), } - } else { - return nil, errors.New("unsupported model for bedrock provider") } - return provider, nil -} - -type BedrockOption func(*bedrockProvider) -func WithBedrockSystemMessage(message string) BedrockOption { - return func(a *bedrockProvider) { - a.systemMessage = message + // Return client with nil childProvider if model is not supported + // This will cause an error when used + return &bedrockClient{ + providerOptions: opts, + options: bedrockOpts, + childProvider: nil, } } -func WithBedrockMaxTokens(maxTokens int64) BedrockOption { - return func(a *bedrockProvider) { - a.maxTokens = maxTokens +func (b *bedrockClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) { + if b.childProvider == nil { + return nil, errors.New("unsupported model for bedrock provider") } + return b.childProvider.send(ctx, messages, tools) } -func WithBedrockModel(model models.Model) BedrockOption { - return func(a *bedrockProvider) { - a.model = model +func (b *bedrockClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent { + eventChan := make(chan ProviderEvent) + + if b.childProvider == nil { + go func() { + eventChan <- ProviderEvent{ + Type: EventError, + Error: errors.New("unsupported model for bedrock provider"), + } + close(eventChan) + }() + return eventChan } -} + + return b.childProvider.stream(ctx, messages, tools) +}
\ No newline at end of file diff --git a/internal/llm/provider/gemini.go b/internal/llm/provider/gemini.go index 2d1db2b64..a5e6ed877 100644 --- a/internal/llm/provider/gemini.go +++ b/internal/llm/provider/gemini.go @@ -4,80 +4,68 @@ import ( "context" "encoding/json" "errors" + "fmt" + "io" + "strings" + "time" "github.com/google/generative-ai-go/genai" "github.com/google/uuid" - "github.com/kujtimiihoxha/termai/internal/llm/models" - "github.com/kujtimiihoxha/termai/internal/llm/tools" - "github.com/kujtimiihoxha/termai/internal/message" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/llm/tools" + "github.com/kujtimiihoxha/opencode/internal/logging" + "github.com/kujtimiihoxha/opencode/internal/message" "google.golang.org/api/iterator" "google.golang.org/api/option" ) -type geminiProvider struct { - client *genai.Client - model models.Model - maxTokens int32 - apiKey string - systemMessage string +type geminiOptions struct { + disableCache bool } -type GeminiOption func(*geminiProvider) +type GeminiOption func(*geminiOptions) -func NewGeminiProvider(ctx context.Context, opts ...GeminiOption) (Provider, error) { - provider := &geminiProvider{ - maxTokens: 5000, - } +type geminiClient struct { + providerOptions providerClientOptions + options geminiOptions + client *genai.Client +} - for _, opt := range opts { - opt(provider) - } +type GeminiClient ProviderClient - if provider.systemMessage == "" { - return nil, errors.New("system message is required") +func newGeminiClient(opts providerClientOptions) GeminiClient { + geminiOpts := geminiOptions{} + for _, o := range opts.geminiOptions { + o(&geminiOpts) } - client, err := genai.NewClient(ctx, option.WithAPIKey(provider.apiKey)) + client, err := genai.NewClient(context.Background(), option.WithAPIKey(opts.apiKey)) if err != nil { - return nil, err - } - provider.client = client - - return provider, nil -} - -func WithGeminiSystemMessage(message string) GeminiOption { - return func(p *geminiProvider) { - p.systemMessage = message + logging.Error("Failed to create Gemini client", "error", err) + return nil } -} -func WithGeminiMaxTokens(maxTokens int32) GeminiOption { - return func(p *geminiProvider) { - p.maxTokens = maxTokens + return &geminiClient{ + providerOptions: opts, + options: geminiOpts, + client: client, } } -func WithGeminiModel(model models.Model) GeminiOption { - return func(p *geminiProvider) { - p.model = model - } -} - -func WithGeminiKey(apiKey string) GeminiOption { - return func(p *geminiProvider) { - p.apiKey = apiKey - } -} +func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Content { + var history []*genai.Content -func (p *geminiProvider) Close() { - if p.client != nil { - p.client.Close() - } -} + // Add system message first + history = append(history, &genai.Content{ + Parts: []genai.Part{genai.Text(g.providerOptions.systemMessage)}, + Role: "user", + }) -func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*genai.Content { - var history []*genai.Content + // Add a system response to acknowledge the system message + history = append(history, &genai.Content{ + Parts: []genai.Part{genai.Text("I'll help you with that.")}, + Role: "model", + }) for _, msg := range messages { switch msg.Role { @@ -86,6 +74,7 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g Parts: []genai.Part{genai.Text(msg.Content().String())}, Role: "user", }) + case message.Assistant: content := &genai.Content{ Role: "model", @@ -107,6 +96,7 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g } history = append(history, content) + case message.Tool: for _, result := range msg.ToolResults() { response := map[string]interface{}{"result": result.Content} @@ -114,10 +104,11 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g if err == nil { response = parsed } + var toolCall message.ToolCall - for _, msg := range messages { - if msg.Role == message.Assistant { - for _, call := range msg.ToolCalls() { + for _, m := range messages { + if m.Role == message.Assistant { + for _, call := range m.ToolCalls() { if call.ID == result.ToolCallID { toolCall = call break @@ -140,186 +131,358 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g return history } -func (p *geminiProvider) extractTokenUsage(resp *genai.GenerateContentResponse) TokenUsage { - if resp == nil || resp.UsageMetadata == nil { - return TokenUsage{} - } +func (g *geminiClient) convertTools(tools []tools.BaseTool) []*genai.Tool { + geminiTools := make([]*genai.Tool, 0, len(tools)) - return TokenUsage{ - InputTokens: int64(resp.UsageMetadata.PromptTokenCount), - OutputTokens: int64(resp.UsageMetadata.CandidatesTokenCount), - CacheCreationTokens: 0, // Not directly provided by Gemini - CacheReadTokens: int64(resp.UsageMetadata.CachedContentTokenCount), + for _, tool := range tools { + info := tool.Info() + declaration := &genai.FunctionDeclaration{ + Name: info.Name, + Description: info.Description, + Parameters: &genai.Schema{ + Type: genai.TypeObject, + Properties: convertSchemaProperties(info.Parameters), + Required: info.Required, + }, + } + + geminiTools = append(geminiTools, &genai.Tool{ + FunctionDeclarations: []*genai.FunctionDeclaration{declaration}, + }) } + + return geminiTools } -func (p *geminiProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) { - messages = cleanupMessages(messages) - model := p.client.GenerativeModel(p.model.APIModel) - model.SetMaxOutputTokens(p.maxTokens) +func (g *geminiClient) finishReason(reason genai.FinishReason) message.FinishReason { + reasonStr := reason.String() + switch { + case reasonStr == "STOP": + return message.FinishReasonEndTurn + case reasonStr == "MAX_TOKENS": + return message.FinishReasonMaxTokens + case strings.Contains(reasonStr, "FUNCTION") || strings.Contains(reasonStr, "TOOL"): + return message.FinishReasonToolUse + default: + return message.FinishReasonUnknown + } +} - model.SystemInstruction = genai.NewUserContent(genai.Text(p.systemMessage)) +func (g *geminiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) { + model := g.client.GenerativeModel(g.providerOptions.model.APIModel) + model.SetMaxOutputTokens(int32(g.providerOptions.maxTokens)) + // Convert tools if len(tools) > 0 { - declarations := p.convertToolsToGeminiFunctionDeclarations(tools) - for _, declaration := range declarations { - model.Tools = append(model.Tools, &genai.Tool{FunctionDeclarations: []*genai.FunctionDeclaration{declaration}}) - } + model.Tools = g.convertTools(tools) } - chat := model.StartChat() - chat.History = p.convertToGeminiHistory(messages[:len(messages)-1]) // Exclude last message + // Convert messages + geminiMessages := g.convertMessages(messages) - lastUserMsg := messages[len(messages)-1] - resp, err := chat.SendMessage(ctx, genai.Text(lastUserMsg.Content().String())) - if err != nil { - return nil, err + cfg := config.Get() + if cfg.Debug { + jsonData, _ := json.Marshal(geminiMessages) + logging.Debug("Prepared messages", "messages", string(jsonData)) } - var content string - var toolCalls []message.ToolCall + attempts := 0 + for { + attempts++ + chat := model.StartChat() + chat.History = geminiMessages[:len(geminiMessages)-1] // All but last message + + lastMsg := geminiMessages[len(geminiMessages)-1] + var lastText string + for _, part := range lastMsg.Parts { + if text, ok := part.(genai.Text); ok { + lastText = string(text) + break + } + } - if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil { - for _, part := range resp.Candidates[0].Content.Parts { - switch p := part.(type) { - case genai.Text: - content = string(p) - case genai.FunctionCall: - id := "call_" + uuid.New().String() - args, _ := json.Marshal(p.Args) - toolCalls = append(toolCalls, message.ToolCall{ - ID: id, - Name: p.Name, - Input: string(args), - Type: "function", - }) + resp, err := chat.SendMessage(ctx, genai.Text(lastText)) + // If there is an error we are going to see if we can retry the call + if err != nil { + retry, after, retryErr := g.shouldRetry(attempts, err) + if retryErr != nil { + return nil, retryErr } + if retry { + logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100)) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(after) * time.Millisecond): + continue + } + } + return nil, retryErr } - } - tokenUsage := p.extractTokenUsage(resp) + content := "" + var toolCalls []message.ToolCall + + if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil { + for _, part := range resp.Candidates[0].Content.Parts { + switch p := part.(type) { + case genai.Text: + content = string(p) + case genai.FunctionCall: + id := "call_" + uuid.New().String() + args, _ := json.Marshal(p.Args) + toolCalls = append(toolCalls, message.ToolCall{ + ID: id, + Name: p.Name, + Input: string(args), + Type: "function", + }) + } + } + } - return &ProviderResponse{ - Content: content, - ToolCalls: toolCalls, - Usage: tokenUsage, - }, nil + return &ProviderResponse{ + Content: content, + ToolCalls: toolCalls, + Usage: g.usage(resp), + FinishReason: g.finishReason(resp.Candidates[0].FinishReason), + }, nil + } } -func (p *geminiProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) { - messages = cleanupMessages(messages) - model := p.client.GenerativeModel(p.model.APIModel) - model.SetMaxOutputTokens(p.maxTokens) - - model.SystemInstruction = genai.NewUserContent(genai.Text(p.systemMessage)) +func (g *geminiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent { + model := g.client.GenerativeModel(g.providerOptions.model.APIModel) + model.SetMaxOutputTokens(int32(g.providerOptions.maxTokens)) + // Convert tools if len(tools) > 0 { - declarations := p.convertToolsToGeminiFunctionDeclarations(tools) - for _, declaration := range declarations { - model.Tools = append(model.Tools, &genai.Tool{FunctionDeclarations: []*genai.FunctionDeclaration{declaration}}) - } + model.Tools = g.convertTools(tools) } - chat := model.StartChat() - chat.History = p.convertToGeminiHistory(messages[:len(messages)-1]) // Exclude last message + // Convert messages + geminiMessages := g.convertMessages(messages) - lastUserMsg := messages[len(messages)-1] - - iter := chat.SendMessageStream(ctx, genai.Text(lastUserMsg.Content().String())) + cfg := config.Get() + if cfg.Debug { + jsonData, _ := json.Marshal(geminiMessages) + logging.Debug("Prepared messages", "messages", string(jsonData)) + } + attempts := 0 eventChan := make(chan ProviderEvent) go func() { defer close(eventChan) - var finalResp *genai.GenerateContentResponse - currentContent := "" - toolCalls := []message.ToolCall{} - for { - resp, err := iter.Next() - if err == iterator.Done { - break - } - if err != nil { - eventChan <- ProviderEvent{ - Type: EventError, - Error: err, + attempts++ + chat := model.StartChat() + chat.History = geminiMessages[:len(geminiMessages)-1] // All but last message + + lastMsg := geminiMessages[len(geminiMessages)-1] + var lastText string + for _, part := range lastMsg.Parts { + if text, ok := part.(genai.Text); ok { + lastText = string(text) + break } - return } - finalResp = resp + iter := chat.SendMessageStream(ctx, genai.Text(lastText)) - if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil { - for _, part := range resp.Candidates[0].Content.Parts { - switch p := part.(type) { - case genai.Text: - newText := string(p) - eventChan <- ProviderEvent{ - Type: EventContentDelta, - Content: newText, - } - currentContent += newText - case genai.FunctionCall: - id := "call_" + uuid.New().String() - args, _ := json.Marshal(p.Args) - newCall := message.ToolCall{ - ID: id, - Name: p.Name, - Input: string(args), - Type: "function", - } + currentContent := "" + toolCalls := []message.ToolCall{} + var finalResp *genai.GenerateContentResponse - isNew := true - for _, existing := range toolCalls { - if existing.Name == newCall.Name && existing.Input == newCall.Input { - isNew = false - break + eventChan <- ProviderEvent{Type: EventContentStart} + + for { + resp, err := iter.Next() + if err == iterator.Done { + break + } + if err != nil { + retry, after, retryErr := g.shouldRetry(attempts, err) + if retryErr != nil { + eventChan <- ProviderEvent{Type: EventError, Error: retryErr} + return + } + if retry { + logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100)) + select { + case <-ctx.Done(): + if ctx.Err() != nil { + eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()} } + + return + case <-time.After(time.Duration(after) * time.Millisecond): + break } + } else { + eventChan <- ProviderEvent{Type: EventError, Error: err} + return + } + } + + finalResp = resp + + if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil { + for _, part := range resp.Candidates[0].Content.Parts { + switch p := part.(type) { + case genai.Text: + newText := string(p) + delta := newText[len(currentContent):] + if delta != "" { + eventChan <- ProviderEvent{ + Type: EventContentDelta, + Content: delta, + } + currentContent = newText + } + case genai.FunctionCall: + id := "call_" + uuid.New().String() + args, _ := json.Marshal(p.Args) + newCall := message.ToolCall{ + ID: id, + Name: p.Name, + Input: string(args), + Type: "function", + } - if isNew { - toolCalls = append(toolCalls, newCall) + isNew := true + for _, existing := range toolCalls { + if existing.Name == newCall.Name && existing.Input == newCall.Input { + isNew = false + break + } + } + + if isNew { + toolCalls = append(toolCalls, newCall) + } } } } } - } - tokenUsage := p.extractTokenUsage(finalResp) + eventChan <- ProviderEvent{Type: EventContentStop} - eventChan <- ProviderEvent{ - Type: EventComplete, - Response: &ProviderResponse{ - Content: currentContent, - ToolCalls: toolCalls, - Usage: tokenUsage, - FinishReason: string(finalResp.Candidates[0].FinishReason.String()), - }, + if finalResp != nil { + eventChan <- ProviderEvent{ + Type: EventComplete, + Response: &ProviderResponse{ + Content: currentContent, + ToolCalls: toolCalls, + Usage: g.usage(finalResp), + FinishReason: g.finishReason(finalResp.Candidates[0].FinishReason), + }, + } + return + } + + // If we get here, we need to retry + if attempts > maxRetries { + eventChan <- ProviderEvent{ + Type: EventError, + Error: fmt.Errorf("maximum retry attempts reached: %d retries", maxRetries), + } + return + } + + // Wait before retrying + select { + case <-ctx.Done(): + if ctx.Err() != nil { + eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()} + } + return + case <-time.After(time.Duration(2000*(1<<(attempts-1))) * time.Millisecond): + continue + } } }() - return eventChan, nil + return eventChan } -func (p *geminiProvider) convertToolsToGeminiFunctionDeclarations(tools []tools.BaseTool) []*genai.FunctionDeclaration { - declarations := make([]*genai.FunctionDeclaration, len(tools)) +func (g *geminiClient) shouldRetry(attempts int, err error) (bool, int64, error) { + // Check if error is a rate limit error + if attempts > maxRetries { + return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries) + } - for i, tool := range tools { - info := tool.Info() - declarations[i] = &genai.FunctionDeclaration{ - Name: info.Name, - Description: info.Description, - Parameters: &genai.Schema{ - Type: genai.TypeObject, - Properties: convertSchemaProperties(info.Parameters), - Required: info.Required, - }, + // Gemini doesn't have a standard error type we can check against + // So we'll check the error message for rate limit indicators + if errors.Is(err, io.EOF) { + return false, 0, err + } + + errMsg := err.Error() + isRateLimit := false + + // Check for common rate limit error messages + if contains(errMsg, "rate limit", "quota exceeded", "too many requests") { + isRateLimit = true + } + + if !isRateLimit { + return false, 0, err + } + + // Calculate backoff with jitter + backoffMs := 2000 * (1 << (attempts - 1)) + jitterMs := int(float64(backoffMs) * 0.2) + retryMs := backoffMs + jitterMs + + return true, int64(retryMs), nil +} + +func (g *geminiClient) toolCalls(resp *genai.GenerateContentResponse) []message.ToolCall { + var toolCalls []message.ToolCall + + if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil { + for _, part := range resp.Candidates[0].Content.Parts { + if funcCall, ok := part.(genai.FunctionCall); ok { + id := "call_" + uuid.New().String() + args, _ := json.Marshal(funcCall.Args) + toolCalls = append(toolCalls, message.ToolCall{ + ID: id, + Name: funcCall.Name, + Input: string(args), + Type: "function", + }) + } } } - return declarations + return toolCalls +} + +func (g *geminiClient) usage(resp *genai.GenerateContentResponse) TokenUsage { + if resp == nil || resp.UsageMetadata == nil { + return TokenUsage{} + } + + return TokenUsage{ + InputTokens: int64(resp.UsageMetadata.PromptTokenCount), + OutputTokens: int64(resp.UsageMetadata.CandidatesTokenCount), + CacheCreationTokens: 0, // Not directly provided by Gemini + CacheReadTokens: int64(resp.UsageMetadata.CachedContentTokenCount), + } +} + +func WithGeminiDisableCache() GeminiOption { + return func(options *geminiOptions) { + options.disableCache = true + } +} + +// Helper functions +func parseJsonToMap(jsonStr string) (map[string]interface{}, error) { + var result map[string]interface{} + err := json.Unmarshal([]byte(jsonStr), &result) + return result, err } func convertSchemaProperties(parameters map[string]interface{}) map[string]*genai.Schema { @@ -396,8 +559,11 @@ func mapJSONTypeToGenAI(jsonType string) genai.Type { } } -func parseJsonToMap(jsonStr string) (map[string]interface{}, error) { - var result map[string]interface{} - err := json.Unmarshal([]byte(jsonStr), &result) - return result, err +func contains(s string, substrs ...string) bool { + for _, substr := range substrs { + if strings.Contains(strings.ToLower(s), strings.ToLower(substr)) { + return true + } + } + return false } diff --git a/internal/llm/provider/openai.go b/internal/llm/provider/openai.go index dbfde3fa8..40d263242 100644 --- a/internal/llm/provider/openai.go +++ b/internal/llm/provider/openai.go @@ -2,89 +2,69 @@ package provider import ( "context" + "encoding/json" "errors" - - "github.com/kujtimiihoxha/termai/internal/llm/models" - "github.com/kujtimiihoxha/termai/internal/llm/tools" - "github.com/kujtimiihoxha/termai/internal/message" + "fmt" + "io" + "time" + + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/llm/tools" + "github.com/kujtimiihoxha/opencode/internal/logging" + "github.com/kujtimiihoxha/opencode/internal/message" "github.com/openai/openai-go" "github.com/openai/openai-go/option" + "github.com/openai/openai-go/shared" ) -type openaiProvider struct { - client openai.Client - model models.Model - maxTokens int64 - baseURL string - apiKey string - systemMessage string +type openaiOptions struct { + baseURL string + disableCache bool + reasoningEffort string } -type OpenAIOption func(*openaiProvider) - -func NewOpenAIProvider(opts ...OpenAIOption) (Provider, error) { - provider := &openaiProvider{ - maxTokens: 5000, - } - - for _, opt := range opts { - opt(provider) - } +type OpenAIOption func(*openaiOptions) - clientOpts := []option.RequestOption{ - option.WithAPIKey(provider.apiKey), - } - if provider.baseURL != "" { - clientOpts = append(clientOpts, option.WithBaseURL(provider.baseURL)) - } - - provider.client = openai.NewClient(clientOpts...) - if provider.systemMessage == "" { - return nil, errors.New("system message is required") - } - - return provider, nil +type openaiClient struct { + providerOptions providerClientOptions + options openaiOptions + client openai.Client } -func WithOpenAISystemMessage(message string) OpenAIOption { - return func(p *openaiProvider) { - p.systemMessage = message - } -} +type OpenAIClient ProviderClient -func WithOpenAIMaxTokens(maxTokens int64) OpenAIOption { - return func(p *openaiProvider) { - p.maxTokens = maxTokens +func newOpenAIClient(opts providerClientOptions) OpenAIClient { + openaiOpts := openaiOptions{ + reasoningEffort: "medium", } -} - -func WithOpenAIModel(model models.Model) OpenAIOption { - return func(p *openaiProvider) { - p.model = model + for _, o := range opts.openaiOptions { + o(&openaiOpts) } -} -func WithOpenAIBaseURL(baseURL string) OpenAIOption { - return func(p *openaiProvider) { - p.baseURL = baseURL + openaiClientOptions := []option.RequestOption{} + if opts.apiKey != "" { + openaiClientOptions = append(openaiClientOptions, option.WithAPIKey(opts.apiKey)) + } + if openaiOpts.baseURL != "" { + openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(openaiOpts.baseURL)) } -} -func WithOpenAIKey(apiKey string) OpenAIOption { - return func(p *openaiProvider) { - p.apiKey = apiKey + client := openai.NewClient(openaiClientOptions...) + return &openaiClient{ + providerOptions: opts, + options: openaiOpts, + client: client, } } -func (p *openaiProvider) convertToOpenAIMessages(messages []message.Message) []openai.ChatCompletionMessageParamUnion { - var chatMessages []openai.ChatCompletionMessageParamUnion - - chatMessages = append(chatMessages, openai.SystemMessage(p.systemMessage)) +func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) { + // Add system message first + openaiMessages = append(openaiMessages, openai.SystemMessage(o.providerOptions.systemMessage)) for _, msg := range messages { switch msg.Role { case message.User: - chatMessages = append(chatMessages, openai.UserMessage(msg.Content().String())) + openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String())) case message.Assistant: assistantMsg := openai.ChatCompletionAssistantMessageParam{ @@ -111,23 +91,23 @@ func (p *openaiProvider) convertToOpenAIMessages(messages []message.Message) []o } } - chatMessages = append(chatMessages, openai.ChatCompletionMessageParamUnion{ + openaiMessages = append(openaiMessages, openai.ChatCompletionMessageParamUnion{ OfAssistant: &assistantMsg, }) case message.Tool: for _, result := range msg.ToolResults() { - chatMessages = append(chatMessages, + openaiMessages = append(openaiMessages, openai.ToolMessage(result.Content, result.ToolCallID), ) } } } - return chatMessages + return } -func (p *openaiProvider) convertToOpenAITools(tools []tools.BaseTool) []openai.ChatCompletionToolParam { +func (o *openaiClient) convertTools(tools []tools.BaseTool) []openai.ChatCompletionToolParam { openaiTools := make([]openai.ChatCompletionToolParam, len(tools)) for i, tool := range tools { @@ -148,133 +128,268 @@ func (p *openaiProvider) convertToOpenAITools(tools []tools.BaseTool) []openai.C return openaiTools } -func (p *openaiProvider) extractTokenUsage(usage openai.CompletionUsage) TokenUsage { - cachedTokens := int64(0) - - cachedTokens = usage.PromptTokensDetails.CachedTokens - inputTokens := usage.PromptTokens - cachedTokens - - return TokenUsage{ - InputTokens: inputTokens, - OutputTokens: usage.CompletionTokens, - CacheCreationTokens: 0, // OpenAI doesn't provide this directly - CacheReadTokens: cachedTokens, +func (o *openaiClient) finishReason(reason string) message.FinishReason { + switch reason { + case "stop": + return message.FinishReasonEndTurn + case "length": + return message.FinishReasonMaxTokens + case "tool_calls": + return message.FinishReasonToolUse + default: + return message.FinishReasonUnknown } } -func (p *openaiProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) { - messages = cleanupMessages(messages) - chatMessages := p.convertToOpenAIMessages(messages) - openaiTools := p.convertToOpenAITools(tools) - +func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams { params := openai.ChatCompletionNewParams{ - Model: openai.ChatModel(p.model.APIModel), - Messages: chatMessages, - MaxTokens: openai.Int(p.maxTokens), - Tools: openaiTools, + Model: openai.ChatModel(o.providerOptions.model.APIModel), + Messages: messages, + Tools: tools, } - response, err := p.client.Chat.Completions.New(ctx, params) - if err != nil { - return nil, err + if o.providerOptions.model.CanReason == true { + params.MaxCompletionTokens = openai.Int(o.providerOptions.maxTokens) + switch o.options.reasoningEffort { + case "low": + params.ReasoningEffort = shared.ReasoningEffortLow + case "medium": + params.ReasoningEffort = shared.ReasoningEffortMedium + case "high": + params.ReasoningEffort = shared.ReasoningEffortHigh + default: + params.ReasoningEffort = shared.ReasoningEffortMedium + } + } else { + params.MaxTokens = openai.Int(o.providerOptions.maxTokens) } - content := "" - if response.Choices[0].Message.Content != "" { - content = response.Choices[0].Message.Content - } + return params +} - var toolCalls []message.ToolCall - if len(response.Choices[0].Message.ToolCalls) > 0 { - toolCalls = make([]message.ToolCall, len(response.Choices[0].Message.ToolCalls)) - for i, call := range response.Choices[0].Message.ToolCalls { - toolCalls[i] = message.ToolCall{ - ID: call.ID, - Name: call.Function.Name, - Input: call.Function.Arguments, - Type: "function", +func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) { + params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools)) + cfg := config.Get() + if cfg.Debug { + jsonData, _ := json.Marshal(params) + logging.Debug("Prepared messages", "messages", string(jsonData)) + } + attempts := 0 + for { + attempts++ + openaiResponse, err := o.client.Chat.Completions.New( + ctx, + params, + ) + // If there is an error we are going to see if we can retry the call + if err != nil { + retry, after, retryErr := o.shouldRetry(attempts, err) + if retryErr != nil { + return nil, retryErr + } + if retry { + logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100)) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(after) * time.Millisecond): + continue + } } + return nil, retryErr } - } - tokenUsage := p.extractTokenUsage(response.Usage) + content := "" + if openaiResponse.Choices[0].Message.Content != "" { + content = openaiResponse.Choices[0].Message.Content + } - return &ProviderResponse{ - Content: content, - ToolCalls: toolCalls, - Usage: tokenUsage, - }, nil + return &ProviderResponse{ + Content: content, + ToolCalls: o.toolCalls(*openaiResponse), + Usage: o.usage(*openaiResponse), + FinishReason: o.finishReason(string(openaiResponse.Choices[0].FinishReason)), + }, nil + } } -func (p *openaiProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) { - messages = cleanupMessages(messages) - chatMessages := p.convertToOpenAIMessages(messages) - openaiTools := p.convertToOpenAITools(tools) - - params := openai.ChatCompletionNewParams{ - Model: openai.ChatModel(p.model.APIModel), - Messages: chatMessages, - MaxTokens: openai.Int(p.maxTokens), - Tools: openaiTools, - StreamOptions: openai.ChatCompletionStreamOptionsParam{ - IncludeUsage: openai.Bool(true), - }, +func (o *openaiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent { + params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools)) + params.StreamOptions = openai.ChatCompletionStreamOptionsParam{ + IncludeUsage: openai.Bool(true), } - stream := p.client.Chat.Completions.NewStreaming(ctx, params) + cfg := config.Get() + if cfg.Debug { + jsonData, _ := json.Marshal(params) + logging.Debug("Prepared messages", "messages", string(jsonData)) + } + attempts := 0 eventChan := make(chan ProviderEvent) - toolCalls := make([]message.ToolCall, 0) go func() { - defer close(eventChan) - - acc := openai.ChatCompletionAccumulator{} - currentContent := "" - - for stream.Next() { - chunk := stream.Current() - acc.AddChunk(chunk) - - if tool, ok := acc.JustFinishedToolCall(); ok { - toolCalls = append(toolCalls, message.ToolCall{ - ID: tool.Id, - Name: tool.Name, - Input: tool.Arguments, - Type: "function", - }) - } + for { + attempts++ + openaiStream := o.client.Chat.Completions.NewStreaming( + ctx, + params, + ) + + acc := openai.ChatCompletionAccumulator{} + currentContent := "" + toolCalls := make([]message.ToolCall, 0) + + for openaiStream.Next() { + chunk := openaiStream.Current() + acc.AddChunk(chunk) + + if tool, ok := acc.JustFinishedToolCall(); ok { + toolCalls = append(toolCalls, message.ToolCall{ + ID: tool.Id, + Name: tool.Name, + Input: tool.Arguments, + Type: "function", + }) + } - for _, choice := range chunk.Choices { - if choice.Delta.Content != "" { - eventChan <- ProviderEvent{ - Type: EventContentDelta, - Content: choice.Delta.Content, + for _, choice := range chunk.Choices { + if choice.Delta.Content != "" { + eventChan <- ProviderEvent{ + Type: EventContentDelta, + Content: choice.Delta.Content, + } + currentContent += choice.Delta.Content } - currentContent += choice.Delta.Content } } - } - if err := stream.Err(); err != nil { - eventChan <- ProviderEvent{ - Type: EventError, - Error: err, + err := openaiStream.Err() + if err == nil || errors.Is(err, io.EOF) { + // Stream completed successfully + eventChan <- ProviderEvent{ + Type: EventComplete, + Response: &ProviderResponse{ + Content: currentContent, + ToolCalls: toolCalls, + Usage: o.usage(acc.ChatCompletion), + FinishReason: o.finishReason(string(acc.ChatCompletion.Choices[0].FinishReason)), + }, + } + close(eventChan) + return + } + + // If there is an error we are going to see if we can retry the call + retry, after, retryErr := o.shouldRetry(attempts, err) + if retryErr != nil { + eventChan <- ProviderEvent{Type: EventError, Error: retryErr} + close(eventChan) + return + } + if retry { + logging.WarnPersist("Retrying due to rate limit... attempt %d of %d", logging.PersistTimeArg, time.Millisecond*time.Duration(after+100)) + select { + case <-ctx.Done(): + // context cancelled + if ctx.Err() == nil { + eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()} + } + close(eventChan) + return + case <-time.After(time.Duration(after) * time.Millisecond): + continue + } } + eventChan <- ProviderEvent{Type: EventError, Error: retryErr} + close(eventChan) return } + }() - tokenUsage := p.extractTokenUsage(acc.Usage) + return eventChan +} - eventChan <- ProviderEvent{ - Type: EventComplete, - Response: &ProviderResponse{ - Content: currentContent, - ToolCalls: toolCalls, - Usage: tokenUsage, - }, +func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) { + var apierr *openai.Error + if !errors.As(err, &apierr) { + return false, 0, err + } + + if apierr.StatusCode != 429 && apierr.StatusCode != 500 { + return false, 0, err + } + + if attempts > maxRetries { + return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries) + } + + retryMs := 0 + retryAfterValues := apierr.Response.Header.Values("Retry-After") + + backoffMs := 2000 * (1 << (attempts - 1)) + jitterMs := int(float64(backoffMs) * 0.2) + retryMs = backoffMs + jitterMs + if len(retryAfterValues) > 0 { + if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil { + retryMs = retryMs * 1000 } - }() + } + return true, int64(retryMs), nil +} + +func (o *openaiClient) toolCalls(completion openai.ChatCompletion) []message.ToolCall { + var toolCalls []message.ToolCall - return eventChan, nil + if len(completion.Choices) > 0 && len(completion.Choices[0].Message.ToolCalls) > 0 { + for _, call := range completion.Choices[0].Message.ToolCalls { + toolCall := message.ToolCall{ + ID: call.ID, + Name: call.Function.Name, + Input: call.Function.Arguments, + Type: "function", + Finished: true, + } + toolCalls = append(toolCalls, toolCall) + } + } + + return toolCalls +} + +func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage { + cachedTokens := completion.Usage.PromptTokensDetails.CachedTokens + inputTokens := completion.Usage.PromptTokens - cachedTokens + + return TokenUsage{ + InputTokens: inputTokens, + OutputTokens: completion.Usage.CompletionTokens, + CacheCreationTokens: 0, // OpenAI doesn't provide this directly + CacheReadTokens: cachedTokens, + } +} + +func WithOpenAIBaseURL(baseURL string) OpenAIOption { + return func(options *openaiOptions) { + options.baseURL = baseURL + } +} + +func WithOpenAIDisableCache() OpenAIOption { + return func(options *openaiOptions) { + options.disableCache = true + } +} + +func WithReasoningEffort(effort string) OpenAIOption { + return func(options *openaiOptions) { + defaultReasoningEffort := "medium" + switch effort { + case "low", "medium", "high": + defaultReasoningEffort = effort + default: + logging.Warn("Invalid reasoning effort, using default: medium") + } + options.reasoningEffort = defaultReasoningEffort + } } diff --git a/internal/llm/provider/provider.go b/internal/llm/provider/provider.go index 938a8c0ad..283a0d983 100644 --- a/internal/llm/provider/provider.go +++ b/internal/llm/provider/provider.go @@ -2,23 +2,28 @@ package provider import ( "context" + "fmt" - "github.com/kujtimiihoxha/termai/internal/llm/tools" - "github.com/kujtimiihoxha/termai/internal/message" + "github.com/kujtimiihoxha/opencode/internal/llm/models" + "github.com/kujtimiihoxha/opencode/internal/llm/tools" + "github.com/kujtimiihoxha/opencode/internal/message" ) -// EventType represents the type of streaming event type EventType string +const maxRetries = 8 + const ( EventContentStart EventType = "content_start" + EventToolUseStart EventType = "tool_use_start" + EventToolUseDelta EventType = "tool_use_delta" + EventToolUseStop EventType = "tool_use_stop" EventContentDelta EventType = "content_delta" EventThinkingDelta EventType = "thinking_delta" EventContentStop EventType = "content_stop" EventComplete EventType = "complete" EventError EventType = "error" EventWarning EventType = "warning" - EventInfo EventType = "info" ) type TokenUsage struct { @@ -32,59 +37,152 @@ type ProviderResponse struct { Content string ToolCalls []message.ToolCall Usage TokenUsage - FinishReason string + FinishReason message.FinishReason } type ProviderEvent struct { - Type EventType + Type EventType + Content string Thinking string + Response *ProviderResponse ToolCall *message.ToolCall Error error - Response *ProviderResponse - - // Used for giving users info on e.x retry - Info string } - type Provider interface { SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) - StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) + StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent + + Model() models.Model +} + +type providerClientOptions struct { + apiKey string + model models.Model + maxTokens int64 + systemMessage string + + anthropicOptions []AnthropicOption + openaiOptions []OpenAIOption + geminiOptions []GeminiOption + bedrockOptions []BedrockOption } -func cleanupMessages(messages []message.Message) []message.Message { - // First pass: filter out canceled messages - var cleanedMessages []message.Message +type ProviderClientOption func(*providerClientOptions) + +type ProviderClient interface { + send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) + stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent +} + +type baseProvider[C ProviderClient] struct { + options providerClientOptions + client C +} + +func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption) (Provider, error) { + clientOptions := providerClientOptions{} + for _, o := range opts { + o(&clientOptions) + } + switch providerName { + case models.ProviderAnthropic: + return &baseProvider[AnthropicClient]{ + options: clientOptions, + client: newAnthropicClient(clientOptions), + }, nil + case models.ProviderOpenAI: + return &baseProvider[OpenAIClient]{ + options: clientOptions, + client: newOpenAIClient(clientOptions), + }, nil + case models.ProviderGemini: + return &baseProvider[GeminiClient]{ + options: clientOptions, + client: newGeminiClient(clientOptions), + }, nil + case models.ProviderBedrock: + return &baseProvider[BedrockClient]{ + options: clientOptions, + client: newBedrockClient(clientOptions), + }, nil + case models.ProviderMock: + // TODO: implement mock client for test + panic("not implemented") + } + return nil, fmt.Errorf("provider not supported: %s", providerName) +} + +func (p *baseProvider[C]) cleanMessages(messages []message.Message) (cleaned []message.Message) { for _, msg := range messages { - if msg.FinishReason() != "canceled" { - cleanedMessages = append(cleanedMessages, msg) + // The message has no content + if len(msg.Parts) == 0 { + continue } + cleaned = append(cleaned, msg) } + return +} - // Second pass: filter out tool messages without a corresponding tool call - var result []message.Message - toolMessageIDs := make(map[string]bool) +func (p *baseProvider[C]) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) { + messages = p.cleanMessages(messages) + return p.client.send(ctx, messages, tools) +} - for _, msg := range cleanedMessages { - if msg.Role == message.Assistant { - for _, toolCall := range msg.ToolCalls() { - toolMessageIDs[toolCall.ID] = true // Mark as referenced - } - } +func (p *baseProvider[C]) Model() models.Model { + return p.options.model +} + +func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent { + messages = p.cleanMessages(messages) + return p.client.stream(ctx, messages, tools) +} + +func WithAPIKey(apiKey string) ProviderClientOption { + return func(options *providerClientOptions) { + options.apiKey = apiKey } +} - // Keep only messages that aren't unreferenced tool messages - for _, msg := range cleanedMessages { - if msg.Role == message.Tool { - for _, toolCall := range msg.ToolResults() { - if referenced, exists := toolMessageIDs[toolCall.ToolCallID]; exists && referenced { - result = append(result, msg) - } - } - } else { - result = append(result, msg) - } +func WithModel(model models.Model) ProviderClientOption { + return func(options *providerClientOptions) { + options.model = model + } +} + +func WithMaxTokens(maxTokens int64) ProviderClientOption { + return func(options *providerClientOptions) { + options.maxTokens = maxTokens + } +} + +func WithSystemMessage(systemMessage string) ProviderClientOption { + return func(options *providerClientOptions) { + options.systemMessage = systemMessage + } +} + +func WithAnthropicOptions(anthropicOptions ...AnthropicOption) ProviderClientOption { + return func(options *providerClientOptions) { + options.anthropicOptions = anthropicOptions + } +} + +func WithOpenAIOptions(openaiOptions ...OpenAIOption) ProviderClientOption { + return func(options *providerClientOptions) { + options.openaiOptions = openaiOptions + } +} + +func WithGeminiOptions(geminiOptions ...GeminiOption) ProviderClientOption { + return func(options *providerClientOptions) { + options.geminiOptions = geminiOptions + } +} + +func WithBedrockOptions(bedrockOptions ...BedrockOption) ProviderClientOption { + return func(options *providerClientOptions) { + options.bedrockOptions = bedrockOptions } - return result } diff --git a/internal/llm/tools/bash.go b/internal/llm/tools/bash.go index 4e80ae60a..a17506197 100644 --- a/internal/llm/tools/bash.go +++ b/internal/llm/tools/bash.go @@ -5,10 +5,11 @@ import ( "encoding/json" "fmt" "strings" + "time" - "github.com/kujtimiihoxha/termai/internal/config" - "github.com/kujtimiihoxha/termai/internal/llm/tools/shell" - "github.com/kujtimiihoxha/termai/internal/permission" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/llm/tools/shell" + "github.com/kujtimiihoxha/opencode/internal/permission" ) type BashParams struct { @@ -21,6 +22,10 @@ type BashPermissionsParams struct { Timeout int `json:"timeout"` } +type BashResponseMetadata struct { + StartTime int64 `json:"start_time"` + EndTime int64 `json:"end_time"` +} type bashTool struct { permissions permission.Service } @@ -46,7 +51,7 @@ var safeReadOnlyCommands = []string{ "git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote", "git rev-parse", "git config --get", "git config --list", "git describe", "git blame", "git grep", "git shortlog", - "go version", "go list", "go env", "go doc", "go vet", "go fmt", "go mod", "go test", "go build", "go run", "go install", "go clean", + "go version", "go help", "go list", "go env", "go doc", "go vet", "go fmt", "go mod", "go test", "go build", "go run", "go install", "go clean", } func bashDescription() string { @@ -117,16 +122,16 @@ When the user asks you to create a new git commit, follow these steps carefully: </commit_analysis> 4. Create the commit with a message ending with: -🤖 Generated with termai -Co-Authored-By: termai <[email protected]> +🤖 Generated with opencode +Co-Authored-By: opencode <[email protected]> - In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example: <example> git commit -m "$(cat <<'EOF' Commit message here. - 🤖 Generated with termai - Co-Authored-By: termai <[email protected]> + 🤖 Generated with opencode + Co-Authored-By: opencode <[email protected]> EOF )" </example> @@ -188,7 +193,7 @@ gh pr create --title "the pr title" --body "$(cat <<'EOF' ## Test plan [Checklist of TODOs for testing the pull request...] -🤖 Generated with termai +🤖 Generated with opencode EOF )" </example> @@ -256,9 +261,15 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) } } } + + sessionID, messageID := GetContextValues(ctx) + if sessionID == "" || messageID == "" { + return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file") + } if !isSafeReadOnly { p := b.permissions.Request( permission.CreatePermissionRequest{ + SessionID: sessionID, Path: config.WorkingDirectory(), ToolName: BashToolName, Action: "execute", @@ -269,13 +280,14 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) }, ) if !p { - return NewTextErrorResponse("permission denied"), nil + return ToolResponse{}, permission.ErrorPermissionDenied } } + startTime := time.Now() shell := shell.GetPersistentShell(config.WorkingDirectory()) stdout, stderr, exitCode, interrupted, err := shell.Exec(ctx, params.Command, params.Timeout) if err != nil { - return NewTextErrorResponse(fmt.Sprintf("error executing command: %s", err)), nil + return ToolResponse{}, fmt.Errorf("error executing command: %w", err) } stdout = truncateOutput(stdout) @@ -304,10 +316,14 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) stdout += "\n" + errorMessage } + metadata := BashResponseMetadata{ + StartTime: startTime.UnixMilli(), + EndTime: time.Now().UnixMilli(), + } if stdout == "" { - return NewTextResponse("no output"), nil + return WithResponseMetadata(NewTextResponse("no output"), metadata), nil } - return NewTextResponse(stdout), nil + return WithResponseMetadata(NewTextResponse(stdout), metadata), nil } func truncateOutput(content string) string { diff --git a/internal/llm/tools/bash_test.go b/internal/llm/tools/bash_test.go deleted file mode 100644 index 97be3683a..000000000 --- a/internal/llm/tools/bash_test.go +++ /dev/null @@ -1,371 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "os" - "strings" - "testing" - "time" - - "github.com/kujtimiihoxha/termai/internal/permission" - "github.com/kujtimiihoxha/termai/internal/pubsub" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestBashTool_Info(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - info := tool.Info() - - assert.Equal(t, BashToolName, info.Name) - assert.NotEmpty(t, info.Description) - assert.Contains(t, info.Parameters, "command") - assert.Contains(t, info.Parameters, "timeout") - assert.Contains(t, info.Required, "command") -} - -func TestBashTool_Run(t *testing.T) { - // Save original working directory - origWd, err := os.Getwd() - require.NoError(t, err) - defer func() { - os.Chdir(origWd) - }() - - t.Run("executes command successfully", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - params := BashParams{ - Command: "echo 'Hello World'", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Equal(t, "Hello World\n", response.Content) - }) - - t.Run("handles invalid parameters", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - call := ToolCall{ - Name: BashToolName, - Input: "invalid json", - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "invalid parameters") - }) - - t.Run("handles missing command", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - params := BashParams{ - Command: "", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "missing command") - }) - - t.Run("handles banned commands", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - - for _, bannedCmd := range bannedCommands { - params := BashParams{ - Command: bannedCmd + " arg1 arg2", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "not allowed", "Command %s should be blocked", bannedCmd) - } - }) - - t.Run("handles multi-word safe commands without permission check", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(false)) - - // Test with multi-word safe commands - multiWordCommands := []string{ - "go env", - } - - for _, cmd := range multiWordCommands { - params := BashParams{ - Command: cmd, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.NotContains(t, response.Content, "permission denied", - "Command %s should be allowed without permission", cmd) - } - }) - - t.Run("handles permission denied", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(false)) - - // Test with a command that requires permission - params := BashParams{ - Command: "mkdir test_dir", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "permission denied") - }) - - t.Run("handles command timeout", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - params := BashParams{ - Command: "sleep 2", - Timeout: 100, // 100ms timeout - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "aborted") - }) - - t.Run("handles command with stderr output", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - params := BashParams{ - Command: "echo 'error message' >&2", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "error message") - }) - - t.Run("handles command with both stdout and stderr", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - params := BashParams{ - Command: "echo 'stdout message' && echo 'stderr message' >&2", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "stdout message") - assert.Contains(t, response.Content, "stderr message") - }) - - t.Run("handles context cancellation", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - params := BashParams{ - Command: "sleep 5", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - ctx, cancel := context.WithCancel(context.Background()) - - // Cancel the context after a short delay - go func() { - time.Sleep(100 * time.Millisecond) - cancel() - }() - - response, err := tool.Run(ctx, call) - require.NoError(t, err) - assert.Contains(t, response.Content, "aborted") - }) - - t.Run("respects max timeout", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - params := BashParams{ - Command: "echo 'test'", - Timeout: MaxTimeout + 1000, // Exceeds max timeout - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Equal(t, "test\n", response.Content) - }) - - t.Run("uses default timeout for zero or negative timeout", func(t *testing.T) { - tool := NewBashTool(newMockPermissionService(true)) - params := BashParams{ - Command: "echo 'test'", - Timeout: -100, // Negative timeout - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: BashToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Equal(t, "test\n", response.Content) - }) -} - -func TestTruncateOutput(t *testing.T) { - t.Run("does not truncate short output", func(t *testing.T) { - output := "short output" - result := truncateOutput(output) - assert.Equal(t, output, result) - }) - - t.Run("truncates long output", func(t *testing.T) { - // Create a string longer than MaxOutputLength - longOutput := strings.Repeat("a\n", MaxOutputLength) - result := truncateOutput(longOutput) - - // Check that the result is shorter than the original - assert.Less(t, len(result), len(longOutput)) - - // Check that the truncation message is included - assert.Contains(t, result, "lines truncated") - - // Check that we have the beginning and end of the original string - assert.True(t, strings.HasPrefix(result, "a\n")) - assert.True(t, strings.HasSuffix(result, "a\n")) - }) -} - -func TestCountLines(t *testing.T) { - testCases := []struct { - name string - input string - expected int - }{ - { - name: "empty string", - input: "", - expected: 0, - }, - { - name: "single line", - input: "line1", - expected: 1, - }, - { - name: "multiple lines", - input: "line1\nline2\nline3", - expected: 3, - }, - { - name: "trailing newline", - input: "line1\nline2\n", - expected: 3, // Empty string after last newline counts as a line - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - result := countLines(tc.input) - assert.Equal(t, tc.expected, result) - }) - } -} - -// Mock permission service for testing -type mockPermissionService struct { - *pubsub.Broker[permission.PermissionRequest] - allow bool -} - -func (m *mockPermissionService) GrantPersistant(permission permission.PermissionRequest) { - // Not needed for tests -} - -func (m *mockPermissionService) Grant(permission permission.PermissionRequest) { - // Not needed for tests -} - -func (m *mockPermissionService) Deny(permission permission.PermissionRequest) { - // Not needed for tests -} - -func (m *mockPermissionService) Request(opts permission.CreatePermissionRequest) bool { - return m.allow -} - -func newMockPermissionService(allow bool) permission.Service { - return &mockPermissionService{ - Broker: pubsub.NewBroker[permission.PermissionRequest](), - allow: allow, - } -} diff --git a/internal/llm/tools/diagnostics.go b/internal/llm/tools/diagnostics.go index 1bb02098e..82989c774 100644 --- a/internal/llm/tools/diagnostics.go +++ b/internal/llm/tools/diagnostics.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "github.com/kujtimiihoxha/termai/internal/lsp" - "github.com/kujtimiihoxha/termai/internal/lsp/protocol" + "github.com/kujtimiihoxha/opencode/internal/lsp" + "github.com/kujtimiihoxha/opencode/internal/lsp/protocol" ) type DiagnosticsParams struct { @@ -82,7 +82,7 @@ func (b *diagnosticsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, waitForLspDiagnostics(ctx, params.FilePath, lsps) } - output := appendDiagnostics(params.FilePath, lsps) + output := getDiagnostics(params.FilePath, lsps) return NewTextResponse(output), nil } @@ -154,7 +154,7 @@ func hasDiagnosticsChanged(current, original map[protocol.DocumentUri][]protocol return false } -func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string { +func getDiagnostics(filePath string, lsps map[string]*lsp.Client) string { fileDiagnostics := []string{} projectDiagnostics := []string{} diff --git a/internal/llm/tools/edit.go b/internal/llm/tools/edit.go index 32e2034e4..e2e257875 100644 --- a/internal/llm/tools/edit.go +++ b/internal/llm/tools/edit.go @@ -9,10 +9,12 @@ import ( "strings" "time" - "github.com/kujtimiihoxha/termai/internal/config" - "github.com/kujtimiihoxha/termai/internal/lsp" - "github.com/kujtimiihoxha/termai/internal/permission" - "github.com/sergi/go-diff/diffmatchpatch" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/diff" + "github.com/kujtimiihoxha/opencode/internal/history" + "github.com/kujtimiihoxha/opencode/internal/logging" + "github.com/kujtimiihoxha/opencode/internal/lsp" + "github.com/kujtimiihoxha/opencode/internal/permission" ) type EditParams struct { @@ -22,15 +24,20 @@ type EditParams struct { } type EditPermissionsParams struct { - FilePath string `json:"file_path"` - OldString string `json:"old_string"` - NewString string `json:"new_string"` + FilePath string `json:"file_path"` + Diff string `json:"diff"` +} + +type EditResponseMetadata struct { Diff string `json:"diff"` + Additions int `json:"additions"` + Removals int `json:"removals"` } type editTool struct { lspClients map[string]*lsp.Client permissions permission.Service + files history.Service } const ( @@ -84,10 +91,11 @@ When making edits: Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.` ) -func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Service) BaseTool { +func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool { return &editTool{ lspClients: lspClients, permissions: permissions, + files: files, } } @@ -128,275 +136,354 @@ func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) params.FilePath = filepath.Join(wd, params.FilePath) } + var response ToolResponse + var err error + if params.OldString == "" { - result, err := e.createNewFile(params.FilePath, params.NewString) + response, err = e.createNewFile(ctx, params.FilePath, params.NewString) if err != nil { - return NewTextErrorResponse(fmt.Sprintf("error creating file: %s", err)), nil + return response, err } - return NewTextResponse(result), nil } if params.NewString == "" { - result, err := e.deleteContent(params.FilePath, params.OldString) + response, err = e.deleteContent(ctx, params.FilePath, params.OldString) if err != nil { - return NewTextErrorResponse(fmt.Sprintf("error deleting content: %s", err)), nil + return response, err } - return NewTextResponse(result), nil } - result, err := e.replaceContent(params.FilePath, params.OldString, params.NewString) + response, err = e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString) if err != nil { - return NewTextErrorResponse(fmt.Sprintf("error replacing content: %s", err)), nil + return response, err + } + if response.IsError { + // Return early if there was an error during content replacement + // This prevents unnecessary LSP diagnostics processing + return response, nil } waitForLspDiagnostics(ctx, params.FilePath, e.lspClients) - result = fmt.Sprintf("<result>\n%s\n</result>\n", result) - result += appendDiagnostics(params.FilePath, e.lspClients) - return NewTextResponse(result), nil + text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content) + text += getDiagnostics(params.FilePath, e.lspClients) + response.Content = text + return response, nil } -func (e *editTool) createNewFile(filePath, content string) (string, error) { +func (e *editTool) createNewFile(ctx context.Context, filePath, content string) (ToolResponse, error) { fileInfo, err := os.Stat(filePath) if err == nil { if fileInfo.IsDir() { - return "", fmt.Errorf("path is a directory, not a file: %s", filePath) + return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil } - return "", fmt.Errorf("file already exists: %s. Use the Replace tool to overwrite an existing file", filePath) + return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil } else if !os.IsNotExist(err) { - return "", fmt.Errorf("failed to access file: %w", err) + return ToolResponse{}, fmt.Errorf("failed to access file: %w", err) } dir := filepath.Dir(filePath) if err = os.MkdirAll(dir, 0o755); err != nil { - return "", fmt.Errorf("failed to create parent directories: %w", err) + return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err) } + sessionID, messageID := GetContextValues(ctx) + if sessionID == "" || messageID == "" { + return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file") + } + + diff, additions, removals := diff.GenerateDiff( + "", + content, + filePath, + ) + rootDir := config.WorkingDirectory() + permissionPath := filepath.Dir(filePath) + if strings.HasPrefix(filePath, rootDir) { + permissionPath = rootDir + } p := e.permissions.Request( permission.CreatePermissionRequest{ - Path: filepath.Dir(filePath), + SessionID: sessionID, + Path: permissionPath, ToolName: EditToolName, - Action: "create", + Action: "write", Description: fmt.Sprintf("Create file %s", filePath), Params: EditPermissionsParams{ - FilePath: filePath, - OldString: "", - NewString: content, - Diff: GenerateDiff("", content), + FilePath: filePath, + Diff: diff, }, }, ) if !p { - return "", fmt.Errorf("permission denied") + return ToolResponse{}, permission.ErrorPermissionDenied } err = os.WriteFile(filePath, []byte(content), 0o644) if err != nil { - return "", fmt.Errorf("failed to write file: %w", err) + return ToolResponse{}, fmt.Errorf("failed to write file: %w", err) + } + + // File can't be in the history so we create a new file history + _, err = e.files.Create(ctx, sessionID, filePath, "") + if err != nil { + // Log error but don't fail the operation + return ToolResponse{}, fmt.Errorf("error creating file history: %w", err) + } + + // Add the new content to the file history + _, err = e.files.CreateVersion(ctx, sessionID, filePath, content) + if err != nil { + // Log error but don't fail the operation + logging.Debug("Error creating file history version", "error", err) } recordFileWrite(filePath) recordFileRead(filePath) - return "File created: " + filePath, nil + return WithResponseMetadata( + NewTextResponse("File created: "+filePath), + EditResponseMetadata{ + Diff: diff, + Additions: additions, + Removals: removals, + }, + ), nil } -func (e *editTool) deleteContent(filePath, oldString string) (string, error) { +func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string) (ToolResponse, error) { fileInfo, err := os.Stat(filePath) if err != nil { if os.IsNotExist(err) { - return "", fmt.Errorf("file not found: %s", filePath) + return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil } - return "", fmt.Errorf("failed to access file: %w", err) + return ToolResponse{}, fmt.Errorf("failed to access file: %w", err) } if fileInfo.IsDir() { - return "", fmt.Errorf("path is a directory, not a file: %s", filePath) + return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil } if getLastReadTime(filePath).IsZero() { - return "", fmt.Errorf("you must read the file before editing it. Use the View tool first") + return 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 "", fmt.Errorf("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)) + return 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 } content, err := os.ReadFile(filePath) if err != nil { - return "", fmt.Errorf("failed to read file: %w", err) + return ToolResponse{}, fmt.Errorf("failed to read file: %w", err) } oldContent := string(content) index := strings.Index(oldContent, oldString) if index == -1 { - return "", fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks") + return 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 "", fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match") + return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil } newContent := oldContent[:index] + oldContent[index+len(oldString):] + sessionID, messageID := GetContextValues(ctx) + + if sessionID == "" || messageID == "" { + return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file") + } + + diff, additions, removals := diff.GenerateDiff( + oldContent, + newContent, + filePath, + ) + + rootDir := config.WorkingDirectory() + permissionPath := filepath.Dir(filePath) + if strings.HasPrefix(filePath, rootDir) { + permissionPath = rootDir + } p := e.permissions.Request( permission.CreatePermissionRequest{ - Path: filepath.Dir(filePath), + SessionID: sessionID, + Path: permissionPath, ToolName: EditToolName, - Action: "delete", + Action: "write", Description: fmt.Sprintf("Delete content from file %s", filePath), Params: EditPermissionsParams{ - FilePath: filePath, - OldString: oldString, - NewString: "", - Diff: GenerateDiff(oldContent, newContent), + FilePath: filePath, + Diff: diff, }, }, ) if !p { - return "", fmt.Errorf("permission denied") + return ToolResponse{}, permission.ErrorPermissionDenied } err = os.WriteFile(filePath, []byte(newContent), 0o644) if err != nil { - return "", fmt.Errorf("failed to write file: %w", err) + return ToolResponse{}, fmt.Errorf("failed to write file: %w", err) + } + + // Check if file exists in history + file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID) + if err != nil { + _, err = e.files.Create(ctx, sessionID, filePath, oldContent) + if err != nil { + // Log error but don't fail the operation + return ToolResponse{}, fmt.Errorf("error creating file history: %w", err) + } + } + if file.Content != oldContent { + // User Manually changed the content store an intermediate version + _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent) + if err != nil { + logging.Debug("Error creating file history version", "error", err) + } + } + // Store the new version + _, err = e.files.CreateVersion(ctx, sessionID, filePath, "") + if err != nil { + logging.Debug("Error creating file history version", "error", err) } recordFileWrite(filePath) recordFileRead(filePath) - return "Content deleted from file: " + filePath, nil + return WithResponseMetadata( + NewTextResponse("Content deleted from file: "+filePath), + EditResponseMetadata{ + Diff: diff, + Additions: additions, + Removals: removals, + }, + ), nil } -func (e *editTool) replaceContent(filePath, oldString, newString string) (string, error) { +func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string) (ToolResponse, error) { fileInfo, err := os.Stat(filePath) if err != nil { if os.IsNotExist(err) { - return "", fmt.Errorf("file not found: %s", filePath) + return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil } - return "", fmt.Errorf("failed to access file: %w", err) + return ToolResponse{}, fmt.Errorf("failed to access file: %w", err) } if fileInfo.IsDir() { - return "", fmt.Errorf("path is a directory, not a file: %s", filePath) + return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil } if getLastReadTime(filePath).IsZero() { - return "", fmt.Errorf("you must read the file before editing it. Use the View tool first") + return 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 "", fmt.Errorf("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)) + return 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 } content, err := os.ReadFile(filePath) if err != nil { - return "", fmt.Errorf("failed to read file: %w", err) + return ToolResponse{}, fmt.Errorf("failed to read file: %w", err) } oldContent := string(content) index := strings.Index(oldContent, oldString) if index == -1 { - return "", fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks") + return 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 "", fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match") + return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil } newContent := oldContent[:index] + newString + oldContent[index+len(oldString):] - startIndex := max(0, index-3) - oldEndIndex := min(len(oldContent), index+len(oldString)+3) - newEndIndex := min(len(newContent), index+len(newString)+3) - - diff := GenerateDiff(oldContent[startIndex:oldEndIndex], newContent[startIndex:newEndIndex]) + if oldContent == newContent { + return NewTextErrorResponse("new content is the same as old content. No changes made."), nil + } + sessionID, messageID := GetContextValues(ctx) + if sessionID == "" || messageID == "" { + return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file") + } + diff, additions, removals := diff.GenerateDiff( + oldContent, + newContent, + filePath, + ) + rootDir := config.WorkingDirectory() + permissionPath := filepath.Dir(filePath) + if strings.HasPrefix(filePath, rootDir) { + permissionPath = rootDir + } p := e.permissions.Request( permission.CreatePermissionRequest{ - Path: filepath.Dir(filePath), + SessionID: sessionID, + Path: permissionPath, ToolName: EditToolName, - Action: "replace", + Action: "write", Description: fmt.Sprintf("Replace content in file %s", filePath), Params: EditPermissionsParams{ - FilePath: filePath, - OldString: oldString, - NewString: newString, - Diff: diff, + FilePath: filePath, + Diff: diff, }, }, ) if !p { - return "", fmt.Errorf("permission denied") + return ToolResponse{}, permission.ErrorPermissionDenied } err = os.WriteFile(filePath, []byte(newContent), 0o644) if err != nil { - return "", fmt.Errorf("failed to write file: %w", err) + return ToolResponse{}, fmt.Errorf("failed to write file: %w", err) + } + + // Check if file exists in history + file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID) + if err != nil { + _, err = e.files.Create(ctx, sessionID, filePath, oldContent) + if err != nil { + // Log error but don't fail the operation + return ToolResponse{}, fmt.Errorf("error creating file history: %w", err) + } + } + if file.Content != oldContent { + // User Manually changed the content store an intermediate version + _, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent) + if err != nil { + logging.Debug("Error creating file history version", "error", err) + } + } + // Store the new version + _, err = e.files.CreateVersion(ctx, sessionID, filePath, newContent) + if err != nil { + logging.Debug("Error creating file history version", "error", err) } recordFileWrite(filePath) recordFileRead(filePath) - return "Content replaced in file: " + filePath, nil -} - -func GenerateDiff(oldContent, newContent string) string { - dmp := diffmatchpatch.New() - fileAdmp, fileBdmp, dmpStrings := dmp.DiffLinesToChars(oldContent, newContent) - diffs := dmp.DiffMain(fileAdmp, fileBdmp, false) - diffs = dmp.DiffCharsToLines(diffs, dmpStrings) - diffs = dmp.DiffCleanupSemantic(diffs) - buff := strings.Builder{} - - buff.WriteString("Changes:\n") - - for _, diff := range diffs { - text := diff.Text - - switch diff.Type { - case diffmatchpatch.DiffInsert: - for line := range strings.SplitSeq(text, "\n") { - if line == "" { - continue - } - _, _ = buff.WriteString("+ " + line + "\n") - } - case diffmatchpatch.DiffDelete: - for line := range strings.SplitSeq(text, "\n") { - if line == "" { - continue - } - _, _ = buff.WriteString("- " + line + "\n") - } - case diffmatchpatch.DiffEqual: - lines := strings.Split(text, "\n") - if len(lines) > 3 { - if lines[0] != "" { - _, _ = buff.WriteString(" " + lines[0] + "\n") - } - _, _ = buff.WriteString(" ...\n") - if lines[len(lines)-1] != "" { - _, _ = buff.WriteString(" " + lines[len(lines)-1] + "\n") - } - } else { - for _, line := range lines { - if line == "" { - continue - } - _, _ = buff.WriteString(" " + line + "\n") - } - } - } - } - return buff.String() + return WithResponseMetadata( + NewTextResponse("Content replaced in file: "+filePath), + EditResponseMetadata{ + Diff: diff, + Additions: additions, + Removals: removals, + }), nil } diff --git a/internal/llm/tools/edit_test.go b/internal/llm/tools/edit_test.go deleted file mode 100644 index dbc6e488f..000000000 --- a/internal/llm/tools/edit_test.go +++ /dev/null @@ -1,509 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "testing" - "time" - - "github.com/kujtimiihoxha/termai/internal/lsp" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestEditTool_Info(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - info := tool.Info() - - assert.Equal(t, EditToolName, info.Name) - assert.NotEmpty(t, info.Description) - assert.Contains(t, info.Parameters, "file_path") - assert.Contains(t, info.Parameters, "old_string") - assert.Contains(t, info.Parameters, "new_string") - assert.Contains(t, info.Required, "file_path") - assert.Contains(t, info.Required, "old_string") - assert.Contains(t, info.Required, "new_string") -} - -func TestEditTool_Run(t *testing.T) { - // Create a temporary directory for testing - tempDir, err := os.MkdirTemp("", "edit_tool_test") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - - t.Run("creates a new file successfully", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - filePath := filepath.Join(tempDir, "new_file.txt") - content := "This is a test content" - - params := EditParams{ - FilePath: filePath, - OldString: "", - NewString: content, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "File created") - - // Verify file was created with correct content - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, content, string(fileContent)) - }) - - t.Run("creates file with nested directories", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt") - content := "Content in nested directory" - - params := EditParams{ - FilePath: filePath, - OldString: "", - NewString: content, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "File created") - - // Verify file was created with correct content - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, content, string(fileContent)) - }) - - t.Run("fails to create file that already exists", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file first - filePath := filepath.Join(tempDir, "existing_file.txt") - initialContent := "Initial content" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Try to create the same file - params := EditParams{ - FilePath: filePath, - OldString: "", - NewString: "New content", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "file already exists") - }) - - t.Run("fails to create file when path is a directory", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a directory - dirPath := filepath.Join(tempDir, "test_dir") - err := os.Mkdir(dirPath, 0o755) - require.NoError(t, err) - - // Try to create a file with the same path as the directory - params := EditParams{ - FilePath: dirPath, - OldString: "", - NewString: "Some content", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "path is a directory") - }) - - t.Run("replaces content successfully", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file first - filePath := filepath.Join(tempDir, "replace_content.txt") - initialContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Record the file read to avoid modification time check failure - recordFileRead(filePath) - - // Replace content - oldString := "Line 2\nLine 3" - newString := "Line 2 modified\nLine 3 modified" - params := EditParams{ - FilePath: filePath, - OldString: oldString, - NewString: newString, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "Content replaced") - - // Verify file was updated with correct content - expectedContent := "Line 1\nLine 2 modified\nLine 3 modified\nLine 4\nLine 5" - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, expectedContent, string(fileContent)) - }) - - t.Run("deletes content successfully", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file first - filePath := filepath.Join(tempDir, "delete_content.txt") - initialContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Record the file read to avoid modification time check failure - recordFileRead(filePath) - - // Delete content - oldString := "Line 2\nLine 3\n" - params := EditParams{ - FilePath: filePath, - OldString: oldString, - NewString: "", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "Content deleted") - - // Verify file was updated with correct content - expectedContent := "Line 1\nLine 4\nLine 5" - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, expectedContent, string(fileContent)) - }) - - t.Run("handles invalid parameters", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - call := ToolCall{ - Name: EditToolName, - Input: "invalid json", - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "invalid parameters") - }) - - t.Run("handles missing file_path", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - params := EditParams{ - FilePath: "", - OldString: "old", - NewString: "new", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "file_path is required") - }) - - t.Run("handles file not found", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - filePath := filepath.Join(tempDir, "non_existent_file.txt") - params := EditParams{ - FilePath: filePath, - OldString: "old content", - NewString: "new content", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "file not found") - }) - - t.Run("handles old_string not found in file", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file first - filePath := filepath.Join(tempDir, "content_not_found.txt") - initialContent := "Line 1\nLine 2\nLine 3" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Record the file read to avoid modification time check failure - recordFileRead(filePath) - - // Try to replace content that doesn't exist - params := EditParams{ - FilePath: filePath, - OldString: "This content does not exist", - NewString: "new content", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "old_string not found in file") - }) - - t.Run("handles multiple occurrences of old_string", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file with duplicate content - filePath := filepath.Join(tempDir, "duplicate_content.txt") - initialContent := "Line 1\nDuplicate\nLine 3\nDuplicate\nLine 5" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Record the file read to avoid modification time check failure - recordFileRead(filePath) - - // Try to replace content that appears multiple times - params := EditParams{ - FilePath: filePath, - OldString: "Duplicate", - NewString: "Replaced", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "appears multiple times") - }) - - t.Run("handles file modified since last read", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file - filePath := filepath.Join(tempDir, "modified_file.txt") - initialContent := "Initial content" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Record an old read time - fileRecordMutex.Lock() - fileRecords[filePath] = fileRecord{ - path: filePath, - readTime: time.Now().Add(-1 * time.Hour), - } - fileRecordMutex.Unlock() - - // Try to update the file - params := EditParams{ - FilePath: filePath, - OldString: "Initial", - NewString: "Updated", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "has been modified since it was last read") - - // Verify file was not modified - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, initialContent, string(fileContent)) - }) - - t.Run("handles file not read before editing", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file - filePath := filepath.Join(tempDir, "not_read_file.txt") - initialContent := "Initial content" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Try to update the file without reading it first - params := EditParams{ - FilePath: filePath, - OldString: "Initial", - NewString: "Updated", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "you must read the file before editing it") - }) - - t.Run("handles permission denied", func(t *testing.T) { - tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(false)) - - // Create a file - filePath := filepath.Join(tempDir, "permission_denied.txt") - initialContent := "Initial content" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Record the file read to avoid modification time check failure - recordFileRead(filePath) - - // Try to update the file - params := EditParams{ - FilePath: filePath, - OldString: "Initial", - NewString: "Updated", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: EditToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "permission denied") - - // Verify file was not modified - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, initialContent, string(fileContent)) - }) -} - -func TestGenerateDiff(t *testing.T) { - testCases := []struct { - name string - oldContent string - newContent string - expectedDiff string - }{ - { - name: "add content", - oldContent: "Line 1\nLine 2\n", - newContent: "Line 1\nLine 2\nLine 3\n", - expectedDiff: "Changes:\n Line 1\n Line 2\n+ Line 3\n", - }, - { - name: "remove content", - oldContent: "Line 1\nLine 2\nLine 3\n", - newContent: "Line 1\nLine 3\n", - expectedDiff: "Changes:\n Line 1\n- Line 2\n Line 3\n", - }, - { - name: "replace content", - oldContent: "Line 1\nLine 2\nLine 3\n", - newContent: "Line 1\nModified Line\nLine 3\n", - expectedDiff: "Changes:\n Line 1\n- Line 2\n+ Modified Line\n Line 3\n", - }, - { - name: "empty to content", - oldContent: "", - newContent: "Line 1\nLine 2\n", - expectedDiff: "Changes:\n+ Line 1\n+ Line 2\n", - }, - { - name: "content to empty", - oldContent: "Line 1\nLine 2\n", - newContent: "", - expectedDiff: "Changes:\n- Line 1\n- Line 2\n", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - diff := GenerateDiff(tc.oldContent, tc.newContent) - assert.Contains(t, diff, tc.expectedDiff) - }) - } -} - diff --git a/internal/llm/tools/fetch.go b/internal/llm/tools/fetch.go index 19e644281..47ff03e57 100644 --- a/internal/llm/tools/fetch.go +++ b/internal/llm/tools/fetch.go @@ -11,8 +11,8 @@ import ( md "github.com/JohannesKaufmann/html-to-markdown" "github.com/PuerkitoBio/goquery" - "github.com/kujtimiihoxha/termai/internal/config" - "github.com/kujtimiihoxha/termai/internal/permission" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/permission" ) type FetchParams struct { @@ -86,6 +86,7 @@ func (t *fetchTool) Info() ToolInfo { "format": map[string]any{ "type": "string", "description": "The format to return the content in (text, markdown, or html)", + "enum": []string{"text", "markdown", "html"}, }, "timeout": map[string]any{ "type": "number", @@ -115,8 +116,14 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error return NewTextErrorResponse("URL must start with http:// or https://"), nil } + sessionID, messageID := GetContextValues(ctx) + if sessionID == "" || messageID == "" { + return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file") + } + p := t.permissions.Request( permission.CreatePermissionRequest{ + SessionID: sessionID, Path: config.WorkingDirectory(), ToolName: FetchToolName, Action: "fetch", @@ -126,7 +133,7 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error ) if !p { - return NewTextErrorResponse("Permission denied to fetch from URL: " + params.URL), nil + return ToolResponse{}, permission.ErrorPermissionDenied } client := t.client @@ -142,14 +149,14 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error req, err := http.NewRequestWithContext(ctx, "GET", params.URL, nil) if err != nil { - return NewTextErrorResponse("Failed to create request: " + err.Error()), nil + return ToolResponse{}, fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("User-Agent", "termai/1.0") + req.Header.Set("User-Agent", "opencode/1.0") resp, err := client.Do(req) if err != nil { - return NewTextErrorResponse("Failed to execute request: " + err.Error()), nil + return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err) } defer resp.Body.Close() diff --git a/internal/llm/tools/glob.go b/internal/llm/tools/glob.go index 4de7971e6..e3c7b7b61 100644 --- a/internal/llm/tools/glob.go +++ b/internal/llm/tools/glob.go @@ -12,7 +12,7 @@ import ( "time" "github.com/bmatcuk/doublestar/v4" - "github.com/kujtimiihoxha/termai/internal/config" + "github.com/kujtimiihoxha/opencode/internal/config" ) const ( @@ -63,6 +63,11 @@ type GlobParams struct { Path string `json:"path"` } +type GlobResponseMetadata struct { + NumberOfFiles int `json:"number_of_files"` + Truncated bool `json:"truncated"` +} + type globTool struct{} func NewGlobTool() BaseTool { @@ -104,7 +109,7 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) files, truncated, err := globFiles(params.Pattern, searchPath, 100) if err != nil { - return NewTextErrorResponse(fmt.Sprintf("error performing glob search: %s", err)), nil + return ToolResponse{}, fmt.Errorf("error finding files: %w", err) } var output string @@ -117,7 +122,13 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) } } - return NewTextResponse(output), nil + return WithResponseMetadata( + NewTextResponse(output), + GlobResponseMetadata{ + NumberOfFiles: len(files), + Truncated: truncated, + }, + ), nil } func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) { @@ -181,6 +192,42 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) { } func skipHidden(path string) bool { + // Check for hidden files (starting with a dot) base := filepath.Base(path) - return base != "." && strings.HasPrefix(base, ".") + if base != "." && strings.HasPrefix(base, ".") { + return true + } + + // List of commonly ignored directories in development projects + commonIgnoredDirs := map[string]bool{ + "node_modules": true, + "vendor": true, + "dist": true, + "build": true, + "target": true, + ".git": true, + ".idea": true, + ".vscode": true, + "__pycache__": true, + "bin": true, + "obj": true, + "out": true, + "coverage": true, + "tmp": true, + "temp": true, + "logs": true, + "generated": true, + "bower_components": true, + "jspm_packages": true, + } + + // Check if any path component is in our ignore list + parts := strings.SplitSeq(path, string(os.PathSeparator)) + for part := range parts { + if commonIgnoredDirs[part] { + return true + } + } + + return false } diff --git a/internal/llm/tools/grep.go b/internal/llm/tools/grep.go index f349e8370..475370ffb 100644 --- a/internal/llm/tools/grep.go +++ b/internal/llm/tools/grep.go @@ -10,21 +10,30 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "time" - "github.com/kujtimiihoxha/termai/internal/config" + "github.com/kujtimiihoxha/opencode/internal/config" ) type GrepParams struct { - Pattern string `json:"pattern"` - Path string `json:"path"` - Include string `json:"include"` + Pattern string `json:"pattern"` + Path string `json:"path"` + Include string `json:"include"` + LiteralText bool `json:"literal_text"` } type grepMatch struct { - path string - modTime time.Time + path string + modTime time.Time + lineNum int + lineText string +} + +type GrepResponseMetadata struct { + NumberOfMatches int `json:"number_of_matches"` + Truncated bool `json:"truncated"` } type grepTool struct{} @@ -40,11 +49,12 @@ WHEN TO USE THIS TOOL: HOW TO USE: - Provide a regex pattern to search for within file contents +- Set literal_text=true if you want to search for the exact text with special characters (recommended for non-regex users) - Optionally specify a starting directory (defaults to current working directory) - Optionally provide an include pattern to filter which files to search - Results are sorted with most recently modified files first -REGEX PATTERN SYNTAX: +REGEX PATTERN SYNTAX (when literal_text=false): - Supports standard regular expression syntax - 'function' searches for the literal text "function" - 'log\..*Error' finds text starting with "log." and ending with "Error" @@ -64,7 +74,8 @@ LIMITATIONS: TIPS: - For faster, more targeted searches, first use Glob to find relevant files, then use Grep - When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead -- Always check if results are truncated and refine your search pattern if needed` +- Always check if results are truncated and refine your search pattern if needed +- Use literal_text=true when searching for exact text containing special characters like dots, parentheses, etc.` ) func NewGrepTool() BaseTool { @@ -88,11 +99,27 @@ func (g *grepTool) Info() ToolInfo { "type": "string", "description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")", }, + "literal_text": map[string]any{ + "type": "boolean", + "description": "If true, the pattern will be treated as literal text with special regex characters escaped. Default is false.", + }, }, Required: []string{"pattern"}, } } +// escapeRegexPattern escapes special regex characters so they're treated as literal characters +func escapeRegexPattern(pattern string) string { + specialChars := []string{"\\", ".", "+", "*", "?", "(", ")", "[", "]", "{", "}", "^", "$", "|"} + escaped := pattern + + for _, char := range specialChars { + escaped = strings.ReplaceAll(escaped, char, "\\"+char) + } + + return escaped +} + func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { var params GrepParams if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil { @@ -103,41 +130,59 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) return NewTextErrorResponse("pattern is required"), nil } + // If literal_text is true, escape the pattern + searchPattern := params.Pattern + if params.LiteralText { + searchPattern = escapeRegexPattern(params.Pattern) + } + searchPath := params.Path if searchPath == "" { searchPath = config.WorkingDirectory() } - matches, truncated, err := searchFiles(params.Pattern, searchPath, params.Include, 100) + matches, truncated, err := searchFiles(searchPattern, searchPath, params.Include, 100) if err != nil { - return NewTextErrorResponse(fmt.Sprintf("error searching files: %s", err)), nil + return ToolResponse{}, fmt.Errorf("error searching files: %w", err) } var output string if len(matches) == 0 { output = "No files found" } else { - output = fmt.Sprintf("Found %d file%s\n%s", - len(matches), - pluralize(len(matches)), - strings.Join(matches, "\n")) + output = fmt.Sprintf("Found %d matches\n", len(matches)) + + currentFile := "" + for _, match := range matches { + if currentFile != match.path { + if currentFile != "" { + output += "\n" + } + currentFile = match.path + output += fmt.Sprintf("%s:\n", match.path) + } + if match.lineNum > 0 { + output += fmt.Sprintf(" Line %d: %s\n", match.lineNum, match.lineText) + } else { + output += fmt.Sprintf(" %s\n", match.path) + } + } if truncated { - output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)" + output += "\n(Results are truncated. Consider using a more specific path or pattern.)" } } - return NewTextResponse(output), nil -} - -func pluralize(count int) string { - if count == 1 { - return "" - } - return "s" + return WithResponseMetadata( + NewTextResponse(output), + GrepResponseMetadata{ + NumberOfMatches: len(matches), + Truncated: truncated, + }, + ), nil } -func searchFiles(pattern, rootPath, include string, limit int) ([]string, bool, error) { +func searchFiles(pattern, rootPath, include string, limit int) ([]grepMatch, bool, error) { matches, err := searchWithRipgrep(pattern, rootPath, include) if err != nil { matches, err = searchFilesWithRegex(pattern, rootPath, include) @@ -155,12 +200,7 @@ func searchFiles(pattern, rootPath, include string, limit int) ([]string, bool, matches = matches[:limit] } - results := make([]string, len(matches)) - for i, m := range matches { - results[i] = m.path - } - - return results, truncated, nil + return matches, truncated, nil } func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) { @@ -169,7 +209,8 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) { return nil, fmt.Errorf("ripgrep not found: %w", err) } - args := []string{"-l", pattern} + // Use -n to show line numbers and include the matched line + args := []string{"-n", pattern} if include != "" { args = append(args, "--glob", include) } @@ -192,14 +233,29 @@ func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) { continue } - fileInfo, err := os.Stat(line) + // Parse ripgrep output format: file:line:content + parts := strings.SplitN(line, ":", 3) + if len(parts) < 3 { + continue + } + + filePath := parts[0] + lineNum, err := strconv.Atoi(parts[1]) + if err != nil { + continue + } + lineText := parts[2] + + fileInfo, err := os.Stat(filePath) if err != nil { continue // Skip files we can't access } matches = append(matches, grepMatch{ - path: line, - modTime: fileInfo.ModTime(), + path: filePath, + modTime: fileInfo.ModTime(), + lineNum: lineNum, + lineText: lineText, }) } @@ -240,15 +296,17 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error return nil } - match, err := fileContainsPattern(path, regex) + match, lineNum, lineText, err := fileContainsPattern(path, regex) if err != nil { return nil // Skip files we can't read } if match { matches = append(matches, grepMatch{ - path: path, - modTime: info.ModTime(), + path: path, + modTime: info.ModTime(), + lineNum: lineNum, + lineText: lineText, }) if len(matches) >= 200 { @@ -265,21 +323,24 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error return matches, nil } -func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, error) { +func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) { file, err := os.Open(filePath) if err != nil { - return false, err + return false, 0, "", err } defer file.Close() scanner := bufio.NewScanner(file) + lineNum := 0 for scanner.Scan() { - if pattern.MatchString(scanner.Text()) { - return true, nil + lineNum++ + line := scanner.Text() + if pattern.MatchString(line) { + return true, lineNum, line, nil } } - return false, scanner.Err() + return false, 0, "", scanner.Err() } func globToRegex(glob string) string { diff --git a/internal/llm/tools/ls.go b/internal/llm/tools/ls.go index 59e8dcd21..05f300c0e 100644 --- a/internal/llm/tools/ls.go +++ b/internal/llm/tools/ls.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - "github.com/kujtimiihoxha/termai/internal/config" + "github.com/kujtimiihoxha/opencode/internal/config" ) type LSParams struct { @@ -23,6 +23,11 @@ type TreeNode struct { Children []*TreeNode `json:"children,omitempty"` } +type LSResponseMetadata struct { + NumberOfFiles int `json:"number_of_files"` + Truncated bool `json:"truncated"` +} + type lsTool struct{} const ( @@ -104,7 +109,7 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { files, truncated, err := listDirectory(searchPath, params.Ignore, MaxLSFiles) if err != nil { - return NewTextErrorResponse(fmt.Sprintf("error listing directory: %s", err)), nil + return ToolResponse{}, fmt.Errorf("error listing directory: %w", err) } tree := createFileTree(files) @@ -114,7 +119,13 @@ func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %d files and directories are included below:\n\n%s", MaxLSFiles, MaxLSFiles, output) } - return NewTextResponse(output), nil + return WithResponseMetadata( + NewTextResponse(output), + LSResponseMetadata{ + NumberOfFiles: len(files), + Truncated: truncated, + }, + ), nil } func listDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) { diff --git a/internal/llm/tools/patch.go b/internal/llm/tools/patch.go new file mode 100644 index 000000000..7e20e378e --- /dev/null +++ b/internal/llm/tools/patch.go @@ -0,0 +1,372 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/diff" + "github.com/kujtimiihoxha/opencode/internal/history" + "github.com/kujtimiihoxha/opencode/internal/logging" + "github.com/kujtimiihoxha/opencode/internal/lsp" + "github.com/kujtimiihoxha/opencode/internal/permission" +) + +type PatchParams struct { + PatchText string `json:"patch_text"` +} + +type PatchResponseMetadata struct { + FilesChanged []string `json:"files_changed"` + Additions int `json:"additions"` + Removals int `json:"removals"` +} + +type patchTool struct { + lspClients map[string]*lsp.Client + permissions permission.Service + files history.Service +} + +const ( + PatchToolName = "patch" + patchDescription = `Applies a patch to multiple files in one operation. This tool is useful for making coordinated changes across multiple files. + +The patch text must follow this format: +*** Begin Patch +*** Update File: /path/to/file +@@ Context line (unique within the file) + Line to keep +-Line to remove ++Line to add + Line to keep +*** Add File: /path/to/new/file ++Content of the new file ++More content +*** Delete File: /path/to/file/to/delete +*** End Patch + +Before using this tool: +1. Use the FileRead tool to understand the files' contents and context +2. Verify all file paths are correct (use the LS tool) + +CRITICAL REQUIREMENTS FOR USING THIS TOOL: + +1. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change +2. PRECISION: All whitespace, indentation, and surrounding code must match exactly +3. VALIDATION: Ensure edits result in idiomatic, correct code +4. PATHS: Always use absolute file paths (starting with /) + +The tool will apply all changes in a single atomic operation.` +) + +func NewPatchTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool { + return &patchTool{ + lspClients: lspClients, + permissions: permissions, + files: files, + } +} + +func (p *patchTool) Info() ToolInfo { + return ToolInfo{ + Name: PatchToolName, + Description: patchDescription, + Parameters: map[string]any{ + "patch_text": map[string]any{ + "type": "string", + "description": "The full patch text that describes all changes to be made", + }, + }, + Required: []string{"patch_text"}, + } +} + +func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { + var params PatchParams + if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil { + return NewTextErrorResponse("invalid parameters"), nil + } + + if params.PatchText == "" { + return NewTextErrorResponse("patch_text is required"), nil + } + + // Identify all files needed for the patch and verify they've been read + filesToRead := diff.IdentifyFilesNeeded(params.PatchText) + for _, filePath := range filesToRead { + absPath := filePath + if !filepath.IsAbs(absPath) { + wd := config.WorkingDirectory() + absPath = filepath.Join(wd, absPath) + } + + if getLastReadTime(absPath).IsZero() { + return NewTextErrorResponse(fmt.Sprintf("you must read the file %s before patching it. Use the FileRead tool first", filePath)), nil + } + + fileInfo, err := os.Stat(absPath) + if err != nil { + if os.IsNotExist(err) { + return NewTextErrorResponse(fmt.Sprintf("file not found: %s", absPath)), nil + } + return ToolResponse{}, fmt.Errorf("failed to access file: %w", err) + } + + if fileInfo.IsDir() { + return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", absPath)), nil + } + + modTime := fileInfo.ModTime() + lastRead := getLastReadTime(absPath) + if modTime.After(lastRead) { + return NewTextErrorResponse( + fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)", + absPath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339), + )), nil + } + } + + // Check for new files to ensure they don't already exist + filesToAdd := diff.IdentifyFilesAdded(params.PatchText) + for _, filePath := range filesToAdd { + absPath := filePath + if !filepath.IsAbs(absPath) { + wd := config.WorkingDirectory() + absPath = filepath.Join(wd, absPath) + } + + _, err := os.Stat(absPath) + if err == nil { + return NewTextErrorResponse(fmt.Sprintf("file already exists and cannot be added: %s", absPath)), nil + } else if !os.IsNotExist(err) { + return ToolResponse{}, fmt.Errorf("failed to check file: %w", err) + } + } + + // Load all required files + currentFiles := make(map[string]string) + for _, filePath := range filesToRead { + absPath := filePath + if !filepath.IsAbs(absPath) { + wd := config.WorkingDirectory() + absPath = filepath.Join(wd, absPath) + } + + content, err := os.ReadFile(absPath) + if err != nil { + return ToolResponse{}, fmt.Errorf("failed to read file %s: %w", absPath, err) + } + currentFiles[filePath] = string(content) + } + + // Process the patch + patch, fuzz, err := diff.TextToPatch(params.PatchText, currentFiles) + if err != nil { + return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %s", err)), nil + } + + if fuzz > 3 { + return NewTextErrorResponse(fmt.Sprintf("patch contains fuzzy matches (fuzz level: %d). Please make your context lines more precise", fuzz)), nil + } + + // Convert patch to commit + commit, err := diff.PatchToCommit(patch, currentFiles) + if err != nil { + return NewTextErrorResponse(fmt.Sprintf("failed to create commit from patch: %s", err)), nil + } + + // Get session ID and message ID + sessionID, messageID := GetContextValues(ctx) + if sessionID == "" || messageID == "" { + return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a patch") + } + + // Request permission for all changes + for path, change := range commit.Changes { + switch change.Type { + case diff.ActionAdd: + dir := filepath.Dir(path) + patchDiff, _, _ := diff.GenerateDiff("", *change.NewContent, path) + p := p.permissions.Request( + permission.CreatePermissionRequest{ + SessionID: sessionID, + Path: dir, + ToolName: PatchToolName, + Action: "create", + Description: fmt.Sprintf("Create file %s", path), + Params: EditPermissionsParams{ + FilePath: path, + Diff: patchDiff, + }, + }, + ) + if !p { + return ToolResponse{}, permission.ErrorPermissionDenied + } + case diff.ActionUpdate: + currentContent := "" + if change.OldContent != nil { + currentContent = *change.OldContent + } + newContent := "" + if change.NewContent != nil { + newContent = *change.NewContent + } + patchDiff, _, _ := diff.GenerateDiff(currentContent, newContent, path) + dir := filepath.Dir(path) + p := p.permissions.Request( + permission.CreatePermissionRequest{ + SessionID: sessionID, + Path: dir, + ToolName: PatchToolName, + Action: "update", + Description: fmt.Sprintf("Update file %s", path), + Params: EditPermissionsParams{ + FilePath: path, + Diff: patchDiff, + }, + }, + ) + if !p { + return ToolResponse{}, permission.ErrorPermissionDenied + } + case diff.ActionDelete: + dir := filepath.Dir(path) + patchDiff, _, _ := diff.GenerateDiff(*change.OldContent, "", path) + p := p.permissions.Request( + permission.CreatePermissionRequest{ + SessionID: sessionID, + Path: dir, + ToolName: PatchToolName, + Action: "delete", + Description: fmt.Sprintf("Delete file %s", path), + Params: EditPermissionsParams{ + FilePath: path, + Diff: patchDiff, + }, + }, + ) + if !p { + return ToolResponse{}, permission.ErrorPermissionDenied + } + } + } + + // Apply the changes to the filesystem + err = diff.ApplyCommit(commit, func(path string, content string) error { + absPath := path + if !filepath.IsAbs(absPath) { + wd := config.WorkingDirectory() + absPath = filepath.Join(wd, absPath) + } + + // Create parent directories if needed + dir := filepath.Dir(absPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create parent directories for %s: %w", absPath, err) + } + + return os.WriteFile(absPath, []byte(content), 0o644) + }, func(path string) error { + absPath := path + if !filepath.IsAbs(absPath) { + wd := config.WorkingDirectory() + absPath = filepath.Join(wd, absPath) + } + return os.Remove(absPath) + }) + if err != nil { + return NewTextErrorResponse(fmt.Sprintf("failed to apply patch: %s", err)), nil + } + + // Update file history for all modified files + changedFiles := []string{} + totalAdditions := 0 + totalRemovals := 0 + + for path, change := range commit.Changes { + absPath := path + if !filepath.IsAbs(absPath) { + wd := config.WorkingDirectory() + absPath = filepath.Join(wd, absPath) + } + changedFiles = append(changedFiles, absPath) + + oldContent := "" + if change.OldContent != nil { + oldContent = *change.OldContent + } + + newContent := "" + if change.NewContent != nil { + newContent = *change.NewContent + } + + // Calculate diff statistics + _, additions, removals := diff.GenerateDiff(oldContent, newContent, path) + totalAdditions += additions + totalRemovals += removals + + // Update history + file, err := p.files.GetByPathAndSession(ctx, absPath, sessionID) + if err != nil && change.Type != diff.ActionAdd { + // If not adding a file, create history entry for existing file + _, err = p.files.Create(ctx, sessionID, absPath, oldContent) + if err != nil { + logging.Debug("Error creating file history", "error", err) + } + } + + if err == nil && change.Type != diff.ActionAdd && file.Content != oldContent { + // User manually changed content, store intermediate version + _, err = p.files.CreateVersion(ctx, sessionID, absPath, oldContent) + if err != nil { + logging.Debug("Error creating file history version", "error", err) + } + } + + // Store new version + if change.Type == diff.ActionDelete { + _, err = p.files.CreateVersion(ctx, sessionID, absPath, "") + } else { + _, err = p.files.CreateVersion(ctx, sessionID, absPath, newContent) + } + if err != nil { + logging.Debug("Error creating file history version", "error", err) + } + + // Record file operations + recordFileWrite(absPath) + recordFileRead(absPath) + } + + // Run LSP diagnostics on all changed files + for _, filePath := range changedFiles { + waitForLspDiagnostics(ctx, filePath, p.lspClients) + } + + result := fmt.Sprintf("Patch applied successfully. %d files changed, %d additions, %d removals", + len(changedFiles), totalAdditions, totalRemovals) + + diagnosticsText := "" + for _, filePath := range changedFiles { + diagnosticsText += getDiagnostics(filePath, p.lspClients) + } + + if diagnosticsText != "" { + result += "\n\nDiagnostics:\n" + diagnosticsText + } + + return WithResponseMetadata( + NewTextResponse(result), + PatchResponseMetadata{ + FilesChanged: changedFiles, + Additions: totalAdditions, + Removals: totalRemovals, + }), nil +} diff --git a/internal/llm/tools/shell/shell.go b/internal/llm/tools/shell/shell.go index 64592f67d..e25bdf3ea 100644 --- a/internal/llm/tools/shell/shell.go +++ b/internal/llm/tools/shell/shell.go @@ -83,11 +83,21 @@ func newPersistentShell(cwd string) *PersistentShell { commandQueue: make(chan *commandExecution, 10), } - go shell.processCommands() + go func() { + defer func() { + if r := recover(); r != nil { + fmt.Fprintf(os.Stderr, "Panic in shell command processor: %v\n", r) + shell.isAlive = false + close(shell.commandQueue) + } + }() + shell.processCommands() + }() go func() { err := cmd.Wait() if err != nil { + // Log the error if needed } shell.isAlive = false close(shell.commandQueue) @@ -116,10 +126,10 @@ func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx } tempDir := os.TempDir() - stdoutFile := filepath.Join(tempDir, fmt.Sprintf("termai-stdout-%d", time.Now().UnixNano())) - stderrFile := filepath.Join(tempDir, fmt.Sprintf("termai-stderr-%d", time.Now().UnixNano())) - statusFile := filepath.Join(tempDir, fmt.Sprintf("termai-status-%d", time.Now().UnixNano())) - cwdFile := filepath.Join(tempDir, fmt.Sprintf("termai-cwd-%d", time.Now().UnixNano())) + stdoutFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stdout-%d", time.Now().UnixNano())) + stderrFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stderr-%d", time.Now().UnixNano())) + statusFile := filepath.Join(tempDir, fmt.Sprintf("opencode-status-%d", time.Now().UnixNano())) + cwdFile := filepath.Join(tempDir, fmt.Sprintf("opencode-cwd-%d", time.Now().UnixNano())) defer func() { os.Remove(stdoutFile) diff --git a/internal/llm/tools/sourcegraph.go b/internal/llm/tools/sourcegraph.go index e1ea962d4..0d38c975f 100644 --- a/internal/llm/tools/sourcegraph.go +++ b/internal/llm/tools/sourcegraph.go @@ -18,6 +18,11 @@ type SourcegraphParams struct { Timeout int `json:"timeout,omitempty"` } +type SourcegraphResponseMetadata struct { + NumberOfMatches int `json:"number_of_matches"` + Truncated bool `json:"truncated"` +} + type sourcegraphTool struct { client *http.Client } @@ -198,7 +203,7 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse, graphqlQueryBytes, err := json.Marshal(request) if err != nil { - return NewTextErrorResponse("Failed to create GraphQL request: " + err.Error()), nil + return ToolResponse{}, fmt.Errorf("failed to marshal GraphQL request: %w", err) } graphqlQuery := string(graphqlQueryBytes) @@ -209,15 +214,15 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse, bytes.NewBuffer([]byte(graphqlQuery)), ) if err != nil { - return NewTextErrorResponse("Failed to create request: " + err.Error()), nil + return ToolResponse{}, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "termai/1.0") + req.Header.Set("User-Agent", "opencode/1.0") resp, err := client.Do(req) if err != nil { - return NewTextErrorResponse("Failed to execute request: " + err.Error()), nil + return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err) } defer resp.Body.Close() @@ -231,12 +236,12 @@ func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse, } body, err := io.ReadAll(resp.Body) if err != nil { - return NewTextErrorResponse("Failed to read response body: " + err.Error()), nil + return ToolResponse{}, fmt.Errorf("failed to read response body: %w", err) } var result map[string]any if err = json.Unmarshal(body, &result); err != nil { - return NewTextErrorResponse("Failed to parse response: " + err.Error()), nil + return ToolResponse{}, fmt.Errorf("failed to unmarshal response: %w", err) } formattedResults, err := formatSourcegraphResults(result, params.ContextWindow) diff --git a/internal/llm/tools/sourcegraph_test.go b/internal/llm/tools/sourcegraph_test.go deleted file mode 100644 index 89829aefc..000000000 --- a/internal/llm/tools/sourcegraph_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSourcegraphTool_Info(t *testing.T) { - tool := NewSourcegraphTool() - info := tool.Info() - - assert.Equal(t, SourcegraphToolName, info.Name) - assert.NotEmpty(t, info.Description) - assert.Contains(t, info.Parameters, "query") - assert.Contains(t, info.Parameters, "count") - assert.Contains(t, info.Parameters, "timeout") - assert.Contains(t, info.Required, "query") -} - -func TestSourcegraphTool_Run(t *testing.T) { - t.Run("handles missing query parameter", func(t *testing.T) { - tool := NewSourcegraphTool() - params := SourcegraphParams{ - Query: "", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: SourcegraphToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "Query parameter is required") - }) - - t.Run("handles invalid parameters", func(t *testing.T) { - tool := NewSourcegraphTool() - call := ToolCall{ - Name: SourcegraphToolName, - Input: "invalid json", - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "Failed to parse sourcegraph parameters") - }) - - t.Run("normalizes count parameter", func(t *testing.T) { - // Test cases for count normalization - testCases := []struct { - name string - inputCount int - expectedCount int - }{ - {"negative count", -5, 10}, // Should use default (10) - {"zero count", 0, 10}, // Should use default (10) - {"valid count", 50, 50}, // Should keep as is - {"excessive count", 150, 100}, // Should cap at 100 - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // Verify count normalization logic directly - assert.NotPanics(t, func() { - // Apply the same normalization logic as in the tool - normalizedCount := tc.inputCount - if normalizedCount <= 0 { - normalizedCount = 10 - } else if normalizedCount > 100 { - normalizedCount = 100 - } - - assert.Equal(t, tc.expectedCount, normalizedCount) - }) - }) - } - }) -} diff --git a/internal/llm/tools/tools.go b/internal/llm/tools/tools.go index e15c1c31f..bf0f8df0b 100644 --- a/internal/llm/tools/tools.go +++ b/internal/llm/tools/tools.go @@ -1,6 +1,9 @@ package tools -import "context" +import ( + "context" + "encoding/json" +) type ToolInfo struct { Name string @@ -11,15 +14,24 @@ type ToolInfo struct { type toolResponseType string +type ( + sessionIDContextKey string + messageIDContextKey string +) + const ( ToolResponseTypeText toolResponseType = "text" ToolResponseTypeImage toolResponseType = "image" + + SessionIDContextKey sessionIDContextKey = "session_id" + MessageIDContextKey messageIDContextKey = "message_id" ) type ToolResponse struct { - Type toolResponseType `json:"type"` - Content string `json:"content"` - IsError bool `json:"is_error"` + Type toolResponseType `json:"type"` + Content string `json:"content"` + Metadata string `json:"metadata,omitempty"` + IsError bool `json:"is_error"` } func NewTextResponse(content string) ToolResponse { @@ -29,6 +41,17 @@ func NewTextResponse(content string) ToolResponse { } } +func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse { + if metadata != nil { + metadataBytes, err := json.Marshal(metadata) + if err != nil { + return response + } + response.Metadata = string(metadataBytes) + } + return response +} + func NewTextErrorResponse(content string) ToolResponse { return ToolResponse{ Type: ToolResponseTypeText, @@ -47,3 +70,15 @@ type BaseTool interface { Info() ToolInfo Run(ctx context.Context, params ToolCall) (ToolResponse, error) } + +func GetContextValues(ctx context.Context) (string, string) { + sessionID := ctx.Value(SessionIDContextKey) + messageID := ctx.Value(MessageIDContextKey) + if sessionID == nil { + return "", "" + } + if messageID == nil { + return sessionID.(string), "" + } + return sessionID.(string), messageID.(string) +} diff --git a/internal/llm/tools/view.go b/internal/llm/tools/view.go index a687be015..dc02b34f3 100644 --- a/internal/llm/tools/view.go +++ b/internal/llm/tools/view.go @@ -10,8 +10,8 @@ import ( "path/filepath" "strings" - "github.com/kujtimiihoxha/termai/internal/config" - "github.com/kujtimiihoxha/termai/internal/lsp" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/lsp" ) type ViewParams struct { @@ -24,6 +24,11 @@ type viewTool struct { lspClients map[string]*lsp.Client } +type ViewResponseMetadata struct { + FilePath string `json:"file_path"` + Content string `json:"content"` +} + const ( ViewToolName = "view" MaxReadSize = 250 * 1024 @@ -135,7 +140,7 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil } - return NewTextErrorResponse(fmt.Sprintf("Failed to access file: %s", err)), nil + return ToolResponse{}, fmt.Errorf("error accessing file: %w", err) } // Check if it's a directory @@ -156,6 +161,7 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) // Check if it's an image file isImage, imageType := isImageFile(filePath) + // TODO: handle images if isImage { return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\nUse a different tool to process images", imageType)), nil } @@ -163,7 +169,7 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) // Read the file content content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit) if err != nil { - return NewTextErrorResponse(fmt.Sprintf("Failed to read file: %s", err)), nil + return ToolResponse{}, fmt.Errorf("error reading file: %w", err) } notifyLspOpenFile(ctx, filePath, v.lspClients) @@ -177,9 +183,15 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) params.Offset+len(strings.Split(content, "\n"))) } output += "\n</file>\n" - output += appendDiagnostics(filePath, v.lspClients) + output += getDiagnostics(filePath, v.lspClients) recordFileRead(filePath) - return NewTextResponse(output), nil + return WithResponseMetadata( + NewTextResponse(output), + ViewResponseMetadata{ + FilePath: filePath, + Content: content, + }, + ), nil } func addLineNumbers(content string, startLine int) string { diff --git a/internal/llm/tools/write.go b/internal/llm/tools/write.go index 7b698d2d8..ec6fc1dc4 100644 --- a/internal/llm/tools/write.go +++ b/internal/llm/tools/write.go @@ -6,11 +6,15 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" - "github.com/kujtimiihoxha/termai/internal/config" - "github.com/kujtimiihoxha/termai/internal/lsp" - "github.com/kujtimiihoxha/termai/internal/permission" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/diff" + "github.com/kujtimiihoxha/opencode/internal/history" + "github.com/kujtimiihoxha/opencode/internal/logging" + "github.com/kujtimiihoxha/opencode/internal/lsp" + "github.com/kujtimiihoxha/opencode/internal/permission" ) type WriteParams struct { @@ -20,12 +24,19 @@ type WriteParams struct { type WritePermissionsParams struct { FilePath string `json:"file_path"` - Content string `json:"content"` + Diff string `json:"diff"` } type writeTool struct { lspClients map[string]*lsp.Client permissions permission.Service + files history.Service +} + +type WriteResponseMetadata struct { + Diff string `json:"diff"` + Additions int `json:"additions"` + Removals int `json:"removals"` } const ( @@ -60,10 +71,11 @@ TIPS: - Always include descriptive comments when making changes to existing code` ) -func NewWriteTool(lspClients map[string]*lsp.Client, permissions permission.Service) BaseTool { +func NewWriteTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool { return &writeTool{ lspClients: lspClients, permissions: permissions, + files: files, } } @@ -122,12 +134,12 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error return NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil } } else if !os.IsNotExist(err) { - return NewTextErrorResponse(fmt.Sprintf("Failed to access file: %s", err)), nil + return ToolResponse{}, fmt.Errorf("error checking file: %w", err) } dir := filepath.Dir(filePath) if err = os.MkdirAll(dir, 0o755); err != nil { - return NewTextErrorResponse(fmt.Sprintf("Failed to create parent directories: %s", err)), nil + return ToolResponse{}, fmt.Errorf("error creating directory: %w", err) } oldContent := "" @@ -138,25 +150,64 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error } } + sessionID, messageID := GetContextValues(ctx) + if sessionID == "" || messageID == "" { + return ToolResponse{}, fmt.Errorf("session_id and message_id are required") + } + + diff, additions, removals := diff.GenerateDiff( + oldContent, + params.Content, + filePath, + ) + + rootDir := config.WorkingDirectory() + permissionPath := filepath.Dir(filePath) + if strings.HasPrefix(filePath, rootDir) { + permissionPath = rootDir + } p := w.permissions.Request( permission.CreatePermissionRequest{ - Path: filePath, + SessionID: sessionID, + Path: permissionPath, ToolName: WriteToolName, - Action: "create", + Action: "write", Description: fmt.Sprintf("Create file %s", filePath), Params: WritePermissionsParams{ FilePath: filePath, - Content: GenerateDiff(oldContent, params.Content), + Diff: diff, }, }, ) if !p { - return NewTextErrorResponse(fmt.Sprintf("Permission denied to create file: %s", filePath)), nil + return ToolResponse{}, permission.ErrorPermissionDenied } err = os.WriteFile(filePath, []byte(params.Content), 0o644) if err != nil { - return NewTextErrorResponse(fmt.Sprintf("Failed to write file: %s", err)), nil + return ToolResponse{}, fmt.Errorf("error writing file: %w", err) + } + + // Check if file exists in history + file, err := w.files.GetByPathAndSession(ctx, filePath, sessionID) + if err != nil { + _, err = w.files.Create(ctx, sessionID, filePath, oldContent) + if err != nil { + // Log error but don't fail the operation + return ToolResponse{}, fmt.Errorf("error creating file history: %w", err) + } + } + if file.Content != oldContent { + // User Manually changed the content store an intermediate version + _, err = w.files.CreateVersion(ctx, sessionID, filePath, oldContent) + if err != nil { + logging.Debug("Error creating file history version", "error", err) + } + } + // Store the new version + _, err = w.files.CreateVersion(ctx, sessionID, filePath, params.Content) + if err != nil { + logging.Debug("Error creating file history version", "error", err) } recordFileWrite(filePath) @@ -165,6 +216,12 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error result := fmt.Sprintf("File successfully written: %s", filePath) result = fmt.Sprintf("<result>\n%s\n</result>", result) - result += appendDiagnostics(filePath, w.lspClients) - return NewTextResponse(result), nil + result += getDiagnostics(filePath, w.lspClients) + return WithResponseMetadata(NewTextResponse(result), + WriteResponseMetadata{ + Diff: diff, + Additions: additions, + Removals: removals, + }, + ), nil } diff --git a/internal/llm/tools/write_test.go b/internal/llm/tools/write_test.go deleted file mode 100644 index 50dafc14f..000000000 --- a/internal/llm/tools/write_test.go +++ /dev/null @@ -1,307 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "testing" - "time" - - "github.com/kujtimiihoxha/termai/internal/lsp" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestWriteTool_Info(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - info := tool.Info() - - assert.Equal(t, WriteToolName, info.Name) - assert.NotEmpty(t, info.Description) - assert.Contains(t, info.Parameters, "file_path") - assert.Contains(t, info.Parameters, "content") - assert.Contains(t, info.Required, "file_path") - assert.Contains(t, info.Required, "content") -} - -func TestWriteTool_Run(t *testing.T) { - // Create a temporary directory for testing - tempDir, err := os.MkdirTemp("", "write_tool_test") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - - t.Run("creates a new file successfully", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - filePath := filepath.Join(tempDir, "new_file.txt") - content := "This is a test content" - - params := WriteParams{ - FilePath: filePath, - Content: content, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: WriteToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "successfully written") - - // Verify file was created with correct content - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, content, string(fileContent)) - }) - - t.Run("creates file with nested directories", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt") - content := "Content in nested directory" - - params := WriteParams{ - FilePath: filePath, - Content: content, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: WriteToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "successfully written") - - // Verify file was created with correct content - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, content, string(fileContent)) - }) - - t.Run("updates existing file", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file first - filePath := filepath.Join(tempDir, "existing_file.txt") - initialContent := "Initial content" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Record the file read to avoid modification time check failure - recordFileRead(filePath) - - // Update the file - updatedContent := "Updated content" - params := WriteParams{ - FilePath: filePath, - Content: updatedContent, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: WriteToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "successfully written") - - // Verify file was updated with correct content - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, updatedContent, string(fileContent)) - }) - - t.Run("handles invalid parameters", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - call := ToolCall{ - Name: WriteToolName, - Input: "invalid json", - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "error parsing parameters") - }) - - t.Run("handles missing file_path", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - params := WriteParams{ - FilePath: "", - Content: "Some content", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: WriteToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "file_path is required") - }) - - t.Run("handles missing content", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - params := WriteParams{ - FilePath: filepath.Join(tempDir, "file.txt"), - Content: "", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: WriteToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "content is required") - }) - - t.Run("handles writing to a directory path", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a directory - dirPath := filepath.Join(tempDir, "test_dir") - err := os.Mkdir(dirPath, 0o755) - require.NoError(t, err) - - params := WriteParams{ - FilePath: dirPath, - Content: "Some content", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: WriteToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "Path is a directory") - }) - - t.Run("handles permission denied", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(false)) - - filePath := filepath.Join(tempDir, "permission_denied.txt") - params := WriteParams{ - FilePath: filePath, - Content: "Content that should not be written", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: WriteToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "Permission denied") - - // Verify file was not created - _, err = os.Stat(filePath) - assert.True(t, os.IsNotExist(err)) - }) - - t.Run("detects file modified since last read", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file - filePath := filepath.Join(tempDir, "modified_file.txt") - initialContent := "Initial content" - err := os.WriteFile(filePath, []byte(initialContent), 0o644) - require.NoError(t, err) - - // Record an old read time - fileRecordMutex.Lock() - fileRecords[filePath] = fileRecord{ - path: filePath, - readTime: time.Now().Add(-1 * time.Hour), - } - fileRecordMutex.Unlock() - - // Try to update the file - params := WriteParams{ - FilePath: filePath, - Content: "Updated content", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: WriteToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "has been modified since it was last read") - - // Verify file was not modified - fileContent, err := os.ReadFile(filePath) - require.NoError(t, err) - assert.Equal(t, initialContent, string(fileContent)) - }) - - t.Run("skips writing when content is identical", func(t *testing.T) { - tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true)) - - // Create a file - filePath := filepath.Join(tempDir, "identical_content.txt") - content := "Content that won't change" - err := os.WriteFile(filePath, []byte(content), 0o644) - require.NoError(t, err) - - // Record a read time - recordFileRead(filePath) - - // Try to write the same content - params := WriteParams{ - FilePath: filePath, - Content: content, - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: WriteToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - assert.Contains(t, response.Content, "already contains the exact content") - }) -} |
