summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-16 01:46:20 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-16 01:46:20 +0000
commit0e37086ee468e6e3b697c32b7f02280ee06f5116 (patch)
tree3a1dc153ad715a4386fb844d2fa3993b0e757cf7 /internal/api
parentd911021b7e4a0c9f77ca9996b0ebdabb03c56696 (diff)
fix: permission denied and host key verification errors; add gemini elaboration fallback
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/elaborate.go140
-rw-r--r--internal/api/server_test.go18
2 files changed, 109 insertions, 49 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)
}
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index a670f33..4899a5c 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -147,14 +147,16 @@ func testServerWithGeminiMockRunner(t *testing.T) (*Server, *storage.DB) {
mockBinDir := t.TempDir()
mockGeminiPath := filepath.Join(mockBinDir, "mock-gemini-binary.sh")
mockScriptContent := `#!/bin/bash
-# Mock gemini binary that outputs stream-json wrapped in markdown to stdout.
-echo "```json"
-echo "{\"type\":\"content_block_start\",\"content_block\":{\"text\":\"Hello, Gemini!\",\"type\":\"text\"}}"
-echo "{\"type\":\"content_block_delta\",\"content_block\":{\"text\":\" How are you?\"}}"
-echo "{\"type\":\"content_block_end\"}"
-echo "{\"type\":\"message_delta\",\"message\":{\"role\":\"model\"}}"
-echo "{\"type\":\"message_end\"}"
-echo "```"
+OUTPUT_FILE=$(mktemp)
+echo "` + "```json" + `" > "$OUTPUT_FILE"
+echo "{\"type\":\"content_block_start\",\"content_block\":{\"text\":\"Hello, Gemini!\",\"type\":\"text\"}}" >> "$OUTPUT_FILE"
+echo "{\"type\":\"content_block_delta\",\"content_block\":{\"text\":\" How are you?\"}}" >> "$OUTPUT_FILE"
+echo "{\"type\":\"content_block_end\"}" >> "$OUTPUT_FILE"
+echo "{\"type\":\"message_delta\",\"message\":{\"role\":\"model\"}}" >> "$OUTPUT_FILE"
+echo "{\"type\":\"message_end\"}" >> "$OUTPUT_FILE"
+echo "` + "```" + `" >> "$OUTPUT_FILE"
+cat "$OUTPUT_FILE"
+rm "$OUTPUT_FILE"
exit 0
`
if err := os.WriteFile(mockGeminiPath, []byte(mockScriptContent), 0755); err != nil {