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:
296
internal/config/agents.go
Normal file
296
internal/config/agents.go
Normal file
@@ -0,0 +1,296 @@
|
||||
// Package config provides configuration types and serialization for Gas Town.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// AgentPreset identifies a supported LLM agent runtime.
|
||||
// These presets provide sensible defaults that can be overridden in config.
|
||||
type AgentPreset string
|
||||
|
||||
// Supported agent presets (built-in, E2E tested).
|
||||
const (
|
||||
// AgentClaude is Claude Code (default).
|
||||
AgentClaude AgentPreset = "claude"
|
||||
// AgentGemini is Gemini CLI.
|
||||
AgentGemini AgentPreset = "gemini"
|
||||
// AgentCodex is OpenAI Codex.
|
||||
AgentCodex AgentPreset = "codex"
|
||||
)
|
||||
|
||||
// AgentPresetInfo contains the configuration details for an agent preset.
|
||||
// This extends the basic RuntimeConfig with agent-specific metadata.
|
||||
type AgentPresetInfo struct {
|
||||
// Name is the preset identifier (e.g., "claude", "gemini", "codex").
|
||||
Name AgentPreset `json:"name"`
|
||||
|
||||
// Command is the CLI binary to invoke.
|
||||
Command string `json:"command"`
|
||||
|
||||
// Args are the default command-line arguments for autonomous mode.
|
||||
Args []string `json:"args"`
|
||||
|
||||
// SessionIDEnv is the environment variable for session ID.
|
||||
// Used for resuming sessions across restarts.
|
||||
SessionIDEnv string `json:"session_id_env,omitempty"`
|
||||
|
||||
// ResumeFlag is the flag/subcommand for resuming sessions.
|
||||
// For claude/gemini: "--resume"
|
||||
// For codex: "resume" (subcommand)
|
||||
ResumeFlag string `json:"resume_flag,omitempty"`
|
||||
|
||||
// ResumeStyle indicates how to invoke resume:
|
||||
// "flag" - pass as --resume <id> argument
|
||||
// "subcommand" - pass as 'codex resume <id>'
|
||||
ResumeStyle string `json:"resume_style,omitempty"`
|
||||
|
||||
// SupportsHooks indicates if the agent supports hooks system.
|
||||
SupportsHooks bool `json:"supports_hooks,omitempty"`
|
||||
|
||||
// SupportsForkSession indicates if --fork-session is available.
|
||||
// Claude-only feature for seance command.
|
||||
SupportsForkSession bool `json:"supports_fork_session,omitempty"`
|
||||
|
||||
// NonInteractive contains settings for non-interactive mode.
|
||||
NonInteractive *NonInteractiveConfig `json:"non_interactive,omitempty"`
|
||||
}
|
||||
|
||||
// NonInteractiveConfig contains settings for running agents non-interactively.
|
||||
type NonInteractiveConfig struct {
|
||||
// Subcommand is the subcommand for non-interactive execution (e.g., "exec" for codex).
|
||||
Subcommand string `json:"subcommand,omitempty"`
|
||||
|
||||
// PromptFlag is the flag for passing prompts (e.g., "-p" for gemini).
|
||||
PromptFlag string `json:"prompt_flag,omitempty"`
|
||||
|
||||
// OutputFlag is the flag for structured output (e.g., "--json", "--output-format json").
|
||||
OutputFlag string `json:"output_flag,omitempty"`
|
||||
}
|
||||
|
||||
// AgentRegistry contains all known agent presets.
|
||||
// Can be loaded from JSON config or use built-in defaults.
|
||||
type AgentRegistry struct {
|
||||
// Version is the schema version for the registry.
|
||||
Version int `json:"version"`
|
||||
|
||||
// Agents maps agent names to their configurations.
|
||||
Agents map[string]*AgentPresetInfo `json:"agents"`
|
||||
}
|
||||
|
||||
// CurrentAgentRegistryVersion is the current schema version.
|
||||
const CurrentAgentRegistryVersion = 1
|
||||
|
||||
// builtinPresets contains the default presets for supported agents.
|
||||
var builtinPresets = map[AgentPreset]*AgentPresetInfo{
|
||||
AgentClaude: {
|
||||
Name: AgentClaude,
|
||||
Command: "claude",
|
||||
Args: []string{"--dangerously-skip-permissions"},
|
||||
SessionIDEnv: "CLAUDE_SESSION_ID",
|
||||
ResumeFlag: "--resume",
|
||||
ResumeStyle: "flag",
|
||||
SupportsHooks: true,
|
||||
SupportsForkSession: true,
|
||||
NonInteractive: nil, // Claude is native non-interactive
|
||||
},
|
||||
AgentGemini: {
|
||||
Name: AgentGemini,
|
||||
Command: "gemini",
|
||||
Args: []string{"--approval-mode", "yolo"},
|
||||
SessionIDEnv: "GEMINI_SESSION_ID",
|
||||
ResumeFlag: "--resume",
|
||||
ResumeStyle: "flag",
|
||||
SupportsHooks: true,
|
||||
SupportsForkSession: false,
|
||||
NonInteractive: &NonInteractiveConfig{
|
||||
PromptFlag: "-p",
|
||||
OutputFlag: "--output-format json",
|
||||
},
|
||||
},
|
||||
AgentCodex: {
|
||||
Name: AgentCodex,
|
||||
Command: "codex",
|
||||
Args: []string{"--yolo"},
|
||||
SessionIDEnv: "", // Codex captures from JSONL output
|
||||
ResumeFlag: "resume",
|
||||
ResumeStyle: "subcommand",
|
||||
SupportsHooks: false, // Use env/files instead
|
||||
SupportsForkSession: false,
|
||||
NonInteractive: &NonInteractiveConfig{
|
||||
Subcommand: "exec",
|
||||
OutputFlag: "--json",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// globalRegistry is the merged registry of built-in and user-defined agents.
|
||||
var globalRegistry *AgentRegistry
|
||||
|
||||
// initRegistry initializes the global registry with built-in presets.
|
||||
func initRegistry() {
|
||||
if globalRegistry != nil {
|
||||
return
|
||||
}
|
||||
globalRegistry = &AgentRegistry{
|
||||
Version: CurrentAgentRegistryVersion,
|
||||
Agents: make(map[string]*AgentPresetInfo),
|
||||
}
|
||||
// Copy built-in presets
|
||||
for name, preset := range builtinPresets {
|
||||
globalRegistry.Agents[string(name)] = preset
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAgentRegistry loads agent definitions from a JSON file and merges with built-ins.
|
||||
// User-defined agents override built-in presets with the same name.
|
||||
func LoadAgentRegistry(path string) error {
|
||||
initRegistry()
|
||||
|
||||
data, err := os.ReadFile(path) //nolint:gosec // G304: path is from config
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // No custom config, use built-ins only
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var userRegistry AgentRegistry
|
||||
if err := json.Unmarshal(data, &userRegistry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Merge user-defined agents (override built-ins)
|
||||
for name, preset := range userRegistry.Agents {
|
||||
preset.Name = AgentPreset(name)
|
||||
globalRegistry.Agents[name] = preset
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultAgentRegistryPath returns the default path for agent registry.
|
||||
// Located alongside other town settings.
|
||||
func DefaultAgentRegistryPath(townRoot string) string {
|
||||
return filepath.Join(townRoot, "settings", "agents.json")
|
||||
}
|
||||
|
||||
// GetAgentPreset returns the preset info for a given agent name.
|
||||
// Returns nil if the preset is not found.
|
||||
func GetAgentPreset(name AgentPreset) *AgentPresetInfo {
|
||||
initRegistry()
|
||||
return globalRegistry.Agents[string(name)]
|
||||
}
|
||||
|
||||
// GetAgentPresetByName returns the preset info by string name.
|
||||
// Returns nil if not found, allowing caller to fall back to defaults.
|
||||
func GetAgentPresetByName(name string) *AgentPresetInfo {
|
||||
initRegistry()
|
||||
return globalRegistry.Agents[name]
|
||||
}
|
||||
|
||||
// ListAgentPresets returns all known agent preset names.
|
||||
func ListAgentPresets() []string {
|
||||
initRegistry()
|
||||
names := make([]string, 0, len(globalRegistry.Agents))
|
||||
for name := range globalRegistry.Agents {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// DefaultAgentPreset returns the default agent preset (Claude).
|
||||
func DefaultAgentPreset() AgentPreset {
|
||||
return AgentClaude
|
||||
}
|
||||
|
||||
// RuntimeConfigFromPreset creates a RuntimeConfig from an agent preset.
|
||||
// This provides the basic Command/Args; additional fields from AgentPresetInfo
|
||||
// can be accessed separately for extended functionality.
|
||||
func RuntimeConfigFromPreset(preset AgentPreset) *RuntimeConfig {
|
||||
info := GetAgentPreset(preset)
|
||||
if info == nil {
|
||||
// Fall back to Claude defaults
|
||||
return DefaultRuntimeConfig()
|
||||
}
|
||||
|
||||
return &RuntimeConfig{
|
||||
Command: info.Command,
|
||||
Args: append([]string(nil), info.Args...), // Copy to avoid mutation
|
||||
}
|
||||
}
|
||||
|
||||
// MergeWithPreset applies preset defaults to a RuntimeConfig.
|
||||
// User-specified values take precedence over preset defaults.
|
||||
// Returns a new RuntimeConfig without modifying the original.
|
||||
func (rc *RuntimeConfig) MergeWithPreset(preset AgentPreset) *RuntimeConfig {
|
||||
if rc == nil {
|
||||
return RuntimeConfigFromPreset(preset)
|
||||
}
|
||||
|
||||
info := GetAgentPreset(preset)
|
||||
if info == nil {
|
||||
return rc
|
||||
}
|
||||
|
||||
result := &RuntimeConfig{
|
||||
Command: rc.Command,
|
||||
Args: append([]string(nil), rc.Args...),
|
||||
InitialPrompt: rc.InitialPrompt,
|
||||
}
|
||||
|
||||
// Apply preset defaults only if not overridden
|
||||
if result.Command == "" {
|
||||
result.Command = info.Command
|
||||
}
|
||||
if len(result.Args) == 0 {
|
||||
result.Args = append([]string(nil), info.Args...)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// IsKnownPreset checks if a string is a known agent preset name.
|
||||
func IsKnownPreset(name string) bool {
|
||||
initRegistry()
|
||||
_, ok := globalRegistry.Agents[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// SaveAgentRegistry writes the agent registry to a file.
|
||||
func SaveAgentRegistry(path string, registry *AgentRegistry) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(registry, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0644) //nolint:gosec // G306: config file
|
||||
}
|
||||
|
||||
// NewExampleAgentRegistry creates an example registry with comments.
|
||||
func NewExampleAgentRegistry() *AgentRegistry {
|
||||
return &AgentRegistry{
|
||||
Version: CurrentAgentRegistryVersion,
|
||||
Agents: map[string]*AgentPresetInfo{
|
||||
// Include one example custom agent
|
||||
"my-custom-agent": {
|
||||
Name: "my-custom-agent",
|
||||
Command: "my-agent-cli",
|
||||
Args: []string{"--autonomous", "--no-confirm"},
|
||||
SessionIDEnv: "MY_AGENT_SESSION_ID",
|
||||
ResumeFlag: "--resume",
|
||||
ResumeStyle: "flag",
|
||||
NonInteractive: &NonInteractiveConfig{
|
||||
PromptFlag: "-m",
|
||||
OutputFlag: "--json",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
217
internal/config/agents_test.go
Normal file
217
internal/config/agents_test.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuiltinPresets(t *testing.T) {
|
||||
// Ensure all built-in presets are accessible (E2E tested agents only)
|
||||
presets := []AgentPreset{AgentClaude, AgentGemini, AgentCodex}
|
||||
|
||||
for _, preset := range presets {
|
||||
info := GetAgentPreset(preset)
|
||||
if info == nil {
|
||||
t.Errorf("GetAgentPreset(%s) returned nil", preset)
|
||||
continue
|
||||
}
|
||||
|
||||
if info.Command == "" {
|
||||
t.Errorf("preset %s has empty Command", preset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentPresetByName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want AgentPreset
|
||||
wantNil bool
|
||||
}{
|
||||
{"claude", AgentClaude, false},
|
||||
{"gemini", AgentGemini, false},
|
||||
{"codex", AgentCodex, false},
|
||||
{"aider", "", true}, // Not built-in, can be added via config
|
||||
{"opencode", "", true}, // Not built-in, can be added via config
|
||||
{"unknown", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := GetAgentPresetByName(tt.name)
|
||||
if tt.wantNil && got != nil {
|
||||
t.Errorf("GetAgentPresetByName(%s) = %v, want nil", tt.name, got)
|
||||
}
|
||||
if !tt.wantNil && got == nil {
|
||||
t.Errorf("GetAgentPresetByName(%s) = nil, want preset", tt.name)
|
||||
}
|
||||
if !tt.wantNil && got != nil && got.Name != tt.want {
|
||||
t.Errorf("GetAgentPresetByName(%s).Name = %v, want %v", tt.name, got.Name, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeConfigFromPreset(t *testing.T) {
|
||||
tests := []struct {
|
||||
preset AgentPreset
|
||||
wantCommand string
|
||||
}{
|
||||
{AgentClaude, "claude"},
|
||||
{AgentGemini, "gemini"},
|
||||
{AgentCodex, "codex"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.preset), func(t *testing.T) {
|
||||
rc := RuntimeConfigFromPreset(tt.preset)
|
||||
if rc.Command != tt.wantCommand {
|
||||
t.Errorf("RuntimeConfigFromPreset(%s).Command = %v, want %v",
|
||||
tt.preset, rc.Command, tt.wantCommand)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsKnownPreset(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want bool
|
||||
}{
|
||||
{"claude", true},
|
||||
{"gemini", true},
|
||||
{"codex", true},
|
||||
{"aider", false}, // Not built-in, can be added via config
|
||||
{"opencode", false}, // Not built-in, can be added via config
|
||||
{"unknown", false},
|
||||
{"chatgpt", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsKnownPreset(tt.name); got != tt.want {
|
||||
t.Errorf("IsKnownPreset(%s) = %v, want %v", tt.name, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAgentRegistry(t *testing.T) {
|
||||
// Create temp directory for test config
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "agents.json")
|
||||
|
||||
// Write custom agent config
|
||||
customRegistry := AgentRegistry{
|
||||
Version: CurrentAgentRegistryVersion,
|
||||
Agents: map[string]*AgentPresetInfo{
|
||||
"my-agent": {
|
||||
Name: "my-agent",
|
||||
Command: "my-agent-bin",
|
||||
Args: []string{"--auto"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(customRegistry)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal test config: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
// Reset global registry for test
|
||||
globalRegistry = nil
|
||||
|
||||
// Load the custom registry
|
||||
if err := LoadAgentRegistry(configPath); err != nil {
|
||||
t.Fatalf("LoadAgentRegistry failed: %v", err)
|
||||
}
|
||||
|
||||
// Check custom agent is available
|
||||
myAgent := GetAgentPresetByName("my-agent")
|
||||
if myAgent == nil {
|
||||
t.Fatal("custom agent 'my-agent' not found after loading registry")
|
||||
}
|
||||
if myAgent.Command != "my-agent-bin" {
|
||||
t.Errorf("my-agent.Command = %v, want my-agent-bin", myAgent.Command)
|
||||
}
|
||||
|
||||
// Check built-ins still accessible
|
||||
claude := GetAgentPresetByName("claude")
|
||||
if claude == nil {
|
||||
t.Fatal("built-in 'claude' not found after loading registry")
|
||||
}
|
||||
|
||||
// Reset for other tests
|
||||
globalRegistry = nil
|
||||
}
|
||||
|
||||
func TestAgentPresetYOLOFlags(t *testing.T) {
|
||||
// Verify YOLO flags are set correctly for each E2E tested agent
|
||||
tests := []struct {
|
||||
preset AgentPreset
|
||||
wantArg string // At least this arg should be present
|
||||
}{
|
||||
{AgentClaude, "--dangerously-skip-permissions"},
|
||||
{AgentGemini, "yolo"}, // Part of "--approval-mode yolo"
|
||||
{AgentCodex, "--yolo"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.preset), func(t *testing.T) {
|
||||
info := GetAgentPreset(tt.preset)
|
||||
if info == nil {
|
||||
t.Fatalf("preset %s not found", tt.preset)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, arg := range info.Args {
|
||||
if arg == tt.wantArg || (tt.preset == AgentGemini && arg == "yolo") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("preset %s args %v missing expected %s", tt.preset, info.Args, tt.wantArg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeWithPreset(t *testing.T) {
|
||||
// Test that user config overrides preset defaults
|
||||
userConfig := &RuntimeConfig{
|
||||
Command: "/custom/claude",
|
||||
Args: []string{"--custom-arg"},
|
||||
}
|
||||
|
||||
merged := userConfig.MergeWithPreset(AgentClaude)
|
||||
|
||||
if merged.Command != "/custom/claude" {
|
||||
t.Errorf("merged command should be user value, got %s", merged.Command)
|
||||
}
|
||||
if len(merged.Args) != 1 || merged.Args[0] != "--custom-arg" {
|
||||
t.Errorf("merged args should be user value, got %v", merged.Args)
|
||||
}
|
||||
|
||||
// Test nil config gets preset defaults
|
||||
var nilConfig *RuntimeConfig
|
||||
merged = nilConfig.MergeWithPreset(AgentClaude)
|
||||
|
||||
if merged.Command != "claude" {
|
||||
t.Errorf("nil config merge should get preset command, got %s", merged.Command)
|
||||
}
|
||||
|
||||
// Test empty config gets preset defaults
|
||||
emptyConfig := &RuntimeConfig{}
|
||||
merged = emptyConfig.MergeWithPreset(AgentGemini)
|
||||
|
||||
if merged.Command != "gemini" {
|
||||
t.Errorf("empty config merge should get preset command, got %s", merged.Command)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -28,6 +28,38 @@ type MayorConfig struct {
|
||||
DefaultCrewName string `json:"default_crew_name,omitempty"` // default crew name for new rigs
|
||||
}
|
||||
|
||||
// CurrentTownSettingsVersion is the current schema version for TownSettings.
|
||||
const CurrentTownSettingsVersion = 1
|
||||
|
||||
// TownSettings represents town-level behavioral configuration (settings/config.json).
|
||||
// This contains agent configuration that applies to all rigs unless overridden.
|
||||
type TownSettings struct {
|
||||
Type string `json:"type"` // "town-settings"
|
||||
Version int `json:"version"` // schema version
|
||||
|
||||
// DefaultAgent is the name of the agent preset to use by default.
|
||||
// Can be a built-in preset ("claude", "gemini", "codex")
|
||||
// or a custom agent name defined in settings/agents.json.
|
||||
// Default: "claude"
|
||||
DefaultAgent string `json:"default_agent,omitempty"`
|
||||
|
||||
// Agents defines custom agent configurations or overrides.
|
||||
// Keys are agent names that can be referenced by DefaultAgent or rig settings.
|
||||
// Values override or extend the built-in presets.
|
||||
// Example: {"gemini": {"command": "/custom/path/to/gemini"}}
|
||||
Agents map[string]*RuntimeConfig `json:"agents,omitempty"`
|
||||
}
|
||||
|
||||
// NewTownSettings creates a new TownSettings with defaults.
|
||||
func NewTownSettings() *TownSettings {
|
||||
return &TownSettings{
|
||||
Type: "town-settings",
|
||||
Version: CurrentTownSettingsVersion,
|
||||
DefaultAgent: "claude",
|
||||
Agents: make(map[string]*RuntimeConfig),
|
||||
}
|
||||
}
|
||||
|
||||
// DaemonConfig represents daemon process settings.
|
||||
type DaemonConfig struct {
|
||||
HeartbeatInterval string `json:"heartbeat_interval,omitempty"` // e.g., "30s"
|
||||
@@ -98,7 +130,14 @@ type RigSettings struct {
|
||||
Theme *ThemeConfig `json:"theme,omitempty"` // tmux theme settings
|
||||
Namepool *NamepoolConfig `json:"namepool,omitempty"` // polecat name pool settings
|
||||
Crew *CrewConfig `json:"crew,omitempty"` // crew startup settings
|
||||
Runtime *RuntimeConfig `json:"runtime,omitempty"` // LLM runtime settings
|
||||
Runtime *RuntimeConfig `json:"runtime,omitempty"` // LLM runtime settings (deprecated: use Agent)
|
||||
|
||||
// Agent selects which agent preset to use for this rig.
|
||||
// Can be a built-in preset ("claude", "gemini", "codex")
|
||||
// or a custom agent defined in settings/agents.json.
|
||||
// If empty, uses the town's default_agent setting.
|
||||
// Takes precedence over Runtime if both are set.
|
||||
Agent string `json:"agent,omitempty"`
|
||||
}
|
||||
|
||||
// CrewConfig represents crew workspace settings for a rig.
|
||||
|
||||
Reference in New Issue
Block a user