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