返回 DeepSeek-Reasonix
config.go
根目录 / internal / config / config.go
1 // Package config loads Reasonix's runtime configuration from TOML. Resolution order:
2 // flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.
3 // Secrets come from the environment via api_key_env and are never stored in
4 // config files.
5 package config
6
7 import (
8 "errors"
9 "fmt"
10 "io"
11 "io/fs"
12 "net/netip"
13 "net/url"
14 "os"
15 "path/filepath"
16 "regexp"
17 "runtime"
18 "strings"
19
20 fileencoding "reasonix/internal/fileutil/encoding"
21 "reasonix/internal/netclient"
22 "reasonix/internal/provider"
23 )
24
25 var validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
26
27 // IsValidSkillName reports whether name is a usable skill identifier.
28 func IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }
29
30 // SkillNameKey normalizes a skill identifier for config comparisons.
31 func SkillNameKey(name string) string {
32 name = strings.TrimSpace(name)
33 if !IsValidSkillName(name) {
34 return ""
35 }
36 if runtime.GOOS == "windows" {
37 return strings.ToLower(name)
38 }
39 return name
40 }
41
42 // Config is Reasonix's runtime configuration.
43 type Config struct {
44 ConfigVersion int `toml:"config_version"`
45 DefaultModel string `toml:"default_model"`
46 Language string `toml:"language"` // ui/model language tag (e.g. "zh"); empty = auto-detect from $LANG / $REASONIX_LANG
47 CredentialsStore string `toml:"credentials_store"`
48 UI UIConfig `toml:"ui"`
49 CLI CLIConfig `toml:"cli"`
50 Desktop DesktopConfig `toml:"desktop"`
51 Telemetry TelemetryConfig `toml:"telemetry"`
52 Notifications NotificationsConfig `toml:"notifications"`
53 Agent AgentConfig `toml:"agent"`
54 Providers []ProviderEntry `toml:"providers"`
55 Tools ToolsConfig `toml:"tools"`
56 Permissions PermissionsConfig `toml:"permissions"`
57 Sandbox SandboxConfig `toml:"sandbox"`
58 Network NetworkConfig `toml:"network"`
59 Environment EnvironmentConfig `toml:"environment"`
60 Plugins []PluginEntry `toml:"plugins"`
61 Skills SkillsConfig `toml:"skills"`
62 Statusline StatuslineConfig `toml:"statusline"`
63 LSP LSPConfig `toml:"lsp"`
64 Bot BotConfig `toml:"bot"`
65 Serve ServeConfig `toml:"serve"`
66 Secrets SecretsConfig `toml:"secrets"`
67 Remote RemoteConfig `toml:"remote"`
68
69 systemPromptFileSource promptFileSource
70 providerSources map[string]providerSourceScope
71 shadowedProjectProviders []ProviderEntry
72 ignoredProjectDefaultModel string
73 ignoredLegacyStepLimits bool
74 expansionEnv map[string]string
75 pluginPackageOwners map[string]string
76 pluginPackageSkillOwners map[string][]string
77 pluginPackageAgentOwners map[string][]string
78 editLoadErr error
79 // loadWarnings are non-fatal issues observed while loading config (corrupt
80 // user/project files recovered via last-known-good or defaults). They never
81 // rewrite the original file; the UI may surface them for doctor repair.
82 loadWarnings []string
83 }
84
85 type promptFileSource uint8
86
87 const (
88 promptFileSourceUnknown promptFileSource = iota
89 promptFileSourceUser
90 promptFileSourceProject
91 )
92
93 type systemPromptFileError struct {
94 configured string
95 candidates []string
96 errors []error
97 allMissing bool
98 }
99
100 func (e *systemPromptFileError) Error() string {
101 detail := "could not be read from any configured location"
102 if e.allMissing {
103 detail = "not found at any configured location"
104 }
105 message := fmt.Sprintf("system_prompt_file %q %s: %s", e.configured, detail, strings.Join(e.candidates, ", "))
106 if !e.allMissing && len(e.errors) > 0 {
107 message += ": " + errors.Join(e.errors...).Error()
108 }
109 return message
110 }
111
112 func (e *systemPromptFileError) Unwrap() error { return errors.Join(e.errors...) }
113
114 // IsMissingSystemPromptFile reports whether every allowed location for a
115 // configured prompt file was absent. Permission, containment, and other I/O
116 // failures deliberately return false so callers do not start without an
117 // explicitly configured prompt.
118 func IsMissingSystemPromptFile(err error) bool {
119 var target *systemPromptFileError
120 return errors.As(err, &target) && target.allMissing
121 }
122
123 // TelemetryConfig controls content-free CLI usage metrics. It is user-global:
124 // project reasonix.toml values are ignored so a cloned repository cannot opt a
125 // user into reporting.
126 type TelemetryConfig struct {
127 CLIMetrics string `toml:"cli_metrics"` // auto|on|off; empty means consent has not been requested
128 }
129
130 // CLITelemetryConfigured reports whether the user has made an explicit CLI
131 // telemetry choice. The runtime policy still treats an absent value as auto,
132 // but persistence must preserve absence until the first eligible consent prompt.
133 func (c *Config) CLITelemetryConfigured() bool {
134 if c == nil {
135 return false
136 }
137 switch strings.ToLower(strings.TrimSpace(c.Telemetry.CLIMetrics)) {
138 case "auto", "on", "off":
139 return true
140 default:
141 return false
142 }
143 }
144
145 // CLITelemetryMode returns the normalized CLI telemetry policy.
146 func (c *Config) CLITelemetryMode() string {
147 if c == nil {
148 return "auto"
149 }
150 switch strings.ToLower(strings.TrimSpace(c.Telemetry.CLIMetrics)) {
151 case "on":
152 return "on"
153 case "off":
154 return "off"
155 default:
156 return "auto"
157 }
158 }
159
160 // LoadWarnings returns non-fatal config load issues (corrupt files recovered in
161 // memory). The returned slice is a copy.
162 func (c *Config) LoadWarnings() []string {
163 if c == nil || len(c.loadWarnings) == 0 {
164 return nil
165 }
166 out := make([]string, len(c.loadWarnings))
167 copy(out, c.loadWarnings)
168 return out
169 }
170
171 // HasLoadWarnings reports whether the load used a degraded in-memory fallback.
172 func (c *Config) HasLoadWarnings() bool {
173 return c != nil && len(c.loadWarnings) > 0
174 }
175
176 func (c *Config) addLoadWarning(msg string) {
177 if c == nil {
178 return
179 }
180 msg = strings.TrimSpace(msg)
181 if msg == "" {
182 return
183 }
184 c.loadWarnings = append(c.loadWarnings, msg)
185 }
186
187 // IgnoredLegacyAgentStepLimits reports whether this load found and ignored the
188 // retired [agent].max_steps or planner_max_steps settings. Boot removes standard
189 // key assignments before loading, while read-only/config-only loads only report
190 // and normalize them in memory.
191 func (c *Config) IgnoredLegacyAgentStepLimits() bool {
192 return c != nil && c.ignoredLegacyStepLimits
193 }
194
195 // IgnoredProjectDefaultModel returns the project reasonix.toml default_model
196 // that LoadForRoot ignored because no configured provider serves it (see
197 // restoreUnresolvableProjectDefaultModel), or "" when none was ignored.
198 func (c *Config) IgnoredProjectDefaultModel() string {
199 if c == nil {
200 return ""
201 }
202 return c.ignoredProjectDefaultModel
203 }
204
205 // SecretsConfig controls the credential protection layers. It is a user-global
206 // setting: project reasonix.toml values are ignored (see LoadForRoot), so a
207 // cloned repository cannot silently opt the user into workflow-breaking
208 // protections.
209 type SecretsConfig struct {
210 // FilterSubprocessEnv strips credential-like environment variables
211 // (*_API_KEY, *TOKEN*, *SECRET*, ...) from tool subprocesses (bash, hooks,
212 // LSP, MCP stdio). Default off: it breaks token-based workflows such as
213 // `gh`, HTTPS `git push`, and `npm publish`.
214 FilterSubprocessEnv bool `toml:"filter_subprocess_env"`
215 // ProtectSensitiveFiles makes read/list/search tools treat credential
216 // paths (.env, .git-credentials, .netrc, *.pem/*.key/*.p12/*.pfx, ~/.ssh)
217 // as invisible. Default off because hiding the files breaks legitimate
218 // "edit my .env" workflows.
219 ProtectSensitiveFiles bool `toml:"protect_sensitive_files"`
220 }
221
222 type providerSourceScope string
223
224 const (
225 providerSourceUser providerSourceScope = "user"
226 providerSourceProject providerSourceScope = "project"
227 )
228
229 // UIConfig controls CLI presentation-only settings. Desktop appearance is kept in
230 // DesktopConfig so desktop preferences cannot alter terminal output or prompts.
231 type UIConfig struct {
232 Theme string `toml:"theme"` // auto|dark|light; empty resolves to auto
233 ThemeStyle string `toml:"theme_style"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases
234 ShortcutLayout string `toml:"shortcut_layout"` // classic|desktop; accepted for compatibility
235 CloseBehavior string `toml:"close_behavior"` // legacy desktop close behavior; prefer desktop.close_behavior
236 ShowReasoning bool `toml:"show_reasoning"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed
237 ShowTurnUsage bool `toml:"show_turn_usage"` // show per-request token/cost receipts in the CLI/TUI transcript
238 CursorShape string `toml:"cursor_shape"` // block|underline|bar; empty defaults to bar
239 }
240
241 // CLIConfig controls user-global native CLI behavior. It is separate from
242 // project runtime settings so a repository cannot change the installed
243 // binary's update channel.
244 type CLIConfig struct {
245 // UpdateChannel is decoded for compatibility with pre-single-channel
246 // configurations. Runtime behavior is always the official release channel,
247 // and the canonical renderer intentionally drops this field.
248 UpdateChannel string `toml:"update_channel"`
249 }
250
251 // DesktopConfig controls desktop-only UI preferences. It is intentionally
252 // separate from top-level language and [ui] so desktop choices do not affect CLI
253 // language, terminal colours, or provider-visible prompt/request data.
254 type DesktopConfig struct {
255 Language string `toml:"language"` // auto|en|zh; empty/auto = browser/OS auto-detect
256 Currency string `toml:"currency"` // user-global auto|CNY|USD pricing preference shared by desktop and CLI
257 LayoutStyle string `toml:"layout_style"` // classic|workbench|creation; desktop layout style
258 Theme string `toml:"theme"` // auto|dark|light; empty resolves to auto
259 ThemeStyle string `toml:"theme_style"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases
260 TerminalTheme string `toml:"terminal_theme"` // auto|dark|light; auto follows the desktop app theme
261 ExternalOpener string `toml:"external_opener"` // preferred installed app used by the desktop Open control
262 CloseBehavior string `toml:"close_behavior"` // quit|background; desktop window close behavior
263 DisplayMode string `toml:"display_mode"` // standard|compact (legacy "minimal" maps to compact); transcript display mode
264 StatusBarStyle string `toml:"status_bar_style"` // icon|text; desktop status bar metric labels
265 StatusBarItems []string `toml:"status_bar_items"` // ordered visible desktop status bar items
266 DefaultToolApprovalMode string `toml:"default_tool_approval_mode"` // ask|auto|yolo; defaults to auto for newly-created desktop sessions
267 CheckUpdates *bool `toml:"check_updates"` // startup update checks; nil keeps the default enabled
268 // UpdateChannel is a legacy compatibility field. It is accepted on read but
269 // ignored and omitted from future canonical writes.
270 UpdateChannel string `toml:"update_channel"`
271 Telemetry *bool `toml:"telemetry"` // anonymous launch ping plus scrubbed next-launch native crash diagnostics; nil keeps the default enabled
272 Metrics *bool `toml:"metrics"` // aggregate desktop metrics (anonymous signal/bucket counts, including lifecycle health; no content); nil keeps the default enabled
273 ProviderAccess []string `toml:"provider_access"` // desktop-only list of provider entries shown in Settings > Model > Access
274 ExpandThinking bool `toml:"expand_thinking"` // true = show reasoning text expanded by default; false = collapsed
275 ConversationWidth string `toml:"conversation_width"` // standard|full; max transcript width; empty = standard
276 }
277
278 // DesktopExternalOpener returns the user-selected external opener id. The
279 // desktop shell resolves it against applications installed on the current OS;
280 // an empty or unavailable id safely falls back to the platform file manager.
281 func (c *Config) DesktopExternalOpener() string {
282 if c == nil {
283 return ""
284 }
285 return strings.ToLower(strings.TrimSpace(c.Desktop.ExternalOpener))
286 }
287
288 // NotificationsConfig controls optional system notifications for CLI chat/run.
289 type NotificationsConfig struct {
290 Enabled bool `toml:"enabled"`
291 TurnDone bool `toml:"turn_done"`
292 ApprovalRequest bool `toml:"approval_request"`
293 AskRequest bool `toml:"ask_request"`
294 }
295
296 // EnvironmentConfig controls the stable startup environment block injected into
297 // the model-facing prompt. Enabled nil means the default (enabled); Tools maps a
298 // tool name to an explicit executable path when PATH probing is not enough.
299 type EnvironmentConfig struct {
300 Enabled *bool `toml:"enabled"`
301 Tools map[string]string `toml:"tools"`
302 }
303
304 // EnvironmentEnabled reports whether startup environment probing should feed the
305 // cache-stable system prompt.
306 func (c *Config) EnvironmentEnabled() bool {
307 return c == nil || c.Environment.Enabled == nil || *c.Environment.Enabled
308 }
309
310 // UITheme normalizes ui.theme to a supported value.
311 func (c *Config) UITheme() string {
312 switch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {
313 case "dark":
314 return "dark"
315 case "light":
316 return "light"
317 default:
318 return "auto"
319 }
320 }
321
322 // UIThemeStyle normalizes ui.theme_style. Empty means "pick the default style
323 // for the resolved light/dark shell".
324 func (c *Config) UIThemeStyle() string {
325 return normalizeThemeStyle(c.UI.ThemeStyle)
326 }
327
328 // UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept
329 // for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both
330 // layouts.
331 func (c *Config) UIShortcutLayout() string {
332 switch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {
333 case "desktop", "dual", "dual-axis", "dual_axis":
334 return "desktop"
335 default:
336 return "classic"
337 }
338 }
339
340 // UICursorShape normalizes ui.cursor_shape. The slim "bar" default stays
341 // visible without covering CJK wide characters. Valid values are "block",
342 // "underline", and "bar".
343 func (c *Config) UICursorShape() string {
344 switch strings.ToLower(strings.TrimSpace(c.UI.CursorShape)) {
345 case "block":
346 return "block"
347 case "underline":
348 return "underline"
349 default:
350 return "bar"
351 }
352 }
353
354 func normalizeThemeStyle(style string) string {
355 switch strings.ToLower(strings.TrimSpace(style)) {
356 case "graphite", "aurora", "slate", "carbon", "nocturne", "amber", "ember", "midnight", "sandstone", "porcelain", "linen", "glacier":
357 return strings.ToLower(strings.TrimSpace(style))
358 default:
359 return ""
360 }
361 }
362
363 func normalizeDesktopLayoutStyle(style string) string {
364 switch strings.ToLower(strings.TrimSpace(style)) {
365 case "classic":
366 return "classic"
367 case "workbench", "workspace":
368 return "workbench"
369 case "creation":
370 return "creation"
371 default:
372 return "workbench"
373 }
374 }
375
376 func normalizeCloseBehavior(mode string) string {
377 switch strings.ToLower(strings.TrimSpace(mode)) {
378 case "quit", "exit":
379 return "quit"
380 default:
381 return "background"
382 }
383 }
384
385 // DesktopLanguage normalizes the desktop UI language. Empty means auto-detect
386 // from the browser/OS locale; it deliberately does not read top-level language,
387 // which is used by the CLI/model-facing runtime.
388 func (c *Config) DesktopLanguage() string {
389 switch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {
390 case "en":
391 return "en"
392 case "zh":
393 return "zh"
394 default:
395 return ""
396 }
397 }
398
399 // DesktopCurrency returns the explicit user-global pricing currency. The
400 // persisted field keeps its original desktop namespace for compatibility;
401 // empty means the pricing region follows the desktop/CLI language.
402 func (c *Config) DesktopCurrency() string {
403 if c == nil {
404 return ""
405 }
406 switch strings.ToUpper(strings.TrimSpace(c.Desktop.Currency)) {
407 case "CNY", "RMB", "CNH":
408 return "CNY"
409 case "USD":
410 return "USD"
411 default:
412 return ""
413 }
414 }
415
416 // DesktopTheme normalizes desktop.theme. New desktop users default to the OS
417 // automatic graphite product look; an explicit auto/light/dark is preserved.
418 func (c *Config) DesktopTheme() string {
419 switch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {
420 case "auto":
421 return "auto"
422 case "light":
423 return "light"
424 case "dark":
425 return "dark"
426 default:
427 return "auto"
428 }
429 }
430
431 // DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend
432 // chooses the default style for the resolved desktop theme.
433 func (c *Config) DesktopThemeStyle() string {
434 return normalizeThemeStyle(c.Desktop.ThemeStyle)
435 }
436
437 // DesktopTerminalTheme normalizes the integrated terminal colour preference.
438 // Auto deliberately follows the resolved desktop app theme, including OS theme
439 // changes while desktop.theme is also auto.
440 func (c *Config) DesktopTerminalTheme() string {
441 switch strings.ToLower(strings.TrimSpace(c.Desktop.TerminalTheme)) {
442 case "dark":
443 return "dark"
444 case "light":
445 return "light"
446 default:
447 return "auto"
448 }
449 }
450
451 // DesktopLayoutStyle normalizes the desktop layout style. New installs default
452 // to workbench; explicit classic remains respected.
453 func (c *Config) DesktopLayoutStyle() string {
454 if strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), "workbench") && strings.TrimSpace(c.Desktop.LayoutStyle) == "" {
455 return "workbench"
456 }
457 return normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)
458 }
459
460 // DesktopCloseBehavior normalizes the desktop close-window preference. It falls
461 // back to the legacy ui.close_behavior value for configs written before [desktop]
462 // existed.
463 func (c *Config) DesktopCloseBehavior() string {
464 if strings.TrimSpace(c.Desktop.CloseBehavior) != "" {
465 return normalizeCloseBehavior(c.Desktop.CloseBehavior)
466 }
467 return normalizeCloseBehavior(c.UI.CloseBehavior)
468 }
469
470 // UICloseBehavior is the legacy name for DesktopCloseBehavior.
471 func (c *Config) UICloseBehavior() string {
472 return c.DesktopCloseBehavior()
473 }
474
475 // DesktopDisplayMode normalizes the transcript display mode. Default is
476 // "standard" (flat rendering, no folding).
477 func (c *Config) DesktopDisplayMode() string {
478 switch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {
479 case "standard":
480 return "standard"
481 case "compact", "minimal":
482 return "compact"
483 default:
484 return "standard"
485 }
486 }
487
488 // DesktopConversationWidth returns the normalized desktop conversation width.
489 // Unknown and missing values fall back to standard for backward compatibility.
490 func (c *Config) DesktopConversationWidth() string {
491 if c != nil && strings.EqualFold(strings.TrimSpace(c.Desktop.ConversationWidth), "full") {
492 return "full"
493 }
494 return "standard"
495 }
496
497 // NormalizeToolApprovalMode returns the canonical desktop/session tool approval
498 // posture. Unknown or missing values fall back to ask for safety.
499 func NormalizeToolApprovalMode(mode string) string {
500 switch strings.ToLower(strings.TrimSpace(mode)) {
501 case "auto":
502 return "auto"
503 case "yolo", "full", "full-access", "bypass":
504 return "yolo"
505 default:
506 return "ask"
507 }
508 }
509
510 // DesktopDefaultToolApprovalMode is the Ask/Auto/YOLO default used only when
511 // creating a new desktop session. Existing tabs and restored sessions keep their
512 // own persisted runtime state.
513 func (c *Config) DesktopDefaultToolApprovalMode() string {
514 if c == nil {
515 return "ask"
516 }
517 return NormalizeToolApprovalMode(c.Desktop.DefaultToolApprovalMode)
518 }
519
520 // DesktopStatusBarStyle normalizes the desktop status bar metric label style.
521 // Default is "text"; explicit "icon" preserves the user's compact choice.
522 func (c *Config) DesktopStatusBarStyle() string {
523 switch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {
524 case "icon":
525 return "icon"
526 case "text":
527 return "text"
528 default:
529 return "text"
530 }
531 }
532
533 var defaultDesktopStatusBarItems = []string{
534 "model",
535 "workspace",
536 "git_branch",
537 "cache",
538 "cache_avg",
539 "session_tokens",
540 "turn_tokens",
541 "turn_cost",
542 "session_turns",
543 "context",
544 "compact",
545 "cost",
546 "balance",
547 }
548
549 var knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)
550
551 func desktopStatusBarItemSet(items []string) map[string]bool {
552 out := make(map[string]bool, len(items))
553 for _, item := range items {
554 out[item] = true
555 }
556 return out
557 }
558
559 // DefaultDesktopStatusBarItems returns the default ordered visible desktop
560 // status bar items.
561 func DefaultDesktopStatusBarItems() []string {
562 return append([]string(nil), defaultDesktopStatusBarItems...)
563 }
564
565 // DesktopStatusBarItems normalizes the ordered visible desktop status bar items.
566 // An unset or empty list uses the default full set; explicit non-empty lists
567 // preserve user order and omit hidden items.
568 func (c *Config) DesktopStatusBarItems() []string {
569 return normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)
570 }
571
572 func normalizeDesktopStatusBarItems(items []string) []string {
573 out := make([]string, 0, len(items))
574 seen := map[string]bool{}
575 for _, raw := range items {
576 id := strings.TrimSpace(raw)
577 if !knownDesktopStatusBarItems[id] || seen[id] {
578 continue
579 }
580 out = append(out, id)
581 seen[id] = true
582 }
583 if len(out) == 0 {
584 return DefaultDesktopStatusBarItems()
585 }
586 return out
587 }
588
589 // DesktopCheckUpdates reports whether the desktop should check for updates on
590 // startup. Missing configs default to true so existing users keep update notices.
591 func (c *Config) DesktopCheckUpdates() bool {
592 if c == nil || c.Desktop.CheckUpdates == nil {
593 return true
594 }
595 return *c.Desktop.CheckUpdates
596 }
597
598 // NormalizeCLIUpdateChannel returns the only public native CLI update channel.
599 // The input remains accepted so older preview configurations keep loading.
600 func NormalizeCLIUpdateChannel(_ string) string {
601 return "stable"
602 }
603
604 // CLIUpdateChannel returns the user-global native CLI update channel.
605 func (c *Config) CLIUpdateChannel() string {
606 if c == nil {
607 return "stable"
608 }
609 return NormalizeCLIUpdateChannel(c.CLI.UpdateChannel)
610 }
611
612 // NormalizeDesktopUpdateChannel returns the only public Desktop update channel.
613 // Legacy preview/canary/beta/next values are deliberately ignored so an old
614 // configuration cannot strand the installation on the retired channel.
615 func NormalizeDesktopUpdateChannel(_ string) string {
616 return "stable"
617 }
618
619 // DesktopUpdateChannel returns the desktop channel whose latest pointer should be
620 // checked. Missing or unknown configs default to stable.
621 func (c *Config) DesktopUpdateChannel() string {
622 if c == nil {
623 return "stable"
624 }
625 return NormalizeDesktopUpdateChannel(c.Desktop.UpdateChannel)
626 }
627
628 // ColdResumePruneEnabled reports whether stale tool results are elided when a
629 // session resumes past the provider cache window. Default true (cheaper cold
630 // restart); users keep full history by disabling it.
631 func (c *Config) ColdResumePruneEnabled() bool {
632 if c == nil || c.Agent.ColdResumePrune == nil {
633 return true
634 }
635 return *c.Agent.ColdResumePrune
636 }
637
638 // ResponseLanguage normalizes the top-level language preference for final
639 // answers. Empty means auto: replies follow the current user turn.
640 func (c *Config) ResponseLanguage() string {
641 if c == nil {
642 return "auto"
643 }
644 return NormalizeLanguage(c.Language)
645 }
646
647 // NormalizeLanguage returns one of auto|zh|en for UI/default reply language settings.
648 func NormalizeLanguage(lang string) string {
649 switch strings.ToLower(strings.TrimSpace(lang)) {
650 case "", "auto", "detect", "default":
651 return "auto"
652 case "zh", "cn", "chinese", "中文":
653 return "zh"
654 case "en", "english":
655 return "en"
656 default:
657 return "auto"
658 }
659 }
660
661 // ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:
662 // visible reasoning follows the conversation language already described by the
663 // stable LanguagePolicy. Legacy "default" is treated as auto.
664 func (c *Config) ReasoningLanguage() string {
665 if c == nil {
666 return "auto"
667 }
668 return NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)
669 }
670
671 // NormalizeReasoningLanguage returns one of auto|zh|en.
672 func NormalizeReasoningLanguage(lang string) string {
673 switch strings.ToLower(strings.TrimSpace(lang)) {
674 case "", "auto", "follow", "conversation", "detect", "default", "model", "model-default", "model_default", "provider":
675 return "auto"
676 case "zh", "cn", "chinese", "中文":
677 return "zh"
678 case "en", "english":
679 return "en"
680 default:
681 return "auto"
682 }
683 }
684
685 // DesktopTelemetry reports whether the desktop sends the anonymous launch ping.
686 // It carries no conversation, key, or file data — see desktop/README.md.
687 func (c *Config) DesktopTelemetry() bool {
688 if c == nil || c.Desktop.Telemetry == nil {
689 return true
690 }
691 return *c.Desktop.Telemetry
692 }
693
694 // DesktopMetrics reports whether the desktop sends aggregate desktop metrics —
695 // anonymous (signal, bucket) counters, never content. Default on.
696 func (c *Config) DesktopMetrics() bool {
697 if c == nil || c.Desktop.Metrics == nil {
698 return true
699 }
700 return *c.Desktop.Metrics
701 }
702
703 // LSPConfig governs the optional Language Server Protocol tools (lsp_definition,
704 // lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the
705 // servers themselves are never bundled — each resolves on PATH and the tool
706 // returns an install hint when it is missing, so the capability is dormant until
707 // the user installs a server. Servers overrides or extends the built-in language
708 // → server map, keyed by language id (e.g. "go", "rust", "python").
709 type LSPConfig struct {
710 Enabled bool `toml:"enabled"`
711 Servers map[string]LSPServer `toml:"servers"`
712 }
713
714 // LSPServer overrides a built-in language's server or, when keyed by a new
715 // language, adds one. An empty field falls back to the built-in default for that
716 // language; Extensions is required when adding a language the built-ins don't
717 // cover (e.g. ".ex" for Elixir) so files route to it.
718 type LSPServer struct {
719 Command string `toml:"command"`
720 Args []string `toml:"args"`
721 Env map[string]string `toml:"env"`
722 LanguageID string `toml:"language_id"`
723 Extensions []string `toml:"extensions"`
724 InstallHint string `toml:"install_hint"`
725 }
726
727 // StatuslineConfig configures a custom status line. Command, when set, is run at
728 // startup and after each turn; its first line of stdout replaces the built-in
729 // status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.
730 type StatuslineConfig struct {
731 Command string `toml:"command"`
732 }
733
734 // BotConfig 控制多渠道 IM bot 消息网关。
735 type BotConfig struct {
736 Enabled bool `toml:"enabled"`
737 Model string `toml:"model"` // 用于 bot 的模型名,空则用 default_model
738 ToolApprovalMode string `toml:"tool_approval_mode"`
739 MaxSteps int `toml:"max_steps"`
740 DebounceMs int `toml:"debounce_ms"` // 消息合并窗口,毫秒
741 QueueMode string `toml:"queue_mode"` // steer|followup|collect|interrupt
742 QueueCap int `toml:"queue_cap"`
743 QueueDrop string `toml:"queue_drop"` // summarize|old|new
744 IgnoreSelfMessages bool `toml:"ignore_self_messages"`
745 SelfUserIDs BotSelfUserIDs `toml:"self_user_ids"`
746 Control BotControlConfig `toml:"control"`
747 Pairing BotPairingConfig `toml:"pairing"`
748 Allowlist BotAllowlist `toml:"allowlist"`
749 QQ QQBotConfig `toml:"qq"`
750 Feishu FeishuBotConfig `toml:"feishu"`
751 Weixin WeixinBotConfig `toml:"weixin"`
752 Routes []BotRouteConfig `toml:"routes"`
753 Connections []BotConnectionConfig `toml:"connections"`
754 // DesktopWatchers persists /desktop watch subscriptions so god-view
755 // notifications survive a desktop restart. Managed by the desktop bot
756 // bridge, not the settings UI.
757 DesktopWatchers []BotDesktopWatcherConfig `toml:"desktop_watchers"`
758 }
759
760 // BotDesktopWatcherConfig is one bot chat subscribed to desktop events
761 // (/desktop watch on).
762 type BotDesktopWatcherConfig struct {
763 Platform string `toml:"platform"`
764 ConnectionID string `toml:"connection_id"`
765 Domain string `toml:"domain"`
766 ChatType string `toml:"chat_type"`
767 ChatID string `toml:"chat_id"`
768 }
769
770 type BotSelfUserIDs struct {
771 QQ []string `toml:"qq"`
772 Feishu []string `toml:"feishu"`
773 Weixin []string `toml:"weixin"`
774 }
775
776 type BotControlConfig struct {
777 Enabled bool `toml:"enabled"`
778 Addr string `toml:"addr"`
779 TokenEnv string `toml:"token_env"`
780 }
781
782 type BotRouteConfig struct {
783 ConnectionID string `toml:"connection_id"`
784 Platform string `toml:"platform"`
785 ChatType string `toml:"chat_type"`
786 ChatID string `toml:"chat_id"`
787 UserID string `toml:"user_id"`
788 ThreadID string `toml:"thread_id"`
789 Model string `toml:"model"`
790 ToolApprovalMode string `toml:"tool_approval_mode"`
791 WorkspaceRoot string `toml:"workspace_root"`
792 }
793
794 // BotAllowlist 控制哪些用户可以使用 bot。
795 type BotAllowlist struct {
796 Enabled bool `toml:"enabled"`
797 AllowAll bool `toml:"allow_all"`
798 QQUsers []string `toml:"qq_users"`
799 FeishuUsers []string `toml:"feishu_users"`
800 WeixinUsers []string `toml:"weixin_users"`
801 QQApprovers []string `toml:"qq_approvers"`
802 FeishuApprovers []string `toml:"feishu_approvers"`
803 WeixinApprovers []string `toml:"weixin_approvers"`
804 QQAdmins []string `toml:"qq_admins"`
805 FeishuAdmins []string `toml:"feishu_admins"`
806 WeixinAdmins []string `toml:"weixin_admins"`
807 QQGroups []string `toml:"qq_groups"`
808 FeishuGroups []string `toml:"feishu_groups"`
809 WeixinGroups []string `toml:"weixin_groups"`
810 }
811
812 type BotPairingConfig struct {
813 Enabled bool `toml:"enabled"`
814 RequestTTLMinutes int `toml:"request_ttl_minutes"`
815 MaxPendingPerPlatform int `toml:"max_pending_per_platform"`
816 }
817
818 // BotAccessConfig controls who may use one concrete bot connection.
819 type BotAccessConfig struct {
820 Enabled bool `toml:"enabled"`
821 AllowAll bool `toml:"allow_all"`
822 PairingEnabled bool `toml:"pairing_enabled"`
823 Users []string `toml:"users"`
824 Groups []string `toml:"groups"`
825 Approvers []string `toml:"approvers"`
826 Admins []string `toml:"admins"`
827 }
828
829 // QQBotConfig QQ 官方 Bot API v2 配置。
830 type QQBotConfig struct {
831 Enabled bool `toml:"enabled"`
832 AppID string `toml:"app_id"`
833 AppSecretEnv string `toml:"app_secret_env"` // 环境变量名,如 QQ_BOT_APP_SECRET
834 Sandbox bool `toml:"sandbox"` // true 使用 QQ 沙箱 API / gateway
835 Model string `toml:"model"`
836 ToolApprovalMode string `toml:"tool_approval_mode"`
837 WorkspaceRoot string `toml:"workspace_root"`
838 Access BotAccessConfig `toml:"access"`
839 }
840
841 // FeishuBotConfig 飞书自建应用 Bot 配置。
842 type FeishuBotConfig struct {
843 Enabled bool `toml:"enabled"`
844 Domain string `toml:"domain"` // feishu(默认)| lark
845 AppID string `toml:"app_id"`
846 AppSecretEnv string `toml:"app_secret_env"` // 如 FEISHU_BOT_APP_SECRET
847 VerificationToken string `toml:"verification_token"` // 事件订阅验证 token
848 Mode string `toml:"mode"` // webhook(默认)| websocket
849 WebhookPort int `toml:"webhook_port"` // webhook 模式端口
850 RequireMention bool `toml:"require_mention"`
851 // OutboundMediaRoots contains absolute local directories the loopback /send
852 // control API may attach files from. Media refs must be bare filenames and
853 // must exist in exactly one configured root. Empty (the default) disables
854 // outbound file sending.
855 OutboundMediaRoots []string `toml:"outbound_media_roots"`
856 }
857
858 // WeixinBotConfig 微信 iLink Bot 配置。
859 type WeixinBotConfig struct {
860 Enabled bool `toml:"enabled"`
861 AccountID string `toml:"account_id"`
862 TokenEnv string `toml:"token_env"` // 环境变量名,如 WEIXIN_BOT_TOKEN
863 APIBase string `toml:"api_base"` // iLink API base URL
864 }
865
866 // BotConnectionConfig is the desktop-friendly connection record for IM bot
867 // channels. It keeps install/runtime state separate from legacy per-provider
868 // knobs so the UI can expose a simple "connect first" flow while old configs
869 // keep working.
870 type BotConnectionConfig struct {
871 ID string `toml:"id"`
872 Provider string `toml:"provider"` // qq|feishu|weixin
873 Domain string `toml:"domain"` // feishu|lark|weixin|qq
874 Label string `toml:"label"`
875 Enabled bool `toml:"enabled"`
876 Status string `toml:"status"` // disconnected|pending|connected|error
877 Model string `toml:"model"`
878 ToolApprovalMode string `toml:"tool_approval_mode"`
879 WorkspaceRoot string `toml:"workspace_root"`
880 Access BotAccessConfig `toml:"access"`
881 Credential BotConnectionCredential `toml:"credential"`
882 SessionMappings []BotConnectionSessionMapping `toml:"session_mappings"`
883 LastError string `toml:"last_error"`
884 CreatedAt string `toml:"created_at"`
885 UpdatedAt string `toml:"updated_at"`
886 }
887
888 type BotConnectionCredential struct {
889 AppID string `toml:"app_id"`
890 AppSecretEnv string `toml:"app_secret_env"`
891 AccountID string `toml:"account_id"`
892 TokenEnv string `toml:"token_env"`
893 }
894
895 type BotConnectionSessionMapping struct {
896 RemoteID string `toml:"remote_id"`
897 SessionID string `toml:"session_id"`
898 SessionSource string `toml:"session_source"`
899 ChatType string `toml:"chat_type"`
900 UserID string `toml:"user_id"`
901 ThreadID string `toml:"thread_id"`
902 Scope string `toml:"scope"`
903 WorkspaceRoot string `toml:"workspace_root"`
904 UpdatedAt string `toml:"updated_at"`
905 }
906
907 // ServeConfig controls the HTTP serve frontend security settings.
908 type ServeConfig struct {
909 // AuthMode selects the authentication mode for the HTTP serve frontend.
910 // "none" (default): no authentication.
911 // "token": a pre-shared token in the URL query string.
912 // "password": a login page with bcrypt password verification.
913 AuthMode string `toml:"auth_mode"`
914 // Token is a pre-shared token for auth_mode = "token". When empty, a
915 // cryptographically random token is generated at startup and printed.
916 Token string `toml:"token"`
917 // PasswordHash is a bcrypt hash of the password for auth_mode = "password".
918 // Generate one with: reasonix serve --hash-password --password '...'
919 PasswordHash string `toml:"password_hash"`
920 // BehindProxy indicates the server sits behind a trusted reverse proxy
921 // (nginx, Caddy, Cloudflare, etc.) that sets X-Forwarded-For and
922 // X-Forwarded-Proto headers. When true, those headers are used for
923 // rate-limiting and Secure-cookie decisions. When false (default), they
924 // are ignored — an attacker can otherwise forge them.
925 BehindProxy bool `toml:"behind_proxy"`
926 }
927
928 // NetworkConfig controls ordinary outbound HTTP traffic such as model providers,
929 // wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.
930 // web_fetch reuses these proxy settings while keeping its own SSRF-guarded
931 // dialer.
932 type NetworkConfig struct {
933 // ProxyMode is "auto" (default; environment proxy for now), "env", "custom",
934 // or "off". auto leaves room for OS proxy detection later without changing the
935 // config shape.
936 ProxyMode string `toml:"proxy_mode"`
937 // ProxyURL is an advanced custom override such as "socks5://127.0.0.1:7890".
938 // When set and proxy_mode = "custom", it wins over the structured proxy table.
939 ProxyURL string `toml:"proxy_url"`
940 // NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the
941 // process environment instead.
942 NoProxy string `toml:"no_proxy"`
943 Proxy NetworkProxyConfig `toml:"proxy"`
944 }
945
946 // NetworkProxyConfig is the structured custom-proxy editor shape. Password is
947 // optional and supports ${VAR} expansion, so users can avoid storing it literally.
948 type NetworkProxyConfig struct {
949 Type string `toml:"type"` // http|https|socks5|socks5h
950 Server string `toml:"server"`
951 Port int `toml:"port"`
952 Username string `toml:"username"`
953 Password string `toml:"password"`
954 }
955
956 // NetworkProxySpec returns the expanded proxy settings used by netclient.
957 func (c *Config) NetworkProxySpec() netclient.ProxySpec {
958 return netclient.ProxySpec{
959 Mode: c.Network.ProxyMode,
960 URL: c.expandVars(c.Network.ProxyURL),
961 NoProxy: c.expandVars(c.Network.NoProxy),
962 Type: c.Network.Proxy.Type,
963 Server: c.expandVars(c.Network.Proxy.Server),
964 Port: c.Network.Proxy.Port,
965 Username: c.expandVars(c.Network.Proxy.Username),
966 Password: c.expandVars(c.Network.Proxy.Password),
967 DirectHosts: c.directProxyHosts(),
968 }
969 }
970
971 // directProxyHosts collects the base_url hosts of providers marked no_proxy, so
972 // netclient bypasses the proxy for them without knowing any provider by name.
973 //
974 // Only for an auto-detected proxy (auto/env): that proxy is typically a
975 // GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep
976 // them direct. An explicit proxy_mode = "custom" is the user saying "route
977 // everything through this" — e.g. a mandatory corporate proxy — so honor it for
978 // every provider; a custom-proxy user who wants a host direct uses
979 // network.no_proxy instead (#3635).
980 func (c *Config) directProxyHosts() []string {
981 if c.NetworkProxyMode() == netclient.ModeCustom {
982 return nil
983 }
984 seen := map[string]bool{}
985 var out []string
986 for _, p := range c.Providers {
987 if !p.NoProxy {
988 continue
989 }
990 u, err := url.Parse(strings.TrimSpace(p.BaseURL))
991 if err != nil {
992 continue
993 }
994 if h := u.Hostname(); h != "" && !seen[h] {
995 seen[h] = true
996 out = append(out, h)
997 }
998 }
999 return out
1000 }
1001
1002 // NetworkProxyMode normalizes network.proxy_mode to a known value.
1003 func (c *Config) NetworkProxyMode() string {
1004 return netclient.NormalizeMode(c.Network.ProxyMode)
1005 }
1006
1007 // SkillsConfig configures skill discovery. Paths adds extra "custom"-scope skill
1008 // roots — each a directory of SKILL.md / <name>.md playbooks — scanned between
1009 // the project roots (.reasonix/.agents/.agent/.claude under the workspace) and
1010 // the global roots. ExcludedPaths hides matching discovery roots without deleting
1011 // folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills
1012 // hides named skills from the agent prompt, slash invocation, and skill tools
1013 // while keeping them manageable.
1014 type SkillsConfig struct {
1015 Paths []string `toml:"paths"`
1016 ExcludedPaths []string `toml:"excluded_paths"`
1017 DisabledSkills []string `toml:"disabled_skills"`
1018 MaxDepth int `toml:"max_depth"`
1019 }
1020
1021 // SkillCustomPaths returns the configured custom skill roots with ${VAR}
1022 // expanded; empty entries are dropped.
1023 func (c *Config) SkillCustomPaths() []string {
1024 var out []string
1025 for _, p := range c.Skills.Paths {
1026 if p = c.expandVars(p); strings.TrimSpace(p) != "" {
1027 out = append(out, p)
1028 }
1029 }
1030 return out
1031 }
1032
1033 // SkillExcludedPaths returns configured skill roots that should be hidden from
1034 // discovery, with ${VAR} expanded and empty entries dropped.
1035 func (c *Config) SkillExcludedPaths() []string {
1036 var out []string
1037 for _, p := range c.Skills.ExcludedPaths {
1038 if p = c.expandVars(p); strings.TrimSpace(p) != "" {
1039 out = append(out, p)
1040 }
1041 }
1042 return out
1043 }
1044
1045 // SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill
1046 // packs while Store keeps nested markdown safe by requiring descriptions.
1047 func (c *Config) SkillMaxDepth() int {
1048 const (
1049 defaultDepth = 3
1050 maxDepth = 5
1051 )
1052 if c == nil || c.Skills.MaxDepth == 0 {
1053 return defaultDepth
1054 }
1055 if c.Skills.MaxDepth < 1 {
1056 return 1
1057 }
1058 if c.Skills.MaxDepth > maxDepth {
1059 return maxDepth
1060 }
1061 return c.Skills.MaxDepth
1062 }
1063
1064 // DisabledSkillNames returns valid disabled skill identifiers, preserving the
1065 // first spelling and dropping duplicates/empty entries.
1066 func (c *Config) DisabledSkillNames() []string {
1067 seen := map[string]bool{}
1068 var out []string
1069 for _, name := range c.Skills.DisabledSkills {
1070 name = strings.TrimSpace(name)
1071 if !IsValidSkillName(name) {
1072 continue
1073 }
1074 key := SkillNameKey(name)
1075 if seen[key] {
1076 continue
1077 }
1078 seen[key] = true
1079 out = append(out, name)
1080 }
1081 return out
1082 }
1083
1084 // IsSkillDisabled reports whether name is configured as disabled.
1085 func (c *Config) IsSkillDisabled(name string) bool {
1086 key := SkillNameKey(name)
1087 if key == "" {
1088 return false
1089 }
1090 for _, disabled := range c.DisabledSkillNames() {
1091 if SkillNameKey(disabled) == key {
1092 return true
1093 }
1094 }
1095 return false
1096 }
1097
1098 // SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer
1099 // confinement). WorkspaceRoot is the directory the built-in file writers
1100 // (write_file / edit_file / multi_edit / move_file) may modify; empty means the
1101 // current working directory, so writes stay inside the project by default.
1102 // AllowWrite lists extra directories writers may also touch (e.g. a sibling repo
1103 // or a temp dir). ForbidRead lists files or directories the agent may not read or list
1104 // (e.g. ~/.ssh for secrets). Both support ${VAR} / ${VAR:-default} expansion. Reads are
1105 // unrestricted; confining `bash` is Phase 1 (OS-level sandbox).
1106 type SandboxConfig struct {
1107 WorkspaceRoot string `toml:"workspace_root"`
1108 AllowWrite []string `toml:"allow_write"`
1109 ForbidRead []string `toml:"forbid_read"`
1110 // Bash is the OS-sandbox mode for the bash tool: "enforce" jails each
1111 // command when an OS sandbox is available and refuses bash otherwise; "off"
1112 // runs it unconfined. Empty uses the platform default.
1113 Bash string `toml:"bash"`
1114 // Network allows network egress from inside the bash sandbox. Defaults true
1115 // so module/package downloads keep working; the boundary is then writes.
1116 Network bool `toml:"network"`
1117 }
1118
1119 // WriteRoots returns the directories file-writer tools may modify: the
1120 // workspace root (defaulting to the current working directory when unset), plus
1121 // any AllowWrite extras, with ${VAR} expanded. The roots are returned as given
1122 // (relative or absolute); the confiner resolves them to absolute, symlink-free
1123 // paths. The result is always non-empty, so confinement is on by default.
1124 func (c *Config) WriteRoots() []string {
1125 return c.WriteRootsForRoot(".")
1126 }
1127
1128 // WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the
1129 // config doesn't explicitly set a workspace_root. Desktop tabs pass their
1130 // project root here so tool confinement is correct without changing cwd.
1131 func (c *Config) WriteRootsForRoot(fallbackRoot string) []string {
1132 root := c.expandVars(c.Sandbox.WorkspaceRoot)
1133 if root == "" {
1134 root = fallbackRoot
1135 if root == "" || root == "." {
1136 if wd, err := os.Getwd(); err == nil {
1137 root = wd
1138 } else {
1139 root = "."
1140 }
1141 }
1142 }
1143 roots := []string{root}
1144 for _, d := range c.Sandbox.AllowWrite {
1145 if d = c.expandVars(d); d != "" {
1146 roots = append(roots, d)
1147 }
1148 }
1149 return roots
1150 }
1151
1152 // AllowWriteRoots returns only the configured [sandbox] allow_write extras with
1153 // ${VAR} expanded — the explicit escape-hatch entries, without the workspace
1154 // root that WriteRoots prepends. The session-data write guard treats these as
1155 // user-sanctioned raw access.
1156 func (c *Config) AllowWriteRoots() []string {
1157 var roots []string
1158 for _, d := range c.Sandbox.AllowWrite {
1159 if d = c.expandVars(d); d != "" {
1160 roots = append(roots, d)
1161 }
1162 }
1163 return roots
1164 }
1165
1166 // ForbidReadRoots returns the paths the agent is forbidden from reading
1167 // or listing, with ${VAR} expanded. Relative roots are resolved against the
1168 // current working directory; the confiner resolves them to symlink-free paths.
1169 // Empty when no forbid_read entries are configured.
1170 func (c *Config) ForbidReadRoots() []string {
1171 return c.ForbidReadRootsForRoot(".")
1172 }
1173
1174 // ForbidReadRootsForRoot is like ForbidReadRoots but uses fallbackRoot when
1175 // resolving relative paths (for desktop tabs that pass their project root).
1176 func (c *Config) ForbidReadRootsForRoot(fallbackRoot string) []string {
1177 root := fallbackRoot
1178 if root == "" || root == "." {
1179 if wd, err := os.Getwd(); err == nil {
1180 root = wd
1181 } else {
1182 root = "."
1183 }
1184 }
1185 roots := make([]string, 0, len(c.Sandbox.ForbidRead))
1186 for _, d := range c.Sandbox.ForbidRead {
1187 if d = c.expandVars(d); d != "" {
1188 if !filepath.IsAbs(d) {
1189 d = filepath.Join(root, d)
1190 }
1191 roots = append(roots, d)
1192 }
1193 }
1194 return roots
1195 }
1196
1197 // BashMode normalises the bash-sandbox mode for the current host.
1198 func (c *Config) BashMode() string {
1199 return c.BashModeForGOOS(runtimeGOOS)
1200 }
1201
1202 // BashModeForGOOS normalises the bash-sandbox mode for tests and cross-platform
1203 // rendering. Windows has no OS-level Bash sandbox and forces the effective mode
1204 // off, even when older configs explicitly requested "enforce". macOS/Linux keep
1205 // the existing explicit-mode behavior.
1206 func (c *Config) BashModeForGOOS(goos string) string {
1207 if goos == "windows" {
1208 return "off"
1209 }
1210 switch strings.TrimSpace(c.Sandbox.Bash) {
1211 case "enforce":
1212 return "enforce"
1213 case "off":
1214 return "off"
1215 case "":
1216 return "enforce"
1217 default:
1218 return "enforce"
1219 }
1220 }
1221
1222 // AgentConfig configures the harness loop. PlannerModel is optional: when set
1223 // to another provider's name it enables two-model collaboration, where the
1224 // planner handles low-frequency planning in its own session (kept separate so
1225 // each model's prompt prefix stays cache-stable). SubagentModel is the optional
1226 // default for runAs=subagent skills; SubagentModels overrides it per skill name.
1227 type AgentConfig struct {
1228 SystemPrompt string `toml:"system_prompt"`
1229 SystemPromptFile string `toml:"system_prompt_file"`
1230 // Deprecated compatibility fields. Old TOML and desktop clients may still
1231 // send them, but config loading normalizes both to zero and rendering omits
1232 // them. One-off CLI and unattended bot limits remain separate controls.
1233 MaxSteps int `toml:"max_steps"`
1234 PlannerMaxSteps int `toml:"planner_max_steps"`
1235 Temperature float64 `toml:"temperature"`
1236 PlannerModel string `toml:"planner_model"`
1237 GuardianModel string `toml:"guardian_model"`
1238 GuardianTemperature float64 `toml:"guardian_temperature"`
1239 // RecoveryModel optionally names a dedicated model for the independent
1240 // recovery reviewer. Empty falls back to GuardianModel, then the main model.
1241 RecoveryModel string `toml:"recovery_model"`
1242 // RecoveryTemperature is accepted from older configs but ignored. Auto
1243 // Guard review is deterministic at temperature zero.
1244 RecoveryTemperature float64 `toml:"recovery_temperature"`
1245 SubagentModel string `toml:"subagent_model"`
1246 SubagentModels map[string]string `toml:"subagent_models"`
1247 SubagentEffort string `toml:"subagent_effort"`
1248 SubagentEfforts map[string]string `toml:"subagent_efforts"`
1249 MaxSubagentDepth int `toml:"max_subagent_depth"`
1250 // MaxSubagentConcurrency bounds how many sub-agents (task, fleet items,
1251 // profile skills, nested children) may run at once in one session.
1252 // 0 means the default (6). Values outside 1–32 are clamped on load.
1253 MaxSubagentConcurrency int `toml:"max_subagent_concurrency"`
1254 // MaxParallelWriters bounds concurrent writer-capable sub-agents that
1255 // declare non-overlapping write_paths. 0 means the default (3). Must not
1256 // exceed MaxSubagentConcurrency after normalization.
1257 MaxParallelWriters int `toml:"max_parallel_writers"`
1258 // OutputStyle selects a persona/tone block folded into the system prompt at
1259 // startup (a built-in like "explanatory"/"learning"/"concise", or a custom
1260 // .reasonix/output-styles/<name>.md). Empty = the unmodified prompt.
1261 OutputStyle string `toml:"output_style"`
1262 // Deprecated compatibility field. Automatic plan mode was retired in config
1263 // version 5; old TOML remains readable, but loading normalizes it to "off"
1264 // and rendering omits it. Plan mode remains available as an explicit user
1265 // choice.
1266 AutoPlan string `toml:"auto_plan"`
1267 // ReasoningLanguage controls the preferred language for visible reasoning
1268 // text. Empty/auto follows the conversation language. Applied as transient
1269 // turn context, not the stable prompt.
1270 ReasoningLanguage string `toml:"reasoning_language"`
1271 // Deprecated compatibility field paired with AutoPlan. Old TOML remains
1272 // readable, but loading clears it and rendering omits it.
1273 AutoPlanClassifier string `toml:"auto_plan_classifier"`
1274 // Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.
1275 SoftCompactRatio float64 `toml:"soft_compact_ratio"`
1276 ToolResultSnipRatio float64 `toml:"tool_result_snip_ratio"`
1277 CompactRatio float64 `toml:"compact_ratio"`
1278 CompactForceRatio float64 `toml:"compact_force_ratio"`
1279 // Keep controls which compactable messages stay verbatim beyond the current
1280 // user-fact/digest floor and recent tail. Empty uses the conservative default
1281 // of keeping error tool results.
1282 Keep []string `toml:"keep"`
1283 RecentKeep int `toml:"recent_keep"`
1284 // ColdResumePrune elides stale tool results when a session reopens past the
1285 // provider cache window. nil = default enabled.
1286 ColdResumePrune *bool `toml:"cold_resume_prune"`
1287 // PlanModeReadOnlyCommands is retained for old config/session round trips. Main
1288 // Plan bash calls now use the ordinary Permissions classifier and Sandbox.
1289 PlanModeReadOnlyCommands []string `toml:"plan_mode_read_only_commands"`
1290 }
1291
1292 // ProviderEntry declares a model provider instance. ContextWindow is the model's
1293 // token budget; the harness compacts older history as a turn's prompt approaches
1294 // it (see agent compaction). 0 disables compaction for the instance.
1295 type ProviderEntry struct {
1296 Name string `toml:"name"`
1297 Kind string `toml:"kind"`
1298 BaseURL string `toml:"base_url"`
1299 ChatURL string `toml:"chat_url"`
1300 Model string `toml:"model"` // a single model (back-compat)
1301 Models []string `toml:"models"` // a vendor's model list (one base_url/key, many models)
1302 ModelsURL string `toml:"models_url"` // auto-fetch models from this URL on startup
1303 Default string `toml:"default"` // default model when Models is set (else Models[0])
1304 APIKeyEnv string `toml:"api_key_env"`
1305 PresetID string `toml:"preset_id"` // curated preset identity; UI-only metadata, not sent to model providers.
1306 PresetVersion int `toml:"preset_version"` // curated preset schema version for future migrations.
1307 Headers map[string]string `toml:"headers"` // optional extra HTTP headers for compatible gateways; secrets should stay in api_key_env.
1308 ExtraBody map[string]any `toml:"extra_body"` // optional extra top-level JSON request body fields for OpenAI-compatible gateways.
1309 AuthHeader bool `toml:"auth_header"` // for Anthropic-compatible gateways that expect Authorization: Bearer instead of x-api-key.
1310 // ResponsesMode selects the Responses API context strategy. Empty preserves
1311 // vendor detection; DeepSeek is stateless while compatible endpoints may use
1312 // stateful previous_response_id continuation.
1313 ResponsesMode string `toml:"responses_mode"`
1314 // ResponsesStateful is the legacy boolean form retained for config
1315 // compatibility. ResponsesMode wins when both are present.
1316 ResponsesStateful *bool `toml:"responses_stateful"`
1317 resolvedAPIKey string
1318 resolvedSource CredentialSource
1319 BalanceURL string `toml:"balance_url"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.
1320 ContextWindow int `toml:"context_window"`
1321 // MaxOutputTokens is a protocol-neutral total output budget. Zero lets the
1322 // provider choose a safe default, a positive value is explicit, and a
1323 // negative value omits optional wire limits. Anthropic still requires one.
1324 MaxOutputTokens int `toml:"max_output_tokens"`
1325 Price *provider.Pricing `toml:"price"` // legacy/provider-wide fallback
1326 Prices map[string]*provider.Pricing `toml:"prices"` // optional per-model prices; keys are model ids
1327
1328 persistedOfficialCurrency string
1329
1330 // Thinking / Effort are provider-kind-specific knobs forwarded to the provider
1331 // via Config.Extra. The anthropic provider reads Thinking="adaptive" to enable
1332 // extended thinking and Effort ("low".."max") to tune depth. The
1333 // openai-compatible provider forwards Effort as reasoning_effort for
1334 // thinking-capable models; DeepSeek V4 Flash accepts low|high|max while
1335 // other DeepSeek models retain their model-specific capability mapping.
1336 // Empty = provider default.
1337 Thinking string `toml:"thinking"`
1338 Effort string `toml:"effort"`
1339 // Vision marks the model as accepting image input. When set, images the user
1340 // attaches are embedded in the request (image_url for openai-kind, base64
1341 // blocks for anthropic). Off by default: text-only models 400 on image input,
1342 // and image tokens are heavy — gating keeps text-only flows cheap (the prompt
1343 // prefix is byte-identical with no image, so the cache is unaffected either way).
1344 Vision bool `toml:"vision"`
1345 // VisionModels narrows image input support to specific models in a multi-model
1346 // provider. This lets one provider expose both text-only and multimodal chat
1347 // models without enabling image payloads for every model.
1348 VisionModels []string `toml:"vision_models"`
1349 // VisionDetail sets the openai image_url detail hint (low|high); empty = auto
1350 // (the field is omitted). "low" caps an image to a fixed ~85 tokens for cheap
1351 // coarse reads; ignored by providers without the knob (e.g. anthropic).
1352 VisionDetail string `toml:"vision_detail"`
1353 // WebSearch controls the provider-executed web_search tool for compatible
1354 // Anthropic and Responses endpoints. Nil lets official DeepSeek endpoints use
1355 // their product default; non-nil preserves an explicit user choice across
1356 // config rewrites. DeepSeek returns web_search_tool_result blocks on the
1357 // Anthropic wire and response.web_search_call events on the Responses wire.
1358 WebSearch *bool `toml:"web_search"`
1359 // ReasoningProtocol selects the request shape for OpenAI-compatible reasoning
1360 // models. Empty/auto uses the model capability registry plus endpoint
1361 // heuristics; glm selects GLM's thinking.type toggle; none disables automatic
1362 // reasoning controls for this provider.
1363 ReasoningProtocol string `toml:"reasoning_protocol"`
1364 // SupportedEfforts lists the /effort levels this provider/model exposes.
1365 // When non-empty, it overrides the built-in defaults derived from
1366 // Kind/BaseURL and makes /effort configurable. "auto" is the implicit
1367 // prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort
1368 // (or set one outside this list) to fall back to SupportedEfforts[0].
1369 SupportedEfforts []string `toml:"supported_efforts"`
1370 // DefaultEffort is the /effort level used when the user picks "auto" or
1371 // has not set Effort. Ignored when SupportedEfforts is empty.
1372 DefaultEffort string `toml:"default_effort"`
1373 // ModelOverrides customizes capability metadata after ResolveModel selects a
1374 // concrete model from a multi-model provider. Use it when a gateway exposes
1375 // mixed DeepSeek/OpenAI/no-reasoning or mixed vision/text models under one
1376 // base_url/key.
1377 ModelOverrides map[string]ProviderModelOverride `toml:"model_overrides"`
1378 visionOverride *bool
1379 // NoProxy reaches this provider's base_url directly, never through the proxy.
1380 // For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).
1381 NoProxy bool `toml:"no_proxy"`
1382 // CacheTTLMinutes overrides the vendor-default prefix-cache retention used by
1383 // cold-resume prune. Zero uses the vendor default (DeepSeek/unknown 24h, DashScope/Anthropic 5m).
1384 CacheTTLMinutes int `toml:"cache_ttl_minutes"`
1385 }
1386
1387 type ProviderModelOverride struct {
1388 ReasoningProtocol string `toml:"reasoning_protocol"`
1389 SupportedEfforts []string `toml:"supported_efforts"`
1390 DefaultEffort string `toml:"default_effort"`
1391 Vision *bool `toml:"vision"`
1392 // ContextWindow overrides the provider-wide context budget for this model.
1393 // Zero inherits ProviderEntry.ContextWindow so existing configurations keep
1394 // their current compaction behavior.
1395 ContextWindow int `toml:"context_window"`
1396 // MaxOutputTokens overrides the provider-wide output budget. Zero inherits;
1397 // positive values set a cap and negative values omit optional wire limits.
1398 MaxOutputTokens int `toml:"max_output_tokens"`
1399 }
1400
1401 // ModelList returns the models this provider exposes: the explicit `models` list,
1402 // or the single `model` as a one-element list (back-compat). Empty if neither set.
1403 func (e *ProviderEntry) ModelList() []string {
1404 if len(e.Models) > 0 {
1405 return e.Models
1406 }
1407 if e.Model != "" {
1408 return []string{e.Model}
1409 }
1410 return nil
1411 }
1412
1413 // IsLikelyChatModel reports whether a model ID looks like a chat/completion
1414 // model rather than a specialised audio/vision/embedding model. It applies a
1415 // conservative name-based heuristic — the OpenAI-compatible /models API does
1416 // not return capability/modality metadata, so this is the most reliable
1417 // fallback until providers add such fields.
1418 //
1419 // The heuristic works in two passes:
1420 // 1. Multi-word substring check for compound terms that span separators
1421 // (e.g. "text-embedding", "text-to-speech").
1422 // 2. Token-level check: the model ID is split on common separators (- _ . / :)
1423 // and each token is compared against a set of known non-chat keywords.
1424 //
1425 // "voice" is intentionally absent from the non-chat set because it is too
1426 // broad — legitimate future chat models may include it in their name.
1427 func IsLikelyChatModel(model string) bool {
1428 model = strings.TrimSpace(model)
1429 if model == "" {
1430 return false
1431 }
1432 lower := strings.ToLower(model)
1433
1434 // Pass 1: compound terms that span separator boundaries.
1435 var compoundNonChat = []string{
1436 "text-embedding", "text-to-speech", "speech-to-text",
1437 }
1438 for _, c := range compoundNonChat {
1439 if strings.Contains(lower, c) {
1440 return false
1441 }
1442 }
1443
1444 // Pass 2: token-level check.
1445 tokens := strings.FieldsFunc(lower, func(r rune) bool {
1446 return r == '-' || r == '_' || r == '.' || r == '/' || r == ':'
1447 })
1448 var nonChatTokens = map[string]bool{
1449 "asr": true, "stt": true, "tts": true,
1450 "whisper": true, "embedding": true,
1451 "moderation": true, "rerank": true, "dall": true,
1452 "transcription": true,
1453 }
1454 for _, tok := range tokens {
1455 if nonChatTokens[tok] {
1456 return false
1457 }
1458 }
1459 return true
1460 }
1461
1462 // ChatModelList returns ModelList filtered to likely chat/completion models.
1463 // Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do
1464 // not appear in the chat model picker. Use ModelList() only when the full
1465 // raw provider model list is needed, such as config serialization, provider
1466 // diagnostics, or model-fetch editing.
1467 func (e *ProviderEntry) ChatModelList() []string {
1468 raw := e.ModelList()
1469 if len(raw) == 0 {
1470 return nil
1471 }
1472 out := make([]string, 0, len(raw))
1473 for _, m := range raw {
1474 if IsLikelyChatModel(m) {
1475 out = append(out, m)
1476 }
1477 }
1478 return out
1479 }
1480
1481 // DefaultModel returns the provider's default model: the explicit `default`, else
1482 // the first of ModelList.
1483 func (e *ProviderEntry) DefaultModel() string {
1484 if e.Default != "" {
1485 return e.Default
1486 }
1487 if l := e.ModelList(); len(l) > 0 {
1488 return l[0]
1489 }
1490 return ""
1491 }
1492
1493 // HasModel reports whether m is one of the provider's models.
1494 func (e *ProviderEntry) HasModel(m string) bool {
1495 for _, x := range e.ModelList() {
1496 if x == m {
1497 return true
1498 }
1499 }
1500 return false
1501 }
1502
1503 // PriceForModel returns the configured per-1M-token price for model. Per-model
1504 // prices win; the legacy provider-wide price is a fallback for older configs.
1505 func (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {
1506 if e == nil {
1507 return nil
1508 }
1509 if e.Prices != nil {
1510 if p := e.Prices[strings.TrimSpace(model)]; p != nil {
1511 return clonePricing(p)
1512 }
1513 }
1514 return clonePricing(e.Price)
1515 }
1516
1517 func (e *ProviderEntry) applyModelPrice() {
1518 if e == nil {
1519 return
1520 }
1521 e.Price = e.PriceForModel(e.Model)
1522 }
1523
1524 func (e *ProviderEntry) applyModelOverride() {
1525 if e == nil || len(e.ModelOverrides) == 0 {
1526 return
1527 }
1528 ov, ok := e.modelOverrideForModel(e.Model)
1529 if !ok {
1530 return
1531 }
1532 if ov.ReasoningProtocol != "" {
1533 e.ReasoningProtocol = ov.ReasoningProtocol
1534 }
1535 if ov.SupportedEfforts != nil {
1536 e.SupportedEfforts = append([]string(nil), ov.SupportedEfforts...)
1537 }
1538 if ov.DefaultEffort != "" || ov.SupportedEfforts != nil {
1539 e.DefaultEffort = ov.DefaultEffort
1540 }
1541 if ov.Vision != nil {
1542 e.visionOverride = ov.Vision
1543 }
1544 if ov.ContextWindow > 0 {
1545 e.ContextWindow = ov.ContextWindow
1546 }
1547 if ov.MaxOutputTokens != 0 {
1548 e.MaxOutputTokens = ov.MaxOutputTokens
1549 }
1550 }
1551
1552 func (e *ProviderEntry) modelOverrideForModel(model string) (ProviderModelOverride, bool) {
1553 model = strings.TrimSpace(model)
1554 if e == nil || model == "" || len(e.ModelOverrides) == 0 {
1555 return ProviderModelOverride{}, false
1556 }
1557 if ov, ok := e.ModelOverrides[model]; ok {
1558 return ov, true
1559 }
1560 for k, ov := range e.ModelOverrides {
1561 if strings.EqualFold(strings.TrimSpace(k), model) {
1562 return ov, true
1563 }
1564 }
1565 return ProviderModelOverride{}, false
1566 }
1567
1568 func clonePricing(p *provider.Pricing) *provider.Pricing {
1569 if p == nil {
1570 return nil
1571 }
1572 cp := *p
1573 return &cp
1574 }
1575
1576 // ToolsConfig selects which built-in tools are enabled. Empty means all of them.
1577 type ToolsConfig struct {
1578 Enabled []string `toml:"enabled"`
1579 BashTimeoutSeconds *int `toml:"bash_timeout_seconds"`
1580 MCPStartupTimeoutSeconds *int `toml:"mcp_startup_timeout_seconds"`
1581 MCPCallTimeoutSeconds *int `toml:"mcp_call_timeout_seconds"`
1582 BackgroundJobs BackgroundJobsConfig `toml:"background_jobs"`
1583 Search SearchConfig `toml:"search"`
1584 Shell ShellConfig `toml:"shell"`
1585 }
1586
1587 const (
1588 defaultBashTimeoutSeconds = 120
1589 defaultMCPStartupTimeoutSeconds = 30
1590 defaultMCPCallTimeoutSeconds = 300
1591 defaultBackgroundJobStalledWarningSec = 900
1592 maxBackgroundJobStalledWarningSec = 86400
1593 )
1594
1595 // BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted
1596 // config keeps the historical 120s safety cap, explicit 0 disables the
1597 // tool-local cap, and positive values set a custom cap. Negative values fall
1598 // back to the default so a typo cannot silently remove the safety net.
1599 func (c *Config) BashTimeoutSeconds() int {
1600 if c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {
1601 return defaultBashTimeoutSeconds
1602 }
1603 return *c.Tools.BashTimeoutSeconds
1604 }
1605
1606 // MCPCallTimeoutSeconds returns the default MCP JSON-RPC call timeout in
1607 // seconds. Omitted, zero, and negative values keep the built-in safety cap so a
1608 // hung MCP server cannot block a turn indefinitely.
1609 func (c *Config) MCPCallTimeoutSeconds() int {
1610 if c.Tools.MCPCallTimeoutSeconds == nil || *c.Tools.MCPCallTimeoutSeconds <= 0 {
1611 return defaultMCPCallTimeoutSeconds
1612 }
1613 return *c.Tools.MCPCallTimeoutSeconds
1614 }
1615
1616 // MCPStartupTimeoutSeconds returns the background initialize + tools/list
1617 // safety cap. Omitted, zero, and negative values keep the built-in default so
1618 // a slow but healthy MCP can outlive the short interactive wait without running
1619 // indefinitely.
1620 func (c *Config) MCPStartupTimeoutSeconds() int {
1621 if c.Tools.MCPStartupTimeoutSeconds == nil || *c.Tools.MCPStartupTimeoutSeconds <= 0 {
1622 return defaultMCPStartupTimeoutSeconds
1623 }
1624 return *c.Tools.MCPStartupTimeoutSeconds
1625 }
1626
1627 // BackgroundJobsConfig tunes parent-created background jobs.
1628 type BackgroundJobsConfig struct {
1629 StalledWarningSeconds *int `toml:"stalled_warning_seconds"`
1630 }
1631
1632 // BackgroundJobStalledWarningSeconds returns the stalled warning threshold in
1633 // seconds. Omitted/negative values keep the default, explicit 0 disables the
1634 // notice, and oversized values clamp to one day so a typo cannot become
1635 // effectively invisible.
1636 func (c *Config) BackgroundJobStalledWarningSeconds() int {
1637 if c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {
1638 return defaultBackgroundJobStalledWarningSec
1639 }
1640 if *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {
1641 return maxBackgroundJobStalledWarningSec
1642 }
1643 return *c.Tools.BackgroundJobs.StalledWarningSeconds
1644 }
1645
1646 // SearchConfig tunes the grep tool's engine. Engine is "auto" (default — use
1647 // ripgrep when it's on PATH, else the native Go scanner), "native" (always Go),
1648 // or "rg" (require ripgrep; warn at startup and fall back to native if absent).
1649 // RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.
1650 type SearchConfig struct {
1651 Engine string `toml:"engine"`
1652 RgPath string `toml:"rg_path"`
1653 }
1654
1655 // ShellConfig chooses the interpreter the bash tool runs commands under. Prefer
1656 // is "auto" (default — real bash when present, else PowerShell on Windows),
1657 // "bash", or "powershell"/"pwsh" (force it; warn at startup and fall back to
1658 // auto if absent). Path optionally points at a specific shell executable.
1659 type ShellConfig struct {
1660 Prefer string `toml:"prefer"`
1661 Path string `toml:"path"`
1662 }
1663
1664 // PermissionsConfig declares the per-call permission policy (see
1665 // internal/permission). Mode is the fallback decision for writer tools when no
1666 // rule matches ("ask" | "allow" | "deny"; default "ask"); read-only tools always
1667 // fall back to allow. Allow/Ask/Deny are rule lists of the form "ToolName" or
1668 // "ToolName(glob)". Precedence: deny > ask > allow > fallback.
1669 type PermissionsConfig struct {
1670 Mode string `toml:"mode"`
1671 Allow []string `toml:"allow"`
1672 Ask []string `toml:"ask"`
1673 Deny []string `toml:"deny"`
1674 AllowDynamicBash bool `toml:"allow_dynamic_bash"`
1675 }
1676
1677 // MCPConfigSource records where a merged MCP entry came from. It is runtime
1678 // provenance only and is never serialized back into TOML or .mcp.json.
1679 type MCPConfigSource string
1680
1681 const (
1682 MCPSourceUnknown MCPConfigSource = ""
1683 MCPSourceUserConfig MCPConfigSource = "user_config"
1684 MCPSourceProjectConfig MCPConfigSource = "project_config"
1685 MCPSourceProjectMCPJSON MCPConfigSource = "project_mcp_json"
1686 MCPSourceLegacyUser MCPConfigSource = "legacy_user_config"
1687 MCPSourcePluginPackage MCPConfigSource = "plugin_package"
1688 )
1689
1690 func (s MCPConfigSource) UserAuthorized() bool {
1691 switch s {
1692 case MCPSourceUserConfig, MCPSourceLegacyUser, MCPSourcePluginPackage,
1693 MCPSourceProjectConfig, MCPSourceProjectMCPJSON:
1694 return true
1695 default:
1696 return false
1697 }
1698 }
1699
1700 // ProjectScoped reports whether an MCP entry belongs to one workspace. Project
1701 // scope remains useful for provenance, activation, and relative-path handling;
1702 // it no longer implies a separate launch-approval workflow.
1703 func (s MCPConfigSource) ProjectScoped() bool {
1704 return s == MCPSourceProjectConfig || s == MCPSourceProjectMCPJSON
1705 }
1706
1707 // PluginEntry declares an external MCP server. Type selects the transport:
1708 // "stdio" (default) launches Command/Args/Env as a subprocess; "http"
1709 // (a.k.a. streamable-http) and "sse" connect to a remote URL with optional
1710 // static Headers. String fields support ${VAR} / ${VAR:-default} expansion so
1711 // secrets (bearer tokens, keys) come from the environment, not the file. The
1712 // fields mirror Claude Code's mcpServers spec, so entries can come from either
1713 // reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).
1714 type PluginEntry struct {
1715 Name string `toml:"name"`
1716 Type string `toml:"type"` // "stdio" (default) | "http" | "sse"
1717 Command string `toml:"command"`
1718 Args []string `toml:"args"`
1719 Env map[string]string `toml:"env"`
1720 URL string `toml:"url"`
1721 Headers map[string]string `toml:"headers"`
1722 // StartupTimeoutSeconds overrides [tools].mcp_startup_timeout_seconds for
1723 // initialize + tools/list. Zero keeps the global/default cap.
1724 StartupTimeoutSeconds int `toml:"startup_timeout_seconds"`
1725 // CallTimeoutSeconds overrides the default per-call deadline for this MCP
1726 // server. Zero falls back to [tools].mcp_call_timeout_seconds.
1727 CallTimeoutSeconds int `toml:"call_timeout_seconds"`
1728 // ToolTimeoutSeconds overrides the per-call deadline for raw MCP tool names
1729 // from this server. Keys are server-local tool names, not model-visible
1730 // mcp__server__tool names.
1731 ToolTimeoutSeconds map[string]int `toml:"tool_timeout_seconds"`
1732 // AutoStart controls whether the server connects during session startup.
1733 // Nil preserves historical behavior: configured servers start automatically.
1734 AutoStart *bool `toml:"auto_start"`
1735 // Tier is a legacy compatibility field. New config rendering omits it; enabled
1736 // MCP servers connect automatically in the background unless auto_start=false.
1737 // Historical values are accepted for old files:
1738 // "eager" — blocks startup until the handshake completes; required for
1739 // servers whose tools the system prompt depends on.
1740 // "lazy" — legacy alias for background.
1741 // "background" — placeholder + spawn fired at boot but not waited on;
1742 // swap happens once the spawn finishes.
1743 // Empty defaults to "background" so enabled MCPs connect automatically
1744 // without blocking chat. Unknown non-empty values fall back to "background".
1745 Tier string `toml:"tier"`
1746 Source MCPConfigSource `toml:"-" json:"-"`
1747 expansionEnv map[string]string
1748 }
1749
1750 func (e PluginEntry) ShouldAutoStart() bool {
1751 return e.AutoStart == nil || *e.AutoStart
1752 }
1753
1754 // ResolvedTier returns the normalized tier ("eager"|"background") with the
1755 // project default applied. Legacy lazy and unknown values fall back to
1756 // background so enabled MCPs are available without manual connection.
1757 //
1758 // Tier no longer changes runtime process start timing; it remains for config
1759 // compatibility and diagnostics only.
1760 func (e PluginEntry) ResolvedTier() string {
1761 return resolvedMCPTier(e.Tier)
1762 }
1763
1764 func resolvedMCPTier(tier string) string {
1765 switch strings.ToLower(strings.TrimSpace(tier)) {
1766 case "eager":
1767 return "eager"
1768 case "background", "lazy":
1769 return "background"
1770 case "":
1771 return "background"
1772 default:
1773 return "background"
1774 }
1775 }
1776
1777 // AutoStartPlugins returns enabled MCP entries for the catalog. Durable
1778 // enable/disable overrides in mcp-activation.json take precedence over the
1779 // legacy auto_start field. auto_start=false without an override still maps to
1780 // disabled; true/nil map to enabled. "Auto start" no longer means "spawn the
1781 // process at session boot" — enabled servers register cached tools and start
1782 // on first real tool call.
1783 func (c *Config) AutoStartPlugins() []PluginEntry {
1784 return c.EnabledPlugins("", DefaultMCPActivationStore())
1785 }
1786
1787 // EnabledPlugins returns catalog-enabled MCP entries for workspace, consulting
1788 // the activation store when provided.
1789 func (c *Config) EnabledPlugins(workspace string, activation *MCPActivationStore) []PluginEntry {
1790 if c == nil {
1791 return nil
1792 }
1793 out := make([]PluginEntry, 0, len(c.Plugins))
1794 for _, p := range c.Plugins {
1795 enabled := p.ShouldAutoStart()
1796 if activation != nil {
1797 if resolved, err := activation.IsEnabled(p, workspace); err == nil {
1798 enabled = resolved
1799 }
1800 }
1801 if enabled {
1802 out = append(out, p)
1803 }
1804 }
1805 return out
1806 }
1807
1808 // DefaultSystemPrompt is used when config provides none.
1809 const DefaultSystemPrompt = `You are Reasonix, a coding agent.
1810 Use the available tools when they help you complete the user's request.
1811 Keep changes focused and responses concise.`
1812
1813 // UserDecisionPolicy is appended to every system prompt, including user-custom
1814 // prompts, so custom personas cannot accidentally remove the `ask` UI contract.
1815 const UserDecisionPolicy = `User-owned choices: when a consequential decision has no safe, obvious default, call the ask tool so the user can choose. Otherwise proceed with a sensible reversible default. Do not ask in prose when ask is available. In non-interactive runs, state the assumption and take the safest reversible path.`
1816
1817 // LanguagePolicy is the auto fallback appended to the system prompt when no
1818 // concrete UI language is resolved. It is static English text, so it stays part
1819 // of the cache-stable prefix and avoids per-turn language injection.
1820 const LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +
1821 `if they write in Chinese answer in Chinese, in English answer in English, and switch ` +
1822 `whenever they switch. Let this also guide the language you think in. Always keep code, ` +
1823 `identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`
1824
1825 // Default returns the built-in default configuration.
1826 func Default() *Config {
1827 return &Config{
1828 ConfigVersion: 5,
1829 DefaultModel: "deepseek-flash",
1830 CredentialsStore: CredentialsStoreAuto,
1831 UI: UIConfig{Theme: "auto", ShowTurnUsage: true},
1832 Desktop: DesktopConfig{DefaultToolApprovalMode: "auto", ConversationWidth: "standard"},
1833 Notifications: NotificationsConfig{
1834 Enabled: false,
1835 TurnDone: true,
1836 ApprovalRequest: true,
1837 AskRequest: true,
1838 },
1839 Agent: AgentConfig{
1840 SystemPrompt: DefaultSystemPrompt,
1841 // Normal interactive execution has no configurable total round cap. It
1842 // is bounded by adaptive progress guards and context compaction instead.
1843 MaxSteps: 0,
1844 PlannerMaxSteps: 0,
1845 AutoPlan: "off",
1846 SoftCompactRatio: 0.5,
1847 ToolResultSnipRatio: 0.6,
1848 CompactRatio: 0.8,
1849 CompactForceRatio: 0.9,
1850 MaxSubagentDepth: 2,
1851 MaxSubagentConcurrency: 6,
1852 MaxParallelWriters: 3,
1853 },
1854 // Mode "ask" with no rules keeps `reasonix run` autonomous (no TTY → ask
1855 // resolves to allow) while `reasonix` prompts before writers. Users add
1856 // deny/allow rules to harden or quiet specific tools.
1857 Permissions: PermissionsConfig{Mode: "ask"},
1858 // Sandbox uses platform defaults: macOS/Linux jail bash by default;
1859 // Windows has no OS-level Bash sandbox and always forces bash off.
1860 // Network=true here so an absent [sandbox] in a user's file keeps egress
1861 // (zero value would wrongly deny it).
1862 Sandbox: SandboxConfig{Network: true},
1863 // LSP tools on by default, but dormant until a language server is on PATH;
1864 // a missing server yields an install hint rather than an error.
1865 LSP: LSPConfig{Enabled: true},
1866 Network: NetworkConfig{ProxyMode: netclient.ModeAuto},
1867 Bot: BotConfig{
1868 ToolApprovalMode: "ask",
1869 MaxSteps: 25,
1870 DebounceMs: 1500,
1871 QueueMode: "steer",
1872 QueueCap: 20,
1873 QueueDrop: "summarize",
1874 IgnoreSelfMessages: true,
1875 Control: BotControlConfig{Addr: "127.0.0.1:37913", TokenEnv: "REASONIX_BOT_CONTROL_TOKEN"},
1876 Pairing: BotPairingConfig{Enabled: true, RequestTTLMinutes: 60, MaxPendingPerPlatform: 3},
1877 Allowlist: BotAllowlist{Enabled: true},
1878 QQ: QQBotConfig{AppSecretEnv: "QQ_BOT_APP_SECRET"},
1879 Feishu: FeishuBotConfig{Domain: "feishu", AppSecretEnv: "FEISHU_BOT_APP_SECRET", Mode: "webhook", WebhookPort: 8080, RequireMention: true},
1880 Weixin: WeixinBotConfig{AccountID: "default", TokenEnv: "WEIXIN_BOT_TOKEN", APIBase: "https://ilinkai.weixin.qq.com"},
1881 },
1882 Providers: []ProviderEntry{
1883 {Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", BalanceURL: "https://api.deepseek.com/user/balance", ContextWindow: 1_000_000, Price: deepSeekV4FlashPriceUSD()},
1884 {Name: "deepseek-pro", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro", APIKeyEnv: "DEEPSEEK_API_KEY", BalanceURL: "https://api.deepseek.com/user/balance", ContextWindow: 1_000_000, Price: deepSeekV4ProPriceUSD()},
1885 },
1886 }
1887 }
1888
1889 // WriteFile writes the configuration to path as annotated TOML. The write is
1890 // atomic + fsynced so an interrupted write or power loss can never truncate the
1891 // main config into an unparseable state that leaves the app with no usable
1892 // models (#4615, #4708).
1893 func (c *Config) WriteFile(path string) error {
1894 return atomicWriteToConfigFile(path, RenderTOMLForScope(c, renderScopeForPath(path)), configFilePerm(path))
1895 }
1896
1897 // Provider returns the named provider entry.
1898 func (c *Config) Provider(name string) (*ProviderEntry, bool) {
1899 for i := range c.Providers {
1900 if c.Providers[i].Name == name {
1901 return &c.Providers[i], true
1902 }
1903 }
1904 return nil, false
1905 }
1906
1907 // ResolveModel resolves a model reference to a provider entry whose Model is the
1908 // selected model string (a copy, so the config's lists stay intact). It accepts:
1909 // - "provider/model" — that exact model under that provider;
1910 // - a provider name — the provider's default model;
1911 // - a bare model name — the (first) provider that lists it.
1912 //
1913 // The returned entry is ready to build a provider from (NewProvider reads .Model),
1914 // so a single "vendor with many models" entry yields one instance per model
1915 // without duplicating base_url/api_key_env. Single-`model` entries still resolve
1916 // by provider name, keeping older configs working unchanged.
1917 func (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {
1918 if ref == "" {
1919 return nil, false
1920 }
1921 if access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {
1922 ref = retargetDesktopOfficialRef(ref, access)
1923 }
1924 // "provider/model"
1925 if prov, model, ok := strings.Cut(ref, "/"); ok {
1926 if e, found := c.Provider(prov); found && e.HasModel(model) {
1927 cp := *e
1928 cp.Model = model
1929 cp.applyModelPrice()
1930 cp.applyModelOverride()
1931 return &cp, true
1932 }
1933 }
1934 // a provider name → its default model
1935 if e, found := c.Provider(ref); found {
1936 cp := *e
1937 cp.Model = e.DefaultModel()
1938 cp.applyModelPrice()
1939 cp.applyModelOverride()
1940 return &cp, true
1941 }
1942 // a bare model name → the provider that lists it
1943 for i := range c.Providers {
1944 if c.Providers[i].HasModel(ref) {
1945 cp := c.Providers[i]
1946 cp.Model = ref
1947 cp.applyModelPrice()
1948 cp.applyModelOverride()
1949 return &cp, true
1950 }
1951 }
1952 return nil, false
1953 }
1954
1955 // ResolveModelWithFallback resolves a model reference to the canonical
1956 // "provider/model" form used by the desktop runtime. If ref is stale or empty,
1957 // it tries the user's configured default_model before falling back to the first
1958 // configured provider — so preference isn't overwritten by iteration order.
1959 func (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {
1960 ref = strings.TrimSpace(ref)
1961 if ref != "" {
1962 if e, found := c.ResolveModel(ref); found {
1963 return e.Name + "/" + e.Model, false, true
1964 }
1965 }
1966 // Before falling back to the first configured provider (which may not be the
1967 // user's preferred choice), try the configured default_model. Skip when ref
1968 // already WAS the DefaultModel (it already failed above, so retrying won't
1969 // help) or when the default provider has no API key configured.
1970 if ref != c.DefaultModel && c.DefaultModel != "" {
1971 if e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {
1972 return e.Name + "/" + e.Model, true, true
1973 }
1974 }
1975 for i := range c.Providers {
1976 p := &c.Providers[i]
1977 // Skip providers with no models or no API key: falling back onto a keyless
1978 // provider just boots the tab onto something that fails on first use. Mirrors
1979 // the Configured() gate the provider-removal/selection paths already apply.
1980 if len(p.ModelList()) == 0 || !p.Configured() {
1981 continue
1982 }
1983 return p.Name + "/" + p.DefaultModel(), true, true
1984 }
1985 return "", false, false
1986 }
1987
1988 // ResolveNewSessionChatModel selects the model for a newly-created chat
1989 // session. Configured candidates win; if every chat candidate is keyless, the
1990 // valid default (or first chat model) is preserved so callers can surface their
1991 // existing missing-key recovery UI. An unknown default is also preserved for
1992 // the CLI's actionable configuration error. Provider order is otherwise stable.
1993 func (c *Config) ResolveNewSessionChatModel() (resolvedRef string, fallback bool, ok bool) {
1994 return c.resolveNewSessionChatModel(nil, true)
1995 }
1996
1997 func (c *Config) resolveNewSessionChatModel(providerAllowed func(string) bool, preserveUnknownDefault bool) (resolvedRef string, fallback bool, ok bool) {
1998 if c == nil {
1999 return "", false, false
2000 }
2001 if providerAllowed == nil {
2002 providerAllowed = func(string) bool { return true }
2003 }
2004
2005 def := strings.TrimSpace(c.DefaultModel)
2006 keylessDefault := ""
2007 if def != "" {
2008 if entry, found := c.ResolveModel(def); found {
2009 if providerAllowed(entry.Name) && IsLikelyChatModel(entry.Model) {
2010 if entry.Configured() {
2011 return def, false, true
2012 }
2013 keylessDefault = def
2014 }
2015 } else if preserveUnknownDefault {
2016 // CLI/boot callers need the stale value intact so their existing
2017 // unknown-model error can name it and explain the providers that
2018 // replaced it. Desktop uses its recovery UI and does not preserve it.
2019 return def, false, true
2020 }
2021 }
2022
2023 keylessFallback := ""
2024 for i := range c.Providers {
2025 p := &c.Providers[i]
2026 if !providerAllowed(p.Name) {
2027 continue
2028 }
2029 chatModels := p.ChatModelList()
2030 if len(chatModels) == 0 {
2031 continue
2032 }
2033 model := chatModels[0]
2034 for _, candidate := range chatModels {
2035 if candidate == p.DefaultModel() {
2036 model = candidate
2037 break
2038 }
2039 }
2040 resolved := p.Name + "/" + model
2041 if p.Configured() {
2042 return resolved, true, true
2043 }
2044 if keylessFallback == "" {
2045 keylessFallback = resolved
2046 }
2047 }
2048 if keylessDefault != "" {
2049 return keylessDefault, false, true
2050 }
2051 if keylessFallback != "" {
2052 return keylessFallback, true, true
2053 }
2054 return "", false, false
2055 }
2056
2057 // ResolveDesktopNewSessionModel selects the model for a newly-created desktop
2058 // session. It shares the chat-model fallback policy with other frontends while
2059 // limiting candidates to providers exposed by the desktop access catalog.
2060 func (c *Config) ResolveDesktopNewSessionModel() (resolvedRef string, fallback bool, ok bool) {
2061 if c == nil {
2062 return "", false, false
2063 }
2064 access := desktopProviderAccessMap(c.Desktop.ProviderAccess)
2065 return c.resolveNewSessionChatModel(func(name string) bool {
2066 return c.Desktop.ProviderAccess == nil || access[strings.TrimSpace(name)]
2067 }, false)
2068 }
2069
2070 // APIKey resolves the entry's API key from its api_key_env.
2071 func (e *ProviderEntry) APIKey() string {
2072 if e == nil {
2073 return ""
2074 }
2075 if e.resolvedAPIKey != "" {
2076 return e.resolvedAPIKey
2077 }
2078 if e.APIKeyEnv == "" {
2079 return ""
2080 }
2081 value, _, ok := storedCredentialValue(e.APIKeyEnv)
2082 if !ok {
2083 return ""
2084 }
2085 return value
2086 }
2087
2088 // ResolveAPIKeyFromProcessEnvForProbe pins a setup-time, user-entered key onto
2089 // this entry for an immediate connectivity probe. Normal runtime resolution does
2090 // not call this; loaded provider entries still resolve only from Reasonix's
2091 // global .env.
2092 func (e *ProviderEntry) ResolveAPIKeyFromProcessEnvForProbe() {
2093 if e == nil {
2094 return
2095 }
2096 key := strings.TrimSpace(e.APIKeyEnv)
2097 if key == "" {
2098 return
2099 }
2100 value := strings.TrimSpace(os.Getenv(key))
2101 if value == "" {
2102 return
2103 }
2104 e.resolvedAPIKey = value
2105 e.resolvedSource = CredentialSource{Kind: CredentialSourceEnvironment, Label: "setup prompt"}
2106 }
2107
2108 func (e *ProviderEntry) APIKeySourceLabel() string {
2109 if e == nil || strings.TrimSpace(e.APIKeyEnv) == "" {
2110 return ""
2111 }
2112 if e.resolvedAPIKey != "" {
2113 return credentialSourceLabel(e.resolvedSource)
2114 }
2115 return ResolveCredentialForRootGlobalFirst(".", e.APIKeyEnv).Source.Label
2116 }
2117
2118 // RequiresAPIKey reports whether this provider should be hidden/validated when
2119 // its configured api_key_env is empty. A blank api_key_env means the provider is
2120 // intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy
2121 // api_key_env in config even though they accept unauthenticated requests, so
2122 // loopback/private endpoints are also allowed to run without a resolved key.
2123 func (e *ProviderEntry) RequiresAPIKey() bool {
2124 if e == nil {
2125 return false
2126 }
2127 if strings.TrimSpace(e.APIKeyEnv) == "" {
2128 return providerBaseURLRequiresAPIKey(e.BaseURL)
2129 }
2130 return !providerBaseURLAllowsMissingAPIKey(e.BaseURL)
2131 }
2132
2133 func providerBaseURLRequiresAPIKey(raw string) bool {
2134 switch officialProviderHost(raw) {
2135 case "api.deepseek.com", "api.xiaomimimo.com", "token-plan-cn.xiaomimimo.com", "api.minimaxi.com", "api.openai.com":
2136 return true
2137 default:
2138 return false
2139 }
2140 }
2141
2142 func providerBaseURLAllowsMissingAPIKey(raw string) bool {
2143 u, err := url.Parse(strings.TrimSpace(raw))
2144 if err != nil {
2145 return false
2146 }
2147 host := strings.Trim(strings.ToLower(u.Hostname()), "[]")
2148 if host == "localhost" || strings.HasSuffix(host, ".localhost") {
2149 return true
2150 }
2151 addr, err := netip.ParseAddr(host)
2152 if err != nil {
2153 return false
2154 }
2155 return addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()
2156 }
2157
2158 // Configured reports whether the provider is selectable. Providers that do not
2159 // require an API key are configured by definition; providers that name an env var
2160 // require that variable to resolve unless their endpoint is local/private.
2161 func (e *ProviderEntry) Configured() bool {
2162 return e != nil && (!e.RequiresAPIKey() || e.APIKey() != "")
2163 }
2164
2165 // ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.
2166 func (c *Config) ResolveSystemPrompt() (string, error) {
2167 return c.ResolveSystemPromptForRoot(".")
2168 }
2169
2170 // ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative
2171 // system_prompt_file against root. Desktop tabs pass their workspace root here so
2172 // prompt files are project-scoped even when the process cwd is elsewhere. A path
2173 // inherited from user config may fall back to Reasonix home, while a path chosen
2174 // by project config is confined to the workspace and never probes user files.
2175 func (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {
2176 path := c.Agent.SystemPromptFile
2177 if path == "" {
2178 return c.InlineSystemPrompt(), nil
2179 }
2180
2181 if c.systemPromptFileSource == promptFileSourceProject {
2182 if filepath.IsAbs(path) || !filepath.IsLocal(filepath.Clean(path)) {
2183 return "", fmt.Errorf("project system_prompt_file %q must be a relative path within the workspace", path)
2184 }
2185 candidate := filepath.Join(resolveRoot(root), path)
2186 b, err := readProjectSystemPromptFile(root, path)
2187 if err != nil {
2188 return "", newSystemPromptFileError(path, []string{candidate}, []error{err})
2189 }
2190 return strings.TrimSpace(string(b)), nil
2191 }
2192
2193 if filepath.IsAbs(path) {
2194 b, err := fileencoding.ReadFileUTF8(path)
2195 if err != nil {
2196 return "", newSystemPromptFileError(path, []string{path}, []error{err})
2197 }
2198 return strings.TrimSpace(string(b)), nil
2199 }
2200
2201 candidates := []string{filepath.Join(resolveRoot(root), path)}
2202 if home := ReasonixHomeDir(); home != "" {
2203 homeCandidate := filepath.Join(home, path)
2204 if filepath.Clean(homeCandidate) != filepath.Clean(candidates[0]) {
2205 candidates = append(candidates, homeCandidate)
2206 }
2207 }
2208 readErrors := make([]error, 0, len(candidates))
2209 for _, candidate := range candidates {
2210 b, err := fileencoding.ReadFileUTF8(candidate)
2211 if err == nil {
2212 return strings.TrimSpace(string(b)), nil
2213 }
2214 readErrors = append(readErrors, fmt.Errorf("%s: %w", candidate, err))
2215 }
2216 return "", newSystemPromptFileError(path, candidates, readErrors)
2217 }
2218
2219 func readProjectSystemPromptFile(root, path string) ([]byte, error) {
2220 workspace, err := filepath.Abs(resolveRoot(root))
2221 if err != nil {
2222 return nil, fmt.Errorf("resolve workspace root: %w", err)
2223 }
2224 rootHandle, err := os.OpenRoot(workspace)
2225 if err != nil {
2226 return nil, fmt.Errorf("open workspace root %q: %w", workspace, err)
2227 }
2228 defer rootHandle.Close()
2229 f, err := rootHandle.Open(filepath.Clean(path))
2230 if err != nil {
2231 return nil, err
2232 }
2233 defer f.Close()
2234 b, err := io.ReadAll(f)
2235 if err != nil {
2236 return nil, err
2237 }
2238 return fileencoding.DecodeToUTF8(b), nil
2239 }
2240
2241 func newSystemPromptFileError(configured string, candidates []string, readErrors []error) error {
2242 allMissing := len(readErrors) > 0
2243 for _, err := range readErrors {
2244 if !errors.Is(err, fs.ErrNotExist) {
2245 allMissing = false
2246 break
2247 }
2248 }
2249 return &systemPromptFileError{
2250 configured: configured,
2251 candidates: append([]string(nil), candidates...),
2252 errors: append([]error(nil), readErrors...),
2253 allMissing: allMissing,
2254 }
2255 }
2256
2257 // InlineSystemPrompt returns the configured system_prompt, or DefaultSystemPrompt
2258 // when unset. It is the fallback when system_prompt_file cannot be read.
2259 func (c *Config) InlineSystemPrompt() string {
2260 if strings.TrimSpace(c.Agent.SystemPrompt) == "" {
2261 return DefaultSystemPrompt
2262 }
2263 return c.Agent.SystemPrompt
2264 }
2265
2266 // Validate checks that the selected model's provider is usable.
2267 func (c *Config) Validate(model string) error {
2268 e, ok := c.ResolveModel(model)
2269 if !ok {
2270 return fmt.Errorf("unknown model %q (configured: %s)", model, c.providerNames())
2271 }
2272 if e.Kind == "" {
2273 return fmt.Errorf("provider %q: kind is required", model)
2274 }
2275 if e.BaseURL == "" {
2276 return fmt.Errorf("provider %q: base_url is required", model)
2277 }
2278 if strings.TrimSpace(e.APIKeyEnv) != "" && !IsValidCredentialKey(e.APIKeyEnv) {
2279 return fmt.Errorf("provider %q: api_key_env %q is invalid; use letters, numbers, and underscores, not a model name", model, e.APIKeyEnv)
2280 }
2281 if e.RequiresAPIKey() && e.APIKey() == "" {
2282 return fmt.Errorf("provider %q: missing env %s", model, e.APIKeyEnv)
2283 }
2284 return nil
2285 }
2286
2287 func (c *Config) providerNames() string {
2288 names := make([]string, len(c.Providers))
2289 for i, p := range c.Providers {
2290 names[i] = p.Name
2291 }
2292 return strings.Join(names, ", ")
2293 }
2294
2294 lines GO