diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-06-03 23:01:39 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-06-03 23:01:39 +0000 |
| commit | 68cf1e24dd2ca2612b72babc4b35280cd3512f92 (patch) | |
| tree | 14b73f21570a2f42ae729ca7e9676de6628d6819 /internal/api/elaborate.go | |
| parent | b28cfc6ff288d083f6c8e9c055b69bfcadbceccc (diff) | |
| parent | 7388337d3be5cc7f65ef547e30b2e39884dd165b (diff) | |
merge: integrate oss branch — budget gating, MCP back-channel, events, loopback-only bind
Key features from OSS branch:
- Budget gating: rolling per-provider spend caps with BUDGET_EXCEEDED task state
- Agent MCP back-channel: runners mint tokens; /mcp endpoint resolves them
- Chatbot MCP server at /chatbot/mcp (requires api_token in config)
- Event timeline: unified event stream replacing ad-hoc question/summary flows
- Loopback-only default bind (127.0.0.1:8484); external_bind_allowed=true to expose
- repository_url required on task creation (enforces traceability)
- Auto-checker: spawns verification task after each execution
Conflict resolutions:
- serve.go: keep cfg.Runners.XEnabled() conditional registration from main + agentRegistry from oss
- config.go: keep RunnersConfig from main + BudgetConfig/ExternalBindAllowed from oss
- server.go: handleStaticFiles (base-path rewrite) kept; deduplicated duplicate from both sides
- executor/claude.go: accepted oss deletion (ClaudeRunner replaced by ContainerRunner)
- container_test.go: added noopChannel + AgentChannel parameter to Run call
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/api/elaborate.go')
| -rw-r--r-- | internal/api/elaborate.go | 337 |
1 files changed, 0 insertions, 337 deletions
diff --git a/internal/api/elaborate.go b/internal/api/elaborate.go index 8676b36..e8930a6 100644 --- a/internal/api/elaborate.go +++ b/internal/api/elaborate.go @@ -6,128 +6,13 @@ import ( "encoding/json" "fmt" "net/http" - "os" "os/exec" - "path/filepath" - "sort" "strings" "time" - - "github.com/thepeterstone/claudomator/internal/llm" ) const elaborateTimeout = 30 * time.Second -func buildElaboratePrompt(workDir string) string { - workDirLine := ` "project_dir": string — leave empty unless you have a specific reason to set it,` - if workDir != "" { - workDirLine = fmt.Sprintf(` "project_dir": string — use %q for tasks that operate on this codebase, empty string otherwise,`, workDir) - } - return `You are a task configuration assistant for Claudomator, an AI task runner that executes tasks by running Claude or Gemini as a subprocess. - -Your ONLY job is to convert any user request into a Claudomator task JSON object. You MUST always output valid JSON. Never ask clarifying questions. Never explain. Never refuse. Make reasonable assumptions and produce the JSON. - -Output ONLY a valid JSON object matching this schema (no markdown fences, no prose, no explanation): - -{ - "name": string — short imperative title (≤60 chars), - "description": string — 1-2 sentence summary, - "agent": { - "type": "claude" | "gemini", - "model": string — "sonnet" for claude, "gemini-2.5-flash-lite" for gemini, - "instructions": string — detailed, step-by-step instructions for the agent. Must end with a "## Acceptance Criteria" section listing measurable conditions that define success. For coding tasks, include TDD requirements (write failing tests first, then implement), -` + workDirLine + ` - "max_budget_usd": number — conservative estimate (0.25–5.00), - "allowed_tools": array — every tool the task genuinely needs. Include "Write" if creating files, "Edit" if modifying files, "Read" if reading files, "Bash" for shell/git/test commands, "Grep"/"Glob" for searching. - }, - "timeout": string — e.g. "15m", - "priority": string — "normal" | "high" | "low", - "tags": array — relevant lowercase tags -}` -} - -// elaboratedTask mirrors the task creation schema for elaboration responses. -type elaboratedTask struct { - Name string `json:"name"` - Description string `json:"description"` - Agent elaboratedAgent `json:"agent"` - Timeout string `json:"timeout"` - Priority string `json:"priority"` - Tags []string `json:"tags"` -} - -type elaboratedAgent struct { - Type string `json:"type"` - Model string `json:"model"` - Instructions string `json:"instructions"` - ProjectDir string `json:"project_dir"` - MaxBudgetUSD float64 `json:"max_budget_usd"` - AllowedTools []string `json:"allowed_tools"` -} - -// sanitizeElaboratedTask enforces tool completeness and dev practice compliance. -// It modifies t in place, inferring missing tools from instruction keywords and -// appending required sections when they are absent. -func sanitizeElaboratedTask(t *elaboratedTask) { - lower := strings.ToLower(t.Agent.Instructions) - - // Build current tool set. - toolSet := make(map[string]bool, len(t.Agent.AllowedTools)) - for _, tool := range t.Agent.AllowedTools { - toolSet[tool] = true - } - - // Infer missing tools from instruction keywords. - type rule struct { - tool string - keywords []string - } - rules := []rule{ - {"Write", []string{"create file", "write file", "new file", "write to", "save to", "output to", "generate file", "creates a file", "create a new file"}}, - {"Edit", []string{"edit", "modify", "refactor", "replace", "patch"}}, - {"Read", []string{"read", "inspect", "examine", "look at the file"}}, - {"Bash", []string{"run", "execute", "bash", "shell", "command", "build", "compile", "git", "install", "make"}}, - {"Grep", []string{"search for", "grep", "find in", "locate in"}}, - {"Glob", []string{"find file", "list file", "search file"}}, - } - for _, r := range rules { - if toolSet[r.tool] { - continue - } - for _, kw := range r.keywords { - if strings.Contains(lower, kw) { - toolSet[r.tool] = true - break - } - } - } - // Edit without Read is almost always wrong. - if toolSet["Edit"] && !toolSet["Read"] { - toolSet["Read"] = true - } - // Rebuild the list only when tools were added. - if len(toolSet) > len(t.Agent.AllowedTools) { - tools := make([]string, 0, len(toolSet)) - for tool := range toolSet { - tools = append(tools, tool) - } - sort.Strings(tools) - t.Agent.AllowedTools = tools - } - - // Append an acceptance criteria section when none is present. - if !strings.Contains(lower, "acceptance") && - !strings.Contains(lower, "done when") && - !strings.Contains(lower, "success criteria") { - t.Agent.Instructions += "\n\n## Acceptance Criteria\nBefore finishing, verify all stated goals are met, tests pass (if applicable), and no unintended side effects were introduced." - } - - // Append a TDD reminder for coding tasks that do not already mention tests. - if (toolSet["Edit"] || toolSet["Write"]) && !strings.Contains(lower, "test") { - t.Agent.Instructions += "\n\n## Dev Practices\nFollow TDD: write a failing test first, then implement the minimum code to make it pass. Commit all changes before finishing." - } -} - // claudeJSONResult is the top-level object returned by `claude --output-format json`. type claudeJSONResult struct { Result string `json:"result"` @@ -167,142 +52,6 @@ func (s *Server) geminiBinaryPath() string { return "gemini" } -func readProjectContext(workDir string) string { - if workDir == "" { - return "" - } - var sb strings.Builder - for _, filename := range []string{"CLAUDE.md", ".agent/worklog.md"} { - path := filepath.Join(workDir, filename) - if data, err := os.ReadFile(path); err == nil { - if sb.Len() > 0 { - sb.WriteString("\n\n") - } - sb.WriteString(fmt.Sprintf("--- %s ---\n%s", filename, string(data))) - } - } - return sb.String() -} - -func (s *Server) appendRawNarrative(workDir, prompt string) { - if workDir == "" { - return - } - docsDir := filepath.Join(workDir, "docs") - if err := os.MkdirAll(docsDir, 0755); err != nil { - s.logger.Error("elaborate: failed to create docs directory", "error", err, "path", docsDir) - return - } - path := filepath.Join(docsDir, "RAW_NARRATIVE.md") - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - s.logger.Error("elaborate: failed to open RAW_NARRATIVE.md", "error", err, "path", path) - return - } - defer f.Close() - - entry := fmt.Sprintf("\n--- %s ---\n%s\n", time.Now().Format(time.RFC3339), prompt) - if _, err := f.WriteString(entry); err != nil { - s.logger.Error("elaborate: failed to write to RAW_NARRATIVE.md", "error", err, "path", path) - } -} - -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 -} - -// elaborateWithLocal runs elaboration through an OpenAI-compatible local LLM. -// It uses the same prompt template as the Claude/Gemini paths and requests -// json_object response format so we can decode directly without the -// markdown-fence cleanup needed for the CLI paths. -func elaborateWithLocal(ctx context.Context, c *llm.Client, workDir, fullPrompt string) (*elaboratedTask, error) { - if c == nil { - return nil, fmt.Errorf("local llm: no client configured") - } - systemPrompt := buildElaboratePrompt(workDir) - resp, err := c.Chat(ctx, llm.ChatRequest{ - Messages: []llm.Message{ - {Role: "system", Content: systemPrompt}, - {Role: "user", Content: fullPrompt}, - }, - ResponseJSON: true, - }) - if err != nil { - return nil, fmt.Errorf("local llm: %w", err) - } - body := strings.TrimSpace(resp.Content) - var result elaboratedTask - if jerr := json.Unmarshal([]byte(extractJSON(body)), &result); jerr != nil { - return nil, fmt.Errorf("local llm: parse JSON: %w (response: %s)", jerr, body) - } - 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 -} - // elaboratedStorySubtask is a leaf unit within a story task. type elaboratedStorySubtask struct { Name string `json:"name"` @@ -493,89 +242,3 @@ func (s *Server) handleElaborateStory(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, result) } - -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"}) - return - } - - var input struct { - Prompt string `json:"prompt"` - ProjectID string `json:"project_id"` - // project_dir kept for backward compat; project_id takes precedence - ProjectDir string `json:"project_dir"` - } - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) - return - } - if input.Prompt == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "prompt is required"}) - return - } - - workDir := s.workDir - if input.ProjectID != "" { - if proj, err := s.store.GetProject(input.ProjectID); err == nil { - workDir = proj.LocalPath - } - } else if input.ProjectDir != "" { - workDir = input.ProjectDir - } - - if workDir != s.workDir { - go s.appendRawNarrative(workDir, input.Prompt) - } - - projectContext := readProjectContext(workDir) - fullPrompt := input.Prompt - if projectContext != "" { - fullPrompt = fmt.Sprintf("Project context from %s:\n%s\n\nUser request: %s", workDir, projectContext, input.Prompt) - } - - ctx, cancel := context.WithTimeout(r.Context(), elaborateTimeout) - defer cancel() - - var result *elaboratedTask - var err error - - // Try local LLM first when configured. Falls back to Claude → Gemini on - // hard failure of each prior attempt. - if s.llm != nil { - result, err = elaborateWithLocal(ctx, s.llm, workDir, fullPrompt) - if err != nil { - s.logger.Warn("elaborate: local llm failed, falling back to claude", "error", err) - result = nil - } - } - if result == nil { - result, err = s.elaborateWithClaude(ctx, workDir, fullPrompt) - if err != nil { - s.logger.Warn("elaborate: claude failed, falling back to gemini", "error", err) - result, err = s.elaborateWithGemini(ctx, workDir, fullPrompt) - if err != nil { - s.logger.Error("elaborate: 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 == "" { - writeJSON(w, http.StatusBadGateway, map[string]string{ - "error": "elaboration failed: missing required fields in response", - }) - return - } - - if result.Agent.Type == "" { - result.Agent.Type = "claude" - } - - sanitizeElaboratedTask(result) - - writeJSON(w, http.StatusOK, result) -} |
