fix(config): implement role_agents support in BuildStartupCommand (#19) (#456)

* fix(config): implement role_agents support in BuildStartupCommand

The role_agents field in TownSettings and RigSettings existed but was
not being used by the startup command builders. All services fell back
to the default agent instead of using role-specific agent assignments.

Changes:
- BuildStartupCommand now extracts GT_ROLE from envVars and uses
  ResolveRoleAgentConfig() for role-based agent selection
- BuildStartupCommandWithAgentOverride follows the same pattern when
  no explicit override is provided
- refinery/manager.go uses ResolveRoleAgentConfig with constants
- cmd/start.go uses ResolveRoleAgentConfig with constants
- Updated comments from hardcoded agent name to generic "agent"
- Added ValidateAgentConfig() to check agent exists and binary is in PATH
- Added lookupAgentConfigIfExists() helper for validation
- ResolveRoleAgentConfig now warns to stderr and falls back to default
  if configured agent is invalid or binary is missing

Resolution priority (now working):
1. Explicit --agent override
2. Rig's role_agents[role] (validated)
3. Town's role_agents[role] (validated)
4. Rig's agent setting
5. Town's default_agent
6. Hardcoded default fallback

Adds tests for:
- TestBuildStartupCommand_UsesRoleAgentsFromTownSettings
- TestBuildStartupCommand_RigRoleAgentsOverridesTownRoleAgents
- TestBuildAgentStartupCommand_UsesRoleAgents
- TestValidateAgentConfig
- TestResolveRoleAgentConfig_FallsBackOnInvalidAgent

Fixes: role_agents configuration not being applied to services

* fix(config): add GT_ROOT to BuildStartupCommandWithAgentOverride

- Fixes missing GT_ROOT and GT_SESSION_ID_ENV exports in
  BuildStartupCommandWithAgentOverride, matching BuildStartupCommand behavior
- Adds test for override priority over role_agents
- Adds test verifying GT_ROOT is included in command

This addresses the Greptile review comment about agents started with
an override not having access to town-level resources.

Co-authored-by: Steve Yegge <steve.yegge@gmail.com>
This commit is contained in:
Subhrajit Makur
2026-01-14 03:04:22 +05:30
committed by GitHub
parent fa99e615f0
commit c61b67eb03
4 changed files with 393 additions and 30 deletions

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
@@ -898,6 +899,48 @@ func ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride string) (*R
return lookupAgentConfig(agentName, townSettings, rigSettings), agentName, nil
}
// ValidateAgentConfig checks if an agent configuration is valid and the binary exists.
// Returns an error describing the issue, or nil if valid.
func ValidateAgentConfig(agentName string, townSettings *TownSettings, rigSettings *RigSettings) error {
// Check if agent exists in config
rc := lookupAgentConfigIfExists(agentName, townSettings, rigSettings)
if rc == nil {
return fmt.Errorf("agent %q not found in config or built-in presets", agentName)
}
// Check if binary exists on system
if _, err := exec.LookPath(rc.Command); err != nil {
return fmt.Errorf("agent %q binary %q not found in PATH", agentName, rc.Command)
}
return nil
}
// lookupAgentConfigIfExists looks up an agent by name but returns nil if not found
// (instead of falling back to default). Used for validation.
func lookupAgentConfigIfExists(name string, townSettings *TownSettings, rigSettings *RigSettings) *RuntimeConfig {
// Check rig's custom agents
if rigSettings != nil && rigSettings.Agents != nil {
if custom, ok := rigSettings.Agents[name]; ok && custom != nil {
return fillRuntimeDefaults(custom)
}
}
// Check town's custom agents
if townSettings != nil && townSettings.Agents != nil {
if custom, ok := townSettings.Agents[name]; ok && custom != nil {
return fillRuntimeDefaults(custom)
}
}
// Check built-in presets
if preset := GetAgentPresetByName(name); preset != nil {
return RuntimeConfigFromPreset(AgentPreset(name))
}
return nil
}
// ResolveRoleAgentConfig resolves the agent configuration for a specific role.
// It checks role-specific agent assignments before falling back to the default agent.
//
@@ -906,6 +949,9 @@ func ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride string) (*R
// 2. Town's RoleAgents[role] - if set, look up that agent
// 3. Fall back to ResolveAgentConfig (rig's Agent → town's DefaultAgent → "claude")
//
// If a configured agent is not found or its binary doesn't exist, a warning is
// printed to stderr and it falls back to the default agent.
//
// role is one of: "mayor", "deacon", "witness", "refinery", "polecat", "crew".
// townRoot is the path to the town directory (e.g., ~/gt).
// rigPath is the path to the rig directory (e.g., ~/gt/gastown), or empty for town-level roles.
@@ -935,14 +981,22 @@ func ResolveRoleAgentConfig(role, townRoot, rigPath string) *RuntimeConfig {
// Check rig's RoleAgents first
if rigSettings != nil && rigSettings.RoleAgents != nil {
if agentName, ok := rigSettings.RoleAgents[role]; ok && agentName != "" {
return lookupAgentConfig(agentName, townSettings, rigSettings)
if err := ValidateAgentConfig(agentName, townSettings, rigSettings); err != nil {
fmt.Fprintf(os.Stderr, "warning: role_agents[%s]=%s - %v, falling back to default\n", role, agentName, err)
} else {
return lookupAgentConfig(agentName, townSettings, rigSettings)
}
}
}
// Check town's RoleAgents
if townSettings.RoleAgents != nil {
if agentName, ok := townSettings.RoleAgents[role]; ok && agentName != "" {
return lookupAgentConfig(agentName, townSettings, rigSettings)
if err := ValidateAgentConfig(agentName, townSettings, rigSettings); err != nil {
fmt.Fprintf(os.Stderr, "warning: role_agents[%s]=%s - %v, falling back to default\n", role, agentName, err)
} else {
return lookupAgentConfig(agentName, townSettings, rigSettings)
}
}
}
@@ -1150,13 +1204,26 @@ func findTownRootFromCwd() (string, error) {
// envVars is a map of environment variable names to values.
// rigPath is optional - if empty, tries to detect town root from cwd.
// prompt is optional - if provided, appended as the initial prompt.
//
// If envVars contains GT_ROLE, the function uses role-based agent resolution
// (ResolveRoleAgentConfig) to select the appropriate agent for the role.
// This enables per-role model selection via role_agents in settings.
func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) string {
var rc *RuntimeConfig
var townRoot string
// Extract role from envVars for role-based agent resolution
role := envVars["GT_ROLE"]
if rigPath != "" {
// Derive town root from rig path
townRoot = filepath.Dir(rigPath)
rc = ResolveAgentConfig(townRoot, rigPath)
if role != "" {
// Use role-based agent resolution for per-role model selection
rc = ResolveRoleAgentConfig(role, townRoot, rigPath)
} else {
rc = ResolveAgentConfig(townRoot, rigPath)
}
} else {
// Try to detect town root from cwd for town-level agents (mayor, deacon)
var err error
@@ -1164,7 +1231,12 @@ func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) stri
if err != nil {
rc = DefaultRuntimeConfig()
} else {
rc = ResolveAgentConfig(townRoot, "")
if role != "" {
// Use role-based agent resolution for per-role model selection
rc = ResolveRoleAgentConfig(role, townRoot, "")
} else {
rc = ResolveAgentConfig(townRoot, "")
}
}
}
@@ -1222,32 +1294,69 @@ func PrependEnv(command string, envVars map[string]string) string {
// BuildStartupCommandWithAgentOverride builds a startup command like BuildStartupCommand,
// but uses agentOverride if non-empty.
//
// Resolution priority:
// 1. agentOverride (explicit override)
// 2. role_agents[GT_ROLE] (if GT_ROLE is in envVars)
// 3. Default agent resolution (rig's Agent → town's DefaultAgent → "claude")
func BuildStartupCommandWithAgentOverride(envVars map[string]string, rigPath, prompt, agentOverride string) (string, error) {
var rc *RuntimeConfig
var townRoot string
// Extract role from envVars for role-based agent resolution (when no override)
role := envVars["GT_ROLE"]
if rigPath != "" {
townRoot := filepath.Dir(rigPath)
var err error
rc, _, err = ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride)
if err != nil {
return "", err
townRoot = filepath.Dir(rigPath)
if agentOverride != "" {
var err error
rc, _, err = ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride)
if err != nil {
return "", err
}
} else if role != "" {
// No override, use role-based agent resolution
rc = ResolveRoleAgentConfig(role, townRoot, rigPath)
} else {
rc = ResolveAgentConfig(townRoot, rigPath)
}
} else {
townRoot, err := findTownRootFromCwd()
var err error
townRoot, err = findTownRootFromCwd()
if err != nil {
rc = DefaultRuntimeConfig()
} else {
var resolveErr error
rc, _, resolveErr = ResolveAgentConfigWithOverride(townRoot, "", agentOverride)
if resolveErr != nil {
return "", resolveErr
if agentOverride != "" {
var resolveErr error
rc, _, resolveErr = ResolveAgentConfigWithOverride(townRoot, "", agentOverride)
if resolveErr != nil {
return "", resolveErr
}
} else if role != "" {
// No override, use role-based agent resolution
rc = ResolveRoleAgentConfig(role, townRoot, "")
} else {
rc = ResolveAgentConfig(townRoot, "")
}
}
}
// Copy env vars to avoid mutating caller map
resolvedEnv := make(map[string]string, len(envVars)+2)
for k, v := range envVars {
resolvedEnv[k] = v
}
// Add GT_ROOT so agents can find town-level resources (formulas, etc.)
if townRoot != "" {
resolvedEnv["GT_ROOT"] = townRoot
}
if rc.Session != nil && rc.Session.SessionIDEnv != "" {
resolvedEnv["GT_SESSION_ID_ENV"] = rc.Session.SessionIDEnv
}
// Build environment export prefix
var exports []string
for k, v := range envVars {
for k, v := range resolvedEnv {
exports = append(exports, fmt.Sprintf("%s=%s", k, v))
}
sort.Strings(exports)
@@ -1266,7 +1375,6 @@ func BuildStartupCommandWithAgentOverride(envVars map[string]string, rigPath, pr
return cmd, nil
}
// BuildAgentStartupCommand is a convenience function for starting agent sessions.
// It sets standard environment variables (GT_ROLE, BD_ACTOR, GIT_AUTHOR_NAME)
// and builds the full startup command.

View File

@@ -6,6 +6,8 @@ import (
"strings"
"testing"
"time"
"github.com/steveyegge/gastown/internal/constants"
)
func TestTownConfigRoundTrip(t *testing.T) {
@@ -1193,6 +1195,189 @@ func TestBuildStartupCommand_UsesRigAgentWhenRigPathProvided(t *testing.T) {
}
}
func TestBuildStartupCommand_UsesRoleAgentsFromTownSettings(t *testing.T) {
t.Parallel()
townRoot := t.TempDir()
rigPath := filepath.Join(townRoot, "testrig")
// Configure town settings with role_agents
townSettings := NewTownSettings()
townSettings.DefaultAgent = "claude"
townSettings.RoleAgents = map[string]string{
constants.RoleRefinery: "gemini",
constants.RoleWitness: "codex",
}
if err := SaveTownSettings(TownSettingsPath(townRoot), townSettings); err != nil {
t.Fatalf("SaveTownSettings: %v", err)
}
// Create empty rig settings (no agent override)
rigSettings := NewRigSettings()
if err := SaveRigSettings(RigSettingsPath(rigPath), rigSettings); err != nil {
t.Fatalf("SaveRigSettings: %v", err)
}
t.Run("refinery role gets gemini from role_agents", func(t *testing.T) {
cmd := BuildStartupCommand(map[string]string{"GT_ROLE": constants.RoleRefinery}, rigPath, "")
if !strings.Contains(cmd, "gemini") {
t.Fatalf("expected gemini for refinery role, got: %q", cmd)
}
})
t.Run("witness role gets codex from role_agents", func(t *testing.T) {
cmd := BuildStartupCommand(map[string]string{"GT_ROLE": constants.RoleWitness}, rigPath, "")
if !strings.Contains(cmd, "codex") {
t.Fatalf("expected codex for witness role, got: %q", cmd)
}
})
t.Run("crew role falls back to default_agent (not in role_agents)", func(t *testing.T) {
cmd := BuildStartupCommand(map[string]string{"GT_ROLE": constants.RoleCrew}, rigPath, "")
if !strings.Contains(cmd, "claude") {
t.Fatalf("expected claude fallback for crew role, got: %q", cmd)
}
})
t.Run("no role falls back to default resolution", func(t *testing.T) {
cmd := BuildStartupCommand(map[string]string{}, rigPath, "")
if !strings.Contains(cmd, "claude") {
t.Fatalf("expected claude for no role, got: %q", cmd)
}
})
}
func TestBuildStartupCommand_RigRoleAgentsOverridesTownRoleAgents(t *testing.T) {
t.Parallel()
townRoot := t.TempDir()
rigPath := filepath.Join(townRoot, "testrig")
// Town settings has witness = gemini
townSettings := NewTownSettings()
townSettings.DefaultAgent = "claude"
townSettings.RoleAgents = map[string]string{
constants.RoleWitness: "gemini",
}
if err := SaveTownSettings(TownSettingsPath(townRoot), townSettings); err != nil {
t.Fatalf("SaveTownSettings: %v", err)
}
// Rig settings overrides witness to codex
rigSettings := NewRigSettings()
rigSettings.RoleAgents = map[string]string{
constants.RoleWitness: "codex",
}
if err := SaveRigSettings(RigSettingsPath(rigPath), rigSettings); err != nil {
t.Fatalf("SaveRigSettings: %v", err)
}
cmd := BuildStartupCommand(map[string]string{"GT_ROLE": constants.RoleWitness}, rigPath, "")
if !strings.Contains(cmd, "codex") {
t.Fatalf("expected codex from rig role_agents override, got: %q", cmd)
}
if strings.Contains(cmd, "gemini") {
t.Fatalf("did not expect town role_agents (gemini) in command: %q", cmd)
}
}
func TestBuildAgentStartupCommand_UsesRoleAgents(t *testing.T) {
t.Parallel()
townRoot := t.TempDir()
rigPath := filepath.Join(townRoot, "testrig")
// Configure town settings with role_agents
townSettings := NewTownSettings()
townSettings.DefaultAgent = "claude"
townSettings.RoleAgents = map[string]string{
constants.RoleRefinery: "codex",
}
if err := SaveTownSettings(TownSettingsPath(townRoot), townSettings); err != nil {
t.Fatalf("SaveTownSettings: %v", err)
}
// Create empty rig settings
rigSettings := NewRigSettings()
if err := SaveRigSettings(RigSettingsPath(rigPath), rigSettings); err != nil {
t.Fatalf("SaveRigSettings: %v", err)
}
// BuildAgentStartupCommand passes role via GT_ROLE env var
cmd := BuildAgentStartupCommand(constants.RoleRefinery, "testrig/refinery", rigPath, "")
if !strings.Contains(cmd, "codex") {
t.Fatalf("expected codex for refinery role, got: %q", cmd)
}
if !strings.Contains(cmd, "GT_ROLE="+constants.RoleRefinery) {
t.Fatalf("expected GT_ROLE=%s in command: %q", constants.RoleRefinery, cmd)
}
}
func TestValidateAgentConfig(t *testing.T) {
t.Parallel()
t.Run("valid built-in agent", func(t *testing.T) {
// claude is a built-in preset and binary should exist
err := ValidateAgentConfig("claude", nil, nil)
// Note: This may fail if claude binary is not installed, which is expected
if err != nil && !strings.Contains(err.Error(), "not found in PATH") {
t.Errorf("unexpected error for claude: %v", err)
}
})
t.Run("invalid agent name", func(t *testing.T) {
err := ValidateAgentConfig("nonexistent-agent-xyz", nil, nil)
if err == nil {
t.Error("expected error for nonexistent agent")
}
if !strings.Contains(err.Error(), "not found in config or built-in presets") {
t.Errorf("unexpected error message: %v", err)
}
})
t.Run("custom agent with missing binary", func(t *testing.T) {
townSettings := NewTownSettings()
townSettings.Agents = map[string]*RuntimeConfig{
"my-custom-agent": {
Command: "nonexistent-binary-xyz123",
Args: []string{"--some-flag"},
},
}
err := ValidateAgentConfig("my-custom-agent", townSettings, nil)
if err == nil {
t.Error("expected error for missing binary")
}
if !strings.Contains(err.Error(), "not found in PATH") {
t.Errorf("unexpected error message: %v", err)
}
})
}
func TestResolveRoleAgentConfig_FallsBackOnInvalidAgent(t *testing.T) {
t.Parallel()
townRoot := t.TempDir()
rigPath := filepath.Join(townRoot, "testrig")
// Configure town settings with an invalid agent for refinery
townSettings := NewTownSettings()
townSettings.DefaultAgent = "claude"
townSettings.RoleAgents = map[string]string{
constants.RoleRefinery: "nonexistent-agent-xyz", // Invalid agent
}
if err := SaveTownSettings(TownSettingsPath(townRoot), townSettings); err != nil {
t.Fatalf("SaveTownSettings: %v", err)
}
// Create empty rig settings
rigSettings := NewRigSettings()
if err := SaveRigSettings(RigSettingsPath(rigPath), rigSettings); err != nil {
t.Fatalf("SaveRigSettings: %v", err)
}
// Should fall back to default (claude) when agent is invalid
rc := ResolveRoleAgentConfig(constants.RoleRefinery, townRoot, rigPath)
if rc.Command != "claude" {
t.Errorf("expected fallback to claude, got: %s", rc.Command)
}
}
func TestGetRuntimeCommand_UsesRigAgentWhenRigPathProvided(t *testing.T) {
t.Parallel()
townRoot := t.TempDir()
@@ -2204,8 +2389,8 @@ func TestEscalationConfigGetRouteForSeverity(t *testing.T) {
}{
{SeverityLow, []string{"bead"}},
{SeverityMedium, []string{"bead", "mail:mayor"}},
{SeverityHigh, []string{"bead", "mail:mayor"}}, // fallback for missing
{SeverityCritical, []string{"bead", "mail:mayor"}}, // fallback for missing
{SeverityHigh, []string{"bead", "mail:mayor"}}, // fallback for missing
{SeverityCritical, []string{"bead", "mail:mayor"}}, // fallback for missing
}
for _, tt := range tests {
@@ -2321,3 +2506,73 @@ func TestEscalationConfigPath(t *testing.T) {
t.Errorf("EscalationConfigPath = %q, want %q", path, expected)
}
}
func TestBuildStartupCommandWithAgentOverride_PriorityOverRoleAgents(t *testing.T) {
t.Parallel()
townRoot := t.TempDir()
rigPath := filepath.Join(townRoot, "testrig")
// Configure town settings with role_agents: refinery = codex
townSettings := NewTownSettings()
townSettings.DefaultAgent = "claude"
townSettings.RoleAgents = map[string]string{
constants.RoleRefinery: "codex",
}
if err := SaveTownSettings(TownSettingsPath(townRoot), townSettings); err != nil {
t.Fatalf("SaveTownSettings: %v", err)
}
// Create empty rig settings
rigSettings := NewRigSettings()
if err := SaveRigSettings(RigSettingsPath(rigPath), rigSettings); err != nil {
t.Fatalf("SaveRigSettings: %v", err)
}
// agentOverride = "gemini" should take priority over role_agents[refinery] = "codex"
cmd, err := BuildStartupCommandWithAgentOverride(
map[string]string{"GT_ROLE": constants.RoleRefinery},
rigPath,
"",
"gemini", // explicit override
)
if err != nil {
t.Fatalf("BuildStartupCommandWithAgentOverride: %v", err)
}
if !strings.Contains(cmd, "gemini") {
t.Errorf("expected gemini (override) in command, got: %q", cmd)
}
if strings.Contains(cmd, "codex") {
t.Errorf("did not expect codex (role_agents) when override is set: %q", cmd)
}
}
func TestBuildStartupCommandWithAgentOverride_IncludesGTRoot(t *testing.T) {
t.Parallel()
townRoot := t.TempDir()
rigPath := filepath.Join(townRoot, "testrig")
// Create necessary config files
townSettings := NewTownSettings()
if err := SaveTownSettings(TownSettingsPath(townRoot), townSettings); err != nil {
t.Fatalf("SaveTownSettings: %v", err)
}
if err := SaveRigSettings(RigSettingsPath(rigPath), NewRigSettings()); err != nil {
t.Fatalf("SaveRigSettings: %v", err)
}
cmd, err := BuildStartupCommandWithAgentOverride(
map[string]string{"GT_ROLE": constants.RoleWitness},
rigPath,
"",
"gemini",
)
if err != nil {
t.Fatalf("BuildStartupCommandWithAgentOverride: %v", err)
}
// Should include GT_ROOT in export
if !strings.Contains(cmd, "GT_ROOT="+townRoot) {
t.Errorf("expected GT_ROOT=%s in command, got: %q", townRoot, cmd)
}
}