feat(config): Add multi-agent support with pluggable registry

Implements agent abstraction layer to support multiple AI coding agents.

Built-in presets (E2E tested):
- Claude Code (default)
- Gemini CLI
- OpenAI Codex

Key changes:
- Add AgentRegistry with built-in presets and custom agent support
- Add TownSettings with default_agent and custom agents map
- Add Agent field to RigSettings for per-rig agent selection
- Update ResolveAgentConfig for hierarchical config resolution
- Update spawn paths to use configured agent instead of hardcoded claude

Configuration hierarchy (first match wins):
1. Rig's Runtime config (backwards compat)
2. Rig's Agent -> custom agents -> built-in presets
3. Town's default_agent setting
4. Fallback to Claude

Additional agents (aider, opencode, etc.) can be added via config file:
settings/agents.json

Addresses Issue #10: Agent Agnostic Engine with Multi-provider support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
mayor
2026-01-04 14:46:54 -05:00
committed by Raymond Weitekamp
parent 8dea4acf8c
commit ea1a41f1f5
7 changed files with 709 additions and 20 deletions

View File

@@ -642,6 +642,8 @@ func LoadOrCreateMessagingConfig(path string) (*MessagingConfig, error) {
// LoadRuntimeConfig loads the RuntimeConfig from a rig's settings.
// Falls back to defaults if settings don't exist or don't specify runtime config.
// rigPath should be the path to the rig directory (e.g., ~/gt/gastown).
//
// Deprecated: Use ResolveAgentConfig for full agent resolution with town settings.
func LoadRuntimeConfig(rigPath string) *RuntimeConfig {
settingsPath := filepath.Join(rigPath, "settings", "config.json")
settings, err := LoadRigSettings(settingsPath)
@@ -662,15 +664,139 @@ func LoadRuntimeConfig(rigPath string) *RuntimeConfig {
return rc
}
// TownSettingsPath returns the path to town settings file.
func TownSettingsPath(townRoot string) string {
return filepath.Join(townRoot, "settings", "config.json")
}
// RigSettingsPath returns the path to rig settings file.
func RigSettingsPath(rigPath string) string {
return filepath.Join(rigPath, "settings", "config.json")
}
// LoadOrCreateTownSettings loads town settings or creates defaults if missing.
func LoadOrCreateTownSettings(path string) (*TownSettings, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed internally
if err != nil {
if os.IsNotExist(err) {
return NewTownSettings(), nil
}
return nil, err
}
var settings TownSettings
if err := json.Unmarshal(data, &settings); err != nil {
return nil, err
}
return &settings, nil
}
// ResolveAgentConfig resolves the agent configuration for a rig.
// It looks up the agent by name in town settings (custom agents) and built-in presets.
//
// Resolution order:
// 1. If rig has Runtime set directly, use it (backwards compatibility)
// 2. If rig has Agent set, look it up in:
// a. Town's custom agents (from TownSettings.Agents)
// b. Built-in presets (claude, gemini, codex)
// 3. If rig has no Agent set, use town's default_agent
// 4. Fall back to claude defaults
//
// townRoot is the path to the town directory (e.g., ~/gt).
// rigPath is the path to the rig directory (e.g., ~/gt/gastown).
func ResolveAgentConfig(townRoot, rigPath string) *RuntimeConfig {
// Load rig settings
rigSettings, err := LoadRigSettings(RigSettingsPath(rigPath))
if err != nil {
rigSettings = nil
}
// Backwards compatibility: if Runtime is set directly, use it
if rigSettings != nil && rigSettings.Runtime != nil {
rc := rigSettings.Runtime
return fillRuntimeDefaults(rc)
}
// Load town settings for agent lookup
townSettings, err := LoadOrCreateTownSettings(TownSettingsPath(townRoot))
if err != nil {
townSettings = NewTownSettings()
}
// Load custom agent registry if it exists
_ = LoadAgentRegistry(DefaultAgentRegistryPath(townRoot))
// Determine which agent name to use
agentName := ""
if rigSettings != nil && rigSettings.Agent != "" {
agentName = rigSettings.Agent
} else if townSettings.DefaultAgent != "" {
agentName = townSettings.DefaultAgent
} else {
agentName = "claude" // ultimate fallback
}
// Look up the agent configuration
return lookupAgentConfig(agentName, townSettings)
}
// lookupAgentConfig looks up an agent by name.
// First checks town's custom agents, then built-in presets from agents.go.
func lookupAgentConfig(name string, townSettings *TownSettings) *RuntimeConfig {
// First 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 from agents.go
if preset := GetAgentPresetByName(name); preset != nil {
return RuntimeConfigFromPreset(AgentPreset(name))
}
// Fallback to claude defaults
return DefaultRuntimeConfig()
}
// fillRuntimeDefaults fills in default values for empty RuntimeConfig fields.
func fillRuntimeDefaults(rc *RuntimeConfig) *RuntimeConfig {
if rc == nil {
return DefaultRuntimeConfig()
}
// Create a copy to avoid modifying the original
result := &RuntimeConfig{
Command: rc.Command,
Args: rc.Args,
InitialPrompt: rc.InitialPrompt,
}
if result.Command == "" {
result.Command = "claude"
}
if result.Args == nil {
result.Args = []string{"--dangerously-skip-permissions"}
}
return result
}
// GetRuntimeCommand is a convenience function that returns the full command string
// for starting an LLM session. It loads the config and builds the command.
// for starting an LLM session. It resolves the agent config and builds the command.
func GetRuntimeCommand(rigPath string) string {
return LoadRuntimeConfig(rigPath).BuildCommand()
if rigPath == "" {
return DefaultRuntimeConfig().BuildCommand()
}
// Derive town root from rig path (rig is typically ~/gt/<rigname>)
townRoot := filepath.Dir(rigPath)
return ResolveAgentConfig(townRoot, rigPath).BuildCommand()
}
// GetRuntimeCommandWithPrompt returns the full command with an initial prompt.
func GetRuntimeCommandWithPrompt(rigPath, prompt string) string {
return LoadRuntimeConfig(rigPath).BuildCommandWithPrompt(prompt)
if rigPath == "" {
return DefaultRuntimeConfig().BuildCommandWithPrompt(prompt)
}
townRoot := filepath.Dir(rigPath)
return ResolveAgentConfig(townRoot, rigPath).BuildCommandWithPrompt(prompt)
}
// BuildStartupCommand builds a full startup command with environment exports.
@@ -680,7 +806,9 @@ func GetRuntimeCommandWithPrompt(rigPath, prompt string) string {
func BuildStartupCommand(envVars map[string]string, rigPath, prompt string) string {
var rc *RuntimeConfig
if rigPath != "" {
rc = LoadRuntimeConfig(rigPath)
// Derive town root from rig path
townRoot := filepath.Dir(rigPath)
rc = ResolveAgentConfig(townRoot, rigPath)
} else {
rc = DefaultRuntimeConfig()
}