mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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]>
340 lines
13 KiB
Go
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, ", ")
|
|
}
|