{"repo": "519lab/thoth-agent", "files": {"thoth_cli/config.py": {"6ea86353f0498a4182f02bc8d70e00a3ee3bd1a1": {"t": 1785674664.792855, "n": 238150, "h": "4dc88f193131a902", "c": "\"\"\"\nConfiguration management for Thoth Agent.\n\nConfig files are stored in ~/.thoth/ for easy access:\n- ~/.thoth/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.thoth/.env - API keys and secrets\n\nThis module provides:\n- thoth config - Show current configuration\n- thoth config edit - Open config in editor\n- thoth config set - Set a specific value\n- thoth config wizard - Re-run setup wizard\n\"\"\"\n\nimport copy\nimport logging\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nimport threading\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\nlogger = logging.getLogger(__name__)\n\n# Track which (config_path, mtime_ns, size) tuples we've already warned about\n# so concurrent CLI/gateway loads of a broken config.yaml don't spam stderr\n# every time. Cleared automatically when the file changes (different mtime).\n_CONFIG_PARSE_WARNED: set = set()\n\n\ndef _warn_config_parse_failure(config_path: Path, exc: Exception) -> None:\n \"\"\"Surface a config.yaml parse failure to user, log, and stderr.\n\n A YAML parse error in ``~/.thoth/config.yaml`` causes ``load_config()``\n to silently fall back to ``DEFAULT_CONFIG``, which means every user\n override (auxiliary providers, fallback chain, model overrides, etc.)\n is dropped. Before this helper that was a one-line ``print(...)`` that\n scrolled off-screen on the first invocation and was never seen again.\n\n Now: warn once per (path, mtime_ns, size) on stderr **and** in\n ``agent.log`` / ``errors.log`` at WARNING level so ``thoth logs``\n surfaces it. Re-warns automatically if the file changes (different\n mtime/size), so users editing the config see the next failure.\n \"\"\"\n try:\n st = config_path.stat()\n key = (str(config_path), st.st_mtime_ns, st.st_size)\n except OSError:\n key = (str(config_path), 0, 0)\n if key in _CONFIG_PARSE_WARNED:\n return\n _CONFIG_PARSE_WARNED.add(key)\n\n msg = (\n f\"Failed to parse {config_path}: {exc}. \"\n f\"Falling back to default config — every user override \"\n f\"(auxiliary providers, fallback chain, model settings) is being IGNORED. \"\n f\"Fix the YAML and restart.\"\n )\n logger.warning(msg)\n try:\n sys.stderr.write(f\"⚠️ thoth config: {msg}\\n\")\n sys.stderr.flush()\n except Exception:\n pass\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n_LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {}\n# (path, mtime_ns, size) -> cached expanded config dict.\n# load_config() returns a deepcopy of the cached value when the file\n# hasn't changed since the last load, skipping yaml.safe_load +\n# _deep_merge + _normalize_* + _expand_env_vars (~13 ms/call).\n# save_config() + migrate_config() write via atomic_yaml_write which\n# produces a fresh inode, so stat() sees a new mtime_ns and the next\n# load repopulates automatically — no explicit invalidation hook.\n_LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}\n# (path, mtime_ns, size) -> cached raw yaml dict. Same pattern as\n# _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want\n# the user's on-disk values without defaults merged in.\n_RAW_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}\n# Serializes all config read/write paths. libyaml's C extension is not\n# thread-safe for concurrent safe_load() on the same file, and multiple\n# tool threads (approval.py, browser_tool.py, setup flows) hit\n# load_config / read_raw_config / save_config from different threads\n# during long agent runs. RLock (not Lock) because save_config internally\n# calls read_raw_config. Also covers mutation of the module-level cache\n# dicts above.\n_CONFIG_LOCK = threading.RLock()\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset({\n \"OPENAI_API_KEY\", \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\", \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\", \"DISCORD_HOME_CHANNEL_NAME\",\n \"TELEGRAM_HOME_CHANNEL\", \"TELEGRAM_HOME_CHANNEL_NAME\",\n \"SLACK_HOME_CHANNEL\", \"SLACK_HOME_CHANNEL_NAME\",\n \"SIGNAL_ACCOUNT\", \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\", \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"SIGNAL_HOME_CHANNEL\", \"SIGNAL_HOME_CHANNEL_NAME\",\n \"SMS_HOME_CHANNEL\", \"SMS_HOME_CHANNEL_NAME\",\n \"BLUEBUBBLES_SERVER_URL\", \"BLUEBUBBLES_PASSWORD\",\n \"BLUEBUBBLES_HOME_CHANNEL\", \"BLUEBUBBLES_HOME_CHANNEL_NAME\",\n \"IRC_SERVER\", \"IRC_PORT\", \"IRC_NICKNAME\", \"IRC_CHANNEL\",\n \"IRC_USE_TLS\", \"IRC_SERVER_PASSWORD\", \"IRC_NICKSERV_PASSWORD\",\n \"TERMINAL_ENV\", \"TERMINAL_SSH_KEY\", \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\", \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\", \"MATTERMOST_HOME_CHANNEL_NAME\", \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\", \"MATRIX_ENCRYPTION\", \"MATRIX_DEVICE_ID\", \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\", \"MATRIX_FREE_RESPONSE_ROOMS\", \"MATRIX_AUTO_THREAD\", \"MATRIX_DM_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n # Langfuse observability plugin — optional tuning keys + standard SDK vars.\n # Activation is via plugins.enabled (opt-in through `thoth plugins enable\n # observability/langfuse`); credentials gate the plugin at runtime.\n \"THOTH_LANGFUSE_ENV\",\n \"THOTH_LANGFUSE_RELEASE\",\n \"THOTH_LANGFUSE_SAMPLE_RATE\",\n \"THOTH_LANGFUSE_MAX_CHARS\",\n \"THOTH_LANGFUSE_DEBUG\",\n \"LANGFUSE_PUBLIC_KEY\",\n \"LANGFUSE_SECRET_KEY\",\n \"LANGFUSE_BASE_URL\",\n})\nimport yaml\n\nfrom thoth_cli.cli_name import cli_name\nfrom thoth_cli.colors import Colors, color\nfrom thoth_cli.default_soul import DEFAULT_SOUL_MD\n\n\n# =============================================================================\n# Managed mode (Homebrew)\n# =============================================================================\n\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"THOTH_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Thoth is running in package-manager-managed mode.\n\n Signalled by the THOTH_MANAGED env var (set by the packaging wrapper).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade thoth-agent\"\n return None\n\n\ndef detect_install_method(project_root: Optional[Path] = None) -> str:\n \"\"\"Detect how Thoth was installed: 'docker', 'homebrew', 'git', or 'pip'.\n\n Resolution order:\n 1. Stamped ``~/.thoth/.install_method`` file (written by installers)\n 2. THOTH_MANAGED env (Homebrew)\n 3. Container detection (/.dockerenv, /run/.containerenv, cgroup)\n 4. .git directory presence -> 'git'\n 5. Fallback -> 'pip'\n \"\"\"\n stamp = get_thoth_home() / \".install_method\"\n try:\n method = stamp.read_text(encoding=\"utf-8\").strip().lower()\n if method:\n return method\n except OSError:\n pass\n managed = get_managed_system()\n if managed:\n return managed.lower().replace(\" \", \"-\")\n from thoth_constants import is_container\n if is_container():\n return \"docker\"\n if project_root is None:\n project_root = Path(__file__).parent.parent.resolve()\n if (project_root / \".git\").is_dir():\n return \"git\"\n return \"pip\"\n\n\ndef stamp_install_method(method: str) -> None:\n \"\"\"Write the install method to ~/.thoth/.install_method.\"\"\"\n stamp = get_thoth_home() / \".install_method\"\n try:\n stamp.parent.mkdir(parents=True, exist_ok=True)\n stamp.write_text(method + \"\\n\", encoding=\"utf-8\")\n except OSError:\n pass\n\n\ndef recommended_update_command_for_method(method: str) -> str:\n \"\"\"Return the update command or guidance for a given install method.\"\"\"\n if method == \"homebrew\":\n return \"brew upgrade thoth-agent\"\n if method == \"docker\":\n return \"Rebuild your image from the latest source (no published Thoth image yet)\"\n if method == \"pip\":\n import shutil\n uv = shutil.which(\"uv\")\n if uv:\n return \"uv pip install --upgrade thoth-agent\"\n return \"pip install --upgrade thoth-agent\"\n return \"thoth update\"\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n managed_cmd = get_managed_update_command()\n if managed_cmd:\n return managed_cmd\n method = detect_install_method()\n return recommended_update_command_for_method(method)\n\n\ndef format_managed_message(action: str = \"modify this Thoth installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"THOTH_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Thoth installation is managed by Homebrew \"\n f\"(THOTH_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade thoth-agent\"\n )\n\n return (\n f\"Cannot {action}: this Thoth installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Thoth.\"\n )\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from thoth_constants — canonical definition lives there.\nfrom thoth_constants import get_thoth_home # noqa: F811,E402\nfrom utils import atomic_replace\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_thoth_home() / \"config.yaml\"\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_thoth_home() / \".env\"\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the packaging wrapper sets group-readable\n permissions (0750) so interactive users in the thoth group can\n share state with the gateway service.\n\n The mode can be overridden via the THOTH_HOME_MODE environment variable\n (e.g. THOTH_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse THOTH_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"THOTH_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _is_container() -> bool:\n \"\"\"Detect if we're running inside a Docker/Podman/LXC container.\n\n When Thoth runs in a container with volume-mounted config files, forcing\n 0o600 permissions breaks multi-process setups where the gateway and\n dashboard run as different UIDs or the volume mount requires broader\n permissions.\n \"\"\"\n # Explicit opt-out\n if os.environ.get(\"THOTH_CONTAINER\") or os.environ.get(\"THOTH_SKIP_CHMOD\"):\n return True\n # Docker / Podman marker file\n if os.path.exists(\"/.dockerenv\"):\n return True\n # LXC / cgroup-based detection\n try:\n with open(\"/proc/1/cgroup\", \"r\", encoding=\"utf-8\") as f:\n cgroup_content = f.read()\n if \"docker\" in cgroup_content or \"lxc\" in cgroup_content or \"kubepods\" in cgroup_content:\n return True\n except (OSError, IOError):\n pass\n return False\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the packaging wrapper sets\n group-readable permissions (0640) on config files.\n\n Skipped in containers — Docker/Podman volume mounts often need broader\n permissions. Set THOTH_SKIP_CHMOD=1 to force-skip on other systems.\n \"\"\"\n if is_managed() or _is_container():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into THOTH_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n return\n soul_path.write_text(DEFAULT_SOUL_MD, encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_thoth_home():\n \"\"\"Ensure ~/.thoth directory structure exists with secure permissions.\n\n In managed mode the packaging wrapper may pre-create dirs with\n setgid + group-writable (2770). We set umask(0o007) so any files we\n create (e.g. SOUL.md) are group-writable (0660), and mkdir any dirs\n the wrapper did not seed so first run never hard-fails.\n \"\"\"\n home = get_thoth_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_thoth_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\n \"cron\", \"sessions\", \"logs\", \"logs/curator\", \"memories\",\n \"pairing\", \"hooks\", \"image_cache\", \"audio_cache\", \"skills\",\n ):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n\n\ndef _ensure_thoth_home_managed(home: Path):\n \"\"\"Managed-mode variant: ensure dirs exist (group-writable), seed SOUL.md.\n\n A managed package (e.g. Homebrew) installs into its own prefix and does\n not seed the user's ~/.thoth subtree, so we mkdir the dirs ourselves\n under the caller's umask(0o007) rather than requiring them up front.\n \"\"\"\n home.mkdir(parents=True, exist_ok=True)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"logs/curator\", \"memories\"):\n (home / subdir).mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md will be created as 0660\n _ensure_default_soul_md(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"thoth-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n #\n # 180s is calibrated for realistic in-flight agent turns: a typical\n # coding conversation mid-reasoning runs 60–150s per call, so a 60s\n # budget routinely interrupted legitimate work on /restart. Raise\n # further in config.yaml if you run very-long-reasoning models.\n \"restart_drain_timeout\": 180,\n # Max app-level retry attempts for API errors (connection drops,\n # provider timeouts, 5xx, etc.) before the agent surfaces the\n # failure. The OpenAI SDK already does its own low-level retries\n # (max_retries=2 default) for transient network errors; this is\n # the Thoth-level retry loop that wraps the whole call. Lower\n # this to 1 if you use fallback providers and want fast failover\n # on flaky primaries; raise it if you prefer to tolerate longer\n # provider hiccups on a single provider.\n \"api_max_retries\": 3,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Maximum time (seconds) the gateway will block an agent waiting for\n # a clarify-tool response from the user. Hit this and the agent\n # unblocks with \"[user did not respond within Xm]\" so it can adapt\n # rather than pinning the running-agent guard forever. CLI clarify\n # blocks indefinitely (input() is synchronous) and ignores this.\n \"clarify_timeout\": 600,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n # Lower values mean faster feedback on slow tasks but more chat\n # noise; 180s is a compromise that catches spinning weak-model runs\n # (60+ tool iterations with tiny output) before users assume the\n # bot is dead and /restart.\n \"gateway_notify_interval\": 180,\n # Freshness window for the gateway auto-continue note (seconds).\n # After a gateway crash/restart/SIGTERM mid-run, the next user\n # message gets a \"[System note: your previous turn was\n # interrupted — process the unfinished tool result(s) first]\"\n # prepended so the model picks up where it left off. That's the\n # right behaviour while the interruption is fresh, but stale\n # markers (transcript last touched hours or days ago) can revive\n # an unrelated old task when the user's next message starts new\n # work. This window is the max age of the last persisted\n # transcript row for which we still inject the continue note.\n # Default 3600s comfortably covers a long turn (gateway_timeout\n # default is 1800s) plus runtime slack. Set to 0 to disable the\n # gate and restore pre-fix behaviour (always inject).\n \"gateway_auto_continue_freshness\": 3600,\n # How user-attached images are presented to the main model on each turn.\n # \"auto\" — attach natively when the active model reports\n # supports_vision=True AND the user hasn't explicitly\n # configured auxiliary.vision.provider. Otherwise fall\n # back to text (vision_analyze pre-analysis).\n # \"native\" — always attach natively; non-vision models will either\n # error at the provider or get a last-chance text fallback\n # (see run_agent._prepare_messages_for_api).\n # \"text\" — always pre-analyze with vision_analyze and prepend the\n # description as text; the main model never sees pixels.\n # Affects gateway platforms, the TUI, and CLI /attach. vision_analyze\n # remains available as a tool regardless of this setting — the routing\n # only controls how inbound user images are presented.\n \"image_input_mode\": \"auto\",\n \"disabled_toolsets\": [],\n },\n \n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n # Extra files to source in the login shell when building the\n # per-session environment snapshot. Use this when tools like nvm,\n # pyenv, asdf, or custom PATH entries are registered by files that\n # a bash login shell would skip — most commonly ``~/.bashrc``\n # (bash doesn't source bashrc in non-interactive login mode) or\n # zsh-specific files like ``~/.zshrc`` / ``~/.zprofile``.\n # Paths support ``~`` / ``${VAR}``. Missing files are silently\n # skipped. When empty, Thoth auto-sources ``~/.profile``,\n # ``~/.bash_profile``, and ``~/.bashrc`` (in that order) if the\n # snapshot shell is bash (this is the ``auto_source_bashrc``\n # behaviour — disable with that key if you want strict login-only\n # semantics).\n \"shell_init_files\": [],\n # When true (default), Thoth sources the user's shell rc files\n # (``~/.profile``, ``~/.bash_profile``, ``~/.bashrc``) in the\n # login shell used to build the environment snapshot. This\n # captures PATH additions, shell functions, and aliases — which a\n # plain ``bash -l -c`` would otherwise miss because bash skips\n # bashrc in non-interactive login mode, and because a default\n # Debian/Ubuntu ``~/.bashrc`` short-circuits on non-interactive\n # sources. ``~/.profile`` and ``~/.bash_profile`` are tried first\n # because ``n`` / ``nvm`` / ``asdf`` installers typically write\n # their PATH exports there without an interactivity guard. Turn\n # this off if your rc files misbehave when sourced\n # non-interactively (e.g. one that hard-exits on TTY checks).\n \"auto_source_bashrc\": True,\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Thoth\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"vercel_runtime\": \"node24\",\n # Container resource limits (docker, singularity, modal, daytona, vercel_sandbox — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example:\n # [\"/home/user/projects:/workspace/projects\",\n # \"/home/user/.thoth/cache/documents:/output\"]\n # For gateway MEDIA delivery, write inside Docker to /output/... and emit\n # the host-visible path in MEDIA:, not the container path.\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n \"docker_extra_args\": [], # Extra flags passed verbatim to docker run\n # Explicit opt-in: run the Docker container as the host user's uid:gid\n # (via `--user`). When enabled, files written into bind-mounted dirs\n # (docker_volumes, the persistent workspace, or the auto-mounted cwd)\n # are owned by your host user instead of root, which avoids needing\n # `sudo chown` after container runs. Default off to preserve behavior\n # for images whose entrypoints expect to start as root (e.g. the\n # bundled Thoth image, which drops to the `thoth` user via gosu).\n # When on, SETUID/SETGID caps are omitted from the container since\n # no privilege drop is needed.\n \"docker_run_as_host_user\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n\n \"web\": {\n \"backend\": \"\", # shared fallback — applies to both search and extract\n \"search_backend\": \"\", # per-capability override for web_search (e.g. \"searxng\")\n \"extract_backend\": \"\", # per-capability override for web_extract (e.g. \"native\")\n },\n\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n # Browser engine for local mode. Passed as ``--engine `` to\n # agent-browser v0.25.3+.\n # \"auto\" — use Chrome (default, don't pass --engine at all)\n # \"lightpanda\" — use Lightpanda (1.3-5.8x faster navigation, no screenshots)\n # \"chrome\" — explicitly request Chrome\n # Also settable via AGENT_BROWSER_ENGINE env var.\n \"engine\": \"auto\",\n \"auto_local_for_private_urls\": True, # When a cloud provider is set, auto-spawn local Chromium for LAN/localhost URLs instead of sending them to the cloud\n \"cdp_url\": \"\", # Optional persistent CDP endpoint for attaching to an existing Chromium/Chrome\n # CDP supervisor — dialog + frame detection via a persistent WebSocket.\n # Active only when a CDP-capable backend is attached (Browserbase or\n # local Chrome via /browser connect). See\n # website/docs/developer-guide/browser-supervisor.md.\n \"dialog_policy\": \"must_respond\", # must_respond | auto_dismiss | auto_accept\n \"dialog_timeout_s\": 300, # Safety auto-dismiss after N seconds under must_respond\n \"camofox\": {\n # When true, Thoth sends a stable profile-scoped userId to Camofox\n # so the server maps it to a persistent Firefox profile automatically.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n # Optional externally managed Camofox identity. Useful when another\n # app owns the visible browser and Thoth should operate in it.\n \"user_id\": \"\",\n \"session_key\": \"\",\n # Rehydrate tab_id from Camofox before creating a new tab.\n \"adopt_existing_tab\": False,\n },\n },\n\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once\n # per conversation turn (on first write_file/patch call). Use /rollback\n # to restore.\n #\n # Defaults changed in v2 (single shared shadow store, real pruning):\n # - enabled: True -> False (opt-in; most users never use /rollback)\n # - max_snapshots: 50 -> 20 (now actually enforced via ref rewrite)\n # - auto_prune: False -> True (orphans/stale pruned automatically)\n # Opt in via ``thoth chat --checkpoints`` or set enabled=True here.\n \"checkpoints\": {\n \"enabled\": False,\n # Max checkpoints to keep per working directory. Pre-v2 this only\n # limited the `/rollback` listing; v2 actually rewrites the ref and\n # garbage-collects older commits.\n \"max_snapshots\": 20,\n # Hard ceiling on total ``~/.thoth/checkpoints/`` size (MB). When\n # exceeded, the oldest checkpoint per project is dropped in a\n # round-robin pass until total size falls under the cap.\n # 0 disables the size cap.\n \"max_total_size_mb\": 500,\n # Skip any single file larger than this when staging a checkpoint.\n # Prevents accidental snapshotting of datasets, model weights, and\n # other large generated assets. 0 disables the filter.\n \"max_file_size_mb\": 10,\n # Auto-maintenance: thoth sweeps the checkpoint base at startup\n # (at most once per ``min_interval_hours``) and:\n # * deletes project entries whose workdir no longer exists (orphan)\n # * deletes project entries whose last_touch is older than\n # ``retention_days``\n # * GCs the single shared store to reclaim unreachable objects\n # * enforces ``max_total_size_mb`` across remaining projects\n # * deletes ``legacy-*`` archives older than ``retention_days``\n \"auto_prune\": True,\n \"retention_days\": 7,\n \"delete_orphans\": True,\n \"min_interval_hours\": 24,\n },\n\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n\n # Tool-output truncation thresholds. When terminal output or a\n # single read_file page exceeds these limits, Thoth truncates the\n # payload sent to the model (keeping head + tail for terminal,\n # enforcing pagination for read_file). Tuning these trades context\n # footprint against how much raw output the model can see in one\n # shot. Ported from anomalyco/opencode PR #23770.\n #\n # - max_bytes: terminal_tool output cap, in chars\n # (default 50_000 ≈ 12-15K tokens).\n # - max_lines: read_file pagination cap — the maximum `limit`\n # a single read_file call can request before\n # being clamped (default 2000).\n # - max_line_length: per-line cap applied when read_file emits a\n # line-numbered view (default 2000 chars).\n \"tool_output\": {\n \"max_bytes\": 50_000,\n \"max_lines\": 2000,\n \"max_line_length\": 2000,\n },\n\n # Tool loop guardrails nudge models when they repeat failed or\n # non-progressing tool calls. Soft warnings are always-on by default;\n # hard stops are opt-in so interactive CLI/TUI sessions keep flowing.\n \"tool_loop_guardrails\": {\n \"warnings_enabled\": True,\n \"hard_stop_enabled\": False,\n \"warn_after\": {\n \"exact_failure\": 2,\n \"same_tool_failure\": 3,\n \"idempotent_no_progress\": 2,\n },\n \"hard_stop_after\": {\n \"exact_failure\": 5,\n \"same_tool_failure\": 8,\n \"idempotent_no_progress\": 5,\n },\n },\n\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n \"hygiene_hard_message_limit\": 400, # gateway session-hygiene force-compress threshold by message count\n \"protect_first_n\": 3, # non-system head messages always preserved\n # verbatim, in ADDITION to the system prompt\n # (which is always implicitly protected). Set to\n # 0 for long-running rolling-compaction sessions\n # where you want nothing pinned except the\n # system prompt + rolling summary + recent tail.\n \"abort_on_summary_failure\": False, # When True, auto-compression that fails\n # to generate a summary (aux LLM errored / returned\n # non-JSON / timed out) aborts entirely instead of\n # dropping the middle window with a static\n # \"summary unavailable\" placeholder. Messages are\n # preserved unchanged and the session \"freezes\" at\n # its current size until the user runs /compress\n # (which bypasses the failure cooldown) or /new.\n # Default False matches historical behavior; set to\n # True if you'd rather pause than silently lose\n # context turns when your aux model is flaky.\n },\n\n # Anthropic prompt caching (Claude via OpenRouter or native Anthropic API).\n # cache_ttl must be \"5m\" or \"1h\" (Anthropic-supported tiers); other values are ignored.\n \"prompt_caching\": {\n \"cache_ttl\": \"5m\",\n },\n\n # OpenRouter-specific settings.\n # response_cache: enable OpenRouter response caching (X-OpenRouter-Cache header).\n # When enabled, identical requests return cached responses for free (zero billing).\n # This is separate from Anthropic prompt caching and works alongside it.\n # See: https://openrouter.ai/docs/guides/features/response-caching\n # response_cache_ttl: how long cached responses remain valid, in seconds (1-86400).\n # Default 300 (5 minutes). Only used when response_cache is enabled.\n # min_coding_score: knob for the openrouter/pareto-code router (0.0-1.0).\n # Only applied when model.model is \"openrouter/pareto-code\". Higher\n # values route to stronger (more expensive) coders; lower values open\n # up cheaper, faster options. Default 0.65 lands on the mid-tier\n # coder on the current Pareto frontier. Empty string = let OpenRouter\n # pick the strongest available coder (router's documented default\n # when the plugins block is omitted).\n # See: https://openrouter.ai/docs/guides/routing/routers/pareto-router\n \"openrouter\": {\n \"response_cache\": True,\n \"response_cache_ttl\": 300,\n \"min_coding_score\": 0.65,\n },\n\n # AWS Bedrock provider configuration.\n # Only used when model.provider is \"bedrock\".\n \"bedrock\": {\n \"region\": \"\", # AWS region for Bedrock API calls (empty = AWS_REGION env var → us-east-1)\n \"discovery\": {\n \"enabled\": True, # Auto-discover models via ListFoundationModels\n \"provider_filter\": [], # Only show models from these providers (e.g. [\"anthropic\", \"amazon\"])\n \"refresh_interval\": 3600, # Cache discovery results for this many seconds\n },\n \"guardrail\": {\n # Amazon Bedrock Guardrails — content filtering and safety policies.\n # Create a guardrail in the Bedrock console, then set the ID and version here.\n # See: https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html\n \"guardrail_identifier\": \"\", # e.g. \"abc123def456\"\n \"guardrail_version\": \"\", # e.g. \"1\" or \"DRAFT\"\n \"stream_processing_mode\": \"async\", # \"sync\" or \"async\"\n \"trace\": \"disabled\", # \"enabled\", \"disabled\", or \"enabled_full\"\n },\n },\n\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n #\n # extra_body: forwarded verbatim as request body fields on every aux call\n # for that task. Use this to set provider-specific knobs (independent of\n # main-agent settings). On OpenRouter you can set provider routing prefs\n # and the Pareto Code coding-score floor here. Example:\n #\n # auxiliary:\n # compression:\n # provider: openrouter\n # model: openrouter/pareto-code\n # extra_body:\n # provider: # OpenRouter provider routing\n # order: [anthropic, google]\n # sort: throughput # or price | latency\n # plugins: # OpenRouter Pareto Code router\n # - id: pareto-router\n # min_coding_score: 0.5\n #\n # Each aux task is independent — main-agent provider_routing and\n # openrouter.min_coding_score do NOT propagate to aux calls by design.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | nous | codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"extra_body\": {}, # OpenAI-compatible provider-specific request fields\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n \"extra_body\": {},\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n \"extra_body\": {},\n },\n # Note: session_search no longer uses an auxiliary LLM (PR #27590 —\n # single-shape tool returns DB content directly). The old\n # ``auxiliary.session_search.*`` block was removed here. Existing\n # values in user config.yaml files are harmless leftovers and ignored.\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n \"extra_body\": {},\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n \"extra_body\": {},\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n \"extra_body\": {},\n },\n \"title_generation\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n \"extra_body\": {},\n },\n # Triage specifier — flesh out a rough one-liner in the Kanban\n # Triage column into a concrete spec, then promote it to ``todo``.\n # Invoked by ``thoth kanban specify`` (single id or --all). Set a\n # cheap, capable model here (gemini-flash works well); the main\n # model is overkill for short spec expansion.\n \"triage_specifier\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120,\n \"extra_body\": {},\n },\n # Kanban decomposer — decomposes a triage task into a graph of\n # child tasks routed to specialist profiles by description.\n # Invoked by ``thoth kanban decompose`` and the kanban\n # auto-decompose dispatcher tick. Returns a JSON task graph;\n # uses more tokens than the specifier so allow more headroom.\n \"kanban_decomposer\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 180,\n \"extra_body\": {},\n },\n # Profile describer — auto-generates a 1-2 sentence description\n # of what a profile is good at. Invoked by\n # ``thoth profile describe --auto`` and the dashboard's\n # auto-generate button. Short, cheap call.\n \"profile_describer\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 60,\n \"extra_body\": {},\n },\n # Curator — skill-usage review fork. Timeout is generous because the\n # review pass can take several minutes on reasoning models (umbrella\n # building over hundreds of candidate skills). \"auto\" = use main chat\n # model; override via `thoth model` → auxiliary → Curator to route\n # to a cheaper aux model (e.g. openrouter google/gemini-3-flash-preview).\n \"curator\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600,\n \"extra_body\": {},\n },\n },\n \n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\", # interrupt | queue | steer\n # When true, auto-resumes the most recent human-facing session on\n # launch instead of forging a fresh one. Mirrors `thoth -c` muscle\n # memory. Default off so existing users aren't surprised.\n \"tui_auto_resume_recent\": False,\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": False,\n \"timestamps\": False, # Show [HH:MM] on user and assistant labels\n \"final_response_markdown\": \"strip\", # render | strip | raw\n # Preserve recent classic CLI output across Ctrl+L, /redraw, and\n # terminal resize full-screen clears. Disable if a terminal emulator\n # behaves badly with replayed scrollback.\n \"persistent_output\": True,\n \"persistent_output_max_lines\": 200,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n # File-mutation verifier footer. When true (default), the agent\n # appends a one-line advisory to its final response whenever a\n # write_file / patch call failed during the turn and was never\n # superseded by a successful write to the same path. This catches\n # the \"batch of parallel patches, half fail, model claims success\"\n # class of over-claim that otherwise forces users to run\n # `git status` to verify edits landed. Set false to suppress.\n \"file_mutation_verifier\": True,\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n # UI language for static user-facing messages (approval prompts, a\n # handful of gateway slash-command replies). Does NOT affect agent\n # responses, log lines, tool outputs, or slash-command descriptions.\n # Supported: en, zh, ja, de, es, fr, tr, uk. Unknown values fall back to en.\n \"language\": \"en\",\n # TUI busy indicator style: kaomoji (default), emoji, unicode (braille\n # spinner), or ascii. Live-swappable via `/indicator