package config import ( "errors" "fmt" "os" "path/filepath" "time" "github.com/BurntSushi/toml" ) // Project represents a named workspace project used for webhook routing. type Project struct { Name string `toml:"name"` Dir string `toml:"dir"` } // LocalModel configures an OpenAI-compatible local LLM endpoint used for // internal helpers (classifier, elaboration, future summarization) and as // the backend for the "local" runner. If Endpoint is empty, the LocalRunner // is not registered and the classifier falls back to the Gemini CLI. // // PreferForElaborate gates whether the API server's elaboration handler // uses this client. It defaults to true when Endpoint is set; users with a // slow or low-quality local model can disable it. type LocalModel struct { Endpoint string `toml:"endpoint"` // e.g. "http://localhost:11434/v1" Model string `toml:"model"` // e.g. "llama3.1:8b" TimeoutSeconds int `toml:"timeout_seconds"` // default 60 DefaultTemperature float64 `toml:"default_temperature"` // default 0.2 APIKey string `toml:"api_key"` // optional bearer token PreferForElaborate *bool `toml:"prefer_for_elaborate"` // pointer so default-true survives parse } // UseForElaborate returns true when elaboration should try this local model // before falling back to Claude/Gemini. Default is true when Endpoint is set. func (m LocalModel) UseForElaborate() bool { if m.Endpoint == "" { return false } if m.PreferForElaborate == nil { return true } return *m.PreferForElaborate } // RunnersConfig controls which agent runners are registered. Each field is a // *bool so that nil (absent from TOML) means "enabled by default", while an // explicit false disables the runner without affecting others. // // The local runner also requires LocalModel.Endpoint to be set; setting // Local = true without an endpoint has no effect. type RunnersConfig struct { Claude *bool `toml:"claude"` Gemini *bool `toml:"gemini"` Local *bool `toml:"local"` Container *bool `toml:"container"` // Anthropic gates the native provider.Provider-backed NativeRunner // registered under agent type "anthropic" (internal/provider/anthropic). // It is distinct from Claude, which gates the Docker/CLI-subprocess // ContainerRunner for agent type "claude" — the two are separate // execution paths (and separate budget buckets) even though both bill // the same Anthropic account. Also requires [providers.anthropic].api_key // to be set; Anthropic = true with no configured key has no effect. Anthropic *bool `toml:"anthropic"` // Google gates the native provider.Provider-backed NativeRunner // registered under agent type "google" (internal/provider/google). It is // distinct from Gemini, which gates the Docker/CLI-subprocess // ContainerRunner for agent type "gemini" — the two are separate // execution paths (and separate budget buckets), same reasoning as // Anthropic vs. Claude above. Also requires [providers.google].api_key to // be set; Google = true with no configured key has no effect. Google *bool `toml:"google"` // Groq, OpenRouter, and OpenAI gate NativeRunners registered under agent // types "groq"/"openrouter"/"openai", all backed by // internal/provider/openaicompat (they're OpenAI-wire-compatible, so no // dedicated adapter package is needed — see docs/api-keys-setup.md). // Each also requires the matching [providers.].api_key to be set; // true with no configured key has no effect, same as Anthropic/Google. Groq *bool `toml:"groq"` OpenRouter *bool `toml:"openrouter"` OpenAI *bool `toml:"openai"` } func (r RunnersConfig) ClaudeEnabled() bool { return r.Claude == nil || *r.Claude } func (r RunnersConfig) GeminiEnabled() bool { return r.Gemini == nil || *r.Gemini } func (r RunnersConfig) LocalEnabled() bool { return r.Local == nil || *r.Local } func (r RunnersConfig) ContainerEnabled() bool { return r.Container == nil || *r.Container } func (r RunnersConfig) AnthropicEnabled() bool { return r.Anthropic == nil || *r.Anthropic } func (r RunnersConfig) GoogleEnabled() bool { return r.Google == nil || *r.Google } func (r RunnersConfig) GroqEnabled() bool { return r.Groq == nil || *r.Groq } func (r RunnersConfig) OpenRouterEnabled() bool { return r.OpenRouter == nil || *r.OpenRouter } func (r RunnersConfig) OpenAIEnabled() bool { return r.OpenAI == nil || *r.OpenAI } // ProviderConfig configures a native (or OpenAI-compatible) LLM provider // backend for the multi-provider harness — see docs/api-keys-setup.md. Phase // 1 added this type so config had somewhere for it to land, without being // read by runner construction. Phase 2 is the first consumer: // [providers.anthropic] (APIKey + optional Endpoint/DefaultModel/ // TimeoutSeconds) wires internal/provider/anthropic.New into a NativeRunner // registered under agent type "anthropic" — see internal/cli/serve.go and // internal/cli/run.go. Keyed by provider name in the [providers.] TOML // table, e.g. [providers.groq], [providers.anthropic]. type ProviderConfig struct { Endpoint string `toml:"endpoint"` APIKey string `toml:"api_key"` DefaultModel string `toml:"default_model"` TimeoutSeconds int `toml:"timeout_seconds"` } type Config struct { DataDir string `toml:"data_dir"` DBPath string `toml:"-"` LogDir string `toml:"-"` DropsDir string `toml:"-"` SSHAuthSock string `toml:"ssh_auth_sock"` ClaudeBinaryPath string `toml:"claude_binary_path"` GeminiBinaryPath string `toml:"gemini_binary_path"` ClaudeImage string `toml:"claude_image"` GeminiImage string `toml:"gemini_image"` // SandboxImage is the docker image used by sandbox.DockerSandbox (the // tool-execution sandbox for native-API-driven runners, e.g. the // "anthropic" NativeRunner) — distinct from ClaudeImage/GeminiImage, // which are for ContainerRunner's claude/gemini CLI-subprocess path. // Defaults to the same image (images/agent-base) since it already has // everything a generic git/bash sandbox needs; see // internal/sandbox/dockersandbox.go's package doc for the tradeoff. SandboxImage string `toml:"sandbox_image"` MaxConcurrent int `toml:"max_concurrent"` ShutdownTimeout time.Duration `toml:"shutdown_timeout"` DefaultTimeout string `toml:"default_timeout"` ServerAddr string `toml:"server_addr"` WebhookURL string `toml:"webhook_url"` WorkspaceRoot string `toml:"workspace_root"` WebhookSecret string `toml:"webhook_secret"` APIToken string `toml:"api_token"` // shared bearer for web UI + chatbot MCP; empty disables both auth and the chatbot MCP endpoint Projects []Project `toml:"projects"` VAPIDPublicKey string `toml:"vapid_public_key"` VAPIDPrivateKey string `toml:"vapid_private_key"` VAPIDEmail string `toml:"vapid_email"` ClaudeConfigDir string `toml:"claude_config_dir"` LocalModel LocalModel `toml:"local_model"` Runners RunnersConfig `toml:"runners"` Budget BudgetConfig `toml:"budget"` // Scheduler configures internal/scheduler.Scheduler, the retry-then- // escalate loop for role-typed tasks. Started only by `serve` (the // one-shot `run` command has no background scheduler). Scheduler SchedulerConfig `toml:"scheduler"` // Providers configures native/OpenAI-compatible LLM backends by name (see // ProviderConfig). Unused by runner construction in Phase 1. Providers map[string]ProviderConfig `toml:"providers"` // ExternalBindAllowed must be explicitly true to bind a non-loopback address. ExternalBindAllowed bool `toml:"external_bind_allowed"` } // BudgetConfig configures rolling per-provider spend caps. With no providers // set, budget gating is disabled. type BudgetConfig struct { Window string `toml:"window"` Provider5hUSD map[string]float64 `toml:"provider_5h_usd"` } // SchedulerConfig configures internal/scheduler.Scheduler's poll loop. type SchedulerConfig struct { // PollIntervalSeconds is how often the scheduler checks for role-typed // FAILED tasks to retry or escalate. Defaults to 30 when zero/unset. PollIntervalSeconds int `toml:"poll_interval_seconds"` // AskUserTimeoutSeconds is how long a role-typed task may sit BLOCKED on // an unanswered ask_user question before the scheduler resumes it itself // with a system-authored fallback answer (escalating to the next tier of // the role's ladder where possible) and flags it tasks.needs_review for // a human to double-check later. Defaults to 600 (10 minutes) when // zero/unset. AskUserTimeoutSeconds int `toml:"ask_user_timeout_seconds"` } // PollInterval returns the configured poll interval, defaulting to 30s. func (c SchedulerConfig) PollInterval() time.Duration { if c.PollIntervalSeconds <= 0 { return 30 * time.Second } return time.Duration(c.PollIntervalSeconds) * time.Second } // AskUserTimeout returns the configured ask-user timeout, defaulting to 10 // minutes when zero/unset. Ten minutes is long enough that a human actively // working alongside the agent (this is a self-hosted, typically // single-operator system, not a large on-call team watching a queue) has a // realistic chance to notice a clarification request and answer it, but // short enough that a role-typed task doesn't sit stalled for hours — often // the common case, not the exception, in this deployment shape — waiting on // input that may never come. func (c SchedulerConfig) AskUserTimeout() time.Duration { if c.AskUserTimeoutSeconds <= 0 { return 10 * time.Minute } return time.Duration(c.AskUserTimeoutSeconds) * time.Second } func Default() (*Config, error) { home, err := os.UserHomeDir() if err != nil { return nil, fmt.Errorf("cannot determine home directory: %w", err) } if home == "" { return nil, errors.New("cannot determine home directory: HOME is empty") } dataDir := filepath.Join(home, ".claudomator") return &Config{ DataDir: dataDir, DBPath: filepath.Join(dataDir, "claudomator.db"), LogDir: filepath.Join(dataDir, "executions"), DropsDir: filepath.Join(dataDir, "drops"), SSHAuthSock: os.Getenv("SSH_AUTH_SOCK"), ClaudeBinaryPath: "claude", GeminiBinaryPath: "gemini", ClaudeImage: "claudomator-agent:latest", GeminiImage: "claudomator-agent:latest", SandboxImage: "claudomator-agent:latest", MaxConcurrent: 3, DefaultTimeout: "15m", ServerAddr: "127.0.0.1:8484", WorkspaceRoot: "/workspace", ClaudeConfigDir: "/workspace/claudomator/credentials/claude", }, nil } // LoadFile loads a TOML config file on top of the defaults. // Fields not present in the file retain their default values. func LoadFile(path string) (*Config, error) { cfg, err := Default() if err != nil { return nil, err } if _, err := toml.DecodeFile(path, cfg); err != nil { return nil, fmt.Errorf("loading config file %q: %w", path, err) } return cfg, nil } // EnsureDirs creates the data directory structure. func (c *Config) EnsureDirs() error { for _, dir := range []string{c.DataDir, c.LogDir, c.DropsDir} { if err := os.MkdirAll(dir, 0700); err != nil { return err } } return nil }