Files
2026-07-13 13:00:08 +08:00

1864 lines
73 KiB
Go

// Package config loads Reasonix's runtime configuration from TOML. Resolution order:
// flag > project ./reasonix.toml > user config.toml (in the OS user-config dir) > built-in defaults.
// User-global runtime controls, such as agent step limits, are documented exceptions.
// Secrets come from the environment via api_key_env and are never stored in
// config files.
package config
import (
"fmt"
"net/netip"
"net/url"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"reasonix/internal/fileutil"
fileencoding "reasonix/internal/fileutil/encoding"
"reasonix/internal/netclient"
"reasonix/internal/provider"
)
var validSkillName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
// IsValidSkillName reports whether name is a usable skill identifier.
func IsValidSkillName(name string) bool { return validSkillName.MatchString(name) }
// SkillNameKey normalizes a skill identifier for config comparisons.
func SkillNameKey(name string) string {
name = strings.TrimSpace(name)
if !IsValidSkillName(name) {
return ""
}
if runtime.GOOS == "windows" {
return strings.ToLower(name)
}
return name
}
// Config is Reasonix's runtime configuration.
type Config struct {
ConfigVersion int `toml:"config_version"`
DefaultModel string `toml:"default_model"`
Language string `toml:"language"` // ui/model language tag (e.g. "zh"); empty = auto-detect from $LANG / $REASONIX_LANG
CredentialsStore string `toml:"credentials_store"`
UI UIConfig `toml:"ui"`
Desktop DesktopConfig `toml:"desktop"`
Notifications NotificationsConfig `toml:"notifications"`
Agent AgentConfig `toml:"agent"`
Providers []ProviderEntry `toml:"providers"`
Tools ToolsConfig `toml:"tools"`
Permissions PermissionsConfig `toml:"permissions"`
Sandbox SandboxConfig `toml:"sandbox"`
Network NetworkConfig `toml:"network"`
Environment EnvironmentConfig `toml:"environment"`
Plugins []PluginEntry `toml:"plugins"`
Skills SkillsConfig `toml:"skills"`
Statusline StatuslineConfig `toml:"statusline"`
LSP LSPConfig `toml:"lsp"`
Bot BotConfig `toml:"bot"`
Serve ServeConfig `toml:"serve"`
Secrets SecretsConfig `toml:"secrets"`
providerSources map[string]providerSourceScope
shadowedProjectProviders []ProviderEntry
ignoredProjectDefaultModel string
expansionEnv map[string]string
pluginPackageOwners map[string]string
pluginPackageSkillOwners map[string][]string
}
// IgnoredProjectDefaultModel returns the project reasonix.toml default_model
// that LoadForRoot ignored because no configured provider serves it (see
// restoreUnresolvableProjectDefaultModel), or "" when none was ignored.
func (c *Config) IgnoredProjectDefaultModel() string {
if c == nil {
return ""
}
return c.ignoredProjectDefaultModel
}
// SecretsConfig controls the credential protection layers. It is a user-global
// setting: project reasonix.toml values are ignored (see LoadForRoot), so a
// cloned repository cannot silently switch off redaction or opt the user into
// workflow-breaking protections.
type SecretsConfig struct {
// RedactToolOutput masks credential-shaped values in tool output before it
// enters model context and UI events. Nil keeps the default enabled.
// Session transcripts and background-job artifacts on disk are always
// redacted, regardless of this switch.
RedactToolOutput *bool `toml:"redact_tool_output"`
// FilterSubprocessEnv strips credential-like environment variables
// (*_API_KEY, *TOKEN*, *SECRET*, ...) from tool subprocesses (bash, hooks,
// LSP, MCP stdio). Default off: it breaks token-based workflows such as
// `gh`, HTTPS `git push`, and `npm publish`.
FilterSubprocessEnv bool `toml:"filter_subprocess_env"`
// ProtectSensitiveFiles makes read/list/search tools treat credential
// paths (.env, .git-credentials, .netrc, *.pem/*.key/*.p12/*.pfx, ~/.ssh)
// as invisible. Default off: output redaction already masks the values,
// and hiding the files breaks legitimate "edit my .env" workflows.
ProtectSensitiveFiles bool `toml:"protect_sensitive_files"`
}
// SecretsRedactToolOutput reports whether live tool output redaction is
// enabled (default true).
func (c *Config) SecretsRedactToolOutput() bool {
return c == nil || c.Secrets.RedactToolOutput == nil || *c.Secrets.RedactToolOutput
}
type providerSourceScope string
const (
providerSourceUser providerSourceScope = "user"
providerSourceProject providerSourceScope = "project"
)
// UIConfig controls CLI presentation-only settings. Desktop appearance is kept in
// DesktopConfig so desktop preferences cannot alter terminal output or prompts.
type UIConfig struct {
Theme string `toml:"theme"` // auto|dark|light; empty resolves to auto
ThemeStyle string `toml:"theme_style"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases
ShortcutLayout string `toml:"shortcut_layout"` // classic|desktop; accepted for compatibility
CloseBehavior string `toml:"close_behavior"` // legacy desktop close behavior; prefer desktop.close_behavior
ShowReasoning bool `toml:"show_reasoning"` // Ctrl+O / /verbose: show thinking text in CLI; false = collapsed
CursorShape string `toml:"cursor_shape"` // block|underline|bar; empty defaults to underline
}
// DesktopConfig controls desktop-only UI preferences. It is intentionally
// separate from top-level language and [ui] so desktop choices do not affect CLI
// language, terminal colours, or provider-visible prompt/request data.
type DesktopConfig struct {
Language string `toml:"language"` // auto|en|zh; empty/auto = browser/OS auto-detect
LayoutStyle string `toml:"layout_style"` // classic|workbench|creation; desktop layout style
Theme string `toml:"theme"` // auto|dark|light; empty resolves to auto
ThemeStyle string `toml:"theme_style"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases
CloseBehavior string `toml:"close_behavior"` // quit|background; desktop window close behavior
DisplayMode string `toml:"display_mode"` // standard|compact (legacy "minimal" maps to compact); transcript display mode
StatusBarStyle string `toml:"status_bar_style"` // icon|text; desktop status bar metric labels
StatusBarItems []string `toml:"status_bar_items"` // ordered visible desktop status bar items
DefaultToolApprovalMode string `toml:"default_tool_approval_mode"` // ask|auto|yolo; defaults to auto for newly-created desktop sessions
CheckUpdates *bool `toml:"check_updates"` // startup update checks; nil keeps the default enabled
Telemetry *bool `toml:"telemetry"` // anonymous launch ping (install id + version + OS); nil keeps the default enabled
Metrics *bool `toml:"metrics"` // aggregate desktop metrics (anonymous signal/bucket counts; no content); nil keeps the default enabled
ProviderAccess []string `toml:"provider_access"` // desktop-only list of provider entries shown in Settings > Model > Access
ExpandThinking bool `toml:"expand_thinking"` // true = show reasoning text expanded by default; false = collapsed
}
// NotificationsConfig controls optional system notifications for CLI chat/run.
type NotificationsConfig struct {
Enabled bool `toml:"enabled"`
TurnDone bool `toml:"turn_done"`
ApprovalRequest bool `toml:"approval_request"`
AskRequest bool `toml:"ask_request"`
}
// EnvironmentConfig controls the stable startup environment block injected into
// the model-facing prompt. Enabled nil means the default (enabled); Tools maps a
// tool name to an explicit executable path when PATH probing is not enough.
type EnvironmentConfig struct {
Enabled *bool `toml:"enabled"`
Tools map[string]string `toml:"tools"`
}
// EnvironmentEnabled reports whether startup environment probing should feed the
// cache-stable system prompt.
func (c *Config) EnvironmentEnabled() bool {
return c == nil || c.Environment.Enabled == nil || *c.Environment.Enabled
}
// UITheme normalizes ui.theme to a supported value.
func (c *Config) UITheme() string {
switch strings.ToLower(strings.TrimSpace(c.UI.Theme)) {
case "dark":
return "dark"
case "light":
return "light"
default:
return "auto"
}
}
// UIThemeStyle normalizes ui.theme_style. Empty means "pick the default style
// for the resolved light/dark shell".
func (c *Config) UIThemeStyle() string {
return normalizeThemeStyle(c.UI.ThemeStyle)
}
// UIShortcutLayout normalizes the legacy CLI shortcut layout setting. It is kept
// for compatibility; Shift+Tab toggles Plan and Ctrl+Y toggles YOLO in both
// layouts.
func (c *Config) UIShortcutLayout() string {
switch strings.ToLower(strings.TrimSpace(c.UI.ShortcutLayout)) {
case "desktop", "dual", "dual-axis", "dual_axis":
return "desktop"
default:
return "classic"
}
}
// UICursorShape normalizes ui.cursor_shape. Defaults to "underline" to avoid
// block-cursor visual corruption with CJK wide characters in the textarea
// (Bubble Tea real-cursor + CJK column-counting drift). Valid values:
// "block", "underline", "bar".
func (c *Config) UICursorShape() string {
switch strings.ToLower(strings.TrimSpace(c.UI.CursorShape)) {
case "block":
return "block"
case "bar":
return "bar"
default:
return "underline"
}
}
func normalizeThemeStyle(style string) string {
switch strings.ToLower(strings.TrimSpace(style)) {
case "graphite", "aurora", "slate", "carbon", "nocturne", "amber", "ember", "midnight", "sandstone", "porcelain", "linen", "glacier":
return strings.ToLower(strings.TrimSpace(style))
default:
return ""
}
}
func normalizeDesktopLayoutStyle(style string) string {
switch strings.ToLower(strings.TrimSpace(style)) {
case "classic":
return "classic"
case "workbench", "workspace":
return "workbench"
case "creation":
return "creation"
default:
return "workbench"
}
}
func normalizeCloseBehavior(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "quit", "exit":
return "quit"
default:
return "background"
}
}
// DesktopLanguage normalizes the desktop UI language. Empty means auto-detect
// from the browser/OS locale; it deliberately does not read top-level language,
// which is used by the CLI/model-facing runtime.
func (c *Config) DesktopLanguage() string {
switch strings.ToLower(strings.TrimSpace(c.Desktop.Language)) {
case "en":
return "en"
case "zh":
return "zh"
default:
return ""
}
}
// DesktopTheme normalizes desktop.theme. New desktop users default to the OS
// automatic graphite product look; an explicit auto/light/dark is preserved.
func (c *Config) DesktopTheme() string {
switch strings.ToLower(strings.TrimSpace(c.Desktop.Theme)) {
case "auto":
return "auto"
case "light":
return "light"
case "dark":
return "dark"
default:
return "auto"
}
}
// DesktopThemeStyle normalizes desktop.theme_style. Empty means the frontend
// chooses the default style for the resolved desktop theme.
func (c *Config) DesktopThemeStyle() string {
return normalizeThemeStyle(c.Desktop.ThemeStyle)
}
// DesktopLayoutStyle normalizes the desktop layout style. New installs default
// to workbench; explicit classic remains respected.
func (c *Config) DesktopLayoutStyle() string {
if strings.EqualFold(strings.TrimSpace(c.Desktop.ThemeStyle), "workbench") && strings.TrimSpace(c.Desktop.LayoutStyle) == "" {
return "workbench"
}
return normalizeDesktopLayoutStyle(c.Desktop.LayoutStyle)
}
// DesktopCloseBehavior normalizes the desktop close-window preference. It falls
// back to the legacy ui.close_behavior value for configs written before [desktop]
// existed.
func (c *Config) DesktopCloseBehavior() string {
if strings.TrimSpace(c.Desktop.CloseBehavior) != "" {
return normalizeCloseBehavior(c.Desktop.CloseBehavior)
}
return normalizeCloseBehavior(c.UI.CloseBehavior)
}
// UICloseBehavior is the legacy name for DesktopCloseBehavior.
func (c *Config) UICloseBehavior() string {
return c.DesktopCloseBehavior()
}
// DesktopDisplayMode normalizes the transcript display mode. Default is
// "standard" (flat rendering, no folding).
func (c *Config) DesktopDisplayMode() string {
switch strings.ToLower(strings.TrimSpace(c.Desktop.DisplayMode)) {
case "standard":
return "standard"
case "compact", "minimal":
return "compact"
default:
return "standard"
}
}
// NormalizeToolApprovalMode returns the canonical desktop/session tool approval
// posture. Unknown or missing values fall back to ask for safety.
func NormalizeToolApprovalMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "auto":
return "auto"
case "yolo", "full", "full-access", "bypass":
return "yolo"
default:
return "ask"
}
}
// DesktopDefaultToolApprovalMode is the Ask/Auto/YOLO default used only when
// creating a new desktop session. Existing tabs and restored sessions keep their
// own persisted runtime state.
func (c *Config) DesktopDefaultToolApprovalMode() string {
if c == nil {
return "ask"
}
return NormalizeToolApprovalMode(c.Desktop.DefaultToolApprovalMode)
}
// DesktopStatusBarStyle normalizes the desktop status bar metric label style.
// Default is "text"; explicit "icon" preserves the user's compact choice.
func (c *Config) DesktopStatusBarStyle() string {
switch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {
case "icon":
return "icon"
case "text":
return "text"
default:
return "text"
}
}
var defaultDesktopStatusBarItems = []string{
"model",
"workspace",
"git_branch",
"cache",
"cache_avg",
"session_tokens",
"turn_tokens",
"turn_cost",
"session_turns",
"context",
"compact",
"cost",
"balance",
}
var knownDesktopStatusBarItems = desktopStatusBarItemSet(defaultDesktopStatusBarItems)
func desktopStatusBarItemSet(items []string) map[string]bool {
out := make(map[string]bool, len(items))
for _, item := range items {
out[item] = true
}
return out
}
// DefaultDesktopStatusBarItems returns the default ordered visible desktop
// status bar items.
func DefaultDesktopStatusBarItems() []string {
return append([]string(nil), defaultDesktopStatusBarItems...)
}
// DesktopStatusBarItems normalizes the ordered visible desktop status bar items.
// An unset or empty list uses the default full set; explicit non-empty lists
// preserve user order and omit hidden items.
func (c *Config) DesktopStatusBarItems() []string {
return normalizeDesktopStatusBarItems(c.Desktop.StatusBarItems)
}
func normalizeDesktopStatusBarItems(items []string) []string {
out := make([]string, 0, len(items))
seen := map[string]bool{}
for _, raw := range items {
id := strings.TrimSpace(raw)
if !knownDesktopStatusBarItems[id] || seen[id] {
continue
}
out = append(out, id)
seen[id] = true
}
if len(out) == 0 {
return DefaultDesktopStatusBarItems()
}
return out
}
// DesktopCheckUpdates reports whether the desktop should check for updates on
// startup. Missing configs default to true so existing users keep update notices.
func (c *Config) DesktopCheckUpdates() bool {
if c == nil || c.Desktop.CheckUpdates == nil {
return true
}
return *c.Desktop.CheckUpdates
}
// ColdResumePruneEnabled reports whether stale tool results are elided when a
// session resumes past the provider cache window. Default true (cheaper cold
// restart); users keep full history by disabling it.
func (c *Config) ColdResumePruneEnabled() bool {
if c == nil || c.Agent.ColdResumePrune == nil {
return true
}
return *c.Agent.ColdResumePrune
}
// ResponseLanguage normalizes the top-level language preference for final
// answers. Empty means auto: replies follow the current user turn.
func (c *Config) ResponseLanguage() string {
if c == nil {
return "auto"
}
return NormalizeLanguage(c.Language)
}
// NormalizeLanguage returns one of auto|zh|en for UI/default reply language settings.
func NormalizeLanguage(lang string) string {
switch strings.ToLower(strings.TrimSpace(lang)) {
case "", "auto", "detect", "default":
return "auto"
case "zh", "cn", "chinese", "中文":
return "zh"
case "en", "english":
return "en"
default:
return "auto"
}
}
// ReasoningLanguage normalizes agent.reasoning_language. Empty means auto:
// visible reasoning follows the conversation language already described by the
// stable LanguagePolicy. Legacy "default" is treated as auto.
func (c *Config) ReasoningLanguage() string {
if c == nil {
return "auto"
}
return NormalizeReasoningLanguage(c.Agent.ReasoningLanguage)
}
// NormalizeReasoningLanguage returns one of auto|zh|en.
func NormalizeReasoningLanguage(lang string) string {
switch strings.ToLower(strings.TrimSpace(lang)) {
case "", "auto", "follow", "conversation", "detect", "default", "model", "model-default", "model_default", "provider":
return "auto"
case "zh", "cn", "chinese", "中文":
return "zh"
case "en", "english":
return "en"
default:
return "auto"
}
}
// DesktopTelemetry reports whether the desktop sends the anonymous launch ping.
// It carries no conversation, key, or file data — see desktop/README.md.
func (c *Config) DesktopTelemetry() bool {
if c == nil || c.Desktop.Telemetry == nil {
return true
}
return *c.Desktop.Telemetry
}
// DesktopMetrics reports whether the desktop sends aggregate desktop metrics —
// anonymous (signal, bucket) counters, never content. Default on.
func (c *Config) DesktopMetrics() bool {
if c == nil || c.Desktop.Metrics == nil {
return true
}
return *c.Desktop.Metrics
}
// LSPConfig governs the optional Language Server Protocol tools (lsp_definition,
// lsp_references, lsp_hover, lsp_diagnostics). Enabled defaults to true; the
// servers themselves are never bundled — each resolves on PATH and the tool
// returns an install hint when it is missing, so the capability is dormant until
// the user installs a server. Servers overrides or extends the built-in language
// → server map, keyed by language id (e.g. "go", "rust", "python").
type LSPConfig struct {
Enabled bool `toml:"enabled"`
Servers map[string]LSPServer `toml:"servers"`
}
// LSPServer overrides a built-in language's server or, when keyed by a new
// language, adds one. An empty field falls back to the built-in default for that
// language; Extensions is required when adding a language the built-ins don't
// cover (e.g. ".ex" for Elixir) so files route to it.
type LSPServer struct {
Command string `toml:"command"`
Args []string `toml:"args"`
Env map[string]string `toml:"env"`
LanguageID string `toml:"language_id"`
Extensions []string `toml:"extensions"`
InstallHint string `toml:"install_hint"`
}
// StatuslineConfig configures a custom status line. Command, when set, is run at
// startup and after each turn; its first line of stdout replaces the built-in
// status data row. A JSON payload (model, context tokens, cwd) is fed on stdin.
type StatuslineConfig struct {
Command string `toml:"command"`
}
// BotConfig 控制多渠道 IM bot 消息网关。
type BotConfig struct {
Enabled bool `toml:"enabled"`
Model string `toml:"model"` // 用于 bot 的模型名,空则用 default_model
ToolApprovalMode string `toml:"tool_approval_mode"`
MaxSteps int `toml:"max_steps"`
DebounceMs int `toml:"debounce_ms"` // 消息合并窗口,毫秒
QueueMode string `toml:"queue_mode"` // steer|followup|collect|interrupt
QueueCap int `toml:"queue_cap"`
QueueDrop string `toml:"queue_drop"` // summarize|old|new
IgnoreSelfMessages bool `toml:"ignore_self_messages"`
SelfUserIDs BotSelfUserIDs `toml:"self_user_ids"`
Control BotControlConfig `toml:"control"`
Pairing BotPairingConfig `toml:"pairing"`
Allowlist BotAllowlist `toml:"allowlist"`
QQ QQBotConfig `toml:"qq"`
Feishu FeishuBotConfig `toml:"feishu"`
Weixin WeixinBotConfig `toml:"weixin"`
Routes []BotRouteConfig `toml:"routes"`
Connections []BotConnectionConfig `toml:"connections"`
// DesktopWatchers persists /desktop watch subscriptions so god-view
// notifications survive a desktop restart. Managed by the desktop bot
// bridge, not the settings UI.
DesktopWatchers []BotDesktopWatcherConfig `toml:"desktop_watchers"`
}
// BotDesktopWatcherConfig is one bot chat subscribed to desktop events
// (/desktop watch on).
type BotDesktopWatcherConfig struct {
Platform string `toml:"platform"`
ConnectionID string `toml:"connection_id"`
Domain string `toml:"domain"`
ChatType string `toml:"chat_type"`
ChatID string `toml:"chat_id"`
}
type BotSelfUserIDs struct {
QQ []string `toml:"qq"`
Feishu []string `toml:"feishu"`
Weixin []string `toml:"weixin"`
}
type BotControlConfig struct {
Enabled bool `toml:"enabled"`
Addr string `toml:"addr"`
TokenEnv string `toml:"token_env"`
}
type BotRouteConfig struct {
ConnectionID string `toml:"connection_id"`
Platform string `toml:"platform"`
ChatType string `toml:"chat_type"`
ChatID string `toml:"chat_id"`
UserID string `toml:"user_id"`
ThreadID string `toml:"thread_id"`
Model string `toml:"model"`
ToolApprovalMode string `toml:"tool_approval_mode"`
WorkspaceRoot string `toml:"workspace_root"`
}
// BotAllowlist 控制哪些用户可以使用 bot。
type BotAllowlist struct {
Enabled bool `toml:"enabled"`
AllowAll bool `toml:"allow_all"`
QQUsers []string `toml:"qq_users"`
FeishuUsers []string `toml:"feishu_users"`
WeixinUsers []string `toml:"weixin_users"`
QQApprovers []string `toml:"qq_approvers"`
FeishuApprovers []string `toml:"feishu_approvers"`
WeixinApprovers []string `toml:"weixin_approvers"`
QQAdmins []string `toml:"qq_admins"`
FeishuAdmins []string `toml:"feishu_admins"`
WeixinAdmins []string `toml:"weixin_admins"`
QQGroups []string `toml:"qq_groups"`
FeishuGroups []string `toml:"feishu_groups"`
WeixinGroups []string `toml:"weixin_groups"`
}
type BotPairingConfig struct {
Enabled bool `toml:"enabled"`
RequestTTLMinutes int `toml:"request_ttl_minutes"`
MaxPendingPerPlatform int `toml:"max_pending_per_platform"`
}
// BotAccessConfig controls who may use one concrete bot connection.
type BotAccessConfig struct {
Enabled bool `toml:"enabled"`
AllowAll bool `toml:"allow_all"`
PairingEnabled bool `toml:"pairing_enabled"`
Users []string `toml:"users"`
Groups []string `toml:"groups"`
Approvers []string `toml:"approvers"`
Admins []string `toml:"admins"`
}
// QQBotConfig QQ 官方 Bot API v2 配置。
type QQBotConfig struct {
Enabled bool `toml:"enabled"`
AppID string `toml:"app_id"`
AppSecretEnv string `toml:"app_secret_env"` // 环境变量名,如 QQ_BOT_APP_SECRET
Sandbox bool `toml:"sandbox"` // true 使用 QQ 沙箱 API / gateway
Model string `toml:"model"`
ToolApprovalMode string `toml:"tool_approval_mode"`
WorkspaceRoot string `toml:"workspace_root"`
Access BotAccessConfig `toml:"access"`
}
// FeishuBotConfig 飞书自建应用 Bot 配置。
type FeishuBotConfig struct {
Enabled bool `toml:"enabled"`
Domain string `toml:"domain"` // feishu(默认)| lark
AppID string `toml:"app_id"`
AppSecretEnv string `toml:"app_secret_env"` // 如 FEISHU_BOT_APP_SECRET
VerificationToken string `toml:"verification_token"` // 事件订阅验证 token
Mode string `toml:"mode"` // webhook(默认)| websocket
WebhookPort int `toml:"webhook_port"` // webhook 模式端口
RequireMention bool `toml:"require_mention"`
// OutboundMediaRoots contains absolute local directories the loopback /send
// control API may attach files from. Media refs must be bare filenames and
// must exist in exactly one configured root. Empty (the default) disables
// outbound file sending.
OutboundMediaRoots []string `toml:"outbound_media_roots"`
}
// WeixinBotConfig 微信 iLink Bot 配置。
type WeixinBotConfig struct {
Enabled bool `toml:"enabled"`
AccountID string `toml:"account_id"`
TokenEnv string `toml:"token_env"` // 环境变量名,如 WEIXIN_BOT_TOKEN
APIBase string `toml:"api_base"` // iLink API base URL
}
// BotConnectionConfig is the desktop-friendly connection record for IM bot
// channels. It keeps install/runtime state separate from legacy per-provider
// knobs so the UI can expose a simple "connect first" flow while old configs
// keep working.
type BotConnectionConfig struct {
ID string `toml:"id"`
Provider string `toml:"provider"` // qq|feishu|weixin
Domain string `toml:"domain"` // feishu|lark|weixin|qq
Label string `toml:"label"`
Enabled bool `toml:"enabled"`
Status string `toml:"status"` // disconnected|pending|connected|error
Model string `toml:"model"`
ToolApprovalMode string `toml:"tool_approval_mode"`
WorkspaceRoot string `toml:"workspace_root"`
Access BotAccessConfig `toml:"access"`
Credential BotConnectionCredential `toml:"credential"`
SessionMappings []BotConnectionSessionMapping `toml:"session_mappings"`
LastError string `toml:"last_error"`
CreatedAt string `toml:"created_at"`
UpdatedAt string `toml:"updated_at"`
}
type BotConnectionCredential struct {
AppID string `toml:"app_id"`
AppSecretEnv string `toml:"app_secret_env"`
AccountID string `toml:"account_id"`
TokenEnv string `toml:"token_env"`
}
type BotConnectionSessionMapping struct {
RemoteID string `toml:"remote_id"`
SessionID string `toml:"session_id"`
SessionSource string `toml:"session_source"`
ChatType string `toml:"chat_type"`
UserID string `toml:"user_id"`
ThreadID string `toml:"thread_id"`
Scope string `toml:"scope"`
WorkspaceRoot string `toml:"workspace_root"`
UpdatedAt string `toml:"updated_at"`
}
// ServeConfig controls the HTTP serve frontend security settings.
type ServeConfig struct {
// AuthMode selects the authentication mode for the HTTP serve frontend.
// "none" (default): no authentication.
// "token": a pre-shared token in the URL query string.
// "password": a login page with bcrypt password verification.
AuthMode string `toml:"auth_mode"`
// Token is a pre-shared token for auth_mode = "token". When empty, a
// cryptographically random token is generated at startup and printed.
Token string `toml:"token"`
// PasswordHash is a bcrypt hash of the password for auth_mode = "password".
// Generate one with: reasonix serve --hash-password --password '...'
PasswordHash string `toml:"password_hash"`
// BehindProxy indicates the server sits behind a trusted reverse proxy
// (nginx, Caddy, Cloudflare, etc.) that sets X-Forwarded-For and
// X-Forwarded-Proto headers. When true, those headers are used for
// rate-limiting and Secure-cookie decisions. When false (default), they
// are ignored — an attacker can otherwise forge them.
BehindProxy bool `toml:"behind_proxy"`
}
// NetworkConfig controls ordinary outbound HTTP traffic such as model providers,
// wallet-balance lookups, updater checks, CodeGraph downloads, and web_fetch.
// web_fetch reuses these proxy settings while keeping its own SSRF-guarded
// dialer.
type NetworkConfig struct {
// ProxyMode is "auto" (default; environment proxy for now), "env", "custom",
// or "off". auto leaves room for OS proxy detection later without changing the
// config shape.
ProxyMode string `toml:"proxy_mode"`
// ProxyURL is an advanced custom override such as "socks5://127.0.0.1:7890".
// When set and proxy_mode = "custom", it wins over the structured proxy table.
ProxyURL string `toml:"proxy_url"`
// NoProxy is honored for custom proxies. Env/auto modes use NO_PROXY from the
// process environment instead.
NoProxy string `toml:"no_proxy"`
Proxy NetworkProxyConfig `toml:"proxy"`
}
// NetworkProxyConfig is the structured custom-proxy editor shape. Password is
// optional and supports ${VAR} expansion, so users can avoid storing it literally.
type NetworkProxyConfig struct {
Type string `toml:"type"` // http|https|socks5|socks5h
Server string `toml:"server"`
Port int `toml:"port"`
Username string `toml:"username"`
Password string `toml:"password"`
}
// NetworkProxySpec returns the expanded proxy settings used by netclient.
func (c *Config) NetworkProxySpec() netclient.ProxySpec {
return netclient.ProxySpec{
Mode: c.Network.ProxyMode,
URL: c.expandVars(c.Network.ProxyURL),
NoProxy: c.expandVars(c.Network.NoProxy),
Type: c.Network.Proxy.Type,
Server: c.expandVars(c.Network.Proxy.Server),
Port: c.Network.Proxy.Port,
Username: c.expandVars(c.Network.Proxy.Username),
Password: c.expandVars(c.Network.Proxy.Password),
DirectHosts: c.directProxyHosts(),
}
}
// directProxyHosts collects the base_url hosts of providers marked no_proxy, so
// netclient bypasses the proxy for them without knowing any provider by name.
//
// Only for an auto-detected proxy (auto/env): that proxy is typically a
// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep
// them direct. An explicit proxy_mode = "custom" is the user saying "route
// everything through this" — e.g. a mandatory corporate proxy — so honor it for
// every provider; a custom-proxy user who wants a host direct uses
// network.no_proxy instead (#3635).
func (c *Config) directProxyHosts() []string {
if c.NetworkProxyMode() == netclient.ModeCustom {
return nil
}
seen := map[string]bool{}
var out []string
for _, p := range c.Providers {
if !p.NoProxy {
continue
}
u, err := url.Parse(strings.TrimSpace(p.BaseURL))
if err != nil {
continue
}
if h := u.Hostname(); h != "" && !seen[h] {
seen[h] = true
out = append(out, h)
}
}
return out
}
// NetworkProxyMode normalizes network.proxy_mode to a known value.
func (c *Config) NetworkProxyMode() string {
return netclient.NormalizeMode(c.Network.ProxyMode)
}
// SkillsConfig configures skill discovery. Paths adds extra "custom"-scope skill
// roots — each a directory of SKILL.md / <name>.md playbooks — scanned between
// the project roots (.reasonix/.agents/.agent/.claude under the workspace) and
// the global roots. ExcludedPaths hides matching discovery roots without deleting
// folders. ~, relative paths, and ${VAR} expansion are supported. DisabledSkills
// hides named skills from the agent prompt, slash invocation, and skill tools
// while keeping them manageable.
type SkillsConfig struct {
Paths []string `toml:"paths"`
ExcludedPaths []string `toml:"excluded_paths"`
DisabledSkills []string `toml:"disabled_skills"`
MaxDepth int `toml:"max_depth"`
}
// SkillCustomPaths returns the configured custom skill roots with ${VAR}
// expanded; empty entries are dropped.
func (c *Config) SkillCustomPaths() []string {
var out []string
for _, p := range c.Skills.Paths {
if p = c.expandVars(p); strings.TrimSpace(p) != "" {
out = append(out, p)
}
}
return out
}
// SkillExcludedPaths returns configured skill roots that should be hidden from
// discovery, with ${VAR} expanded and empty entries dropped.
func (c *Config) SkillExcludedPaths() []string {
var out []string
for _, p := range c.Skills.ExcludedPaths {
if p = c.expandVars(p); strings.TrimSpace(p) != "" {
out = append(out, p)
}
}
return out
}
// SkillMaxDepth bounds nested skill discovery. Depth 3 favors bundled skill
// packs while Store keeps nested markdown safe by requiring descriptions.
func (c *Config) SkillMaxDepth() int {
const (
defaultDepth = 3
maxDepth = 5
)
if c == nil || c.Skills.MaxDepth == 0 {
return defaultDepth
}
if c.Skills.MaxDepth < 1 {
return 1
}
if c.Skills.MaxDepth > maxDepth {
return maxDepth
}
return c.Skills.MaxDepth
}
// DisabledSkillNames returns valid disabled skill identifiers, preserving the
// first spelling and dropping duplicates/empty entries.
func (c *Config) DisabledSkillNames() []string {
seen := map[string]bool{}
var out []string
for _, name := range c.Skills.DisabledSkills {
name = strings.TrimSpace(name)
if !IsValidSkillName(name) {
continue
}
key := SkillNameKey(name)
if seen[key] {
continue
}
seen[key] = true
out = append(out, name)
}
return out
}
// IsSkillDisabled reports whether name is configured as disabled.
func (c *Config) IsSkillDisabled(name string) bool {
key := SkillNameKey(name)
if key == "" {
return false
}
for _, disabled := range c.DisabledSkillNames() {
if SkillNameKey(disabled) == key {
return true
}
}
return false
}
// SandboxConfig bounds the blast radius of tool calls (Phase 0: file-writer
// confinement). WorkspaceRoot is the directory the built-in file writers
// (write_file / edit_file / multi_edit / move_file) may modify; empty means the
// current working directory, so writes stay inside the project by default.
// AllowWrite lists extra directories writers may also touch (e.g. a sibling repo
// or a temp dir). ForbidRead lists directories the agent may not read or list at all
// (e.g. ~/.ssh for secrets). Both support ${VAR} / ${VAR:-default} expansion. Reads are
// unrestricted; confining `bash` is Phase 1 (OS-level sandbox).
type SandboxConfig struct {
WorkspaceRoot string `toml:"workspace_root"`
AllowWrite []string `toml:"allow_write"`
ForbidRead []string `toml:"forbid_read"`
// Bash is the OS-sandbox mode for the bash tool: "enforce" jails each
// command when an OS sandbox is available and refuses bash otherwise; "off"
// runs it unconfined. Empty uses the platform default.
Bash string `toml:"bash"`
// Network allows network egress from inside the bash sandbox. Defaults true
// so module/package downloads keep working; the boundary is then writes.
Network bool `toml:"network"`
}
// WriteRoots returns the directories file-writer tools may modify: the
// workspace root (defaulting to the current working directory when unset), plus
// any AllowWrite extras, with ${VAR} expanded. The roots are returned as given
// (relative or absolute); the confiner resolves them to absolute, symlink-free
// paths. The result is always non-empty, so confinement is on by default.
func (c *Config) WriteRoots() []string {
return c.WriteRootsForRoot(".")
}
// WriteRootsForRoot is like WriteRoots but falls back to fallbackRoot when the
// config doesn't explicitly set a workspace_root. Desktop tabs pass their
// project root here so tool confinement is correct without changing cwd.
func (c *Config) WriteRootsForRoot(fallbackRoot string) []string {
root := c.expandVars(c.Sandbox.WorkspaceRoot)
if root == "" {
root = fallbackRoot
if root == "" || root == "." {
if wd, err := os.Getwd(); err == nil {
root = wd
} else {
root = "."
}
}
}
roots := []string{root}
for _, d := range c.Sandbox.AllowWrite {
if d = c.expandVars(d); d != "" {
roots = append(roots, d)
}
}
return roots
}
// AllowWriteRoots returns only the configured [sandbox] allow_write extras with
// ${VAR} expanded — the explicit escape-hatch entries, without the workspace
// root that WriteRoots prepends. The session-data write guard treats these as
// user-sanctioned raw access.
func (c *Config) AllowWriteRoots() []string {
var roots []string
for _, d := range c.Sandbox.AllowWrite {
if d = c.expandVars(d); d != "" {
roots = append(roots, d)
}
}
return roots
}
// ForbidReadRoots returns the directories the agent is forbidden from reading
// or listing, with ${VAR} expanded. Relative roots are resolved against the
// current working directory; the confiner resolves them to symlink-free paths.
// Empty when no forbid_read entries are configured.
func (c *Config) ForbidReadRoots() []string {
return c.ForbidReadRootsForRoot(".")
}
// ForbidReadRootsForRoot is like ForbidReadRoots but uses fallbackRoot when
// resolving relative paths (for desktop tabs that pass their project root).
func (c *Config) ForbidReadRootsForRoot(fallbackRoot string) []string {
root := fallbackRoot
if root == "" || root == "." {
if wd, err := os.Getwd(); err == nil {
root = wd
} else {
root = "."
}
}
roots := make([]string, 0, len(c.Sandbox.ForbidRead))
for _, d := range c.Sandbox.ForbidRead {
if d = c.expandVars(d); d != "" {
if !filepath.IsAbs(d) {
d = filepath.Join(root, d)
}
roots = append(roots, d)
}
}
return roots
}
// BashMode normalises the bash-sandbox mode for the current host.
func (c *Config) BashMode() string {
return c.BashModeForGOOS(runtimeGOOS)
}
// BashModeForGOOS normalises the bash-sandbox mode for tests and cross-platform
// rendering. Windows currently forces bash sandboxing off, even when older
// configs explicitly requested "enforce", because the native backend still
// breaks common Git Bash/MSYS2, Docker, and git workflows. macOS/Linux keep the
// existing explicit-mode behavior.
func (c *Config) BashModeForGOOS(goos string) string {
if goos == "windows" {
return "off"
}
switch strings.TrimSpace(c.Sandbox.Bash) {
case "enforce":
return "enforce"
case "off":
return "off"
case "":
return "enforce"
default:
return "enforce"
}
}
// AgentConfig configures the harness loop. PlannerModel is optional: when set
// to another provider's name it enables two-model collaboration, where the
// planner handles low-frequency planning in its own session (kept separate so
// each model's prompt prefix stays cache-stable). SubagentModel is the optional
// default for runAs=subagent skills; SubagentModels overrides it per skill name.
type AgentConfig struct {
SystemPrompt string `toml:"system_prompt"`
SystemPromptFile string `toml:"system_prompt_file"`
MaxSteps int `toml:"max_steps"` // tool-call rounds per turn; 0 = unlimited
PlannerMaxSteps int `toml:"planner_max_steps"` // planner read-only tool-call rounds; 0 = unlimited
Temperature float64 `toml:"temperature"`
PlannerModel string `toml:"planner_model"`
GuardianModel string `toml:"guardian_model"`
GuardianTemperature float64 `toml:"guardian_temperature"`
SubagentModel string `toml:"subagent_model"`
SubagentModels map[string]string `toml:"subagent_models"`
SubagentEffort string `toml:"subagent_effort"`
SubagentEfforts map[string]string `toml:"subagent_efforts"`
MaxSubagentDepth int `toml:"max_subagent_depth"`
// OutputStyle selects a persona/tone block folded into the system prompt at
// startup (a built-in like "explanatory"/"learning"/"concise", or a custom
// .reasonix/output-styles/<name>.md). Empty = the unmodified prompt.
OutputStyle string `toml:"output_style"`
// AutoPlan controls whether interactive turns that look multi-step start in
// plan mode automatically: "off" keeps plan mode manual, "on" enables the
// approval gate. Legacy "ask" is treated as "on".
AutoPlan string `toml:"auto_plan"`
// ReasoningLanguage controls the preferred language for visible reasoning
// text. Empty/auto follows the conversation language. Applied as transient
// turn context, not the stable prompt.
ReasoningLanguage string `toml:"reasoning_language"`
// AutoPlanClassifier optionally names a provider/model used to classify
// borderline auto-plan decisions. Empty keeps the zero-cost heuristic path.
AutoPlanClassifier string `toml:"auto_plan_classifier"`
// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.
SoftCompactRatio float64 `toml:"soft_compact_ratio"`
ToolResultSnipRatio float64 `toml:"tool_result_snip_ratio"`
CompactRatio float64 `toml:"compact_ratio"`
CompactForceRatio float64 `toml:"compact_force_ratio"`
// Keep controls which compactable messages stay verbatim beyond the current
// user-fact/digest floor and recent tail. Empty uses the conservative default
// of keeping error tool results.
Keep []string `toml:"keep"`
RecentKeep int `toml:"recent_keep"`
// ColdResumePrune elides stale tool results when a session reopens past the
// provider cache window. nil = default enabled.
ColdResumePrune *bool `toml:"cold_resume_prune"`
// PlanModeAllowedTools names extra custom tools the plan-mode policy may treat
// as read-only. It cannot unlock known blocked tools or unsafe bash commands.
PlanModeAllowedTools []string `toml:"plan_mode_allowed_tools"`
// PlanModeReadOnlyCommands names concrete shell command prefixes that plan mode
// may treat as read-only. Shell operators, background execution, and shell
// interpreter prefixes remain blocked.
PlanModeReadOnlyCommands []string `toml:"plan_mode_read_only_commands"`
// MemoryCompiler controls the v5 execution-memory compiler. Missing configs
// default to enabled so users get the self-improving planner unless they opt
// out explicitly.
MemoryCompiler MemoryCompilerConfig `toml:"memory_compiler"`
}
// MemoryCompilerConfig controls the v5 execution-memory compiler.
type MemoryCompilerConfig struct {
Enabled *bool `toml:"enabled"`
Verbosity string `toml:"verbosity"`
}
const (
MemoryCompilerVerbosityObserve = "observe"
MemoryCompilerVerbosityCompact = "compact"
)
// MemoryCompilerEnabled reports whether the v5 execution-memory compiler should
// participate in future turns. Missing config defaults to true.
func (c *Config) MemoryCompilerEnabled() bool {
if c == nil || c.Agent.MemoryCompiler.Enabled == nil {
return true
}
return *c.Agent.MemoryCompiler.Enabled
}
// MemoryCompilerVerbosity reports how much Memory v5 state should be injected
// into model-facing turns. The default observes and learns without prompt
// injection, so Memory v5 IR is not provider-visible unless opted in.
func (c *Config) MemoryCompilerVerbosity() string {
if c == nil {
return MemoryCompilerVerbosityObserve
}
return NormalizeMemoryCompilerVerbosity(c.Agent.MemoryCompiler.Verbosity)
}
// NormalizeMemoryCompilerVerbosity accepts current and legacy spellings for the
// Memory v5 injection mode.
func NormalizeMemoryCompilerVerbosity(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case "", "observe", "observed", "silent", "minimal", "none":
return MemoryCompilerVerbosityObserve
case "compact", "inject", "injected", "contract", "on":
return MemoryCompilerVerbosityCompact
default:
return MemoryCompilerVerbosityObserve
}
}
// ProviderEntry declares a model provider instance. ContextWindow is the model's
// token budget; the harness compacts older history as a turn's prompt approaches
// it (see agent compaction). 0 disables compaction for the instance.
type ProviderEntry struct {
Name string `toml:"name"`
Kind string `toml:"kind"`
BaseURL string `toml:"base_url"`
ChatURL string `toml:"chat_url"`
Model string `toml:"model"` // a single model (back-compat)
Models []string `toml:"models"` // a vendor's model list (one base_url/key, many models)
ModelsURL string `toml:"models_url"` // auto-fetch models from this URL on startup
Default string `toml:"default"` // default model when Models is set (else Models[0])
APIKeyEnv string `toml:"api_key_env"`
PresetID string `toml:"preset_id"` // curated preset identity; UI-only metadata, not sent to model providers.
PresetVersion int `toml:"preset_version"` // curated preset schema version for future migrations.
Headers map[string]string `toml:"headers"` // optional extra HTTP headers for compatible gateways; secrets should stay in api_key_env.
ExtraBody map[string]any `toml:"extra_body"` // optional extra top-level JSON request body fields for OpenAI-compatible gateways.
AuthHeader bool `toml:"auth_header"` // for Anthropic-compatible gateways that expect Authorization: Bearer instead of x-api-key.
resolvedAPIKey string
resolvedSource CredentialSource
BalanceURL string `toml:"balance_url"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.
ContextWindow int `toml:"context_window"`
Price *provider.Pricing `toml:"price"` // legacy/provider-wide fallback
Prices map[string]*provider.Pricing `toml:"prices"` // optional per-model prices; keys are model ids
// Thinking / Effort are provider-kind-specific knobs forwarded to the provider
// via Config.Extra. The anthropic provider reads Thinking="adaptive" to enable
// extended thinking and Effort ("low".."max") to tune depth. The
// openai-compatible provider forwards Effort as reasoning_effort for
// thinking-capable models; DeepSeek accepts high|max.
// Empty = provider default.
Thinking string `toml:"thinking"`
Effort string `toml:"effort"`
// Vision marks the model as accepting image input. When set, images the user
// attaches are embedded in the request (image_url for openai-kind, base64
// blocks for anthropic). Off by default: text-only models 400 on image input,
// and image tokens are heavy — gating keeps text-only flows cheap (the prompt
// prefix is byte-identical with no image, so the cache is unaffected either way).
Vision bool `toml:"vision"`
// VisionModels narrows image input support to specific models in a multi-model
// provider. This lets one provider expose both text-only and multimodal chat
// models without enabling image payloads for every model.
VisionModels []string `toml:"vision_models"`
// VisionDetail sets the openai image_url detail hint (low|high); empty = auto
// (the field is omitted). "low" caps an image to a fixed ~85 tokens for cheap
// coarse reads; ignored by providers without the knob (e.g. anthropic).
VisionDetail string `toml:"vision_detail"`
// ReasoningProtocol selects the request shape for OpenAI-compatible reasoning
// models. Empty/auto uses the model capability registry plus endpoint
// heuristics; none disables automatic reasoning controls for this provider.
ReasoningProtocol string `toml:"reasoning_protocol"`
// SupportedEfforts lists the /effort levels this provider/model exposes.
// When non-empty, it overrides the built-in defaults derived from
// Kind/BaseURL and makes /effort configurable. "auto" is the implicit
// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort
// (or set one outside this list) to fall back to SupportedEfforts[0].
SupportedEfforts []string `toml:"supported_efforts"`
// DefaultEffort is the /effort level used when the user picks "auto" or
// has not set Effort. Ignored when SupportedEfforts is empty.
DefaultEffort string `toml:"default_effort"`
// ModelOverrides customizes capability metadata after ResolveModel selects a
// concrete model from a multi-model provider. Use it when a gateway exposes
// mixed DeepSeek/OpenAI/no-reasoning or mixed vision/text models under one
// base_url/key.
ModelOverrides map[string]ProviderModelOverride `toml:"model_overrides"`
visionOverride *bool
// NoProxy reaches this provider's base_url directly, never through the proxy.
// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).
NoProxy bool `toml:"no_proxy"`
}
type ProviderModelOverride struct {
ReasoningProtocol string `toml:"reasoning_protocol"`
SupportedEfforts []string `toml:"supported_efforts"`
DefaultEffort string `toml:"default_effort"`
Vision *bool `toml:"vision"`
}
// ModelList returns the models this provider exposes: the explicit `models` list,
// or the single `model` as a one-element list (back-compat). Empty if neither set.
func (e *ProviderEntry) ModelList() []string {
if len(e.Models) > 0 {
return e.Models
}
if e.Model != "" {
return []string{e.Model}
}
return nil
}
// IsLikelyChatModel reports whether a model ID looks like a chat/completion
// model rather than a specialised audio/vision/embedding model. It applies a
// conservative name-based heuristic — the OpenAI-compatible /models API does
// not return capability/modality metadata, so this is the most reliable
// fallback until providers add such fields.
//
// The heuristic works in two passes:
// 1. Multi-word substring check for compound terms that span separators
// (e.g. "text-embedding", "text-to-speech").
// 2. Token-level check: the model ID is split on common separators (- _ . / :)
// and each token is compared against a set of known non-chat keywords.
//
// "voice" is intentionally absent from the non-chat set because it is too
// broad — legitimate future chat models may include it in their name.
func IsLikelyChatModel(model string) bool {
model = strings.TrimSpace(model)
if model == "" {
return false
}
lower := strings.ToLower(model)
// Pass 1: compound terms that span separator boundaries.
var compoundNonChat = []string{
"text-embedding", "text-to-speech", "speech-to-text",
}
for _, c := range compoundNonChat {
if strings.Contains(lower, c) {
return false
}
}
// Pass 2: token-level check.
tokens := strings.FieldsFunc(lower, func(r rune) bool {
return r == '-' || r == '_' || r == '.' || r == '/' || r == ':'
})
var nonChatTokens = map[string]bool{
"asr": true, "stt": true, "tts": true,
"whisper": true, "embedding": true,
"moderation": true, "rerank": true, "dall": true,
"transcription": true,
}
for _, tok := range tokens {
if nonChatTokens[tok] {
return false
}
}
return true
}
// ChatModelList returns ModelList filtered to likely chat/completion models.
// Non-chat models (TTS, STT, ASR, embedding, etc.) are excluded so they do
// not appear in the chat model picker. Use ModelList() only when the full
// raw provider model list is needed, such as config serialization, provider
// diagnostics, or model-fetch editing.
func (e *ProviderEntry) ChatModelList() []string {
raw := e.ModelList()
if len(raw) == 0 {
return nil
}
out := make([]string, 0, len(raw))
for _, m := range raw {
if IsLikelyChatModel(m) {
out = append(out, m)
}
}
return out
}
// DefaultModel returns the provider's default model: the explicit `default`, else
// the first of ModelList.
func (e *ProviderEntry) DefaultModel() string {
if e.Default != "" {
return e.Default
}
if l := e.ModelList(); len(l) > 0 {
return l[0]
}
return ""
}
// HasModel reports whether m is one of the provider's models.
func (e *ProviderEntry) HasModel(m string) bool {
for _, x := range e.ModelList() {
if x == m {
return true
}
}
return false
}
// PriceForModel returns the configured per-1M-token price for model. Per-model
// prices win; the legacy provider-wide price is a fallback for older configs.
func (e *ProviderEntry) PriceForModel(model string) *provider.Pricing {
if e == nil {
return nil
}
if e.Prices != nil {
if p := e.Prices[strings.TrimSpace(model)]; p != nil {
return clonePricing(p)
}
}
return clonePricing(e.Price)
}
func (e *ProviderEntry) applyModelPrice() {
if e == nil {
return
}
e.Price = e.PriceForModel(e.Model)
}
func (e *ProviderEntry) applyModelOverride() {
if e == nil || len(e.ModelOverrides) == 0 {
return
}
ov, ok := e.modelOverrideForModel(e.Model)
if !ok {
return
}
if ov.ReasoningProtocol != "" {
e.ReasoningProtocol = ov.ReasoningProtocol
}
if ov.SupportedEfforts != nil {
e.SupportedEfforts = append([]string(nil), ov.SupportedEfforts...)
}
if ov.DefaultEffort != "" || ov.SupportedEfforts != nil {
e.DefaultEffort = ov.DefaultEffort
}
if ov.Vision != nil {
e.visionOverride = ov.Vision
}
}
func (e *ProviderEntry) modelOverrideForModel(model string) (ProviderModelOverride, bool) {
model = strings.TrimSpace(model)
if e == nil || model == "" || len(e.ModelOverrides) == 0 {
return ProviderModelOverride{}, false
}
if ov, ok := e.ModelOverrides[model]; ok {
return ov, true
}
for k, ov := range e.ModelOverrides {
if strings.EqualFold(strings.TrimSpace(k), model) {
return ov, true
}
}
return ProviderModelOverride{}, false
}
func clonePricing(p *provider.Pricing) *provider.Pricing {
if p == nil {
return nil
}
cp := *p
return &cp
}
// ToolsConfig selects which built-in tools are enabled. Empty means all of them.
type ToolsConfig struct {
Enabled []string `toml:"enabled"`
BashTimeoutSeconds *int `toml:"bash_timeout_seconds"`
MCPCallTimeoutSeconds *int `toml:"mcp_call_timeout_seconds"`
BackgroundJobs BackgroundJobsConfig `toml:"background_jobs"`
Search SearchConfig `toml:"search"`
Shell ShellConfig `toml:"shell"`
}
const (
defaultBashTimeoutSeconds = 120
defaultMCPCallTimeoutSeconds = 300
defaultBackgroundJobStalledWarningSec = 900
maxBackgroundJobStalledWarningSec = 86400
)
// BashTimeoutSeconds returns the foreground bash timeout in seconds. An omitted
// config keeps the historical 120s safety cap, explicit 0 disables the
// tool-local cap, and positive values set a custom cap. Negative values fall
// back to the default so a typo cannot silently remove the safety net.
func (c *Config) BashTimeoutSeconds() int {
if c.Tools.BashTimeoutSeconds == nil || *c.Tools.BashTimeoutSeconds < 0 {
return defaultBashTimeoutSeconds
}
return *c.Tools.BashTimeoutSeconds
}
// MCPCallTimeoutSeconds returns the default MCP JSON-RPC call timeout in
// seconds. Omitted, zero, and negative values keep the built-in safety cap so a
// hung MCP server cannot block a turn indefinitely.
func (c *Config) MCPCallTimeoutSeconds() int {
if c.Tools.MCPCallTimeoutSeconds == nil || *c.Tools.MCPCallTimeoutSeconds <= 0 {
return defaultMCPCallTimeoutSeconds
}
return *c.Tools.MCPCallTimeoutSeconds
}
// BackgroundJobsConfig tunes parent-created background jobs.
type BackgroundJobsConfig struct {
StalledWarningSeconds *int `toml:"stalled_warning_seconds"`
}
// BackgroundJobStalledWarningSeconds returns the stalled warning threshold in
// seconds. Omitted/negative values keep the default, explicit 0 disables the
// notice, and oversized values clamp to one day so a typo cannot become
// effectively invisible.
func (c *Config) BackgroundJobStalledWarningSeconds() int {
if c.Tools.BackgroundJobs.StalledWarningSeconds == nil || *c.Tools.BackgroundJobs.StalledWarningSeconds < 0 {
return defaultBackgroundJobStalledWarningSec
}
if *c.Tools.BackgroundJobs.StalledWarningSeconds > maxBackgroundJobStalledWarningSec {
return maxBackgroundJobStalledWarningSec
}
return *c.Tools.BackgroundJobs.StalledWarningSeconds
}
// SearchConfig tunes the grep tool's engine. Engine is "auto" (default — use
// ripgrep when it's on PATH, else the native Go scanner), "native" (always Go),
// or "rg" (require ripgrep; warn at startup and fall back to native if absent).
// RgPath optionally points at a specific ripgrep binary instead of a PATH lookup.
type SearchConfig struct {
Engine string `toml:"engine"`
RgPath string `toml:"rg_path"`
}
// ShellConfig chooses the interpreter the bash tool runs commands under. Prefer
// is "auto" (default — real bash when present, else PowerShell on Windows),
// "bash", or "powershell"/"pwsh" (force it; warn at startup and fall back to
// auto if absent). Path optionally points at a specific shell executable.
type ShellConfig struct {
Prefer string `toml:"prefer"`
Path string `toml:"path"`
}
// PermissionsConfig declares the per-call permission policy (see
// internal/permission). Mode is the fallback decision for writer tools when no
// rule matches ("ask" | "allow" | "deny"; default "ask"); read-only tools always
// fall back to allow. Allow/Ask/Deny are rule lists of the form "ToolName" or
// "ToolName(glob)". Precedence: deny > ask > allow > fallback.
type PermissionsConfig struct {
Mode string `toml:"mode"`
Allow []string `toml:"allow"`
Ask []string `toml:"ask"`
Deny []string `toml:"deny"`
}
// PluginEntry declares an external MCP server. Type selects the transport:
// "stdio" (default) launches Command/Args/Env as a subprocess; "http"
// (a.k.a. streamable-http) and "sse" connect to a remote URL with optional
// static Headers. String fields support ${VAR} / ${VAR:-default} expansion so
// secrets (bearer tokens, keys) come from the environment, not the file. The
// fields mirror Claude Code's mcpServers spec, so entries can come from either
// reasonix.toml's [[plugins]] or a project-root .mcp.json (see loadMCPJSON).
type PluginEntry struct {
Name string `toml:"name"`
Type string `toml:"type"` // "stdio" (default) | "http" | "sse"
Command string `toml:"command"`
Args []string `toml:"args"`
Env map[string]string `toml:"env"`
URL string `toml:"url"`
Headers map[string]string `toml:"headers"`
// CallTimeoutSeconds overrides the default per-call deadline for this MCP
// server. Zero falls back to [tools].mcp_call_timeout_seconds.
CallTimeoutSeconds int `toml:"call_timeout_seconds"`
// ToolTimeoutSeconds overrides the per-call deadline for raw MCP tool names
// from this server. Keys are server-local tool names, not model-visible
// mcp__server__tool names.
ToolTimeoutSeconds map[string]int `toml:"tool_timeout_seconds"`
// TrustedReadOnlyTools names raw MCP tool names that Reasonix should treat as
// trusted read-only for planner / plan-mode / read-only research surfaces.
// Use this only for tools whose semantics are known to be side-effect free.
TrustedReadOnlyTools []string `toml:"trusted_read_only_tools"`
// AutoStart controls whether the server connects during session startup.
// Nil preserves historical behavior: configured servers start automatically.
AutoStart *bool `toml:"auto_start"`
// Tier is a legacy compatibility field. New config rendering omits it; enabled
// MCP servers connect automatically in the background unless auto_start=false.
// Historical values are accepted for old files:
// "eager" — blocks startup until the handshake completes; required for
// servers whose tools the system prompt depends on.
// "lazy" — legacy alias for background.
// "background" — placeholder + spawn fired at boot but not waited on;
// swap happens once the spawn finishes.
// Empty defaults to "background" so enabled MCPs connect automatically
// without blocking chat. Unknown non-empty values fall back to "background".
Tier string `toml:"tier"`
expansionEnv map[string]string
}
func (e PluginEntry) ShouldAutoStart() bool {
return e.AutoStart == nil || *e.AutoStart
}
// ResolvedTier returns the normalized tier ("eager"|"background") with the
// project default applied. Legacy lazy and unknown values fall back to
// background so enabled MCPs are available without manual connection.
func (e PluginEntry) ResolvedTier() string {
return resolvedMCPTier(e.Tier)
}
func resolvedMCPTier(tier string) string {
switch strings.ToLower(strings.TrimSpace(tier)) {
case "eager":
return "eager"
case "background", "lazy":
return "background"
case "":
return "background"
default:
return "background"
}
}
func (c *Config) AutoStartPlugins() []PluginEntry {
out := make([]PluginEntry, 0, len(c.Plugins))
for _, p := range c.Plugins {
if p.ShouldAutoStart() {
out = append(out, p)
}
}
return out
}
// DefaultSystemPrompt is used when config provides none.
const DefaultSystemPrompt = `You are Reasonix, a coding agent focused on executing code tasks.
Use the provided tools to read and write files and run shell commands.
Principles: understand the request before acting; verify with tools instead of
guessing; keep changes minimal and correct; briefly summarize what you did.
For multi-step work, track progress with the todo_write tool: lay out the steps,
keep exactly one in_progress, and flip each to completed as you finish it — update
the list as you go, not just at the end.
In plan mode the harness blocks writer tools: do read-only research, then write a
concise plan as your reply and stop. The user is asked to approve before anything
is changed; once approved, work through the steps, updating the task list as you go.`
// UserDecisionPolicy is appended to every system prompt, including user-custom
// prompts, so custom personas cannot accidentally remove the `ask` UI contract.
const UserDecisionPolicy = `User-owned choices: when a real decision belongs to the user — scope, approach, library, risk, manual validation, or any ambiguous or consequential path — and there is no obvious safe default, call the ask tool with 2-4 concrete options so the UI shows a choice. Do not ask in prose, infer a choice from silence, or continue by choosing for the user; do not choose for the user. Tool-approval bypass modes do not answer ask questions or approve plans. If no interactive user is available, the ask tool returns a model-assumption fallback; state that assumption and choose the safest reversible path.`
// LanguagePolicy is the auto fallback appended to the system prompt when no
// concrete UI language is resolved. It is static English text, so it stays part
// of the cache-stable prefix and avoids per-turn language injection.
const LanguagePolicy = `Reply in the same language the user is using in their most recent message: ` +
`if they write in Chinese answer in Chinese, in English answer in English, and switch ` +
`whenever they switch. Let this also guide the language you think in. Always keep code, ` +
`identifiers, file paths, shell commands, and technical terms in their original form — never translate them.`
// Default returns the built-in default configuration.
func Default() *Config {
return &Config{
ConfigVersion: 4,
DefaultModel: "deepseek-flash",
CredentialsStore: CredentialsStoreAuto,
UI: UIConfig{Theme: "auto"},
Desktop: DesktopConfig{DefaultToolApprovalMode: "auto"},
Notifications: NotificationsConfig{
Enabled: false,
TurnDone: true,
ApprovalRequest: true,
AskRequest: true,
},
Agent: AgentConfig{
SystemPrompt: DefaultSystemPrompt,
// 0 = no step cap: the agent loops until the model gives a final answer,
// the user cancels, or the provider errors. Context stays bounded by
// compaction, not by a round count. Set a positive agent.max_steps only
// if you want a hard guard against runaway.
MaxSteps: 0,
PlannerMaxSteps: 0,
AutoPlan: "off",
SoftCompactRatio: 0.5,
ToolResultSnipRatio: 0.6,
CompactRatio: 0.8,
CompactForceRatio: 0.9,
MaxSubagentDepth: 2,
},
// Mode "ask" with no rules keeps `reasonix run` autonomous (no TTY → ask
// resolves to allow) while `reasonix` prompts before writers. Users add
// deny/allow rules to harden or quiet specific tools.
Permissions: PermissionsConfig{Mode: "ask"},
// Sandbox uses platform defaults: macOS/Linux jail bash by default;
// Windows forces bash off until the native sandbox backend is reliable.
// Network=true here so an absent [sandbox] in a user's file keeps egress
// (zero value would wrongly deny it).
Sandbox: SandboxConfig{Network: true},
// LSP tools on by default, but dormant until a language server is on PATH;
// a missing server yields an install hint rather than an error.
LSP: LSPConfig{Enabled: true},
Network: NetworkConfig{ProxyMode: netclient.ModeAuto},
Bot: BotConfig{
ToolApprovalMode: "ask",
MaxSteps: 25,
DebounceMs: 1500,
QueueMode: "steer",
QueueCap: 20,
QueueDrop: "summarize",
IgnoreSelfMessages: true,
Control: BotControlConfig{Addr: "127.0.0.1:37913", TokenEnv: "REASONIX_BOT_CONTROL_TOKEN"},
Pairing: BotPairingConfig{Enabled: true, RequestTTLMinutes: 60, MaxPendingPerPlatform: 3},
Allowlist: BotAllowlist{Enabled: true},
QQ: QQBotConfig{AppSecretEnv: "QQ_BOT_APP_SECRET"},
Feishu: FeishuBotConfig{Domain: "feishu", AppSecretEnv: "FEISHU_BOT_APP_SECRET", Mode: "webhook", WebhookPort: 8080, RequireMention: true},
Weixin: WeixinBotConfig{AccountID: "default", TokenEnv: "WEIXIN_BOT_TOKEN", APIBase: "https://ilinkai.weixin.qq.com"},
},
Providers: []ProviderEntry{
{Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", BalanceURL: "https://api.deepseek.com/user/balance", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},
{Name: "deepseek-pro", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro", APIKeyEnv: "DEEPSEEK_API_KEY", BalanceURL: "https://api.deepseek.com/user/balance", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},
},
}
}
// WriteFile writes the configuration to path as annotated TOML. The write is
// atomic + fsynced so an interrupted write or power loss can never truncate the
// main config into an unparseable state that leaves the app with no usable
// models (#4615, #4708).
func (c *Config) WriteFile(path string) error {
return fileutil.AtomicWriteFile(path, []byte(RenderTOMLForScope(c, renderScopeForPath(path))), configFilePerm(path))
}
// Provider returns the named provider entry.
func (c *Config) Provider(name string) (*ProviderEntry, bool) {
for i := range c.Providers {
if c.Providers[i].Name == name {
return &c.Providers[i], true
}
}
return nil, false
}
// ResolveModel resolves a model reference to a provider entry whose Model is the
// selected model string (a copy, so the config's lists stay intact). It accepts:
// - "provider/model" — that exact model under that provider;
// - a provider name — the provider's default model;
// - a bare model name — the (first) provider that lists it.
//
// The returned entry is ready to build a provider from (NewProvider reads .Model),
// so a single "vendor with many models" entry yields one instance per model
// without duplicating base_url/api_key_env. Single-`model` entries still resolve
// by provider name, keeping older configs working unchanged.
func (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {
if ref == "" {
return nil, false
}
if access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 {
ref = retargetDesktopOfficialRef(ref, access)
}
// "provider/model"
if prov, model, ok := strings.Cut(ref, "/"); ok {
if e, found := c.Provider(prov); found && e.HasModel(model) {
cp := *e
cp.Model = model
cp.applyModelPrice()
cp.applyModelOverride()
return &cp, true
}
}
// a provider name → its default model
if e, found := c.Provider(ref); found {
cp := *e
cp.Model = e.DefaultModel()
cp.applyModelPrice()
cp.applyModelOverride()
return &cp, true
}
// a bare model name → the provider that lists it
for i := range c.Providers {
if c.Providers[i].HasModel(ref) {
cp := c.Providers[i]
cp.Model = ref
cp.applyModelPrice()
cp.applyModelOverride()
return &cp, true
}
}
return nil, false
}
// ResolveModelWithFallback resolves a model reference to the canonical
// "provider/model" form used by the desktop runtime. If ref is stale or empty,
// it tries the user's configured default_model before falling back to the first
// configured provider — so preference isn't overwritten by iteration order.
func (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) {
ref = strings.TrimSpace(ref)
if ref != "" {
if e, found := c.ResolveModel(ref); found {
return e.Name + "/" + e.Model, false, true
}
}
// Before falling back to the first configured provider (which may not be the
// user's preferred choice), try the configured default_model. Skip when ref
// already WAS the DefaultModel (it already failed above, so retrying won't
// help) or when the default provider has no API key configured.
if ref != c.DefaultModel && c.DefaultModel != "" {
if e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() {
return e.Name + "/" + e.Model, true, true
}
}
for i := range c.Providers {
p := &c.Providers[i]
// Skip providers with no models or no API key: falling back onto a keyless
// provider just boots the tab onto something that fails on first use. Mirrors
// the Configured() gate the provider-removal/selection paths already apply.
if len(p.ModelList()) == 0 || !p.Configured() {
continue
}
return p.Name + "/" + p.DefaultModel(), true, true
}
return "", false, false
}
// APIKey resolves the entry's API key from its api_key_env.
func (e *ProviderEntry) APIKey() string {
if e == nil {
return ""
}
if e.resolvedAPIKey != "" {
return e.resolvedAPIKey
}
if e.APIKeyEnv == "" {
return ""
}
value, _, ok := storedCredentialValue(e.APIKeyEnv)
if !ok {
return ""
}
return value
}
// ResolveAPIKeyFromProcessEnvForProbe pins a setup-time, user-entered key onto
// this entry for an immediate connectivity probe. Normal runtime resolution does
// not call this; loaded provider entries still resolve only from Reasonix's
// global .env.
func (e *ProviderEntry) ResolveAPIKeyFromProcessEnvForProbe() {
if e == nil {
return
}
key := strings.TrimSpace(e.APIKeyEnv)
if key == "" {
return
}
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return
}
e.resolvedAPIKey = value
e.resolvedSource = CredentialSource{Kind: CredentialSourceEnvironment, Label: "setup prompt"}
}
func (e *ProviderEntry) APIKeySourceLabel() string {
if e == nil || strings.TrimSpace(e.APIKeyEnv) == "" {
return ""
}
if e.resolvedAPIKey != "" {
return credentialSourceLabel(e.resolvedSource)
}
return ResolveCredentialForRootGlobalFirst(".", e.APIKeyEnv).Source.Label
}
// RequiresAPIKey reports whether this provider should be hidden/validated when
// its configured api_key_env is empty. A blank api_key_env means the provider is
// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy
// api_key_env in config even though they accept unauthenticated requests, so
// loopback/private endpoints are also allowed to run without a resolved key.
func (e *ProviderEntry) RequiresAPIKey() bool {
if e == nil {
return false
}
if strings.TrimSpace(e.APIKeyEnv) == "" {
return providerBaseURLRequiresAPIKey(e.BaseURL)
}
return !providerBaseURLAllowsMissingAPIKey(e.BaseURL)
}
func providerBaseURLRequiresAPIKey(raw string) bool {
switch officialProviderHost(raw) {
case "api.deepseek.com", "api.xiaomimimo.com", "token-plan-cn.xiaomimimo.com", "api.minimaxi.com", "api.openai.com":
return true
default:
return false
}
}
func providerBaseURLAllowsMissingAPIKey(raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return false
}
host := strings.Trim(strings.ToLower(u.Hostname()), "[]")
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
return true
}
addr, err := netip.ParseAddr(host)
if err != nil {
return false
}
return addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()
}
// Configured reports whether the provider is selectable. Providers that do not
// require an API key are configured by definition; providers that name an env var
// require that variable to resolve unless their endpoint is local/private.
func (e *ProviderEntry) Configured() bool {
return e != nil && (!e.RequiresAPIKey() || e.APIKey() != "")
}
// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.
func (c *Config) ResolveSystemPrompt() (string, error) {
return c.ResolveSystemPromptForRoot(".")
}
// ResolveSystemPromptForRoot is like ResolveSystemPrompt but resolves a relative
// system_prompt_file against root. Desktop tabs pass their workspace root here so
// prompt files are project-scoped even when the process cwd is elsewhere.
func (c *Config) ResolveSystemPromptForRoot(root string) (string, error) {
if c.Agent.SystemPromptFile != "" {
path := c.Agent.SystemPromptFile
if !filepath.IsAbs(path) {
path = filepath.Join(resolveRoot(root), path)
}
b, err := fileencoding.ReadFileUTF8(path)
if err != nil {
return "", fmt.Errorf("system_prompt_file: %w", err)
}
return strings.TrimSpace(string(b)), nil
}
if strings.TrimSpace(c.Agent.SystemPrompt) == "" {
return DefaultSystemPrompt, nil
}
return c.Agent.SystemPrompt, nil
}
// Validate checks that the selected model's provider is usable.
func (c *Config) Validate(model string) error {
e, ok := c.ResolveModel(model)
if !ok {
return fmt.Errorf("unknown model %q (configured: %s)", model, c.providerNames())
}
if e.Kind == "" {
return fmt.Errorf("provider %q: kind is required", model)
}
if e.BaseURL == "" {
return fmt.Errorf("provider %q: base_url is required", model)
}
if strings.TrimSpace(e.APIKeyEnv) != "" && !IsValidCredentialKey(e.APIKeyEnv) {
return fmt.Errorf("provider %q: api_key_env %q is invalid; use letters, numbers, and underscores, not a model name", model, e.APIKeyEnv)
}
if e.RequiresAPIKey() && e.APIKey() == "" {
return fmt.Errorf("provider %q: missing env %s", model, e.APIKeyEnv)
}
return nil
}
func (c *Config) providerNames() string {
names := make([]string, len(c.Providers))
for i, p := range c.Providers {
names[i] = p.Name
}
return strings.Join(names, ", ")
}