summaryrefslogtreecommitdiff
path: root/internal/config/config.go
blob: 19548af4c38d1e0a1e323b5f9512b5e8543cd28d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
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.<name>].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.<name>] 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
}