diff --git a/codeagent-wrapper/internal/app/bench_test.go b/codeagent-wrapper/internal/app/bench_test.go index 589e22f..7fca197 100644 --- a/codeagent-wrapper/internal/app/bench_test.go +++ b/codeagent-wrapper/internal/app/bench_test.go @@ -3,6 +3,7 @@ package wrapper import ( "bytes" "os" + "path/filepath" "testing" config "codeagent-wrapper/internal/config" @@ -29,6 +30,18 @@ func BenchmarkConfigParse_ParseArgs(b *testing.B) { b.Setenv("HOME", home) b.Setenv("USERPROFILE", home) + configDir := filepath.Join(home, ".codeagent") + if err := os.MkdirAll(configDir, 0o755); err != nil { + b.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{ + "agents": { + "develop": { "backend": "codex", "model": "gpt-test" } + } +}`), 0o644); err != nil { + b.Fatal(err) + } + config.ResetModelsConfigCacheForTest() b.Cleanup(config.ResetModelsConfigCacheForTest) diff --git a/codeagent-wrapper/internal/app/cli.go b/codeagent-wrapper/internal/app/cli.go index 107a296..ff47e3c 100644 --- a/codeagent-wrapper/internal/app/cli.go +++ b/codeagent-wrapper/internal/app/cli.go @@ -254,7 +254,11 @@ func buildSingleConfig(cmd *cobra.Command, args []string, rawArgv []string, opts var resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning string if agentName != "" { var resolvedYolo bool - resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning, _, _, resolvedYolo = config.ResolveAgentConfig(agentName) + var err error + resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning, _, _, resolvedYolo, err = config.ResolveAgentConfig(agentName) + if err != nil { + return nil, fmt.Errorf("failed to resolve agent %q: %w", agentName, err) + } yolo = resolvedYolo } diff --git a/codeagent-wrapper/internal/app/main_test.go b/codeagent-wrapper/internal/app/main_test.go index 095e03b..d50873e 100644 --- a/codeagent-wrapper/internal/app/main_test.go +++ b/codeagent-wrapper/internal/app/main_test.go @@ -1392,6 +1392,24 @@ func TestBackendParseArgs_PromptFileFlag(t *testing.T) { func TestBackendParseArgs_PromptFileOverridesAgent(t *testing.T) { defer resetTestHooks() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Cleanup(config.ResetModelsConfigCacheForTest) + config.ResetModelsConfigCacheForTest() + + configDir := filepath.Join(home, ".codeagent") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{ + "agents": { + "develop": { "backend": "codex", "model": "gpt-test" } + } +}`), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + os.Args = []string{"codeagent-wrapper", "--prompt-file", "/tmp/custom.md", "--agent", "develop", "task"} cfg, err := parseArgs() if err != nil { diff --git a/codeagent-wrapper/internal/config/agent.go b/codeagent-wrapper/internal/config/agent.go index 9bdcfb8..dc4814e 100644 --- a/codeagent-wrapper/internal/config/agent.go +++ b/codeagent-wrapper/internal/config/agent.go @@ -7,8 +7,6 @@ import ( "strings" "sync" - ilogger "codeagent-wrapper/internal/logger" - "github.com/goccy/go-json" ) @@ -35,80 +33,85 @@ type ModelsConfig struct { Backends map[string]BackendConfig `json:"backends,omitempty"` } -var defaultModelsConfig = ModelsConfig{ - DefaultBackend: "opencode", - DefaultModel: "opencode/grok-code", - Agents: map[string]AgentModelConfig{ - "oracle": {Backend: "claude", Model: "claude-opus-4-5-20251101", PromptFile: "~/.claude/skills/omo/references/oracle.md", Description: "Technical advisor"}, - "librarian": {Backend: "claude", Model: "claude-sonnet-4-5-20250929", PromptFile: "~/.claude/skills/omo/references/librarian.md", Description: "Researcher"}, - "explore": {Backend: "opencode", Model: "opencode/grok-code", PromptFile: "~/.claude/skills/omo/references/explore.md", Description: "Code search"}, - "develop": {Backend: "codex", Model: "", PromptFile: "~/.claude/skills/omo/references/develop.md", Description: "Code development"}, - "frontend-ui-ux-engineer": {Backend: "gemini", Model: "", PromptFile: "~/.claude/skills/omo/references/frontend-ui-ux-engineer.md", Description: "Frontend engineer"}, - "document-writer": {Backend: "gemini", Model: "", PromptFile: "~/.claude/skills/omo/references/document-writer.md", Description: "Documentation"}, - }, -} +var defaultModelsConfig = ModelsConfig{} + +const modelsConfigTildePath = "~/.codeagent/models.json" + +const modelsConfigExample = `{ + "default_backend": "codex", + "default_model": "gpt-4.1", + "backends": { + "codex": { "api_key": "..." }, + "claude": { "api_key": "..." } + }, + "agents": { + "develop": { + "backend": "codex", + "model": "gpt-4.1", + "prompt_file": "~/.codeagent/prompts/develop.md", + "reasoning": "high", + "yolo": true + } + } +}` var ( modelsConfigOnce sync.Once modelsConfigCached *ModelsConfig + modelsConfigErr error ) -func modelsConfig() *ModelsConfig { +func modelsConfig() (*ModelsConfig, error) { modelsConfigOnce.Do(func() { - modelsConfigCached = loadModelsConfig() + modelsConfigCached, modelsConfigErr = loadModelsConfig() }) - if modelsConfigCached == nil { - return &defaultModelsConfig - } - return modelsConfigCached + return modelsConfigCached, modelsConfigErr } -func loadModelsConfig() *ModelsConfig { +func modelsConfigPath() (string, error) { home, err := os.UserHomeDir() - if err != nil { - ilogger.LogWarn(fmt.Sprintf("Failed to resolve home directory for models config: %v; using defaults", err)) - return &defaultModelsConfig + if err != nil || strings.TrimSpace(home) == "" { + return "", fmt.Errorf("failed to resolve user home directory: %w", err) } configDir := filepath.Clean(filepath.Join(home, ".codeagent")) configPath := filepath.Clean(filepath.Join(configDir, "models.json")) rel, err := filepath.Rel(configDir, configPath) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { - return &defaultModelsConfig + return "", fmt.Errorf("refusing to read models config outside %s: %s", configDir, configPath) + } + return configPath, nil +} + +func modelsConfigHint(configPath string) string { + configPath = strings.TrimSpace(configPath) + if configPath == "" { + return fmt.Sprintf("Create %s with e.g.:\n%s", modelsConfigTildePath, modelsConfigExample) + } + return fmt.Sprintf("Create %s (resolved to %s) with e.g.:\n%s", modelsConfigTildePath, configPath, modelsConfigExample) +} + +func loadModelsConfig() (*ModelsConfig, error) { + configPath, err := modelsConfigPath() + if err != nil { + return nil, fmt.Errorf("%w\n\n%s", err, modelsConfigHint("")) } data, err := os.ReadFile(configPath) // #nosec G304 -- path is fixed under user home and validated to stay within configDir if err != nil { - if !os.IsNotExist(err) { - ilogger.LogWarn(fmt.Sprintf("Failed to read models config %s: %v; using defaults", configPath, err)) + if os.IsNotExist(err) { + return nil, fmt.Errorf("models config not found: %s\n\n%s", configPath, modelsConfigHint(configPath)) } - return &defaultModelsConfig + return nil, fmt.Errorf("failed to read models config %s: %w\n\n%s", configPath, err, modelsConfigHint(configPath)) } var cfg ModelsConfig if err := json.Unmarshal(data, &cfg); err != nil { - ilogger.LogWarn(fmt.Sprintf("Failed to parse models config %s: %v; using defaults", configPath, err)) - return &defaultModelsConfig + return nil, fmt.Errorf("failed to parse models config %s: %w\n\n%s", configPath, err, modelsConfigHint(configPath)) } cfg.DefaultBackend = strings.TrimSpace(cfg.DefaultBackend) - if cfg.DefaultBackend == "" { - cfg.DefaultBackend = defaultModelsConfig.DefaultBackend - } cfg.DefaultModel = strings.TrimSpace(cfg.DefaultModel) - if cfg.DefaultModel == "" { - cfg.DefaultModel = defaultModelsConfig.DefaultModel - } - - // Merge with defaults - for name, agent := range defaultModelsConfig.Agents { - if _, exists := cfg.Agents[name]; !exists { - if cfg.Agents == nil { - cfg.Agents = make(map[string]AgentModelConfig) - } - cfg.Agents[name] = agent - } - } // Normalize backend keys so lookups can be case-insensitive. if len(cfg.Backends) > 0 { @@ -127,7 +130,7 @@ func loadModelsConfig() *ModelsConfig { } } - return &cfg + return &cfg, nil } func LoadDynamicAgent(name string) (AgentModelConfig, bool) { @@ -150,7 +153,10 @@ func LoadDynamicAgent(name string) (AgentModelConfig, bool) { } func ResolveBackendConfig(backendName string) (baseURL, apiKey string) { - cfg := modelsConfig() + cfg, err := modelsConfig() + if err != nil || cfg == nil { + return "", "" + } resolved := resolveBackendConfig(cfg, backendName) return strings.TrimSpace(resolved.BaseURL), strings.TrimSpace(resolved.APIKey) } @@ -172,12 +178,30 @@ func resolveBackendConfig(cfg *ModelsConfig, backendName string) BackendConfig { return BackendConfig{} } -func resolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool) { - cfg := modelsConfig() +func resolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool, err error) { + if err := ValidateAgentName(agentName); err != nil { + return "", "", "", "", "", "", false, err + } + + cfg, err := modelsConfig() + if err != nil { + return "", "", "", "", "", "", false, err + } + if cfg == nil { + return "", "", "", "", "", "", false, fmt.Errorf("models config is nil\n\n%s", modelsConfigHint("")) + } + if agent, ok := cfg.Agents[agentName]; ok { backend = strings.TrimSpace(agent.Backend) if backend == "" { - backend = cfg.DefaultBackend + backend = strings.TrimSpace(cfg.DefaultBackend) + if backend == "" { + configPath, pathErr := modelsConfigPath() + if pathErr != nil { + return "", "", "", "", "", "", false, fmt.Errorf("agent %q has empty backend and default_backend is not set\n\n%s", agentName, modelsConfigHint("")) + } + return "", "", "", "", "", "", false, fmt.Errorf("agent %q has empty backend and default_backend is not set\n\n%s", agentName, modelsConfigHint(configPath)) + } } backendCfg := resolveBackendConfig(cfg, backend) @@ -190,31 +214,46 @@ func resolveAgentConfig(agentName string) (backend, model, promptFile, reasoning apiKey = strings.TrimSpace(backendCfg.APIKey) } - return backend, strings.TrimSpace(agent.Model), agent.PromptFile, agent.Reasoning, baseURL, apiKey, agent.Yolo + model = strings.TrimSpace(agent.Model) + if model == "" { + configPath, pathErr := modelsConfigPath() + if pathErr != nil { + return "", "", "", "", "", "", false, fmt.Errorf("agent %q has empty model; set agents.%s.model in %s\n\n%s", agentName, agentName, modelsConfigTildePath, modelsConfigHint("")) + } + return "", "", "", "", "", "", false, fmt.Errorf("agent %q has empty model; set agents.%s.model in %s\n\n%s", agentName, agentName, modelsConfigTildePath, modelsConfigHint(configPath)) + } + return backend, model, agent.PromptFile, agent.Reasoning, baseURL, apiKey, agent.Yolo, nil } if dynamic, ok := LoadDynamicAgent(agentName); ok { - backend = cfg.DefaultBackend - model = cfg.DefaultModel + backend = strings.TrimSpace(cfg.DefaultBackend) + model = strings.TrimSpace(cfg.DefaultModel) + configPath, pathErr := modelsConfigPath() + if backend == "" || model == "" { + if pathErr != nil { + return "", "", "", "", "", "", false, fmt.Errorf("dynamic agent %q requires default_backend and default_model to be set in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint("")) + } + return "", "", "", "", "", "", false, fmt.Errorf("dynamic agent %q requires default_backend and default_model to be set in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(configPath)) + } backendCfg := resolveBackendConfig(cfg, backend) baseURL = strings.TrimSpace(backendCfg.BaseURL) apiKey = strings.TrimSpace(backendCfg.APIKey) - return backend, model, dynamic.PromptFile, "", baseURL, apiKey, false + return backend, model, dynamic.PromptFile, "", baseURL, apiKey, false, nil } - backend = cfg.DefaultBackend - model = cfg.DefaultModel - backendCfg := resolveBackendConfig(cfg, backend) - baseURL = strings.TrimSpace(backendCfg.BaseURL) - apiKey = strings.TrimSpace(backendCfg.APIKey) - return backend, model, "", "", baseURL, apiKey, false + configPath, pathErr := modelsConfigPath() + if pathErr != nil { + return "", "", "", "", "", "", false, fmt.Errorf("agent %q not found in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint("")) + } + return "", "", "", "", "", "", false, fmt.Errorf("agent %q not found in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(configPath)) } -func ResolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool) { +func ResolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool, err error) { return resolveAgentConfig(agentName) } func ResetModelsConfigCacheForTest() { modelsConfigCached = nil + modelsConfigErr = nil modelsConfigOnce = sync.Once{} } diff --git a/codeagent-wrapper/internal/config/agent_config_test.go b/codeagent-wrapper/internal/config/agent_config_test.go index 58876fd..660473a 100644 --- a/codeagent-wrapper/internal/config/agent_config_test.go +++ b/codeagent-wrapper/internal/config/agent_config_test.go @@ -3,78 +3,43 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) -func TestResolveAgentConfig_Defaults(t *testing.T) { +func TestResolveAgentConfig_NoConfig_ReturnsHelpfulError(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) t.Cleanup(ResetModelsConfigCacheForTest) ResetModelsConfigCacheForTest() - // Test that default agents resolve correctly without config file - tests := []struct { - agent string - wantBackend string - wantModel string - wantPromptFile string - }{ - {"oracle", "claude", "claude-opus-4-5-20251101", "~/.claude/skills/omo/references/oracle.md"}, - {"librarian", "claude", "claude-sonnet-4-5-20250929", "~/.claude/skills/omo/references/librarian.md"}, - {"explore", "opencode", "opencode/grok-code", "~/.claude/skills/omo/references/explore.md"}, - {"frontend-ui-ux-engineer", "gemini", "", "~/.claude/skills/omo/references/frontend-ui-ux-engineer.md"}, - {"document-writer", "gemini", "", "~/.claude/skills/omo/references/document-writer.md"}, + _, _, _, _, _, _, _, err := ResolveAgentConfig("develop") + if err == nil { + t.Fatalf("expected error, got nil") } - - for _, tt := range tests { - t.Run(tt.agent, func(t *testing.T) { - backend, model, promptFile, _, _, _, _ := resolveAgentConfig(tt.agent) - if backend != tt.wantBackend { - t.Errorf("backend = %q, want %q", backend, tt.wantBackend) - } - if model != tt.wantModel { - t.Errorf("model = %q, want %q", model, tt.wantModel) - } - if promptFile != tt.wantPromptFile { - t.Errorf("promptFile = %q, want %q", promptFile, tt.wantPromptFile) - } - }) + msg := err.Error() + if !strings.Contains(msg, modelsConfigTildePath) { + t.Fatalf("error should mention %s, got: %s", modelsConfigTildePath, msg) } -} - -func TestResolveAgentConfig_UnknownAgent(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) - t.Cleanup(ResetModelsConfigCacheForTest) - ResetModelsConfigCacheForTest() - - backend, model, promptFile, _, _, _, _ := resolveAgentConfig("unknown-agent") - if backend != "opencode" { - t.Errorf("unknown agent backend = %q, want %q", backend, "opencode") + if !strings.Contains(msg, filepath.Join(home, ".codeagent", "models.json")) { + t.Fatalf("error should mention resolved config path, got: %s", msg) } - if model != "opencode/grok-code" { - t.Errorf("unknown agent model = %q, want %q", model, "opencode/grok-code") - } - if promptFile != "" { - t.Errorf("unknown agent promptFile = %q, want empty", promptFile) + if !strings.Contains(msg, "\"agents\"") { + t.Fatalf("error should include example config, got: %s", msg) } } func TestLoadModelsConfig_NoFile(t *testing.T) { - home := "/nonexistent/path/that/does/not/exist" + home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) t.Cleanup(ResetModelsConfigCacheForTest) ResetModelsConfigCacheForTest() - cfg := loadModelsConfig() - if cfg.DefaultBackend != "opencode" { - t.Errorf("DefaultBackend = %q, want %q", cfg.DefaultBackend, "opencode") - } - if len(cfg.Agents) != 6 { - t.Errorf("len(Agents) = %d, want 6", len(cfg.Agents)) + _, err := loadModelsConfig() + if err == nil { + t.Fatalf("expected error, got nil") } } @@ -119,7 +84,10 @@ func TestLoadModelsConfig_WithFile(t *testing.T) { t.Cleanup(ResetModelsConfigCacheForTest) ResetModelsConfigCacheForTest() - cfg := loadModelsConfig() + cfg, err := loadModelsConfig() + if err != nil { + t.Fatalf("loadModelsConfig: %v", err) + } if cfg.DefaultBackend != "claude" { t.Errorf("DefaultBackend = %q, want %q", cfg.DefaultBackend, "claude") @@ -140,9 +108,8 @@ func TestLoadModelsConfig_WithFile(t *testing.T) { } } - // Check that defaults are merged - if _, ok := cfg.Agents["oracle"]; !ok { - t.Error("default agent oracle should be merged") + if _, ok := cfg.Agents["oracle"]; ok { + t.Error("oracle should not be present without explicit config") } baseURL, apiKey := ResolveBackendConfig("claude") @@ -153,7 +120,10 @@ func TestLoadModelsConfig_WithFile(t *testing.T) { t.Errorf("ResolveBackendConfig(apiKey) = %q, want %q", apiKey, "backend-key") } - backend, model, _, _, agentBaseURL, agentAPIKey, _ := ResolveAgentConfig("custom-agent") + backend, model, _, _, agentBaseURL, agentAPIKey, _, err := ResolveAgentConfig("custom-agent") + if err != nil { + t.Fatalf("ResolveAgentConfig(custom-agent): %v", err) + } if backend != "codex" { t.Errorf("ResolveAgentConfig(backend) = %q, want %q", backend, "codex") } @@ -183,12 +153,26 @@ func TestResolveAgentConfig_DynamicAgent(t *testing.T) { t.Fatalf("WriteFile: %v", err) } - backend, model, promptFile, _, _, _, _ := resolveAgentConfig("sarsh") - if backend != "opencode" { - t.Errorf("backend = %q, want %q", backend, "opencode") + configDir := filepath.Join(home, ".codeagent") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) } - if model != "opencode/grok-code" { - t.Errorf("model = %q, want %q", model, "opencode/grok-code") + if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{ + "default_backend": "codex", + "default_model": "gpt-test" +}`), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + backend, model, promptFile, _, _, _, _, err := ResolveAgentConfig("sarsh") + if err != nil { + t.Fatalf("ResolveAgentConfig(sarsh): %v", err) + } + if backend != "codex" { + t.Errorf("backend = %q, want %q", backend, "codex") + } + if model != "gpt-test" { + t.Errorf("model = %q, want %q", model, "gpt-test") } if promptFile != "~/.codeagent/agents/sarsh.md" { t.Errorf("promptFile = %q, want %q", promptFile, "~/.codeagent/agents/sarsh.md") @@ -213,9 +197,66 @@ func TestLoadModelsConfig_InvalidJSON(t *testing.T) { t.Cleanup(ResetModelsConfigCacheForTest) ResetModelsConfigCacheForTest() - cfg := loadModelsConfig() - // Should fall back to defaults - if cfg.DefaultBackend != "opencode" { - t.Errorf("invalid JSON should fallback, got DefaultBackend = %q", cfg.DefaultBackend) + _, err := loadModelsConfig() + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +func TestResolveAgentConfig_UnknownAgent_ReturnsError(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Cleanup(ResetModelsConfigCacheForTest) + ResetModelsConfigCacheForTest() + + configDir := filepath.Join(home, ".codeagent") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{ + "default_backend": "codex", + "default_model": "gpt-test", + "agents": { + "develop": { "backend": "codex", "model": "gpt-test" } + } +}`), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + _, _, _, _, _, _, _, err := ResolveAgentConfig("unknown-agent") + if err == nil { + t.Fatalf("expected error, got nil") + } + if !strings.Contains(err.Error(), "unknown-agent") { + t.Fatalf("error should mention agent name, got: %s", err.Error()) + } +} + +func TestResolveAgentConfig_EmptyModel_ReturnsError(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Cleanup(ResetModelsConfigCacheForTest) + ResetModelsConfigCacheForTest() + + configDir := filepath.Join(home, ".codeagent") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{ + "agents": { + "bad-agent": { "backend": "codex", "model": " " } + } +}`), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + _, _, _, _, _, _, _, err := ResolveAgentConfig("bad-agent") + if err == nil { + t.Fatalf("expected error, got nil") + } + if !strings.Contains(strings.ToLower(err.Error()), "empty model") { + t.Fatalf("error should mention empty model, got: %s", err.Error()) } } diff --git a/codeagent-wrapper/internal/executor/env_inject_test.go b/codeagent-wrapper/internal/executor/env_inject_test.go index cce981f..16d7cf1 100644 --- a/codeagent-wrapper/internal/executor/env_inject_test.go +++ b/codeagent-wrapper/internal/executor/env_inject_test.go @@ -46,7 +46,10 @@ func TestEnvInjectionWithAgent(t *testing.T) { defer config.ResetModelsConfigCacheForTest() // Test ResolveAgentConfig - agentBackend, model, _, _, baseURL, apiKey, _ := config.ResolveAgentConfig("test-agent") + agentBackend, model, _, _, baseURL, apiKey, _, err := config.ResolveAgentConfig("test-agent") + if err != nil { + t.Fatalf("ResolveAgentConfig: %v", err) + } t.Logf("ResolveAgentConfig: backend=%q, model=%q, baseURL=%q, apiKey=%q", agentBackend, model, baseURL, apiKey) @@ -118,7 +121,10 @@ func TestEnvInjectionLogic(t *testing.T) { // Step 2: If agent specified, get agent config if agentName != "" { - agentBackend, _, _, _, agentBaseURL, agentAPIKey, _ := config.ResolveAgentConfig(agentName) + agentBackend, _, _, _, agentBaseURL, agentAPIKey, _, err := config.ResolveAgentConfig(agentName) + if err != nil { + t.Fatalf("ResolveAgentConfig(%q): %v", agentName, err) + } t.Logf("Step 2 - ResolveAgentConfig(%q): backend=%q, baseURL=%q, apiKey=%q", agentName, agentBackend, agentBaseURL, agentAPIKey) diff --git a/codeagent-wrapper/internal/executor/executor.go b/codeagent-wrapper/internal/executor/executor.go index aa9774f..db5e8f9 100644 --- a/codeagent-wrapper/internal/executor/executor.go +++ b/codeagent-wrapper/internal/executor/executor.go @@ -1058,15 +1058,17 @@ func RunCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe } if envBackend != nil { - baseURL, apiKey := config.ResolveBackendConfig(cfg.Backend) - if agentName := strings.TrimSpace(taskSpec.Agent); agentName != "" { - agentBackend, _, _, _, agentBaseURL, agentAPIKey, _ := config.ResolveAgentConfig(agentName) - if strings.EqualFold(strings.TrimSpace(agentBackend), strings.TrimSpace(cfg.Backend)) { - baseURL, apiKey = agentBaseURL, agentAPIKey + baseURL, apiKey := config.ResolveBackendConfig(cfg.Backend) + if agentName := strings.TrimSpace(taskSpec.Agent); agentName != "" { + agentBackend, _, _, _, agentBaseURL, agentAPIKey, _, err := config.ResolveAgentConfig(agentName) + if err == nil { + if strings.EqualFold(strings.TrimSpace(agentBackend), strings.TrimSpace(cfg.Backend)) { + baseURL, apiKey = agentBaseURL, agentAPIKey + } + } } - } - if injected := envBackend.Env(baseURL, apiKey); len(injected) > 0 { - cmd.SetEnv(injected) + if injected := envBackend.Env(baseURL, apiKey); len(injected) > 0 { + cmd.SetEnv(injected) // Log injected env vars with masked API keys (to file and stderr) for k, v := range injected { msg := fmt.Sprintf("Env: %s=%s", k, maskSensitiveValue(k, v)) diff --git a/codeagent-wrapper/internal/executor/parallel_config.go b/codeagent-wrapper/internal/executor/parallel_config.go index 57bdff3..a22ee47 100644 --- a/codeagent-wrapper/internal/executor/parallel_config.go +++ b/codeagent-wrapper/internal/executor/parallel_config.go @@ -93,14 +93,17 @@ func ParseParallelConfig(data []byte) (*ParallelConfig, error) { if strings.TrimSpace(task.Agent) == "" { return nil, fmt.Errorf("task block #%d has empty agent field", taskIndex) } - if err := config.ValidateAgentName(task.Agent); err != nil { - return nil, fmt.Errorf("task block #%d invalid agent name: %w", taskIndex, err) - } - backend, model, promptFile, reasoning, _, _, _ := config.ResolveAgentConfig(task.Agent) - if task.Backend == "" { - task.Backend = backend - } - if task.Model == "" { + if err := config.ValidateAgentName(task.Agent); err != nil { + return nil, fmt.Errorf("task block #%d invalid agent name: %w", taskIndex, err) + } + backend, model, promptFile, reasoning, _, _, _, err := config.ResolveAgentConfig(task.Agent) + if err != nil { + return nil, fmt.Errorf("task block #%d failed to resolve agent %q: %w", taskIndex, task.Agent, err) + } + if task.Backend == "" { + task.Backend = backend + } + if task.Model == "" { task.Model = model } if task.ReasoningEffort == "" { diff --git a/skills/do/README.md b/skills/do/README.md index 24773eb..3bd29a2 100644 --- a/skills/do/README.md +++ b/skills/do/README.md @@ -158,7 +158,7 @@ EOF ## ~/.codeagent/models.json Configuration -Optional. Uses codeagent-wrapper built-in config by default. To customize: +Required when using `agent:` in parallel tasks or `--agent`. Create `~/.codeagent/models.json` to configure agent → backend/model mappings: ```json {