From ca5c235ece6ff6c79296129ba067a648959202c2 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 15 Mar 2026 08:09:48 +0000 Subject: [PATCH] feat(agentic): add dispatch and scan MCP tools agentic_dispatch: spawn Gemini/Codex/Claude subagents with prepped workspace context. Builds structured prompts from CLAUDE.md, OpenBrain memories, consumers, and git log. Runs in background with PID tracking. agentic_scan: scan Forge repos for open issues with actionable labels (agentic, help-wanted, bug). Deduplicates cross-label results. Co-Authored-By: Virgil --- pkg/mcp/agentic/dispatch.go | 242 ++++++++++++++++++++++++++++++++++++ pkg/mcp/agentic/prep.go | 7 ++ pkg/mcp/agentic/scan.go | 171 +++++++++++++++++++++++++ 3 files changed, 420 insertions(+) create mode 100644 pkg/mcp/agentic/dispatch.go create mode 100644 pkg/mcp/agentic/scan.go diff --git a/pkg/mcp/agentic/dispatch.go b/pkg/mcp/agentic/dispatch.go new file mode 100644 index 0000000..ee5d2b3 --- /dev/null +++ b/pkg/mcp/agentic/dispatch.go @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// DispatchInput is the input for agentic_dispatch. +type DispatchInput struct { + Repo string `json:"repo"` // Target repo (e.g. "go-io") + Org string `json:"org,omitempty"` // Forge org (default "core") + Task string `json:"task"` // What the agent should do + Agent string `json:"agent,omitempty"` // "gemini" (default), "codex", "claude" + Issue int `json:"issue,omitempty"` // Forge issue to work from + DryRun bool `json:"dry_run,omitempty"` // Preview without executing +} + +// DispatchOutput is the output for agentic_dispatch. +type DispatchOutput struct { + Success bool `json:"success"` + Agent string `json:"agent"` + Repo string `json:"repo"` + WorkDir string `json:"work_dir"` + Prompt string `json:"prompt,omitempty"` + PID int `json:"pid,omitempty"` + OutputFile string `json:"output_file,omitempty"` +} + +func (s *PrepSubsystem) registerDispatchTool(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_dispatch", + Description: "Dispatch a subagent (Gemini, Codex, or Claude) to work on a task in a specific repo. Preps workspace context first, then spawns the agent with a structured prompt.", + }, s.dispatch) +} + +func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, input DispatchInput) (*mcp.CallToolResult, DispatchOutput, error) { + if input.Repo == "" { + return nil, DispatchOutput{}, fmt.Errorf("repo is required") + } + if input.Task == "" { + return nil, DispatchOutput{}, fmt.Errorf("task is required") + } + if input.Org == "" { + input.Org = "core" + } + if input.Agent == "" { + input.Agent = "gemini" + } + + repoPath := filepath.Join(s.codePath, "core", input.Repo) + outputDir := filepath.Join(repoPath, ".core") + + // Step 1: Prep workspace + prepInput := PrepInput{ + Repo: input.Repo, + Org: input.Org, + Issue: input.Issue, + } + _, _, _ = s.prepWorkspace(ctx, req, prepInput) + + // Step 2: Build prompt from prepped context + prompt := s.buildPrompt(input, outputDir) + + if input.DryRun { + return nil, DispatchOutput{ + Success: true, + Agent: input.Agent, + Repo: input.Repo, + WorkDir: repoPath, + Prompt: prompt, + }, nil + } + + // Step 3: Spawn agent + switch input.Agent { + case "gemini": + return s.spawnGemini(repoPath, prompt, input) + case "codex": + return s.spawnCodex(repoPath, prompt, input) + case "claude": + return s.spawnClaude(repoPath, prompt, input) + default: + return nil, DispatchOutput{}, fmt.Errorf("unknown agent: %s (use gemini, codex, or claude)", input.Agent) + } +} + +func (s *PrepSubsystem) buildPrompt(input DispatchInput, outputDir string) string { + var prompt strings.Builder + + prompt.WriteString("You are working on the " + input.Repo + " repository.\n\n") + + // Include CLAUDE.md context + if data, err := os.ReadFile(filepath.Join(outputDir, "CLAUDE.md")); err == nil { + prompt.WriteString("## Project Context (CLAUDE.md)\n\n") + prompt.WriteString(string(data)) + prompt.WriteString("\n\n") + } + + // Include OpenBrain context + if data, err := os.ReadFile(filepath.Join(outputDir, "context.md")); err == nil { + prompt.WriteString(string(data)) + prompt.WriteString("\n\n") + } + + // Include consumers + if data, err := os.ReadFile(filepath.Join(outputDir, "consumers.md")); err == nil { + prompt.WriteString(string(data)) + prompt.WriteString("\n\n") + } + + // Include recent changes + if data, err := os.ReadFile(filepath.Join(outputDir, "recent.md")); err == nil { + prompt.WriteString(string(data)) + prompt.WriteString("\n\n") + } + + // Include TODO if from issue + if data, err := os.ReadFile(filepath.Join(outputDir, "todo.md")); err == nil { + prompt.WriteString(string(data)) + prompt.WriteString("\n\n") + } + + // The actual task + prompt.WriteString("## Your Task\n\n") + prompt.WriteString(input.Task) + prompt.WriteString("\n\n") + + // Conventions + prompt.WriteString("## Conventions\n\n") + prompt.WriteString("- UK English (colour, organisation, centre)\n") + prompt.WriteString("- Conventional commits: type(scope): description\n") + prompt.WriteString("- Co-Author: Co-Authored-By: Virgil \n") + prompt.WriteString("- Licence: EUPL-1.2\n") + prompt.WriteString("- Push to forge: ssh://git@forge.lthn.ai:2223/" + input.Org + "/" + input.Repo + ".git\n") + + return prompt.String() +} + +func (s *PrepSubsystem) spawnGemini(repoPath, prompt string, input DispatchInput) (*mcp.CallToolResult, DispatchOutput, error) { + // Write prompt to temp file (gemini -p has length limits) + promptFile := filepath.Join(repoPath, ".core", "dispatch-prompt.md") + os.WriteFile(promptFile, []byte(prompt), 0644) + + // Output file for capturing results + outputFile := filepath.Join(repoPath, ".core", fmt.Sprintf("dispatch-%s-%d.log", input.Agent, time.Now().Unix())) + + cmd := exec.Command("gemini", "-p", prompt, "--yolo") + cmd.Dir = repoPath + + outFile, _ := os.Create(outputFile) + cmd.Stdout = outFile + cmd.Stderr = outFile + + if err := cmd.Start(); err != nil { + outFile.Close() + return nil, DispatchOutput{}, fmt.Errorf("failed to spawn gemini: %w", err) + } + + // Don't wait — let it run in background + go func() { + cmd.Wait() + outFile.Close() + }() + + return nil, DispatchOutput{ + Success: true, + Agent: "gemini", + Repo: input.Repo, + WorkDir: repoPath, + PID: cmd.Process.Pid, + OutputFile: outputFile, + }, nil +} + +func (s *PrepSubsystem) spawnCodex(repoPath, prompt string, input DispatchInput) (*mcp.CallToolResult, DispatchOutput, error) { + outputFile := filepath.Join(repoPath, ".core", fmt.Sprintf("dispatch-%s-%d.log", input.Agent, time.Now().Unix())) + + cmd := exec.Command("codex", "--approval-mode", "full-auto", "-q", prompt) + cmd.Dir = repoPath + + outFile, _ := os.Create(outputFile) + cmd.Stdout = outFile + cmd.Stderr = outFile + + if err := cmd.Start(); err != nil { + outFile.Close() + return nil, DispatchOutput{}, fmt.Errorf("failed to spawn codex: %w", err) + } + + go func() { + cmd.Wait() + outFile.Close() + }() + + return nil, DispatchOutput{ + Success: true, + Agent: "codex", + Repo: input.Repo, + WorkDir: repoPath, + PID: cmd.Process.Pid, + OutputFile: outputFile, + }, nil +} + +func (s *PrepSubsystem) spawnClaude(repoPath, prompt string, input DispatchInput) (*mcp.CallToolResult, DispatchOutput, error) { + outputFile := filepath.Join(repoPath, ".core", fmt.Sprintf("dispatch-%s-%d.log", input.Agent, time.Now().Unix())) + + cmd := exec.Command("claude", "-p", prompt, "--dangerously-skip-permissions") + cmd.Dir = repoPath + + outFile, _ := os.Create(outputFile) + cmd.Stdout = outFile + cmd.Stderr = outFile + + if err := cmd.Start(); err != nil { + outFile.Close() + return nil, DispatchOutput{}, fmt.Errorf("failed to spawn claude: %w", err) + } + + go func() { + cmd.Wait() + outFile.Close() + }() + + return nil, DispatchOutput{ + Success: true, + Agent: "claude", + Repo: input.Repo, + WorkDir: repoPath, + PID: cmd.Process.Pid, + OutputFile: outputFile, + }, nil +} diff --git a/pkg/mcp/agentic/prep.go b/pkg/mcp/agentic/prep.go index 53633c3..3aac222 100644 --- a/pkg/mcp/agentic/prep.go +++ b/pkg/mcp/agentic/prep.go @@ -74,6 +74,13 @@ func (s *PrepSubsystem) RegisterTools(server *mcp.Server) { Name: "agentic_prep_workspace", Description: "Prepare an agent workspace with CLAUDE.md, wiki KB, specs, OpenBrain context, consumer list, and recent git log for a target repo. Output goes to the repo's .core/ directory.", }, s.prepWorkspace) + + s.registerDispatchTool(server) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_scan", + Description: "Scan Forge repos for open issues with actionable labels (agentic, help-wanted, bug). Returns a list of issues that can be dispatched to subagents.", + }, s.scan) } // Shutdown implements mcp.SubsystemWithShutdown. diff --git a/pkg/mcp/agentic/scan.go b/pkg/mcp/agentic/scan.go new file mode 100644 index 0000000..6b2525a --- /dev/null +++ b/pkg/mcp/agentic/scan.go @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ScanInput is the input for agentic_scan. +type ScanInput struct { + Org string `json:"org,omitempty"` // default "core" + Labels []string `json:"labels,omitempty"` // filter by labels (default: agentic, help-wanted, bug) + Limit int `json:"limit,omitempty"` // max issues to return +} + +// ScanOutput is the output for agentic_scan. +type ScanOutput struct { + Success bool `json:"success"` + Count int `json:"count"` + Issues []ScanIssue `json:"issues"` +} + +// ScanIssue is a single actionable issue. +type ScanIssue struct { + Repo string `json:"repo"` + Number int `json:"number"` + Title string `json:"title"` + Labels []string `json:"labels"` + Assignee string `json:"assignee,omitempty"` + URL string `json:"url"` +} + +func (s *PrepSubsystem) scan(ctx context.Context, _ *mcp.CallToolRequest, input ScanInput) (*mcp.CallToolResult, ScanOutput, error) { + if s.forgeToken == "" { + return nil, ScanOutput{}, fmt.Errorf("no Forge token configured") + } + + if input.Org == "" { + input.Org = "core" + } + if input.Limit == 0 { + input.Limit = 20 + } + if len(input.Labels) == 0 { + input.Labels = []string{"agentic", "help-wanted", "bug"} + } + + var allIssues []ScanIssue + + // Get repos for the org + repos, err := s.listOrgRepos(ctx, input.Org) + if err != nil { + return nil, ScanOutput{}, err + } + + for _, repo := range repos { + for _, label := range input.Labels { + issues, err := s.listRepoIssues(ctx, input.Org, repo, label) + if err != nil { + continue + } + allIssues = append(allIssues, issues...) + + if len(allIssues) >= input.Limit { + break + } + } + if len(allIssues) >= input.Limit { + break + } + } + + // Deduplicate by repo+number + seen := make(map[string]bool) + var unique []ScanIssue + for _, issue := range allIssues { + key := fmt.Sprintf("%s#%d", issue.Repo, issue.Number) + if !seen[key] { + seen[key] = true + unique = append(unique, issue) + } + } + + if len(unique) > input.Limit { + unique = unique[:input.Limit] + } + + return nil, ScanOutput{ + Success: true, + Count: len(unique), + Issues: unique, + }, nil +} + +func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string, error) { + url := fmt.Sprintf("%s/api/v1/orgs/%s/repos?limit=50", s.forgeURL, org) + req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + req.Header.Set("Authorization", "token "+s.forgeToken) + + resp, err := s.client.Do(req) + if err != nil || resp.StatusCode != 200 { + return nil, fmt.Errorf("failed to list repos: %v", err) + } + defer resp.Body.Close() + + var repos []struct { + Name string `json:"name"` + } + json.NewDecoder(resp.Body).Decode(&repos) + + var names []string + for _, r := range repos { + names = append(names, r.Name) + } + return names, nil +} + +func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label string) ([]ScanIssue, error) { + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues?state=open&labels=%s&limit=10&type=issues", + s.forgeURL, org, repo, label) + req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + req.Header.Set("Authorization", "token "+s.forgeToken) + + resp, err := s.client.Do(req) + if err != nil || resp.StatusCode != 200 { + return nil, fmt.Errorf("failed to list issues for %s: %v", repo, err) + } + defer resp.Body.Close() + + var issues []struct { + Number int `json:"number"` + Title string `json:"title"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + Assignee *struct { + Login string `json:"login"` + } `json:"assignee"` + HTMLURL string `json:"html_url"` + } + json.NewDecoder(resp.Body).Decode(&issues) + + var result []ScanIssue + for _, issue := range issues { + var labels []string + for _, l := range issue.Labels { + labels = append(labels, l.Name) + } + assignee := "" + if issue.Assignee != nil { + assignee = issue.Assignee.Login + } + + result = append(result, ScanIssue{ + Repo: repo, + Number: issue.Number, + Title: issue.Title, + Labels: labels, + Assignee: assignee, + URL: strings.Replace(issue.HTMLURL, "https://forge.lthn.ai", s.forgeURL, 1), + }) + } + + return result, nil +}