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 <virgil@lethean.io>
This commit is contained in:
parent
ebd44bf2f2
commit
ca5c235ece
3 changed files with 420 additions and 0 deletions
242
pkg/mcp/agentic/dispatch.go
Normal file
242
pkg/mcp/agentic/dispatch.go
Normal file
|
|
@ -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 <virgil@lethean.io>\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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
171
pkg/mcp/agentic/scan.go
Normal file
171
pkg/mcp/agentic/scan.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue