0d3d190a8b
Three coupled fixes that surfaced from a single FunctionGemma test session where the SLM-as-execution-arm assumption broke down and every subsequent prompt failed with 'session not idle (state: error)'. (A) [slm].register_as_arm config. The SLM has always been unconditionally registered as both classifier AND tier-0 execution arm. Fine for general-purpose models (ministral, qwen3-chat); breaks for task-specialised models (FunctionGemma emits function-call syntax instead of prose; embedding models can't generate). New pointer-bool config: nil/absent preserves the historical default (true), explicit false makes the SLM classifier-only and the execution path skips the slm/* arm. Three table tests cover absent / explicit-false / explicit-true decode paths. (B) Session error recovery. After any routing or engine error, the session moved to StateError and stayed there until restart — every new user prompt got rejected with 'session not idle (state: error)'. ResetError() was already wired for the /init retry path, but the general user-input and slash-command paths didn't call it. Added ResetError() before every user-initiated Send in the TUI so a fresh prompt always represents intent-to-retry. The /init internal retry already had its own ResetError; left alone. (C) filterFeasible per-arm rejection logging. Today's 'no feasible arm for task X' error tells you THAT every arm was rejected but nothing about WHY. Added slog.Debug per rejection (arm, task, complexity, reason, the specific violated constraint) plus a summary line when zero arms are feasible at any quality. Visible with --verbose; quiet otherwise. Surface area expansion only — no behaviour change for users not chasing a bug.
381 lines
14 KiB
Go
381 lines
14 KiB
Go
package config
|
|
|
|
import "time"
|
|
|
|
// Config is the top-level configuration.
|
|
type Config struct {
|
|
// DefaultProfile names the profile loaded when no --profile flag is
|
|
// passed. Only meaningful when ~/.config/gnoma/profiles/ exists; see
|
|
// LoadWithProfile.
|
|
DefaultProfile string `toml:"default_profile"`
|
|
|
|
Provider ProviderSection `toml:"provider"`
|
|
Permission PermissionSection `toml:"permission"`
|
|
Tools ToolsSection `toml:"tools"`
|
|
RateLimits RateLimitSection `toml:"rate_limits"`
|
|
Security SecuritySection `toml:"security"`
|
|
Session SessionSection `toml:"session"`
|
|
SLM SLMSection `toml:"slm"`
|
|
Router RouterSection `toml:"router"`
|
|
Safety SafetySection `toml:"safety"`
|
|
CLIAgents CLIAgentsSection `toml:"cli_agents"`
|
|
Arms []ArmConfig `toml:"arms"`
|
|
Hooks []HookConfig `toml:"hooks"`
|
|
MCPServers []MCPServerConfig `toml:"mcp_servers"`
|
|
Plugins PluginsSection `toml:"plugins"`
|
|
TUI TUISection `toml:"tui"`
|
|
}
|
|
|
|
// SLMSection configures the optional small language model used for task
|
|
// classification and low-complexity task execution.
|
|
//
|
|
// Backend selects how the SLM is reached:
|
|
// - "auto" / "" — pick the best available local backend at startup
|
|
// (Ollama → llama.cpp → llamafile)
|
|
// - "ollama" — talk to a local Ollama daemon
|
|
// - "llamacpp" — talk to a local llama.cpp server
|
|
// - "llamafile" — gnoma manages the llamafile process itself
|
|
// - "openaicompat" — any OpenAI-compatible URL (LM Studio, vLLM, etc.)
|
|
// - "disabled" — skip the SLM entirely; classifier stays heuristic
|
|
//
|
|
// See docs/slm-backends.md for copy-paste presets.
|
|
type SLMSection struct {
|
|
Enabled bool `toml:"enabled"`
|
|
Backend string `toml:"backend"` // auto | ollama | llamacpp | llamafile | openaicompat | disabled (empty = auto)
|
|
Model string `toml:"model"` // model name (ollama/llamacpp/openaicompat); ignored for llamafile
|
|
BaseURL string `toml:"base_url"` // server URL; defaults per-backend
|
|
ModelURL string `toml:"model_url"` // llamafile-only: where to download the binary from
|
|
DataDir string `toml:"data_dir"` // llamafile-only: where to put it (empty = XDG default)
|
|
ExpectedSHA256 string `toml:"expected_sha256"` // llamafile-only: verify hash if non-empty
|
|
StartupTimeout Duration `toml:"startup_timeout"` // llamafile-only: first-launch wait budget; 0 = default 5s
|
|
|
|
// ClassifyTimeout caps each task-classification call to the SLM.
|
|
// 0 here means "use the built-in default" (15s). Cold-start model
|
|
// loads + thinking-mode first-token latency can easily exceed 5s
|
|
// on smaller hardware, so the default is generous. Tune down to
|
|
// 2-3s on fast setups, or up to 30s for very slow ones.
|
|
ClassifyTimeout Duration `toml:"classify_timeout"`
|
|
|
|
// RegisterAsArm controls whether the SLM model is registered as
|
|
// a tier-0 execution arm in addition to its classifier role.
|
|
// nil (absent) → true (preserve historical behaviour: SLM is
|
|
// both classifier and an execution arm for trivial-complexity
|
|
// prompts). Explicitly false → SLM is classifier-only; trivial
|
|
// prompts route to other local arms instead.
|
|
//
|
|
// Set this to false when the SLM model is task-specialised
|
|
// (FunctionGemma, embedding-only models, code-completion-tuned
|
|
// models) and would produce wrong-shape output if asked to
|
|
// answer a general prompt. Pointer type so the absent-value
|
|
// case can be distinguished from explicit false.
|
|
RegisterAsArm *bool `toml:"register_as_arm"`
|
|
}
|
|
|
|
// ArmConfig tunes routing for a single registered arm. Multiple [[arms]]
|
|
// blocks may appear; each is matched by ID against the runtime arm
|
|
// registry. An ID that doesn't match any registered arm logs a warning at
|
|
// startup — typos here are otherwise silent.
|
|
//
|
|
// Example:
|
|
//
|
|
// [[arms]]
|
|
// id = "anthropic/claude-opus-4-7"
|
|
// strengths = ["security_review", "planning"] # task types this arm is preferred for
|
|
// cost_weight = 0.3 # 1.0 = full cost penalty, 0 = ignore cost
|
|
//
|
|
// [[arms]]
|
|
// id = "subprocess/claude"
|
|
// strengths = ["orchestration"]
|
|
//
|
|
// Strength names map to router.TaskType via router.ParseTaskType — same
|
|
// names the SLM classifier emits (snake_case or no separator both work).
|
|
type ArmConfig struct {
|
|
ID string `toml:"id"`
|
|
Strengths []string `toml:"strengths"`
|
|
CostWeight float64 `toml:"cost_weight"`
|
|
}
|
|
|
|
// CLIAgentsSection maps canonical CLI agent names to override binary names.
|
|
//
|
|
// Useful when a user has aliased the canonical binary — e.g. `claude-priv`
|
|
// instead of `claude`, or `gemini-work` instead of `gemini` — and wants
|
|
// gnoma's auto-discovery to find it.
|
|
//
|
|
// Example:
|
|
//
|
|
// [cli_agents]
|
|
// claude = "claude-priv" # use claude-priv as the Claude Code binary
|
|
// gemini = "gemini-work"
|
|
// # vibe is unset → falls back to the canonical "vibe" name
|
|
//
|
|
// An empty value (e.g. `claude = ""`) is treated as "no override" — the
|
|
// canonical name is used.
|
|
type CLIAgentsSection map[string]string
|
|
|
|
// RouterSection holds router-level overrides. Most routing decisions are
|
|
// driven automatically by arm capabilities and the bandit; this section
|
|
// exists for the rare overrides that don't fit elsewhere.
|
|
// SafetySection controls the pre-launch dir-safety classifier — refuse
|
|
// in system roots, warn+keypress in $HOME and other dumping grounds,
|
|
// OK inside any git repo or project marker. Always shows a context
|
|
// banner regardless of tier. See
|
|
// docs/superpowers/plans/2026-05-23-startup-safety-banner.md.
|
|
type SafetySection struct {
|
|
// RefuseInSystemDirs gates the refuse path. When false, system
|
|
// roots like / and /etc are treated as warn-tier instead of refuse.
|
|
// Default: true.
|
|
RefuseInSystemDirs *bool `toml:"refuse_in_system_dirs"`
|
|
// WarnInHome gates the warn-tier check for $HOME and common
|
|
// dumping grounds (~/Desktop, ~/Downloads, /tmp). When false,
|
|
// these all become OK-tier (banner still shown). Default: true.
|
|
WarnInHome *bool `toml:"warn_in_home"`
|
|
// RequireProjectMarker, when true, treats any directory without
|
|
// a recognized project marker as warn-tier (even inside a git
|
|
// repo). Default: false — git repo is enough by default.
|
|
RequireProjectMarker bool `toml:"require_project_marker"`
|
|
}
|
|
|
|
// ResolvedSafety returns the effective Safety settings with defaults
|
|
// applied for any unset pointer fields. Pointer fields are used in the
|
|
// struct so we can distinguish "user omitted the key" from "user set
|
|
// it to false."
|
|
func (s SafetySection) ResolvedSafety() ResolvedSafetySection {
|
|
refuse := true
|
|
if s.RefuseInSystemDirs != nil {
|
|
refuse = *s.RefuseInSystemDirs
|
|
}
|
|
warn := true
|
|
if s.WarnInHome != nil {
|
|
warn = *s.WarnInHome
|
|
}
|
|
return ResolvedSafetySection{
|
|
RefuseInSystemDirs: refuse,
|
|
WarnInHome: warn,
|
|
RequireProjectMarker: s.RequireProjectMarker,
|
|
}
|
|
}
|
|
|
|
// ResolvedSafetySection is the SafetySection with defaults applied.
|
|
// Consumers (cmd/gnoma/main.go, internal/safety) read this rather than
|
|
// the raw config to avoid re-deriving defaults at each call site.
|
|
type ResolvedSafetySection struct {
|
|
RefuseInSystemDirs bool
|
|
WarnInHome bool
|
|
RequireProjectMarker bool
|
|
}
|
|
|
|
type RouterSection struct {
|
|
// ForceTwoStage forces the two-stage tool-routing path regardless of
|
|
// arm context window. Useful for debugging or for forcing the behavior
|
|
// on a large local model. Defaults to false: two-stage activates
|
|
// automatically on local arms with context window <= 16k.
|
|
ForceTwoStage bool `toml:"force_two_stage"`
|
|
|
|
// Prefer biases routing toward local arms ("local"), cloud arms
|
|
// ("cloud"), or leaves the tier-based selection unchanged ("auto").
|
|
// Default: "auto". Implemented as a soft score multiplier — does
|
|
// not hard-filter the dispreferred set. Forced arms (--provider X)
|
|
// and incognito take priority over this knob. See
|
|
// docs/superpowers/plans/2026-05-23-prefer-routing-policy.md.
|
|
Prefer string `toml:"prefer"`
|
|
|
|
// Bandit exposes the selector's tuning knobs. Defaults preserve
|
|
// previous hard-coded behaviour exactly; only set these when you
|
|
// need to tune the EMA quality tracker for an unusual workload.
|
|
Bandit BanditSection `toml:"bandit"`
|
|
}
|
|
|
|
// BanditSection holds the scoring knobs for the EMA quality tracker
|
|
// and the score blend used by the selector. Each field has a sentinel
|
|
// zero value that means "use the built-in default" so an empty TOML
|
|
// block is byte-identical to pre-config behaviour. See
|
|
// internal/router/feedback.go and internal/router/selector.go for the
|
|
// formulas these knobs feed into.
|
|
type BanditSection struct {
|
|
// QualityAlpha is the EMA smoothing factor for arm-quality
|
|
// observations. Larger values weight recent observations more.
|
|
// Default: 0.3 (~3-sample memory). 0.0 here means "use default".
|
|
QualityAlpha float64 `toml:"quality_alpha"`
|
|
|
|
// MinObservations is the minimum number of samples required
|
|
// before observed EMA overrides the heuristic fallback. Default:
|
|
// 3. 0 here means "use default".
|
|
MinObservations int `toml:"min_observations"`
|
|
|
|
// ObservedWeight is the weight of the observed EMA in the
|
|
// observed/heuristic blend inside scoreArm: the final quality is
|
|
// `observed*W + heuristic*(1-W)`. Default: 0.7. 0.0 here means
|
|
// "use default".
|
|
ObservedWeight float64 `toml:"observed_weight"`
|
|
|
|
// StrengthBonus is the quality bonus added when an arm declares
|
|
// the current task type in its Strengths list. Default: 0.15.
|
|
// 0.0 here means "use default".
|
|
StrengthBonus float64 `toml:"strength_bonus"`
|
|
}
|
|
|
|
// MCPServerConfig defines an MCP server to start and connect to.
|
|
//
|
|
// Example:
|
|
//
|
|
// [[mcp_servers]]
|
|
// name = "git"
|
|
// command = "mcp-server-git"
|
|
// args = ["--repo", "."]
|
|
// env = { GIT_DIR = ".git" }
|
|
// timeout = "30s"
|
|
// replace_default = { exec = "bash" } # MCP tool "exec" replaces built-in "bash"
|
|
type MCPServerConfig struct {
|
|
Name string `toml:"name"`
|
|
Command string `toml:"command"`
|
|
Args []string `toml:"args"`
|
|
Env map[string]string `toml:"env"`
|
|
Timeout string `toml:"timeout"`
|
|
ReplaceDefault map[string]string `toml:"replace_default"` // MCP tool name → built-in name
|
|
ToolPolicy map[string]MCPToolPolicy `toml:"tool_policy"` // MCP tool name → policy
|
|
}
|
|
|
|
type MCPToolPolicy struct {
|
|
PathArgs []string `toml:"path_args"`
|
|
}
|
|
|
|
// PluginsSection controls plugin loading.
|
|
//
|
|
// Example:
|
|
//
|
|
// [plugins]
|
|
// enabled = ["git-tools", "docker-tools"]
|
|
// disabled = ["experimental-plugin"]
|
|
type PluginsSection struct {
|
|
Enabled []string `toml:"enabled"`
|
|
Disabled []string `toml:"disabled"`
|
|
}
|
|
|
|
// HookConfig is a single hook entry from TOML config.
|
|
//
|
|
// Example:
|
|
//
|
|
// [[hooks]]
|
|
// name = "block-dangerous-bash"
|
|
// event = "pre_tool_use"
|
|
// type = "command"
|
|
// exec = "bash-safety-check.sh"
|
|
// tool_pattern = "bash*"
|
|
// timeout = "10s"
|
|
// fail_open = false
|
|
type HookConfig struct {
|
|
Name string `toml:"name"`
|
|
Event string `toml:"event"`
|
|
Type string `toml:"type"`
|
|
Exec string `toml:"exec"`
|
|
Timeout string `toml:"timeout"`
|
|
FailOpen bool `toml:"fail_open"`
|
|
ToolPattern string `toml:"tool_pattern"`
|
|
}
|
|
|
|
type SessionSection struct {
|
|
MaxKeep int `toml:"max_keep"`
|
|
}
|
|
|
|
// SecuritySection configures the secret scanner and firewall.
|
|
//
|
|
// Example config:
|
|
//
|
|
// [security]
|
|
// entropy_threshold = 4.5
|
|
// entropy_safelist = ["uuid", "sha_hex", "iso8601", "url"]
|
|
//
|
|
// [[security.patterns]]
|
|
// name = "internal_token"
|
|
// regex = "mycompany_[a-zA-Z0-9]{32}"
|
|
// action = "redact"
|
|
//
|
|
// entropy_safelist names known-safe shapes that bypass the entropy scorer
|
|
// (Phase F-1 FP reduction). Empty / unset preserves pre-F-1 behavior.
|
|
type SecuritySection struct {
|
|
EntropyThreshold float64 `toml:"entropy_threshold"`
|
|
RedactHighEntropy bool `toml:"redact_high_entropy"`
|
|
EntropySafelist []string `toml:"entropy_safelist"`
|
|
Patterns []PatternConfig `toml:"patterns"`
|
|
}
|
|
|
|
type PatternConfig struct {
|
|
Name string `toml:"name"`
|
|
Regex string `toml:"regex"`
|
|
Action string `toml:"action"` // "redact" (default), "block", "warn"
|
|
}
|
|
|
|
type PermissionSection struct {
|
|
Mode string `toml:"mode"`
|
|
Rules []PermissionRule `toml:"rules"`
|
|
}
|
|
|
|
type PermissionRule struct {
|
|
Tool string `toml:"tool"`
|
|
Pattern string `toml:"pattern"`
|
|
Action string `toml:"action"`
|
|
}
|
|
|
|
type ProviderSection struct {
|
|
Default string `toml:"default"`
|
|
Model string `toml:"model"`
|
|
MaxTokens int64 `toml:"max_tokens"`
|
|
Temperature *float64 `toml:"temperature"`
|
|
APIKeys map[string]string `toml:"api_keys"`
|
|
Endpoints map[string]string `toml:"endpoints"`
|
|
}
|
|
|
|
type ToolsSection struct {
|
|
BashTimeout Duration `toml:"bash_timeout"`
|
|
MaxFileSize int64 `toml:"max_file_size"`
|
|
}
|
|
|
|
// RateLimitSection allows overriding default rate limits per provider.
|
|
//
|
|
// Example config:
|
|
//
|
|
// [rate_limits.mistral]
|
|
// tier = "starter"
|
|
// rps = 1
|
|
// spend_cap = 20.0
|
|
//
|
|
// [rate_limits.anthropic]
|
|
// tier = "tier2"
|
|
// rpm = 1000
|
|
// itpm = 450000
|
|
// otpm = 90000
|
|
type RateLimitSection map[string]RateLimitOverride
|
|
|
|
type RateLimitOverride struct {
|
|
Tier string `toml:"tier"`
|
|
RPS float64 `toml:"rps"`
|
|
RPM int `toml:"rpm"`
|
|
RPD int `toml:"rpd"`
|
|
TPM int `toml:"tpm"`
|
|
ITPM int `toml:"itpm"`
|
|
OTPM int `toml:"otpm"`
|
|
TokensMonth int64 `toml:"tokens_month"`
|
|
SpendCap float64 `toml:"spend_cap"`
|
|
}
|
|
|
|
// Duration wraps time.Duration for TOML string parsing (e.g. "30s", "5m").
|
|
type Duration time.Duration
|
|
|
|
func (d *Duration) UnmarshalText(text []byte) error {
|
|
parsed, err := time.ParseDuration(string(text))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*d = Duration(parsed)
|
|
return nil
|
|
}
|
|
|
|
func (d Duration) Duration() time.Duration {
|
|
return time.Duration(d)
|
|
}
|
|
|
|
type TUISection struct {
|
|
Theme string `toml:"theme"`
|
|
Vim bool `toml:"vim"`
|
|
}
|