Module: Rubino::Config::Defaults

Defined in:
lib/rubino/config/defaults.rb

Overview

Default configuration values for the entire system. These mirror the Rich config structure adapted for Ruby.

Constant Summary collapse

DEFAULT_DATABASE_PATH =

Sentinel for the default database path. When config still carries this value, Configuration#database_path resolves it against the resolved home (RUBINO_HOME) instead of a literal ~/.rubino (issue #96).

"<RUBINO_HOME>/rubino.sqlite3"
HOME_COMMANDS_PATH =

Sentinel for the user-home commands directory. Resolved at read time (Commands::Loader/Executor) against the resolved home (RUBINO_HOME) instead of a literal ~/.rubino so commands in a custom home are actually discovered (issue #38).

"<RUBINO_HOME>/commands"
MODULE_DEFAULTS =
{
  "model" => {
    # Public-gem default is OpenAI gpt-4.1 (maintainer directive): it is
    # the most broadly available provider and needs no special provider
    # block to route — just OPENAI_API_KEY — so a defaults-only config is
    # coherent and the first turn works. MiniMax stays an AVAILABLE wizard
    # choice but is NOT the seeded/recommended default. The onboarding
    # wizard's recommended (first) entry mirrors this exact default.
    # provider "auto" derives the concrete provider from the model id
    # (openai/* → openai); the wizard/auto-detect write an explicit
    # provider when the user/env picks a non-OpenAI backend.
    "default" => "openai/gpt-4.1",
    "provider" => "auto",
    "context_length" => nil,
    # nil = inherit the provider default (Hermes injects no temperature).
    # 0.3 used to be hardcoded but is inert under thinking-on (forced to 1)
    # and only surfaced when thinking was disabled (#414).
    "temperature" => nil,
    # Max output tokens for the anthropic-family path (anthropic_compatible
    # MiniMax, native anthropic, bedrock). ruby_llm defaults the Anthropic
    # max_tokens to 4096, which a reasoning model can exhaust on thinking
    # tokens alone → empty visible text. nil = use the adapter default
    # (16384). providers.<name>.max_tokens overrides per-backend.
    "max_tokens" => nil,
    # Thinking/reasoning token budget for the anthropic-family path. nil =
    # adapter default (8000, the reference "medium"). 0 disables thinking.
    # providers.<name>.thinking_budget overrides per-backend.
    "thinking_budget" => nil,
    # Visible-output headroom (tokens) reserved on top of the thinking
    # budget so the model can think AND answer. Mirrors the reference +4096.
    "max_tokens_text_headroom" => 4096,
    # nil = auto-detect from model_id via LLM::ContentBuilder.supports_vision?.
    # Set to true/false to override (e.g. when running behind a gateway that
    # hides the real upstream model name, like the gateway provider's `auto`).
    "supports_vision" => nil
  },
  "providers" => {
    "openai" => {
      "base_url" => nil,
      # Per-READ socket inactivity (resets on every streamed chunk), NOT a
      # total — this is the agent's first-token + inter-token idle bound,
      # same as the OpenAI/Anthropic SDK default. A silent socket fails
      # within this window and is retried pre-first-token. Raise it for a
      # large local Ollama that cold-loads for minutes before token #1.
      "request_timeout_seconds" => 600,
      "stale_timeout_seconds" => 300
    },
    "anthropic" => {
      "base_url" => nil,
      "request_timeout_seconds" => 600
    },
    "bedrock" => {
      "region" => "us-east-1",
      "request_timeout_seconds" => 600
    },
    "gemini" => {
      "request_timeout_seconds" => 600
    },
    # Opt-in provider for an OpenAI-compatible gateway. Point it at any
    # gateway that exposes an OpenAI-style /v1/* API: set base_url and
    # api_key and the agent routes everything here regardless of model id.
    # The gateway decides which upstream (OpenAI/Anthropic/…) and model
    # to call. Set model.provider: "gateway" to enable.
    "gateway" => {
      "openai_compatible" => true,
      "assume_model_exists" => true,
      "base_url" => nil,
      "request_timeout_seconds" => 600
    }
  },
  "auxiliary" => {
    "compression" => {
      "provider" => "main",
      "model" => "",
      "base_url" => nil,
      "timeout" => 120
    },
    "approval" => {
      "provider" => "main",
      "model" => "",
      "base_url" => nil,
      "timeout" => 30
    },
    # Multimodal aux. When set, the `vision` tool delegates here so a
    # text-only primary can still "see" an image. `provider: "main"`
    # reuses the primary's provider/base_url; otherwise both can be
    # overridden. Set `model: "auto-vision"` to let the gateway proxy
    # pick a vision model from the model catalog.
    "vision" => {
      "provider" => "main",
      "model" => "",
      "base_url" => nil,
      "timeout" => 120
    },
    # Document summarization. The `summarize_file` tool delegates here so
    # the raw bytes of a huge file are map-reduced in these aux calls and
    # never enter the main agent context (only the final summary returns).
    # `provider: "main"` reuses the primary's provider/model.
    "summarize" => {
      "provider" => "main",
      "model" => "",
      "base_url" => nil,
      "timeout" => 300
    }
  },
  "agent" => {
    # OUTER rail on tool iterations, enforced in IterationBudget alongside
    # max_tool_iterations (#414): the budget caps at min(max_tool_iterations,
    # max_turns). Previously DEAD config (assigned, never read); now wired as
    # a real ceiling. `--max-turns N` overrides max_tool_iterations directly.
    "max_turns" => 90,
    # Per-turn model↔tool round-trip cap. Raised 8→25 (#399): 8 was a
    # rubino-only outlier (the Hermes reference uses 90; peer tools cluster
    # 10–25 for "stop-and-ask"). 25 matches Cursor's tuned interactive cap —
    # high enough that real multi-file tasks finish, low enough to still
    # catch runaways. Kept at 25 (a deliberate prior decision, #414).
    "max_tool_iterations" => 25,
    # At the iteration cap, in INTERACTIVE mode, prompt the user to
    # continue/summarize/abort instead of silently force-summarizing (#399).
    # false forces the old always-summarize behaviour; headless/non-TTY
    # runs ALWAYS force-summarize regardless of this flag (no human to ask).
    "budget_extension_prompt" => true,
    # The "+N" granted by one budget extension at the cap. nil ⇒ use
    # max_tool_iterations (so one extension doubles the runway). Capped by
    # the outer max_turns rail, which extensions do NOT raise — repeated
    # extensions can never bypass the iteration/turn ceiling.
    "budget_extension_step" => nil,
    # Pure SAFETY-NET wall clock on a single turn, NOT a working-time cap
    # (#408). Hermes' IterationBudget has no clock at all; the old 120s
    # KILLED slow-but-legitimate test/build turns mid-work (and was the
    # root that made the #403 budget-extension loop possible). Raised to a
    # backstop only a genuinely runaway turn should ever hit. nil disables.
    "max_turn_seconds" => 600,
    # 5 retries with exponential backoff = 1+2+4+8+16 = 31s total wait.
    # Sized to absorb common provider blips (MiniMax intl in particular
    # has been observed returning "API server error - please try again"
    # for ~15-25 seconds before recovering) without timing out the user.
    "api_max_retries" => 5,
    # Hard ceiling (seconds) on a single full-jitter backoff draw between
    # retries on the ERROR path: delay = min(base*2^(n-1), cap) + jitter.
    # Caps worst-case per-retry wait so a flapping backend can't stall a
    # turn on one sleep. 16 keeps the worst single wait to ~24s (16 +
    # 0.5*16 jitter) instead of the 60s ERROR_PATH ceiling. (Previously
    # declared but NEVER read — the error path hardcoded the 60s cap.)
    "api_retry_backoff_cap_seconds" => 16,
    # Hard TOTAL wall-time budget (seconds) across all error-path retries
    # for one model call. A permanently-unreachable host (resolves but the
    # port is dead → retryable connection timeout) used to burn ~75-110s
    # across 5 retries before giving up. This is a Codex-style "total
    # elapsed" cap: keep retrying genuinely-transient errors, but once the
    # cumulative backoff already spent PLUS the next planned wait would
    # cross this budget, fail fast with a clear "gave up after ~Ns" message
    # instead of stalling the user. Does NOT shorten legitimate recovery
    # inside the window. nil ⇒ no total cap (count-based only).
    "api_retry_total_timeout_seconds" => 30,
    # Higher ceiling used ONLY for overload (529/503) and MiniMax "unknown
    # error" blips: those backends stay overloaded for tens of seconds, so
    # the 16s cap retries too eagerly back into a still-hot endpoint. 60s
    # lets the backoff ride out the overload window (the reference uses 120s).
    "api_retry_backoff_overload_cap_seconds" => 60,
    # In-turn retries for a 200-OK-but-EMPTY model response (no text, no
    # tool calls). After this many re-issues of the same turn the Loop
    # raises EmptyModelResponseError → run marked failed (never a silent
    # "completed but empty"). Mirrors the reference treating an empty/invalid
    # response as retryable-then-terminal.
    "empty_response_max_retries" => 2,
    # Provider/model fallback chain (Slice 7 — Agent::FallbackChain). An
    # ORDERED list of backends to rotate to when the primary keeps failing
    # (invalid/empty responses, rate-limit, overload, exhausted retries).
    # The primary is implicit (index 0); these are the fallbacks tried in
    # order. EMPTY by default → no fallback, behaviour byte-identical to a
    # single-provider setup. Each entry:
    #   { "provider" => "anthropic", "model" => "claude-...",
    #     "base_url" => nil, "api_key" => nil }
    # provider + model are required; base_url/api_key override the
    # providers.<name> config for that entry (custom endpoints). An entry
    # that resolves to the current provider/model/base_url is skipped
    # (dedup) so we never fall back to the backend that just failed.
    "fallback_models" => [],
    "disabled_toolsets" => [],
    "tool_use_enforcement" => "auto"
  },
  "run" => {
    # SSE watchdog: when a run is "running" but no new event has been
    # written for this many seconds, EventsOperation marks it failed and
    # emits a synthetic run.failed frame. Covers cases the executor's
    # rescue can't (model in infinite tool loop, provider stream hung,
    # OS-level thread death). Set to nil to disable.
    "idle_event_timeout" => 300
  },
  "database" => {
    # Sentinel: resolved at read time (Configuration#database_path) to
    # "<resolved home>/rubino.sqlite3" so the DB follows
    # RUBINO_HOME like config/.env/skills do. An explicit override
    # in config.yml replaces this and is used verbatim (issue #96).
    "path" => DEFAULT_DATABASE_PATH
  },
  "paths" => {
    "home" => "~/.rubino",
    "memory" => "~/.rubino/memories",
    "skills" => "~/.rubino/skills",
    "cron" => "~/.rubino/cron",
    "sessions" => "~/.rubino/sessions",
    "logs" => "~/.rubino/logs"
  },
  "ui" => {
    "adapter" => "cli",
    "theme" => "default",
    "verbose" => false
  },
  "display" => {
    "streaming" => true,
    # Tri-state reasoning render (display.reasoning): "hidden" suppresses
    # thinking entirely, "collapsed" buffers it and commits a one-liner cue
    # ("thought for Ns"), "full" renders the whole reasoning as a dim aside
    # above the answer. Deliberately NOT seeded here (#132): defaults
    # injecting it made the documented legacy display.show_reasoning
    # mapping (true→full, false→hidden, applied only when
    # display.reasoning is unset) unreachable for every config loaded
    # normally. Config::ReasoningPrefs supplies the "collapsed" default
    # when neither key is set.
    "language" => "en",
    "runtime_footer" => { "enabled" => false },
    "interim_assistant_messages" => false,
    # The dim status bar pinned UNDER the chat input (model id + context
    # saturation), refreshed at turn boundaries. Omitted automatically
    # off a TTY or on terminals narrower than 40 columns.
    "statusbar" => true,
    # Head lines of each tool's output shown in the transcript before a
    # dim "… +N lines (full output → context)" marker. DISPLAY-ONLY —
    # the model always receives the full (truncation-capped) output.
    # 0 disables the collapse (old full dump).
    "tool_output_preview_lines" => 3,
    # Cap on the chat input's visual rows: a long/multi-line prompt
    # wraps and grows the input downward up to this many rows, then
    # scrolls vertically (caret kept in view).
    "input_max_rows" => 8
  },
  "paste" => {
    # File-backed paste pipeline (UI::PasteStore). A paste with MORE
    # than collapse_lines lines collapses to a "[Pasted text #N +M
    # lines]" placeholder in the chat input, expanded to the full body
    # when the message is sent (the transcript echo keeps the
    # placeholder). A paste estimated above file_threshold_tokens
    # (chars/4) is written to <home>/sessions/<id>/paste_N.txt instead
    # and the sent message carries a read-tool pointer to it.
    "collapse_lines" => 5,
    # A paste longer than this many CHARS also collapses to the chip, even
    # on a single line — a big one-line paste (long URL/token/minified
    # JSON) would otherwise flood the composer.
    "collapse_chars" => 400,
    "file_threshold_tokens" => 8000
  },
  "notifications" => {
    # Attention signals (UI::Notifier) for the moments the agent needs
    # human eyes: a long turn finishing, an approval prompt, a blocked
    # subagent. CLI-only; never emitted into a pipe.
    "enabled" => true,
    # Ring the terminal bell (BEL). On iTerm2 an OSC 9 escape is also
    # sent so it surfaces as a native macOS notification.
    "bell" => true,
    # Optional shell command spawned non-blocking per event with
    # RUBINO_EVENT (turn_finished|needs_approval|blocked) and
    # RUBINO_MESSAGE in its env — e.g. osascript / notify-send.
    "command" => nil,
    # A turn must run at least this many seconds before its completion
    # notifies; quick turns stay silent.
    "min_turn_seconds" => 10
  },
  "thinking" => {
    # Reasoning effort: off | low | medium | high. Mapped to an Anthropic
    # thinking-token budget (off→0, low→4000, medium→8000, high→16000) on
    # the anthropic-family path. "off" disables thinking. When SET it wins
    # over the model/provider thinking_budget chain; left nil (the default)
    # the budget falls through that chain, whose own default is 8000 — i.e.
    # the effective default effort is already "medium". /think reports
    # "medium" for the nil case.
    "effort" => nil
  },
  "streaming" => {
    "enabled" => true,
    "transport" => "off",
    "edit_interval" => 0.3,
    "buffer_threshold" => 40
  },
  "context" => {
    "engine" => "compressor",
    "max_tokens" => nil
  },
  "compression" => {
    "enabled" => true,
    "threshold" => 0.50,
    "target_ratio" => 0.20,
    "protect_first_n" => 3,
    "protect_last_n" => 20,
    "max_summary_tokens" => 12_000,
    "preserve_tool_pairs" => true
  },
  "memory" => {
    "enabled" => true,
    "backend" => "sqlite",
    "auto_extract" => true,
    # Throttle the background aux-LLM memory extraction to ~every N turns
    # instead of EVERY turn (#412), mirroring Hermes' nudge_interval (10):
    # extraction enqueues only when turns-since-last-extract >= this. 10x
    # fewer aux calls + far less of the conversation shipped to the
    # extractor. nil/<=1 = every turn (old behaviour). The extract is also
    # ALWAYS backgrounded off the interactive critical path (never drained
    # inline on the live CLI turn).
    "auto_extract_interval" => 10,
    "auto_save" => true,
    "user_profile_enabled" => true,
    "project_context_enabled" => true,
    "memory_char_limit" => 2200,
    "user_char_limit" => 1375,
    # Ingest/store cap for the live memory set, kept SEPARATE from the
    # injection budget above. `memory_char_limit` only bounds what gets
    # packed into the prompt at RETRIEVAL time; storing facts must not be
    # throttled by it or long multi-session conversations stall once the
    # injection budget fills. `nil` = unbounded ingest (the default).
    "ingest_char_limit" => nil,
    # Bounded retry budget for the aux extraction call on a transient
    # error (429 rate-limit / overloaded / 5xx). Under concurrent load the
    # aux call used to drop the fact on the first RateLimitError; now it
    # backs off and retries up to this many times (honouring Retry-After)
    # before giving up, and the per-session cursor re-feeds the turn next
    # time even then — so memory isn't lost to a transient rate limit.
    "extract_max_retries" => 3,
    # tiny-Zep SQLite backend tuning. `vector` enables best-effort
    # sqlite-vec/RubyLLM.embed KNN on top of the always-on FTS5 hybrid;
    # off by default so the stock install needs no extra deps. `graph`
    # is the graph-lite 1-hop entity/edge blend (on by default).
    "sqlite" => {
      "vector" => false,
      "graph" => true
    }
  },
  "jobs" => {
    "mode" => "inline",
    "poll_interval" => 2,
    "max_attempts" => 3,
    "retry_backoff_seconds" => 30
  },
  # Nested-subagent (the `task` delegation tool) caps. A subagent CAN now
  # spawn its own subagents; these three caps bound the tree so depth ×
  # fan-out cannot blow past the process's thread/cost budget. All three are
  # enforced in ONE place — Tools::BackgroundTasks#reserve — which refuses a
  # spawn (the tool then surfaces a clear at-capacity / max-depth message).
  "tasks" => {
    # Max nesting depth. depth 0 = a human/top-level-spawned child; the cap
    # bounds chains of subagents-spawning-subagents. 2 ⇒ human→child→grandchild
    # (no deeper).
    "max_depth" => 2,
    # Max LIVE direct children one node (human/top-level or a single
    # subagent) may have at once.
    "max_children_per_node" => 3,
    # Hard global ceiling on total LIVE subagents across the whole tree.
    "max_concurrent_total" => 8,
    # Per-child budget for BILLED live probes (`probe(live:true)`): how many
    # times an owner may run a one-shot model peek over a single child's
    # transcript. Over budget → the model is told to use the FREE
    # live:false snapshot instead. Free snapshots are unlimited.
    "max_live_probes_per_child" => 5,
    # Bound (seconds) a BLOCKING ask_parent waits before the child
    # self-heals and proceeds with its best judgement (S5a). Matches the
    # approvals wait-timeout default — never "forever".
    "ask_parent_timeout" => 900
  },
  "tools" => {
    # Sandbox write/edit/delete tools to workspace_root (terminal.cwd
    # or Dir.pwd). Set to false to let the model touch any path the
    # process can reach — only do this if you trust the model + the
    # approval flow alone.
    "workspace_strict" => true,
    "git" => true,
    # Default ON: the agent ships to run inside an isolated per-customer
    # VM where running shell commands is the whole point. The blast radius
    # is the VM, and security.confirm_policy (default dangerous_only) still
    # routes any DangerousPattern command through an approval prompt while
    # safe commands run unprompted (set confirm_policy: confirm_all to gate
    # every command).
    "shell" => true,
    "ruby" => true,

    # Default ON, matching Hermes (web tools ship in the default toolset,
    # keyless via the DuckDuckGo backend) (#411). Gated at runtime on
    # backend reachability in Registry#web_backend_available? so an
    # unreachable network DEGRADES gracefully (the tool is hidden / its
    # call returns an error string) rather than crashing a turn.
    "web" => true,
    "memory" => true
  },
  "tool_output" => {
    "max_bytes" => 50_000,
    "max_lines" => 2000,
    "max_line_length" => 2000
  },
  "file_read" => {
    "max_chars" => 100_000
  },
  "terminal" => {
    "backend" => "local",
    "cwd" => nil,
    "file_sync_enabled" => false,
    "file_sync_max_mb" => 100
  },
  "approvals" => {
    "mode" => "manual",
    # Auto-allow provably READ-ONLY shell commands (ls, pwd, cat, grep,
    # git log, ...) without an approval prompt. The whole line must
    # parse as safe (Security::ReadonlyCommands): no redirection or
    # command/process substitution, every pipe/&&/; segment from the
    # read-only set, no mutating flags (find -exec/-delete, ...).
    # Anything ambiguous still prompts. The hardline floor and
    # permissions:deny always run first, so this never weakens them.
    "auto_allow_readonly" => true,
    # Extra command names (or leading-token prefixes, e.g. "docker ps")
    # merged into the built-in read-only set. The same parse validation
    # applies to every segment.
    "readonly_commands" => [],
    # How long (seconds) a run waits on a human approval/clarification
    # before giving up. On expiry the gate AUTO-DENIES (never approves)
    # and frees the worker thread — an abandoned approval (closed tab, no
    # answer) must not park a server worker indefinitely (W1). A sane
    # bound (15 min), not the old 24h that effectively never released.
    # Set to nil for a truly unbounded wait (interruptible only by an
    # explicit run stop; discouraged on shared servers). While a decision
    # is pending the SSE idle watchdog is suspended for that run
    # (EventsOperation), so the run is never reaped mid-wait.
    "wait_timeout_seconds" => 900
  },

  # SSRF guard for Run::AttachmentDownloader. Only URLs whose host is in
  # this list (case-insensitive) are fetched into the run workspace; the
  # downloader refuses everything else. ENV["ALLOWED_FILE_URL_HOSTS"]
  # (comma-separated) is merged in too, so a downstream consumer can keep
  # using its existing env knob. Loopback hosts (localhost, 127.0.0.1, ::1) are
  # ALWAYS allowed on top of this list, since an HTTP client co-located on the
  # same host produces loopback attachment URLs.
  # Empty list + empty env = only loopback is fetchable.
  "attachments" => {
    "allowed_hosts" => [],
    # Secure-by-default policy for the universal file-attachment handler
    # (Attachments::Classify / Preamble). Every default is on the secure
    # branch; explicit user config wins (Configuration merges over these).
    # Fail closed: oversize / unsafe / disallowed-kind => warn + skip.
    "policy" => {
      # Hard cap on accepted file size, enforced via lstat BEFORE reading.
      "max_file_bytes" => 26_214_400, # 25 MB
      # Inline budget for text files; over budget => head + read-rest note.
      "inline_text_budget_bytes" => 100_000, # ~25k tokens
      # Kinds the handler will process. Deny one by removing it.
      "allow_kinds" => %w[image text document archive binary],
      # Documents are hint-only by default (cost / injection blast radius);
      # the flag is reserved for a future in-process extract path.
      "auto_extract_documents" => false,
      # Decompression-bomb / runaway-conversion caps for the in-process
      # document converters (Documents::Limits). The 25 MB on-disk
      # max_file_bytes is trivially defeated by zip compression (a 100 KB
      # .docx expands to ~34 MB of XML / 1M paragraphs), so the converter
      # caps BEFORE/DURING conversion: a paragraph/row/page/slide count
      # ceiling, an accumulated decompressed-bytes ceiling (also checked
      # against the OOXML central directory BEFORE the gem inflates), and
      # a wall-clock budget. On any cap it bails to the shell-extraction
      # hint instead of hanging / OOM-killing the turn.
      "convert_max_elements" => 50_000,
      "convert_max_decompressed_bytes" => 5_000_000, # ~5 MB extracted text
      "convert_wall_clock_seconds" => 15.0,
      # Routing an image to an EXTERNAL aux model is data egress; on by
      # default to preserve the existing aux-vision behaviour.
      "aux_vision_egress" => true,
      # Caps for any in-process archive listing (hint-only today, so
      # unused unless listing is enabled).
      "archive" => {
        "max_entries" => 2000,
        "max_uncompressed_bytes" => 268_435_456,
        "max_entry_ratio" => 100,
        "max_total_ratio" => 50,
        "max_nesting_depth" => 1
      }
    }
  },
  "security" => {
    # Prompt policy for shell commands not otherwise allowed/denied:
    #   dangerous_only (DEFAULT, reference-faithful) safe commands run
    #                  unprompted; only DangerousPatterns matches prompt.
    #   confirm_all    (opt-in hardening) every such command prompts.
    # Aligned to Hermes (#409): Hermes has no confirm-policy concept —
    # detect_dangerous_command is its SOLE prompt trigger; non-dangerous
    # commands run unprompted. The old confirm_all default prompted on
    # every npm test / make / ls — huge DX friction. The hardline floor
    # and permissions:deny always precede this regardless of policy, so
    # dangerous_only never weakens the non-bypassable floor. Set
    # confirm_policy: "confirm_all" to restore prompt-on-everything.
    "confirm_policy" => "dangerous_only",
    # EMPTY by default (#409), aligning to Hermes' empty allowlist: once
    # the prompt policy is dangerous_only, safe commands (incl. git status
    # / git diff) already run unprompted via the policy + read-only
    # auto-allow, so the seeded entries were non-load-bearing. A
    # code-loading runner (`bundle exec rspec`, `rake`, `npm test`) is
    # still NOT safely allowlistable (SEC-R2-3: `rspec -r FILE` is RCE);
    # users who want exact-command pre-approval opt in explicitly.
    "command_allowlist" => [],

    "website_blocklist" => {
      "enabled" => false,
      "domains" => [],
      "shared_files" => []
    }
  },
  # Repeated-identical-tool-call guard (DoomLoopDetector). Aligned to
  # Hermes' tool_guardrails (#414): hard_stop OFF by default (WARN, don't
  # block) and a higher threshold, so a legitimate 3rd retry of an
  # idempotent read is no longer hard-denied. With hard_stop:false the
  # policy surfaces a doom-loop WARNING to the model on the Nth identical
  # call but still lets it through; set hard_stop:true to restore the old
  # block-at-threshold behaviour.
  "doom_loop" => {
    "hard_stop" => false,
    "threshold" => 5
  },
  "privacy" => {
    "redact_pii" => false
  },
  "clarify" => {
    "timeout" => 120
  },
  "worktree" => {
    "enabled" => false
  },
  # System-prompt layering. Defaults ship the built-in role prompts
  # from lib/rubino/agent/prompts/*.txt. Customers customise via
  # config.yml:
  #   prompts.preamble — single block prepended after the role
  #     identity; the natural place for "You are running inside
  #     <product>" customer context.
  #   prompts.environment.enabled — when true (default) the assembler
  #     injects an [Environment] block with date/OS/cwd/git/runtimes
  #     and the list of CLI utilities found on PATH. Cached per
  #     process — re-probed every boot, not every turn.
  #   prompts.environment.extra_utilities — additional binaries to
  #     probe beyond EnvironmentInspector::DEFAULT_UTILITIES.
  #   prompts.overrides.<role> — full replacement of the built-in
  #     role prompt (escape hatch; prefer preamble for incremental
  #     tweaks).
  #   prompts.prompt_cache — when true (default) the assembler emits
  #     Anthropic prompt-cache breakpoints (cache_control) on the stable
  #     system prefix and the last tool definition, so the fixed prompt
  #     prefix + tool block are cached across turns (#311). The volatile
  #     tail (fresh relevant-memories + post-compaction summary) is kept
  #     AFTER the system breakpoint so the cached bytes stay byte-stable.
  #     Honored by anthropic-family providers; other providers ignore it.
  "prompts" => {
    "preamble" => nil,
    "environment" => {
      "enabled" => true,
      "extra_utilities" => []
    },
    "overrides" => {},
    "prompt_cache" => true
  },
  "quick_commands" => {},
  "mcp" => {
    "servers" => {}
  },
  "skills" => {
    "enabled" => true,
    # Post-turn skill distillation (Variant B). When true, a successful,
    # tool-heavy turn enqueues DistillSkillJob, which spends ONE auxiliary
    # model call to distil a reusable SKILL.md. Mirrors memory.auto_extract:
    # a separate toggle from `enabled` (which only controls whether skills
    # are loaded/usable) so a deployment — or a test that scripts a fixed
    # number of LLM turns — can keep skills usable while turning off the
    # extra background aux call.
    "auto_distill" => true,
    # Throttle post-turn skill distillation to ~every N turns (#414),
    # mirroring memory.auto_extract_interval, so a tool-heavy session
    # doesn't spend an aux-model call every single turn. nil/<=1 = every
    # eligible turn. The job's own deterministic gate still applies on top.
    "auto_distill_interval" => 10,
    # Discover the skills shipped *inside the gem* (skills/<name>/SKILL.md),
    # so every install gets the built-in catalogue (e.g. ruby-expert) with
    # no copy step, on top of the user paths below. Built-ins are scanned
    # first, so a same-named user skill still overrides them. Set false to
    # run with only your own skills.
    "include_builtin" => true,
    "paths" => [
      ".rubino/skills",
      "~/.rubino/skills"
    ]
  },
  "commands" => {
    "paths" => [
      ".rubino/commands",
      HOME_COMMANDS_PATH
    ],
    # When false (default), !`shell` interpolation in command templates is
    # disabled. Set to true only in trusted environments where you explicitly
    # want command templates to execute shell commands.
    "shell_injection_enabled" => false
  },
  "permissions" => {},
  "formatters" => {},
  "agents" => {},
  "server" => {
    "port" => 4820,
    "auth" => false
  },
  "api" => {
    # Hard cap on JSON request bodies. Anything past this (whether
    # advertised by Content-Length or revealed mid-read) is rejected
    # with 413 before the parser allocates the full payload — keeps a
    # multi-GB POST from OOM-killing the process.
    "max_body_bytes" => 5 * 1024 * 1024,
    # Hard cap on multipart upload payload (POST /v1/files). Checked
    # against Content-Length first, then enforced mid-stream so a
    # truncated/missing Content-Length cannot saturate the disk.
    "max_upload_bytes" => 50 * 1024 * 1024,
    # Token-bucket rate limiter. Unauth bucket (per remote IP) protects
    # /v1/health and /v1/metrics from public floods; auth bucket (per
    # bearer token) caps authenticated callers. Storage is in-memory,
    # so multi-process deployments need a shared backend before this
    # gives meaningful protection across workers.
    "rate_limit_enabled" => true,
    "rate_limit_unauth_per_minute" => 60,
    "rate_limit_auth_per_minute" => 600
  }
}.freeze

Class Method Summary collapse

Class Method Details

.deep_dup(obj) ⇒ Object



658
659
660
661
662
663
664
# File 'lib/rubino/config/defaults.rb', line 658

def deep_dup(obj)
  case obj
  when Hash  then obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
  when Array then obj.map { |v| deep_dup(v) }
  else            obj
  end
end

.dig(*keys) ⇒ Object



670
671
672
# File 'lib/rubino/config/defaults.rb', line 670

def dig(*keys)
  MODULE_DEFAULTS.dig(*keys)
end

.to_hashObject

Deep copy so a Configuration#set on a never-overridden nested section (e.g. display.reasoning) mutates the per-config hash, NOT the shared MODULE_DEFAULTS constant. A shallow .dup left nested section hashes aliased to the constant, so the first /reasoning or /think write poisoned the process-wide default.



654
655
656
# File 'lib/rubino/config/defaults.rb', line 654

def to_hash
  deep_dup(MODULE_DEFAULTS)
end

.to_yamlObject



666
667
668
# File 'lib/rubino/config/defaults.rb', line 666

def to_yaml
  MODULE_DEFAULTS.to_yaml
end