diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-16 01:46:20 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-16 01:46:20 +0000 |
| commit | 0e37086ee468e6e3b697c32b7f02280ee06f5116 (patch) | |
| tree | 3a1dc153ad715a4386fb844d2fa3993b0e757cf7 /internal/api/elaborate.go | |
| parent | d911021b7e4a0c9f77ca9996b0ebdabb03c56696 (diff) | |
fix: permission denied and host key verification errors; add gemini elaboration fallback
Diffstat (limited to 'internal/api/elaborate.go')
| -rw-r--r-- | internal/api/elaborate.go | 140 |
1 files changed, 99 insertions, 41 deletions
diff --git a/internal/api/elaborate.go b/internal/api/elaborate.go index 5df772e..0c681ae 100644 --- a/internal/api/elaborate.go +++ b/internal/api/elaborate.go @@ -128,7 +128,13 @@ func sanitizeElaboratedTask(t *elaboratedTask) { // claudeJSONResult is the top-level object returned by `claude --output-format json`. type claudeJSONResult struct { - Result string `json:"result"` + Result string `json:"result"` + IsError bool `json:"is_error"` +} + +// geminiJSONResult is the top-level object returned by `gemini --output-format json`. +type geminiJSONResult struct { + Response string `json:"response"` } // extractJSON returns the first top-level JSON object found in s, stripping @@ -152,6 +158,13 @@ func (s *Server) claudeBinaryPath() string { return "claude" } +func (s *Server) geminiBinaryPath() string { + if s.geminiBinPath != "" { + return s.geminiBinPath + } + return "gemini" +} + func readProjectContext(workDir string) string { if workDir == "" { return "" @@ -192,6 +205,75 @@ func (s *Server) appendRawNarrative(workDir, prompt string) { } } +func (s *Server) elaborateWithClaude(ctx context.Context, workDir, fullPrompt string) (*elaboratedTask, error) { + cmd := exec.CommandContext(ctx, s.claudeBinaryPath(), + "-p", fullPrompt, + "--system-prompt", buildElaboratePrompt(workDir), + "--output-format", "json", + "--model", "haiku", + ) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Claude returns exit 1 if rate limited but still outputs JSON with is_error: true + err := cmd.Run() + + output := stdout.Bytes() + if len(output) == 0 { + if err != nil { + return nil, fmt.Errorf("claude failed: %w (stderr: %s)", err, stderr.String()) + } + return nil, fmt.Errorf("claude returned no output") + } + + var wrapper claudeJSONResult + if jerr := json.Unmarshal(output, &wrapper); jerr != nil { + return nil, fmt.Errorf("failed to parse claude JSON wrapper: %w (output: %s)", jerr, string(output)) + } + + if wrapper.IsError { + return nil, fmt.Errorf("claude error: %s", wrapper.Result) + } + + var result elaboratedTask + if jerr := json.Unmarshal([]byte(extractJSON(wrapper.Result)), &result); jerr != nil { + return nil, fmt.Errorf("failed to parse elaborated task JSON: %w (result: %s)", jerr, wrapper.Result) + } + + return &result, nil +} + +func (s *Server) elaborateWithGemini(ctx context.Context, workDir, fullPrompt string) (*elaboratedTask, error) { + combinedPrompt := fmt.Sprintf("%s\n\n%s", buildElaboratePrompt(workDir), fullPrompt) + cmd := exec.CommandContext(ctx, s.geminiBinaryPath(), + "-p", combinedPrompt, + "--output-format", "json", + "--model", "gemini-2.5-flash-lite", + ) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("gemini failed: %w (stderr: %s)", err, stderr.String()) + } + + var wrapper geminiJSONResult + if err := json.Unmarshal(stdout.Bytes(), &wrapper); err != nil { + return nil, fmt.Errorf("failed to parse gemini JSON wrapper: %w (output: %s)", err, stdout.String()) + } + + var result elaboratedTask + if err := json.Unmarshal([]byte(extractJSON(wrapper.Response)), &result); err != nil { + return nil, fmt.Errorf("failed to parse elaborated task JSON: %w (response: %s)", err, wrapper.Response) + } + + return &result, nil +} + func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) { if s.elaborateLimiter != nil && !s.elaborateLimiter.allow(realIP(r)) { writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "rate limit exceeded"}) @@ -216,9 +298,6 @@ func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) { workDir = input.ProjectDir } - // Append verbatim user input to RAW_NARRATIVE.md only when the user explicitly - // provided a project_dir — the narrative is per-project human input, not a - // server-level log. if input.ProjectDir != "" { go s.appendRawNarrative(workDir, input.Prompt) } @@ -232,43 +311,22 @@ func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), elaborateTimeout) defer cancel() - cmd := exec.CommandContext(ctx, s.claudeBinaryPath(), - "-p", fullPrompt, - "--system-prompt", buildElaboratePrompt(workDir), - "--output-format", "json", - "--model", "haiku", - ) - - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - s.logger.Error("elaborate: claude subprocess failed", "error", err, "stderr", stderr.String()) - writeJSON(w, http.StatusBadGateway, map[string]string{ - "error": fmt.Sprintf("elaboration failed: %v", err), - }) - return - } + var result *elaboratedTask + var err error - // claude --output-format json wraps the text result in {"result": "...", ...} - var wrapper claudeJSONResult - if err := json.Unmarshal(stdout.Bytes(), &wrapper); err != nil { - s.logger.Error("elaborate: failed to parse claude JSON wrapper", "error", err, "stdout", stdout.String()) - writeJSON(w, http.StatusBadGateway, map[string]string{ - "error": "elaboration failed: invalid JSON from claude", - }) - return - } - - var result elaboratedTask - if err := json.Unmarshal([]byte(extractJSON(wrapper.Result)), &result); err != nil { - s.logger.Error("elaborate: failed to parse elaborated task JSON", "error", err, "result", wrapper.Result) - writeJSON(w, http.StatusBadGateway, map[string]string{ - "error": "elaboration failed: claude returned invalid task JSON", - }) - return + // Try Claude first. + result, err = s.elaborateWithClaude(ctx, workDir, fullPrompt) + if err != nil { + s.logger.Warn("elaborate: claude failed, falling back to gemini", "error", err) + // Fallback to Gemini. + result, err = s.elaborateWithGemini(ctx, workDir, fullPrompt) + if err != nil { + s.logger.Error("elaborate: fallback gemini also failed", "error", err) + writeJSON(w, http.StatusBadGateway, map[string]string{ + "error": fmt.Sprintf("elaboration failed: %v", err), + }) + return + } } if result.Name == "" || result.Agent.Instructions == "" { @@ -282,7 +340,7 @@ func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) { result.Agent.Type = "claude" } - sanitizeElaboratedTask(&result) + sanitizeElaboratedTask(result) writeJSON(w, http.StatusOK, result) } |
