2026-03-16 11:10:33 +00:00
// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"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"
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" :
2026-03-16 17:37:58 +00:00
args := [ ] string { "-p" , prompt , "--output-format" , "text" , "--permission-mode" , "bypassPermissions" , "--no-session-persistence" }
2026-03-16 11:10:33 +00:00
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 , fmt . Errorf ( "unknown agent: %s" , agent )
}
}
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"
}
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 { } , fmt . Errorf ( "prep workspace failed: %w" , 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 , _ := os . ReadFile ( filepath . Join ( wsDir , "PROMPT.md" ) )
return nil , DispatchOutput {
Success : true ,
Agent : input . Agent ,
Repo : input . Repo ,
WorkspaceDir : wsDir ,
Prompt : string ( 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 ,
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 as a detached process
// Uses Setpgid so the agent survives parent (MCP server) death.
// Output goes directly to log file (not buffered in memory).
command , args , err := agentCommand ( input . Agent , prompt )
if err != nil {
return nil , DispatchOutput { } , err
}
outputFile := filepath . Join ( wsDir , fmt . Sprintf ( "agent-%s.log" , input . Agent ) )
outFile , err := os . Create ( outputFile )
if err != nil {
return nil , DispatchOutput { } , fmt . Errorf ( "failed to create log file: %w" , err )
}
// Fully detach from terminal:
// - Setpgid: own process group
// - Stdin from /dev/null
// - TERM=dumb prevents terminal control sequences
// - NO_COLOR=1 disables colour output
devNull , _ := os . Open ( os . DevNull )
cmd := exec . Command ( command , args ... )
cmd . Dir = srcDir
cmd . Stdin = devNull
cmd . Stdout = outFile
cmd . Stderr = outFile
cmd . Env = append ( os . Environ ( ) , "TERM=dumb" , "NO_COLOR=1" , "CI=true" )
cmd . SysProcAttr = & syscall . SysProcAttr { Setpgid : true }
if err := cmd . Start ( ) ; err != nil {
outFile . Close ( )
return nil , DispatchOutput { } , fmt . Errorf ( "failed to spawn %s: %w" , input . Agent , err )
}
pid := cmd . Process . Pid
// Write initial status
writeStatus ( wsDir , & WorkspaceStatus {
Status : "running" ,
Agent : input . Agent ,
Repo : input . Repo ,
Org : input . Org ,
Task : input . Task ,
PID : pid ,
StartedAt : time . Now ( ) ,
Runs : 1 ,
} )
// Background goroutine: close file handle when process exits,
// update status, then drain queue if a slot opened up.
go func ( ) {
cmd . Wait ( )
outFile . Close ( )
// Update status to completed
if st , err := readStatus ( wsDir ) ; err == nil {
st . Status = "completed"
st . PID = 0
writeStatus ( wsDir , st )
}
// Ingest scan findings as issues
s . ingestFindings ( wsDir )
// Drain queue: pop next queued workspace and spawn it
s . drainQueue ( )
} ( )
return nil , DispatchOutput {
Success : true ,
Agent : input . Agent ,
Repo : input . Repo ,
WorkspaceDir : wsDir ,
PID : pid ,
OutputFile : outputFile ,
} , nil
}