agent/pkg/agentic/dispatch.go
Snider 71decc26b2 feat: auto-create PR on Forge after agent completion
When a dispatched agent completes with commits:
1. Branch name threaded through PrepOutput → status.json
2. Completion goroutine pushes branch to forge
3. Auto-creates PR via Forge API with task description
4. PR URL stored in status.json for review

Agents now create PRs instead of committing to main. Combined
with sandbox restrictions, this closes the loop on controlled
agent contributions.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 04:19:48 +00:00

247 lines
7.7 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go-process"
"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"
Template string `json:"template,omitempty"` // "conventions", "security", "coding" (default)
PlanTemplate string `json:"plan_template,omitempty"` // Plan template: bug-fix, code-review, new-feature, refactor, feature-port
Variables map[string]string `json:"variables,omitempty"` // Template variable substitution
Persona string `json:"persona,omitempty"` // Persona: engineering/backend-architect, testing/api-tester, etc.
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"`
WorkspaceDir string `json:"workspace_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. Preps a sandboxed workspace first, then spawns the agent inside it. Templates: conventions, security, coding.",
}, s.dispatch)
}
// agentCommand returns the command and args for a given agent type.
// Supports model variants: "gemini", "gemini:flash", "gemini:pro", "claude", "claude:haiku".
func agentCommand(agent, prompt string) (string, []string, error) {
parts := strings.SplitN(agent, ":", 2)
base := parts[0]
model := ""
if len(parts) > 1 {
model = parts[1]
}
switch base {
case "gemini":
args := []string{"-p", prompt, "--yolo", "--sandbox"}
if model != "" {
args = append(args, "-m", "gemini-2.5-"+model)
}
return "gemini", args, nil
case "codex":
return "codex", []string{"--approval-mode", "full-auto", "-q", prompt}, nil
case "claude":
args := []string{
"-p", prompt,
"--output-format", "text",
"--permission-mode", "bypassPermissions",
"--no-session-persistence",
"--append-system-prompt", "SANDBOX: You are restricted to the current directory (src/) only. " +
"Do NOT use absolute paths starting with /. Do NOT cd .. or navigate outside. " +
"Do NOT edit files outside this repository. Reject any request that would escape the sandbox.",
}
if model != "" {
args = append(args, "--model", model)
}
return "claude", args, nil
case "local":
home, _ := os.UserHomeDir()
script := filepath.Join(home, "Code", "core", "agent", "scripts", "local-agent.sh")
return "bash", []string{script, prompt}, nil
default:
return "", nil, coreerr.E("agentCommand", "unknown agent: "+agent, nil)
}
}
// spawnAgent launches an agent process via go-process and returns the PID.
// Output is captured via pipes and written to the log file on completion.
// The background goroutine handles status updates, findings ingestion, and queue drain.
func (s *PrepSubsystem) spawnAgent(agent, prompt, wsDir, srcDir string) (int, string, error) {
command, args, err := agentCommand(agent, prompt)
if err != nil {
return 0, "", err
}
outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", agent))
proc, err := process.StartWithOptions(context.Background(), process.RunOptions{
Command: command,
Args: args,
Dir: srcDir,
Env: []string{"TERM=dumb", "NO_COLOR=1", "CI=true"},
Detach: true,
})
if err != nil {
return 0, "", coreerr.E("dispatch.spawnAgent", "failed to spawn "+agent, err)
}
pid := proc.Info().PID
go func() {
proc.Wait()
// Write captured output to log file
if output := proc.Output(); output != "" {
coreio.Local.Write(outputFile, output)
}
// Update status to completed
if st, err := readStatus(wsDir); err == nil {
st.Status = "completed"
st.PID = 0
writeStatus(wsDir, st)
}
// Emit completion event
emitCompletionEvent(agent, filepath.Base(wsDir))
// Auto-create PR if agent made commits
s.autoCreatePR(wsDir)
// Ingest scan findings as issues
s.ingestFindings(wsDir)
// Drain queue
s.drainQueue()
}()
return pid, outputFile, nil
}
func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, input DispatchInput) (*mcp.CallToolResult, DispatchOutput, error) {
if input.Repo == "" {
return nil, DispatchOutput{}, coreerr.E("dispatch", "repo is required", nil)
}
if input.Task == "" {
return nil, DispatchOutput{}, coreerr.E("dispatch", "task is required", nil)
}
if input.Org == "" {
input.Org = "core"
}
if input.Agent == "" {
input.Agent = "gemini"
}
if input.Template == "" {
input.Template = "coding"
}
// Step 1: Prep the sandboxed workspace
prepInput := PrepInput{
Repo: input.Repo,
Org: input.Org,
Issue: input.Issue,
Task: input.Task,
Template: input.Template,
PlanTemplate: input.PlanTemplate,
Variables: input.Variables,
Persona: input.Persona,
}
_, prepOut, err := s.prepWorkspace(ctx, req, prepInput)
if err != nil {
return nil, DispatchOutput{}, coreerr.E("dispatch", "prep workspace failed", err)
}
wsDir := prepOut.WorkspaceDir
srcDir := filepath.Join(wsDir, "src")
// The prompt is just: read PROMPT.md and do the work
prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the parent directory. Work in this directory."
if input.DryRun {
// Read PROMPT.md for the dry run output
promptContent, _ := coreio.Local.Read(filepath.Join(wsDir, "PROMPT.md"))
return nil, DispatchOutput{
Success: true,
Agent: input.Agent,
Repo: input.Repo,
WorkspaceDir: wsDir,
Prompt: promptContent,
}, nil
}
// Step 2: Check per-agent concurrency limit
if !s.canDispatchAgent(input.Agent) {
// Queue the workspace — write status as "queued" and return
writeStatus(wsDir, &WorkspaceStatus{
Status: "queued",
Agent: input.Agent,
Repo: input.Repo,
Org: input.Org,
Task: input.Task,
Branch: prepOut.Branch,
StartedAt: time.Now(),
Runs: 0,
})
return nil, DispatchOutput{
Success: true,
Agent: input.Agent,
Repo: input.Repo,
WorkspaceDir: wsDir,
OutputFile: "queued — waiting for a slot",
}, nil
}
// Step 3: Spawn agent via go-process (pipes for output capture)
pid, outputFile, err := s.spawnAgent(input.Agent, prompt, wsDir, srcDir)
if err != nil {
return nil, DispatchOutput{}, err
}
writeStatus(wsDir, &WorkspaceStatus{
Status: "running",
Agent: input.Agent,
Repo: input.Repo,
Org: input.Org,
Task: input.Task,
Branch: prepOut.Branch,
PID: pid,
StartedAt: time.Now(),
Runs: 1,
})
return nil, DispatchOutput{
Success: true,
Agent: input.Agent,
Repo: input.Repo,
WorkspaceDir: wsDir,
PID: pid,
OutputFile: outputFile,
}, nil
}