diff --git a/config.toml b/config.toml index 5379b42..2b402cf 100644 --- a/config.toml +++ b/config.toml @@ -49,7 +49,7 @@ port = 8080 [agents] [agents.coordinator] role = "COORDINATOR" - model_id = "local" + model_id = "default" enable_shell_exec = true streaming = false log_intermediate = true @@ -66,7 +66,7 @@ port = 8080 [agents.coder] role = "SPECIALIST" - model_id = "local" + model_id = "default" enable_shell_exec = true streaming = false log_intermediate = true @@ -82,7 +82,7 @@ port = 8080 [agents.reviewer] role = "SPECIALIST" - model_id = "local" + model_id = "fast" streaming = false log_intermediate = true system_prompt = "You are a Quality Assurance Specialist and Code Reviewer. Your role is to analyze code for potential bugs, security vulnerabilities, and style violations." @@ -97,7 +97,7 @@ port = 8080 [agents.tester] role = "SPECIALIST" - model_id = "local" + model_id = "fast" enable_shell_exec = true streaming = false log_intermediate = true diff --git a/pkg/sidekick/agent.go b/pkg/sidekick/agent.go index dab6a83..5d9ca34 100644 --- a/pkg/sidekick/agent.go +++ b/pkg/sidekick/agent.go @@ -133,7 +133,7 @@ func (s *Sidekick) runStep(ctx context.Context, history *[]Message, } msg, err := CallLLM(ctx, s.Model, *history, s.ToolRegistry, sc, rc) if err != nil { - return false, "", fmt.Errorf("LLM call failed: %w", err) + return false, "", err } s.logIntermediate(msg) *history = append(*history, msg) @@ -141,10 +141,11 @@ func (s *Sidekick) runStep(ctx context.Context, history *[]Message, act, params, parsed, err := s.parseAction(msg) if err != nil { if !parsed { + // Not intended as JSON, just treat as RESPOND act, params = ActionTypeRespond, map[string]interface{}{"response": msg.Content} } else { - *history = append(*history, Message{Role: MessageRoleUser, Content: fmt.Sprintf("Error: %v", err)}) - return false, "", nil + // Intended as structured action but failed validation/parsing + return false, "", fmt.Errorf("the AI returned an invalid response: %w", err) } } if act == ActionTypeRespond { @@ -184,6 +185,9 @@ func (s *Sidekick) dispatch(ctx context.Context, a ActionType, p map[string]inte } s.IntermediateHandler(fmt.Sprintf("Tool Result: %s", truncRes)) } + if tName == "read_file" || tName == "grep_file" { + return res, nil + } return BufferGate(res, tf, s.Config.ToolResponseThreshold) } if a == ActionTypeDelegate { @@ -202,12 +206,23 @@ func (s *Sidekick) parseAction(msg Message) (ActionType, map[string]interface{}, } var env ActionEnvelope c := strings.TrimSpace(msg.Content) + isProbablyJSON := strings.HasPrefix(c, "{") if strings.HasPrefix(c, "```json") && strings.HasSuffix(c, "```") { c = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(c, "```json"), "```")) + isProbablyJSON = true } if err := json.Unmarshal([]byte(c), &env); err != nil { + if isProbablyJSON { + return "", nil, true, fmt.Errorf("malformed JSON action: %w", err) + } return "", nil, false, err } + if env.Action == "" { + if isProbablyJSON { + return "", nil, true, fmt.Errorf("AI response is missing the 'action' field") + } + return ActionTypeRespond, map[string]interface{}{"response": msg.Content}, false, nil + } return env.Action, env.Params, true, nil } diff --git a/pkg/sidekick/llm.go b/pkg/sidekick/llm.go index d9c25b0..a9dfd90 100644 --- a/pkg/sidekick/llm.go +++ b/pkg/sidekick/llm.go @@ -5,12 +5,21 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" "strings" ) +var ( + ErrLLMTimeout = errors.New("the request to the AI model timed out. Please try again later") + ErrLLMRateLimit = errors.New("rate limit exceeded. Please wait a moment and try again") + ErrLLMAuth = errors.New("authentication failed. Please check your API key") + ErrLLMServer = errors.New("the AI provider's server encountered an error. Please try again later") + ErrLLMInvalidResponse = errors.New("the AI model returned an invalid response structure") +) + // LLMClient represents an abstraction over the LLM provider type LLMClient interface { Call(ctx context.Context, model ModelConfig, messages []Message, @@ -63,11 +72,22 @@ func (s *standardLLMClient) Call(ctx context.Context, model ModelConfig, msgs [] resp, err := client.Do(req) if err != nil { + if errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "timeout") { + return Message{}, ErrLLMTimeout + } return Message{}, fmt.Errorf("HTTP request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { + switch resp.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return Message{}, ErrLLMAuth + case http.StatusTooManyRequests: + return Message{}, ErrLLMRateLimit + case http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable: + return Message{}, ErrLLMServer + } body, _ := io.ReadAll(resp.Body) return Message{}, fmt.Errorf("API returned error (%d): %s", resp.StatusCode, string(body)) } @@ -91,7 +111,7 @@ func (s *standardLLMClient) Call(ctx context.Context, model ModelConfig, msgs [] } if err := json.Unmarshal(body, &openAIResp); err != nil { - return Message{}, fmt.Errorf("failed to unmarshal response: %w", err) + return Message{}, fmt.Errorf("%w: failed to parse API response: %v", ErrLLMInvalidResponse, err) } if openAIResp.Error != nil { @@ -99,7 +119,7 @@ func (s *standardLLMClient) Call(ctx context.Context, model ModelConfig, msgs [] } if len(openAIResp.Choices) == 0 { - return Message{}, fmt.Errorf("API returned no choices") + return Message{}, fmt.Errorf("%w: API returned no choices", ErrLLMInvalidResponse) } return openAIResp.Choices[0].Message, nil diff --git a/pkg/sidekick/sidekick_test.go b/pkg/sidekick/sidekick_test.go index 474a999..f98520f 100644 --- a/pkg/sidekick/sidekick_test.go +++ b/pkg/sidekick/sidekick_test.go @@ -2,6 +2,7 @@ package sidekick import ( "context" + "errors" "os" "strings" "testing" @@ -148,3 +149,60 @@ func TestTempFileCleanup(t *testing.T) { t.Errorf("leak: before %d, after %d", bc, ac) } } + +type errorLLMClient struct { + err error +} + +func (e *errorLLMClient) Call(ctx context.Context, model ModelConfig, messages []Message, + tools map[string]ToolDefinition, streamCallback, reasoningCallback func(string)) (Message, error) { + return Message{}, e.err +} + +func TestLLMTimeoutError(t *testing.T) { + SetLLMClient(&errorLLMClient{err: ErrLLMTimeout}) + a := NewSidekick(AgentConfig{MaxIterations: 1}, ModelConfig{}, nil) + _, err := a.Run(context.Background(), nil, nil) + if !errors.Is(err, ErrLLMTimeout) { + t.Errorf("expected ErrLLMTimeout, got %v", err) + } +} + +func TestMalformedJSONAction(t *testing.T) { + mc := &mockLLMClient{responses: []Message{ + {Content: `{"action": "TOOL_CALL", "params": { "missing_brace": `}, + }} + SetLLMClient(mc) + a := NewSidekick(AgentConfig{MaxIterations: 1}, ModelConfig{}, nil) + _, err := a.Run(context.Background(), nil, nil) + if err == nil || !strings.Contains(err.Error(), "malformed JSON action") { + t.Errorf("expected malformed JSON action error, got %v", err) + } +} + +func TestMissingActionField(t *testing.T) { + mc := &mockLLMClient{responses: []Message{ + {Content: `{"params": {"foo": "bar"}}`}, + }} + SetLLMClient(mc) + a := NewSidekick(AgentConfig{MaxIterations: 1}, ModelConfig{}, nil) + _, err := a.Run(context.Background(), nil, nil) + if err == nil || !strings.Contains(err.Error(), "missing the 'action' field") { + t.Errorf("expected missing action field error, got %v", err) + } +} + +func TestPlainTextResponse(t *testing.T) { + mc := &mockLLMClient{responses: []Message{ + {Content: `This is a plain text response.`}, + }} + SetLLMClient(mc) + a := NewSidekick(AgentConfig{MaxIterations: 1}, ModelConfig{}, nil) + res, err := a.Run(context.Background(), nil, nil) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if res != "This is a plain text response." { + t.Errorf("expected plain text response, got %s", res) + } +}