Files
DeepSeek-Reasonix/internal/config/config.go
T
yhhandClaude Opus 4.8 7de6a2474f feat: import Go implementation as v2 kernel (renamed duo → reasonix)
Bring in the finished Go rewrite (developed under the placeholder name
"duo") as the v2 codebase. Code-only import — no upstream git history.

Config- and plugin-driven coding agent: registry core, OpenAI-compatible
providers, builtin tools, MCP (stdio + HTTP) plugins, permission + sandbox,
custom slash commands, @-references, two-model collaboration.

Renamed throughout: duo→reasonix, Duo→Reasonix, DUO→REASONIX — module path,
imports, CLI name, cmd/ dirs, reasonix.toml, .reasonix/commands, and
$REASONIX_* env prefixes. Kept the v2 MIT LICENSE (the Go project had none).

Verified: go build ./... · go vet ./... · go test ./... all green; gofmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-29 18:13:20 -07:00

340 lines
13 KiB
Go

// Package config loads Reasonix's runtime configuration from TOML. Resolution order:
// flag > project ./reasonix.toml > user ~/.config/reasonix/config.toml > built-in defaults.
// Secrets come from the environment via api_key_env and are never stored in
// config files.
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/BurntSushi/toml"
"reasonix/internal/provider"
)
// Config is Reasonix's runtime configuration.
type Config struct {
DefaultModel string `toml:"default_model"`
Language string `toml:"language"` // ui language tag (e.g. "zh"); empty = auto-detect from $LANG / $REASONIX_LANG
Agent AgentConfig `toml:"agent"`
Providers []ProviderEntry `toml:"providers"`
Tools ToolsConfig `toml:"tools"`
Permissions PermissionsConfig `toml:"permissions"`
Sandbox SandboxConfig `toml:"sandbox"`
Plugins []PluginEntry `toml:"plugins"`
}
// 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) 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). 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"`
// Bash is the OS-sandbox mode for the bash tool: "enforce" (default) jails
// each command, "off" runs it unconfined. Phase 1; macOS only for now, with
// a graceful fallback elsewhere (see internal/sandbox).
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 {
root := ExpandVars(c.Sandbox.WorkspaceRoot)
if root == "" {
if wd, err := os.Getwd(); err == nil {
root = wd
} else {
root = "."
}
}
roots := []string{root}
for _, d := range c.Sandbox.AllowWrite {
if d = ExpandVars(d); d != "" {
roots = append(roots, d)
}
}
return roots
}
// BashMode normalises the bash-sandbox mode: only an explicit "off" disables
// it; empty or any other value resolves to "enforce", so the sandbox is on by
// default and fails safe.
func (c *Config) BashMode() string {
if c.Sandbox.Bash == "off" {
return "off"
}
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).
type AgentConfig struct {
SystemPrompt string `toml:"system_prompt"`
SystemPromptFile string `toml:"system_prompt_file"`
MaxSteps int `toml:"max_steps"`
Temperature float64 `toml:"temperature"`
PlannerModel string `toml:"planner_model"`
}
// 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"`
Model string `toml:"model"`
APIKeyEnv string `toml:"api_key_env"`
ContextWindow int `toml:"context_window"`
Price *provider.Pricing `toml:"price"`
}
// ToolsConfig selects which built-in tools are enabled. Empty means all of them.
type ToolsConfig struct {
Enabled []string `toml:"enabled"`
}
// 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"`
}
// 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.`
// Default returns the built-in default configuration (DeepSeek + MiMo presets).
func Default() *Config {
return &Config{
DefaultModel: "deepseek-flash",
Agent: AgentConfig{
SystemPrompt: DefaultSystemPrompt,
MaxSteps: 25,
},
// Mode "ask" with no rules keeps `reasonix run` autonomous (no TTY → ask
// resolves to allow) while `reasonix chat` prompts before writers. Users add
// deny/allow rules to harden or quiet specific tools.
Permissions: PermissionsConfig{Mode: "ask"},
// Sandbox on by default: bash is jailed (macOS), network allowed so
// builds/downloads work. Set bash = "off" to disable. Network=true here
// so an absent [sandbox] in a user's file keeps egress (zero value would
// wrongly deny it).
Sandbox: SandboxConfig{Bash: "enforce", Network: true},
Providers: []ProviderEntry{
{Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", ContextWindow: 1_000_000, Price: &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: "¥"}},
{Name: "deepseek-pro", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro", APIKeyEnv: "DEEPSEEK_API_KEY", ContextWindow: 1_000_000, Price: &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: "¥"}},
{Name: "mimo-pro", Kind: "openai", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2.5-pro", APIKeyEnv: "MIMO_API_KEY", ContextWindow: 1_000_000},
{Name: "mimo-flash", Kind: "openai", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2-flash", APIKeyEnv: "MIMO_API_KEY", ContextWindow: 65_536},
},
}
}
// Load builds the configuration: defaults, then user config, then project
// config, then any MCP servers from Claude Code's .mcp.json. A .env in the
// working directory is loaded first so api_key_env can resolve.
func Load() (*Config, error) {
loadDotEnv()
cfg := Default()
if uc := userConfigPath(); uc != "" {
if err := mergeFile(cfg, uc); err != nil {
return nil, err
}
}
if err := mergeFile(cfg, "reasonix.toml"); err != nil {
return nil, err
}
// Claude Code's .mcp.json (project root) is read last and merged into
// [[plugins]], so a server configured for Claude works here unchanged.
// reasonix.toml wins on a name collision (see mergeMCPJSON).
entries, err := loadMCPJSON(mcpJSONFile)
if err != nil {
return nil, err
}
cfg.mergeMCPJSON(entries)
return cfg, nil
}
// LoadForEdit returns a config to seed the `reasonix init` wizard when reconfiguring:
// the built-in defaults with the file at path (if present) decoded on top, so a
// reconfigure preserves the user's existing providers and agent settings instead
// of resetting to defaults. .env is loaded so api_key_env resolution works while
// the wizard decides which keys are still missing.
func LoadForEdit(path string) *Config {
loadDotEnv()
cfg := Default()
_ = mergeFile(cfg, path)
return cfg
}
// mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error.
func mergeFile(cfg *Config, path string) error {
if _, err := os.Stat(path); err != nil {
return nil
}
if _, err := toml.DecodeFile(path, cfg); err != nil {
return fmt.Errorf("config %s: %w", path, err)
}
return nil
}
func userConfigPath() string {
dir, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(dir, "reasonix", "config.toml")
}
// ArchiveDir is where compacted conversation history is archived for
// traceability (one timestamped .jsonl per compaction). Empty if the user config
// directory cannot be resolved, in which case archiving is skipped.
func ArchiveDir() string {
dir, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(dir, "reasonix", "archive")
}
// SessionDir is where chat sessions are persisted (one .jsonl per session).
// Used by `reasonix chat --continue` / `--resume` to find the recent ones. Empty
// if the user config dir can't be resolved — sessions then aren't saved.
func SessionDir() string {
dir, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(dir, "reasonix", "sessions")
}
// CommandDirs returns the directories scanned for custom slash commands, lowest
// priority first: the user dir (~/.config/reasonix/commands) then the project dir
// (.reasonix/commands), so a project command overrides a user one with the same name.
func CommandDirs() []string {
var dirs []string
if dir, err := os.UserConfigDir(); err == nil {
dirs = append(dirs, filepath.Join(dir, "reasonix", "commands"))
}
dirs = append(dirs, filepath.Join(".reasonix", "commands"))
return dirs
}
// SourcePath returns the highest-priority config file that exists, or "" if none.
func SourcePath() string {
if _, err := os.Stat("reasonix.toml"); err == nil {
return "reasonix.toml"
}
if uc := userConfigPath(); uc != "" {
if _, err := os.Stat(uc); err == nil {
return uc
}
}
return ""
}
// WriteFile writes the configuration to path as annotated TOML.
func (c *Config) WriteFile(path string) error {
return os.WriteFile(path, []byte(RenderTOML(c)), 0o644)
}
// 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
}
// APIKey resolves the entry's API key from its api_key_env.
func (e *ProviderEntry) APIKey() string {
if e.APIKeyEnv == "" {
return ""
}
return os.Getenv(e.APIKeyEnv)
}
// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.
func (c *Config) ResolveSystemPrompt() (string, error) {
if c.Agent.SystemPromptFile != "" {
b, err := os.ReadFile(c.Agent.SystemPromptFile)
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.Provider(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 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, ", ")
}