mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-05 02:30:26 +08:00
- Add AllowedTools/DisallowedTools fields to AgentModelConfig and Config - Update ResolveAgentConfig to return new fields - Pass --allowedTools/--disallowedTools to claude CLI in buildClaudeArgs - Add fields to TaskSpec and propagate through executor - Fix backend selection when taskSpec.Backend is specified but backend=nil Generated with SWE-Agent.ai Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
262 lines
8.8 KiB
Go
262 lines
8.8 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/goccy/go-json"
|
|
)
|
|
|
|
type BackendConfig struct {
|
|
BaseURL string `json:"base_url,omitempty"`
|
|
APIKey string `json:"api_key,omitempty"`
|
|
}
|
|
|
|
type AgentModelConfig struct {
|
|
Backend string `json:"backend"`
|
|
Model string `json:"model"`
|
|
PromptFile string `json:"prompt_file,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Yolo bool `json:"yolo,omitempty"`
|
|
Reasoning string `json:"reasoning,omitempty"`
|
|
BaseURL string `json:"base_url,omitempty"`
|
|
APIKey string `json:"api_key,omitempty"`
|
|
AllowedTools []string `json:"allowed_tools,omitempty"`
|
|
DisallowedTools []string `json:"disallowed_tools,omitempty"`
|
|
}
|
|
|
|
type ModelsConfig struct {
|
|
DefaultBackend string `json:"default_backend"`
|
|
DefaultModel string `json:"default_model"`
|
|
Agents map[string]AgentModelConfig `json:"agents"`
|
|
Backends map[string]BackendConfig `json:"backends,omitempty"`
|
|
}
|
|
|
|
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, error) {
|
|
modelsConfigOnce.Do(func() {
|
|
modelsConfigCached, modelsConfigErr = loadModelsConfig()
|
|
})
|
|
return modelsConfigCached, modelsConfigErr
|
|
}
|
|
|
|
func modelsConfigPath() (string, error) {
|
|
home, err := os.UserHomeDir()
|
|
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 "", 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) {
|
|
return nil, fmt.Errorf("models config not found: %s\n\n%s", configPath, modelsConfigHint(configPath))
|
|
}
|
|
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 {
|
|
return nil, fmt.Errorf("failed to parse models config %s: %w\n\n%s", configPath, err, modelsConfigHint(configPath))
|
|
}
|
|
|
|
cfg.DefaultBackend = strings.TrimSpace(cfg.DefaultBackend)
|
|
cfg.DefaultModel = strings.TrimSpace(cfg.DefaultModel)
|
|
|
|
// Normalize backend keys so lookups can be case-insensitive.
|
|
if len(cfg.Backends) > 0 {
|
|
normalized := make(map[string]BackendConfig, len(cfg.Backends))
|
|
for k, v := range cfg.Backends {
|
|
key := strings.ToLower(strings.TrimSpace(k))
|
|
if key == "" {
|
|
continue
|
|
}
|
|
normalized[key] = v
|
|
}
|
|
if len(normalized) > 0 {
|
|
cfg.Backends = normalized
|
|
} else {
|
|
cfg.Backends = nil
|
|
}
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
func LoadDynamicAgent(name string) (AgentModelConfig, bool) {
|
|
if err := ValidateAgentName(name); err != nil {
|
|
return AgentModelConfig{}, false
|
|
}
|
|
|
|
home, err := os.UserHomeDir()
|
|
if err != nil || strings.TrimSpace(home) == "" {
|
|
return AgentModelConfig{}, false
|
|
}
|
|
|
|
absPath := filepath.Join(home, ".codeagent", "agents", name+".md")
|
|
info, err := os.Stat(absPath)
|
|
if err != nil || info.IsDir() {
|
|
return AgentModelConfig{}, false
|
|
}
|
|
|
|
return AgentModelConfig{PromptFile: "~/.codeagent/agents/" + name + ".md"}, true
|
|
}
|
|
|
|
func ResolveBackendConfig(backendName string) (baseURL, apiKey string) {
|
|
cfg, err := modelsConfig()
|
|
if err != nil || cfg == nil {
|
|
return "", ""
|
|
}
|
|
resolved := resolveBackendConfig(cfg, backendName)
|
|
return strings.TrimSpace(resolved.BaseURL), strings.TrimSpace(resolved.APIKey)
|
|
}
|
|
|
|
func resolveBackendConfig(cfg *ModelsConfig, backendName string) BackendConfig {
|
|
if cfg == nil || len(cfg.Backends) == 0 {
|
|
return BackendConfig{}
|
|
}
|
|
key := strings.ToLower(strings.TrimSpace(backendName))
|
|
if key == "" {
|
|
key = strings.ToLower(strings.TrimSpace(cfg.DefaultBackend))
|
|
}
|
|
if key == "" {
|
|
return BackendConfig{}
|
|
}
|
|
if backend, ok := cfg.Backends[key]; ok {
|
|
return backend
|
|
}
|
|
return BackendConfig{}
|
|
}
|
|
|
|
func resolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool, allowedTools, disallowedTools []string, err error) {
|
|
if err := ValidateAgentName(agentName); err != nil {
|
|
return "", "", "", "", "", "", false, nil, nil, err
|
|
}
|
|
|
|
cfg, err := modelsConfig()
|
|
if err != nil {
|
|
return "", "", "", "", "", "", false, nil, nil, err
|
|
}
|
|
if cfg == nil {
|
|
return "", "", "", "", "", "", false, nil, nil, 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 = strings.TrimSpace(cfg.DefaultBackend)
|
|
if backend == "" {
|
|
configPath, pathErr := modelsConfigPath()
|
|
if pathErr != nil {
|
|
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("agent %q has empty backend and default_backend is not set\n\n%s", agentName, modelsConfigHint(""))
|
|
}
|
|
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("agent %q has empty backend and default_backend is not set\n\n%s", agentName, modelsConfigHint(configPath))
|
|
}
|
|
}
|
|
backendCfg := resolveBackendConfig(cfg, backend)
|
|
|
|
baseURL = strings.TrimSpace(agent.BaseURL)
|
|
if baseURL == "" {
|
|
baseURL = strings.TrimSpace(backendCfg.BaseURL)
|
|
}
|
|
apiKey = strings.TrimSpace(agent.APIKey)
|
|
if apiKey == "" {
|
|
apiKey = strings.TrimSpace(backendCfg.APIKey)
|
|
}
|
|
|
|
model = strings.TrimSpace(agent.Model)
|
|
if model == "" {
|
|
configPath, pathErr := modelsConfigPath()
|
|
if pathErr != nil {
|
|
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("agent %q has empty model; set agents.%s.model in %s\n\n%s", agentName, agentName, modelsConfigTildePath, modelsConfigHint(""))
|
|
}
|
|
return "", "", "", "", "", "", false, nil, nil, 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, agent.AllowedTools, agent.DisallowedTools, nil
|
|
}
|
|
|
|
if dynamic, ok := LoadDynamicAgent(agentName); ok {
|
|
backend = strings.TrimSpace(cfg.DefaultBackend)
|
|
model = strings.TrimSpace(cfg.DefaultModel)
|
|
configPath, pathErr := modelsConfigPath()
|
|
if backend == "" || model == "" {
|
|
if pathErr != nil {
|
|
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("dynamic agent %q requires default_backend and default_model to be set in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(""))
|
|
}
|
|
return "", "", "", "", "", "", false, nil, nil, 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, nil, nil, nil
|
|
}
|
|
|
|
configPath, pathErr := modelsConfigPath()
|
|
if pathErr != nil {
|
|
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("agent %q not found in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(""))
|
|
}
|
|
return "", "", "", "", "", "", false, nil, nil, 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, allowedTools, disallowedTools []string, err error) {
|
|
return resolveAgentConfig(agentName)
|
|
}
|
|
|
|
func ResetModelsConfigCacheForTest() {
|
|
modelsConfigCached = nil
|
|
modelsConfigErr = nil
|
|
modelsConfigOnce = sync.Once{}
|
|
}
|