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