feat: Add rig-level custom agent support (#12)

* feat: Add rig-level custom agent support

Implement rig-level custom agent configuration support to enable per-rig
agent definitions in <rig>/settings/config.json, following the same pattern as
town-level agents in settings/config.json.

Changes:
- Added RigSettings.Agents field to internal/config/types.go
- Added DefaultRigAgentRegistryPath() and LoadRigAgentRegistry() functions to internal/config/agents.go
- Updated ResolveAgentConfigWithOverride() to accept and pass rigSettings parameter
- Updated GetRuntimeCommandWithAgentOverride() to use rigSettings when available
- Updated GetRuntimeCommandWithPromptAndAgentOverride() to use rigSettings
- Updated all Build*WithOverride functions to pass rigSettings

This fixes the issue where rig-level agent settings were loaded but
ignored by lookupAgentConfig, enabling per-rig custom agents for
polecats and crew members.

* test: Add rig-level custom agent tests

Added comprehensive unit tests for rig agent registry functions:
- TestDefaultRigAgentRegistryPath: verifies path construction
- TestLoadRigAgentRegistry: verifies file loading and JSON parsing
- TestLookupAgentConfigWithRigSettings: verifies agent lookup priority (rig > town > builtin)

Added placeholder integration test for future CI/CD setup.

* initial commit

* fix: resolve compilation errors in rig-level custom agent support

- Add missing RigAgentRegistryPath function (alias for DefaultRigAgentRegistryPath)
- Restore ResolveAgentConfigWithOverride function that was incorrectly removed
- Fix ResolveAgentConfig to return single value (not triple)
- Add initRegistryLocked() call to LoadRigAgentRegistry to prevent nil panic
- Fix DefaultRigAgentRegistryPath to use rigPath directly (not parent dir)
- Fix test file syntax errors (remove EOF artifacts)
- Fix test parameter order for lookupAgentConfig calls
- Fix test expectations to match correct custom agent override behavior

* test: implement rig-level custom agent integration test

- Add stub agent script that simulates AI agent with Q&A capability
- Test ResolveAgentConfig correctly picks up rig-level agents
- Test BuildPolecatStartupCommand includes custom agent command
- Test ResolveAgentConfigWithOverride respects rig agents
- Test rig agents override town agents with same name
- Add tmux integration test that spawns session and verifies output
- Stub agent echoes 'STUB_AGENT_STARTED' and handles ping/pong Q&A
- All tests pass including real tmux session verification

* docs: add OpenCode custom agent example to reference

- Show settings/agents.json format for advanced configs
- Include OpenCode example with session resume flags
- Document OPENCODE_PERMISSION env var for autonomous mode

* fix: improve rig-level agent support with docs and test fixes

- Add rig-level agent documentation to reference.md
- Document agent resolution order (rig → town → built-in)
- Deduplicate LoadAgentRegistry/LoadRigAgentRegistry into shared helper
- Fix test isolation in TestLoadRigAgentRegistry
- Fix nil pointer dereference in test assertions (use t.Fatal not t.Error)
This commit is contained in:
Subhrajit Makur
2026-01-08 01:23:29 +05:30
committed by Steve Yegge
parent 2de2d6b7e4
commit 00a59dec44
7 changed files with 746 additions and 20 deletions

View File

@@ -361,13 +361,54 @@ gt config default-agent [name] # Get or set town default agent
**Built-in agents**: `claude`, `gemini`, `codex`, `cursor`, `auggie`, `amp`
**Custom agents**: Define per-town in `mayor/town.json`:
**Custom agents**: Define per-town via CLI or JSON:
```bash
gt config agent set claude-glm "claude-glm --model glm-4"
gt config agent set claude "claude-opus" # Override built-in
gt config default-agent claude-glm # Set default
```
**Advanced agent config** (`settings/agents.json`):
```json
{
"version": 1,
"agents": {
"opencode": {
"command": "opencode",
"args": [],
"resume_flag": "--session",
"resume_style": "flag",
"non_interactive": {
"subcommand": "run",
"output_flag": "--format json"
}
}
}
}
```
**Rig-level agents** (`<rig>/settings/config.json`):
```json
{
"type": "rig-settings",
"version": 1,
"agent": "opencode",
"agents": {
"opencode": {
"command": "opencode",
"args": ["--session"]
}
}
}
```
**Agent resolution order**: rig-level → town-level → built-in presets.
For OpenCode autonomous mode, set env var in your shell profile:
```bash
export OPENCODE_PERMISSION='{"*":"allow"}'
```
### Rig Management
```bash

View File

@@ -215,16 +215,11 @@ func ensureRegistry() {
initRegistryLocked()
}
// LoadAgentRegistry loads agent definitions from a JSON file and merges with built-ins.
// User-defined agents override built-in presets with the same name.
// This function caches loaded paths to avoid redundant file reads.
func LoadAgentRegistry(path string) error {
registryMu.Lock()
defer registryMu.Unlock()
// loadAgentRegistryFromPath loads agent definitions from a JSON file and merges with built-ins.
// Caller must hold registryMu write lock.
func loadAgentRegistryFromPathLocked(path string) error {
initRegistryLocked()
// Check if already loaded from this path
if loadedPaths[path] {
return nil
}
@@ -232,8 +227,8 @@ func LoadAgentRegistry(path string) error {
data, err := os.ReadFile(path) //nolint:gosec // G304: path is from config
if err != nil {
if os.IsNotExist(err) {
loadedPaths[path] = true // Mark as "loaded" (no file)
return nil // No custom config, use built-ins only
loadedPaths[path] = true
return nil
}
return err
}
@@ -243,7 +238,6 @@ func LoadAgentRegistry(path string) error {
return err
}
// Merge user-defined agents (override built-ins)
for name, preset := range userRegistry.Agents {
preset.Name = AgentPreset(name)
globalRegistry.Agents[name] = preset
@@ -253,12 +247,41 @@ func LoadAgentRegistry(path string) error {
return nil
}
// LoadAgentRegistry loads agent definitions from a JSON file and merges with built-ins.
// User-defined agents override built-in presets with the same name.
// This function caches loaded paths to avoid redundant file reads.
func LoadAgentRegistry(path string) error {
registryMu.Lock()
defer registryMu.Unlock()
return loadAgentRegistryFromPathLocked(path)
}
// 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")
}
// DefaultRigAgentRegistryPath returns the default path for rig-level agent registry.
// Located in <rig>/settings/agents.json.
func DefaultRigAgentRegistryPath(rigPath string) string {
return filepath.Join(rigPath, "settings", "agents.json")
}
// RigAgentRegistryPath returns the path for rig-level agent registry.
// Alias for DefaultRigAgentRegistryPath for consistency with other path functions.
func RigAgentRegistryPath(rigPath string) string {
return DefaultRigAgentRegistryPath(rigPath)
}
// LoadRigAgentRegistry loads agent definitions from a rig-level JSON file and merges with built-ins.
// This function works similarly to LoadAgentRegistry but for rig-level configurations.
func LoadRigAgentRegistry(path string) error {
registryMu.Lock()
defer registryMu.Unlock()
return loadAgentRegistryFromPathLocked(path)
}
// GetAgentPreset returns the preset info for a given agent name.
// Returns nil if the preset is not found.
func GetAgentPreset(name AgentPreset) *AgentPresetInfo {

View File

@@ -142,7 +142,7 @@ func TestLoadAgentRegistry(t *testing.T) {
// Reset global registry for test isolation
ResetRegistryForTesting()
// Load the custom registry
// Load should succeed
if err := LoadAgentRegistry(configPath); err != nil {
t.Fatalf("LoadAgentRegistry failed: %v", err)
}
@@ -152,6 +152,7 @@ func TestLoadAgentRegistry(t *testing.T) {
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)
}
@@ -210,6 +211,7 @@ func TestMergeWithPreset(t *testing.T) {
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)
}
@@ -265,12 +267,14 @@ func TestBuildResumeCommand(t *testing.T) {
agentName: "claude",
sessionID: "",
wantEmpty: true,
contains: []string{"claude"},
},
{
name: "unknown agent",
agentName: "unknown-agent",
sessionID: "session-123",
wantEmpty: true,
contains: []string{},
},
}
@@ -502,3 +506,112 @@ func TestCursorAgentPreset(t *testing.T) {
t.Errorf("cursor ResumeStyle = %q, want flag", info.ResumeStyle)
}
}
// TestDefaultRigAgentRegistryPath verifies that the default rig agent registry path is constructed correctly.
func TestDefaultRigAgentRegistryPath(t *testing.T) {
tests := []struct {
rigPath string
expectedPath string
}{
{"/Users/alice/gt/myproject", "/Users/alice/gt/myproject/settings/agents.json"},
{"/tmp/my-rig", "/tmp/my-rig/settings/agents.json"},
{"relative/path", "relative/path/settings/agents.json"},
}
for _, tt := range tests {
t.Run(tt.rigPath, func(t *testing.T) {
got := DefaultRigAgentRegistryPath(tt.rigPath)
want := tt.expectedPath
if got != want {
t.Errorf("DefaultRigAgentRegistryPath(%s) = %s, want %s", tt.rigPath, got, want)
}
})
}
}
// TestLoadRigAgentRegistry verifies that rig-level agent registry is loaded correctly.
func TestLoadRigAgentRegistry(t *testing.T) {
// Reset registry for test isolation
ResetRegistryForTesting()
t.Cleanup(ResetRegistryForTesting)
tmpDir := t.TempDir()
registryPath := filepath.Join(tmpDir, "settings", "agents.json")
configDir := filepath.Join(tmpDir, "settings")
// Create settings directory
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatalf("failed to create settings dir: %v", err)
}
// Write agent registry
registryContent := `{
"version": 1,
"agents": {
"opencode": {
"command": "opencode",
"args": ["--session"],
"non_interactive": {
"subcommand": "run",
"output_flag": "--format json"
}
}
}
}`
if err := os.WriteFile(registryPath, []byte(registryContent), 0644); err != nil {
t.Fatalf("failed to write registry file: %v", err)
}
// Test 1: Load should succeed and merge agents
t.Run("load and merge", func(t *testing.T) {
if err := LoadRigAgentRegistry(registryPath); err != nil {
t.Fatalf("LoadRigAgentRegistry(%s) failed: %v", registryPath, err)
}
info := GetAgentPresetByName("opencode")
if info == nil {
t.Fatal("expected opencode agent to be available after loading rig registry")
}
if info.Command != "opencode" {
t.Errorf("expected opencode agent command to be 'opencode', got %s", info.Command)
}
})
// Test 2: File not found should return nil (no error)
t.Run("file not found", func(t *testing.T) {
nonExistentPath := filepath.Join(tmpDir, "other-rig", "settings", "agents.json")
if err := LoadRigAgentRegistry(nonExistentPath); err != nil {
t.Errorf("LoadRigAgentRegistry(%s) should not error for non-existent file: %v", nonExistentPath, err)
}
// Verify that previously loaded agent (from test 1) is still available
info := GetAgentPresetByName("opencode")
if info == nil {
t.Errorf("expected opencode agent to still be available after loading non-existent path")
return
}
if info.Command != "opencode" {
t.Errorf("expected opencode agent command to be 'opencode', got %s", info.Command)
}
})
// Test 3: Invalid JSON should error
t.Run("invalid JSON", func(t *testing.T) {
invalidRegistryPath := filepath.Join(tmpDir, "bad-rig", "settings", "agents.json")
badConfigDir := filepath.Join(tmpDir, "bad-rig", "settings")
if err := os.MkdirAll(badConfigDir, 0755); err != nil {
t.Fatalf("failed to create bad-rig settings dir: %v", err)
}
invalidContent := `{"version": 1, "agents": {invalid json}}`
if err := os.WriteFile(invalidRegistryPath, []byte(invalidContent), 0644); err != nil {
t.Fatalf("failed to write invalid registry file: %v", err)
}
if err := LoadRigAgentRegistry(invalidRegistryPath); err == nil {
t.Errorf("LoadRigAgentRegistry(%s) should error for invalid JSON: got nil", invalidRegistryPath)
}
})
}

View File

@@ -0,0 +1,433 @@
// Test Rig-Level Custom Agent Support
//
// This integration test verifies that custom agents defined in rig-level
// settings/config.json are correctly loaded and used when spawning polecats.
// It creates a stub agent, configures it at the rig level, and verifies
// the agent is actually used via tmux session capture.
package config
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// TestRigLevelCustomAgentIntegration tests end-to-end rig-level custom agent functionality.
// This test:
// 1. Creates a stub agent script that echoes identifiable output
// 2. Sets up a minimal town/rig with the custom agent configured
// 3. Verifies that BuildPolecatStartupCommand uses the custom agent
// 4. Optionally spawns a tmux session and verifies output (if tmux available)
func TestRigLevelCustomAgentIntegration(t *testing.T) {
tmpDir := t.TempDir()
// Create the stub agent script
stubAgentPath := createStubAgent(t, tmpDir)
// Set up town structure
townRoot := filepath.Join(tmpDir, "town")
rigName := "testrig"
rigPath := filepath.Join(townRoot, rigName)
setupTestTownWithCustomAgent(t, townRoot, rigName, stubAgentPath)
// Test 1: Verify ResolveAgentConfig picks up the custom agent
t.Run("ResolveAgentConfig uses rig-level agent", func(t *testing.T) {
rc := ResolveAgentConfig(townRoot, rigPath)
if rc == nil {
t.Fatal("ResolveAgentConfig returned nil")
}
if rc.Command != stubAgentPath {
t.Errorf("Expected command %q, got %q", stubAgentPath, rc.Command)
}
// Verify args are passed through
if len(rc.Args) != 2 || rc.Args[0] != "--test-mode" || rc.Args[1] != "--stub" {
t.Errorf("Expected args [--test-mode --stub], got %v", rc.Args)
}
})
// Test 2: Verify BuildPolecatStartupCommand includes the custom agent
t.Run("BuildPolecatStartupCommand uses custom agent", func(t *testing.T) {
cmd := BuildPolecatStartupCommand(rigName, "test-polecat", rigPath, "")
if !strings.Contains(cmd, stubAgentPath) {
t.Errorf("Expected command to contain stub agent path %q, got: %s", stubAgentPath, cmd)
}
if !strings.Contains(cmd, "--test-mode") {
t.Errorf("Expected command to contain --test-mode, got: %s", cmd)
}
// Verify environment variables are set
if !strings.Contains(cmd, "GT_ROLE=polecat") {
t.Errorf("Expected GT_ROLE=polecat in command, got: %s", cmd)
}
if !strings.Contains(cmd, "GT_POLECAT=test-polecat") {
t.Errorf("Expected GT_POLECAT=test-polecat in command, got: %s", cmd)
}
})
// Test 3: Verify ResolveAgentConfigWithOverride respects rig agents
t.Run("ResolveAgentConfigWithOverride with rig agent", func(t *testing.T) {
rc, agentName, err := ResolveAgentConfigWithOverride(townRoot, rigPath, "stub-agent")
if err != nil {
t.Fatalf("ResolveAgentConfigWithOverride failed: %v", err)
}
if agentName != "stub-agent" {
t.Errorf("Expected agent name 'stub-agent', got %q", agentName)
}
if rc.Command != stubAgentPath {
t.Errorf("Expected command %q, got %q", stubAgentPath, rc.Command)
}
})
// Test 4: Verify unknown agent override returns error
t.Run("ResolveAgentConfigWithOverride unknown agent errors", func(t *testing.T) {
_, _, err := ResolveAgentConfigWithOverride(townRoot, rigPath, "nonexistent-agent")
if err == nil {
t.Fatal("Expected error for nonexistent agent, got nil")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("Expected 'not found' error, got: %v", err)
}
})
// Test 5: Tmux integration (skip if tmux not available)
t.Run("TmuxSessionWithCustomAgent", func(t *testing.T) {
if _, err := exec.LookPath("tmux"); err != nil {
t.Skip("tmux not available, skipping session test")
}
testTmuxSessionWithStubAgent(t, tmpDir, stubAgentPath, rigName)
})
}
// createStubAgent creates a bash script that simulates an AI agent.
// The script echoes identifiable output and handles simple Q&A.
func createStubAgent(t *testing.T, tmpDir string) string {
t.Helper()
stubScript := `#!/bin/bash
# Stub Agent for Integration Testing
# This simulates an AI agent with identifiable output
AGENT_NAME="STUB_AGENT"
AGENT_VERSION="1.0.0"
echo "=========================================="
echo "STUB_AGENT_STARTED"
echo "Agent: $AGENT_NAME v$AGENT_VERSION"
echo "Args: $@"
echo "Working Dir: $(pwd)"
echo "GT_ROLE: ${GT_ROLE:-not_set}"
echo "GT_POLECAT: ${GT_POLECAT:-not_set}"
echo "GT_RIG: ${GT_RIG:-not_set}"
echo "=========================================="
# Simple Q&A loop
while true; do
echo ""
echo "STUB_AGENT_READY"
echo "Enter question (or 'exit' to quit):"
# Read with timeout for non-interactive testing
if read -t 5 question; then
case "$question" in
"exit"|"quit"|"q")
echo "STUB_AGENT_EXITING"
exit 0
;;
"what is 2+2"*)
echo "STUB_AGENT_ANSWER: 4"
;;
"ping"*)
echo "STUB_AGENT_ANSWER: pong"
;;
"status"*)
echo "STUB_AGENT_ANSWER: operational"
;;
*)
echo "STUB_AGENT_ANSWER: I received your question: $question"
;;
esac
else
# Timeout - check if we should exit
if [ -f "/tmp/stub_agent_stop_$$" ]; then
echo "STUB_AGENT_STOPPING (signal file detected)"
rm -f "/tmp/stub_agent_stop_$$"
exit 0
fi
fi
done
`
stubPath := filepath.Join(tmpDir, "stub-agent")
if err := os.WriteFile(stubPath, []byte(stubScript), 0755); err != nil {
t.Fatalf("Failed to create stub agent: %v", err)
}
return stubPath
}
// setupTestTownWithCustomAgent creates a minimal town/rig structure with a custom agent.
func setupTestTownWithCustomAgent(t *testing.T, townRoot, rigName, stubAgentPath string) {
t.Helper()
rigPath := filepath.Join(townRoot, rigName)
// Create directory structure
dirs := []string{
filepath.Join(townRoot, "mayor"),
filepath.Join(townRoot, "settings"),
filepath.Join(rigPath, "settings"),
filepath.Join(rigPath, "polecats"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("Failed to create directory %s: %v", dir, err)
}
}
// Create town.json
townConfig := map[string]interface{}{
"type": "town",
"version": 2,
"name": "test-town",
"created_at": time.Now().Format(time.RFC3339),
}
writeTownJSON(t, filepath.Join(townRoot, "mayor", "town.json"), townConfig)
// Create town settings (empty, uses defaults)
townSettings := map[string]interface{}{
"type": "town-settings",
"version": 1,
"default_agent": "claude",
}
writeTownJSON(t, filepath.Join(townRoot, "settings", "config.json"), townSettings)
// Create rig settings with custom agent
rigSettings := map[string]interface{}{
"type": "rig-settings",
"version": 1,
"agent": "stub-agent",
"agents": map[string]interface{}{
"stub-agent": map[string]interface{}{
"command": stubAgentPath,
"args": []string{"--test-mode", "--stub"},
},
},
}
writeTownJSON(t, filepath.Join(rigPath, "settings", "config.json"), rigSettings)
// Create rigs.json
rigsConfig := map[string]interface{}{
"version": 1,
"rigs": map[string]interface{}{
rigName: map[string]interface{}{
"git_url": "https://github.com/test/testrepo.git",
"added_at": time.Now().Format(time.RFC3339),
},
},
}
writeTownJSON(t, filepath.Join(townRoot, "mayor", "rigs.json"), rigsConfig)
}
// writeTownJSON writes a JSON config file.
func writeTownJSON(t *testing.T, path string, data interface{}) {
t.Helper()
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
t.Fatalf("Failed to marshal JSON for %s: %v", path, err)
}
if err := os.WriteFile(path, jsonData, 0644); err != nil {
t.Fatalf("Failed to write %s: %v", path, err)
}
}
// testTmuxSessionWithStubAgent tests that a tmux session runs the stub agent.
func testTmuxSessionWithStubAgent(t *testing.T, tmpDir, stubAgentPath, rigName string) {
t.Helper()
sessionName := fmt.Sprintf("gt-test-%d", time.Now().UnixNano())
workDir := tmpDir
// Cleanup session on exit
defer func() {
exec.Command("tmux", "kill-session", "-t", sessionName).Run()
}()
// Create tmux session
cmd := exec.Command("tmux", "new-session", "-d", "-s", sessionName, "-c", workDir)
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to create tmux session: %v", err)
}
// Set environment variables
envVars := map[string]string{
"GT_ROLE": "polecat",
"GT_POLECAT": "test-polecat",
"GT_RIG": rigName,
}
for key, val := range envVars {
cmd := exec.Command("tmux", "set-environment", "-t", sessionName, key, val)
if err := cmd.Run(); err != nil {
t.Logf("Warning: failed to set %s: %v", key, err)
}
}
// Send the stub agent command
agentCmd := fmt.Sprintf("%s --test-mode --stub", stubAgentPath)
cmd = exec.Command("tmux", "send-keys", "-t", sessionName, agentCmd, "Enter")
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to send keys: %v", err)
}
// Wait for agent to start
time.Sleep(2 * time.Second)
// Capture pane output
output := captureTmuxPane(t, sessionName, 50)
// Verify stub agent started
if !strings.Contains(output, "STUB_AGENT_STARTED") {
t.Errorf("Expected STUB_AGENT_STARTED in output, got:\n%s", output)
}
// Verify environment variables were visible to agent
if !strings.Contains(output, "GT_ROLE: polecat") {
t.Logf("Warning: GT_ROLE not visible in agent output (tmux env may not propagate to subshell)")
}
// Send a question and verify response
cmd = exec.Command("tmux", "send-keys", "-t", sessionName, "ping", "Enter")
if err := cmd.Run(); err != nil {
t.Fatalf("Failed to send ping: %v", err)
}
time.Sleep(1 * time.Second)
output = captureTmuxPane(t, sessionName, 50)
if !strings.Contains(output, "STUB_AGENT_ANSWER: pong") {
t.Errorf("Expected 'pong' response, got:\n%s", output)
}
// Send exit command
cmd = exec.Command("tmux", "send-keys", "-t", sessionName, "exit", "Enter")
if err := cmd.Run(); err != nil {
t.Logf("Warning: failed to send exit: %v", err)
}
time.Sleep(500 * time.Millisecond)
// Final capture to verify clean exit
output = captureTmuxPane(t, sessionName, 50)
if !strings.Contains(output, "STUB_AGENT_EXITING") {
t.Logf("Note: Agent may have exited before capture. Output:\n%s", output)
}
t.Logf("Tmux session test completed successfully")
}
// captureTmuxPane captures the output from a tmux pane.
func captureTmuxPane(t *testing.T, sessionName string, lines int) string {
t.Helper()
cmd := exec.Command("tmux", "capture-pane", "-t", sessionName, "-p", "-S", fmt.Sprintf("-%d", lines))
output, err := cmd.Output()
if err != nil {
t.Logf("Warning: failed to capture pane: %v", err)
return ""
}
return string(output)
}
// TestRigAgentOverridesTownAgent verifies rig agents take precedence over town agents.
func TestRigAgentOverridesTownAgent(t *testing.T) {
tmpDir := t.TempDir()
townRoot := filepath.Join(tmpDir, "town")
rigName := "testrig"
rigPath := filepath.Join(townRoot, rigName)
// Create directory structure
dirs := []string{
filepath.Join(townRoot, "mayor"),
filepath.Join(townRoot, "settings"),
filepath.Join(rigPath, "settings"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("Failed to create directory %s: %v", dir, err)
}
}
// Town settings with a custom agent
townSettings := map[string]interface{}{
"type": "town-settings",
"version": 1,
"default_agent": "my-agent",
"agents": map[string]interface{}{
"my-agent": map[string]interface{}{
"command": "/town/path/to/agent",
"args": []string{"--town-level"},
},
},
}
writeTownJSON(t, filepath.Join(townRoot, "settings", "config.json"), townSettings)
// Rig settings with SAME agent name but different config (should override)
rigSettings := map[string]interface{}{
"type": "rig-settings",
"version": 1,
"agent": "my-agent",
"agents": map[string]interface{}{
"my-agent": map[string]interface{}{
"command": "/rig/path/to/agent",
"args": []string{"--rig-level"},
},
},
}
writeTownJSON(t, filepath.Join(rigPath, "settings", "config.json"), rigSettings)
// Create town.json
townConfig := map[string]interface{}{
"type": "town",
"version": 2,
"name": "test-town",
"created_at": time.Now().Format(time.RFC3339),
}
writeTownJSON(t, filepath.Join(townRoot, "mayor", "town.json"), townConfig)
// Resolve agent config
rc := ResolveAgentConfig(townRoot, rigPath)
if rc == nil {
t.Fatal("ResolveAgentConfig returned nil")
}
// Rig agent should take precedence
if rc.Command != "/rig/path/to/agent" {
t.Errorf("Expected rig agent command '/rig/path/to/agent', got %q", rc.Command)
}
if len(rc.Args) != 1 || rc.Args[0] != "--rig-level" {
t.Errorf("Expected rig args [--rig-level], got %v", rc.Args)
}
}

View File

@@ -824,6 +824,9 @@ func ResolveAgentConfig(townRoot, rigPath string) *RuntimeConfig {
// Load custom agent registry if it exists
_ = LoadAgentRegistry(DefaultAgentRegistryPath(townRoot))
// Load rig-level custom agent registry if it exists (for per-rig custom agents)
_ = LoadRigAgentRegistry(RigAgentRegistryPath(rigPath))
// Determine which agent name to use
agentName := ""
if rigSettings != nil && rigSettings.Agent != "" {
@@ -834,8 +837,7 @@ func ResolveAgentConfig(townRoot, rigPath string) *RuntimeConfig {
agentName = "claude" // ultimate fallback
}
// Look up the agent configuration
return lookupAgentConfig(agentName, townSettings)
return lookupAgentConfig(agentName, townSettings, rigSettings)
}
// ResolveAgentConfigWithOverride resolves the agent configuration for a rig, with an optional override.
@@ -864,6 +866,9 @@ func ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride string) (*R
// Load custom agent registry if it exists
_ = LoadAgentRegistry(DefaultAgentRegistryPath(townRoot))
// Load rig-level custom agent registry if it exists (for per-rig custom agents)
_ = LoadRigAgentRegistry(RigAgentRegistryPath(rigPath))
// Determine which agent name to use
agentName := ""
if agentOverride != "" {
@@ -876,13 +881,21 @@ func ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride string) (*R
agentName = "claude" // ultimate fallback
}
// If an override is requested, validate it exists.
// If an override is requested, validate it exists
if agentOverride != "" {
// Check rig-level custom agents first
if rigSettings != nil && rigSettings.Agents != nil {
if custom, ok := rigSettings.Agents[agentName]; ok && custom != nil {
return fillRuntimeDefaults(custom), agentName, nil
}
}
// Then check town-level custom agents
if townSettings.Agents != nil {
if custom, ok := townSettings.Agents[agentName]; ok && custom != nil {
return fillRuntimeDefaults(custom), agentName, nil
}
}
// Then check built-in presets
if preset := GetAgentPresetByName(agentName); preset != nil {
return RuntimeConfigFromPreset(AgentPreset(agentName)), agentName, nil
}
@@ -890,13 +903,20 @@ func ResolveAgentConfigWithOverride(townRoot, rigPath, agentOverride string) (*R
}
// Normal lookup path (no override)
return lookupAgentConfig(agentName, townSettings), agentName, nil
return lookupAgentConfig(agentName, townSettings, rigSettings), agentName, nil
}
// 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
// Checks rig-level custom agents first, then town's custom agents, then built-in presets from agents.go.
func lookupAgentConfig(name string, townSettings *TownSettings, rigSettings *RigSettings) *RuntimeConfig {
// First check rig's custom agents (NEW - fix for rig-level agent support)
if rigSettings != nil && rigSettings.Agents != nil {
if custom, ok := rigSettings.Agents[name]; ok && custom != nil {
return fillRuntimeDefaults(custom)
}
}
// Then check town's custom agents (existing)
if townSettings != nil && townSettings.Agents != nil {
if custom, ok := townSettings.Agents[name]; ok && custom != nil {
return fillRuntimeDefaults(custom)

View File

@@ -1570,3 +1570,94 @@ func TestSaveTownSettings(t *testing.T) {
}
})
}
// TestLookupAgentConfigWithRigSettings verifies that lookupAgentConfig checks
// rig-level agents first, then town-level agents, then built-ins.
func TestLookupAgentConfigWithRigSettings(t *testing.T) {
tests := []struct {
name string
rigSettings *RigSettings
townSettings *TownSettings
expectedCommand string
expectedFrom string
}{
{
name: "rig-custom-agent",
rigSettings: &RigSettings{
Agent: "default-rig-agent",
Agents: map[string]*RuntimeConfig{
"rig-custom-agent": {
Command: "custom-rig-cmd",
Args: []string{"--rig-flag"},
},
},
},
townSettings: &TownSettings{
Agents: map[string]*RuntimeConfig{
"town-custom-agent": {
Command: "custom-town-cmd",
Args: []string{"--town-flag"},
},
},
},
expectedCommand: "custom-rig-cmd",
expectedFrom: "rig",
},
{
name: "town-custom-agent",
rigSettings: &RigSettings{
Agents: map[string]*RuntimeConfig{
"other-rig-agent": {
Command: "other-rig-cmd",
},
},
},
townSettings: &TownSettings{
Agents: map[string]*RuntimeConfig{
"town-custom-agent": {
Command: "custom-town-cmd",
Args: []string{"--town-flag"},
},
},
},
expectedCommand: "custom-town-cmd",
expectedFrom: "town",
},
{
name: "unknown-agent",
rigSettings: nil,
townSettings: nil,
expectedCommand: "claude",
expectedFrom: "builtin",
},
{
name: "claude",
rigSettings: &RigSettings{
Agent: "claude",
},
townSettings: &TownSettings{
Agents: map[string]*RuntimeConfig{
"claude": {
Command: "custom-claude",
},
},
},
expectedCommand: "custom-claude",
expectedFrom: "town",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rc := lookupAgentConfig(tt.name, tt.townSettings, tt.rigSettings)
if rc == nil {
t.Errorf("lookupAgentConfig(%s) returned nil", tt.name)
}
if rc.Command != tt.expectedCommand {
t.Errorf("lookupAgentConfig(%s).Command = %s, want %s", tt.name, rc.Command, tt.expectedCommand)
}
})
}
}

View File

@@ -195,6 +195,11 @@ type RigSettings struct {
// If empty, uses the town's default_agent setting.
// Takes precedence over Runtime if both are set.
Agent string `json:"agent,omitempty"`
// Agents defines custom agent configurations or overrides for this rig.
// Similar to TownSettings.Agents but applies to this rig only.
// Allows per-rig custom agents for polecats and crew members.
Agents map[string]*RuntimeConfig `json:"agents,omitempty"`
}
// CrewConfig represents crew workspace settings for a rig.