mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
feat(code): add [startup].mode default approval mode (#4573)
Add a `[startup].mode` option to `~/.deepagents/config.toml` for `deepagents-code`, accepting `manual` (default) or `dangerously-auto`, to set the interactive TUI's default approval mode without passing `-y`/`--auto-approve` each launch. --- Lets users pick the interactive TUI's default approval mode in `~/.deepagents/config.toml` instead of passing `-y`/`--auto-approve` on every launch. The `[startup].mode` option accepts `manual` (default, human-in-the-loop approvals) or `dangerously-auto` (auto-approve gated tool calls at launch). Precedence: an explicit `-y`/`--auto-approve` flag still wins; when omitted, `[startup].mode` decides; the built-in fallback is `manual`, so existing behavior is unchanged. Implemented by switching the flag's argparse default to `None` (so "omitted" is distinguishable from "set") and resolving the effective value at interactive dispatch. A new validated `load_startup_mode` loader mirrors the existing `[threads].sort_order` pattern and falls back to `manual` on unset/unreadable/invalid values. Follows the `config.toml`-only approach (a `ConfigOption` in the manifest for `dcode config` discovery, no new env var). Non-interactive (`-n`) mode computes its own approval behavior and is untouched. Made by [Open SWE](https://openswe.vercel.app/agents/82b4ad9e-f42e-7f73-ae2f-5a0830f3f8cc) ## References - Plan: https://openswe.vercel.app/agents/82b4ad9e-f42e-7f73-ae2f-5a0830f3f8cc/plan --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -71,12 +71,13 @@ class OptionKind(Enum):
|
||||
All kinds flow through `resolve_scalar`. The scalar kinds (`BOOL`,
|
||||
`BOOL_PRESENCE`, `INT`, `FLOAT`, `STR`) are coerced inline by
|
||||
`_coerce_env`/`_coerce_toml`. `SHELL_LIST_DELEGATE`, `SKILLS_DIRS_DELEGATE`,
|
||||
and `PTC_DELEGATE` defer to a bespoke parser (their semantics — colon-split
|
||||
Path resolution, comma + `recommended`/`all` sentinels, the PTC allowlist —
|
||||
do not compress into a generic coercion). `THEME_DELEGATE` is resolved
|
||||
separately at the top of `resolve_scalar` and never reaches the inline
|
||||
coercers. `STRUCTURED` marks user-defined tables that the scalar resolver
|
||||
only passes through for display.
|
||||
`PTC_DELEGATE`, and `STARTUP_MODE_DELEGATE` defer to bespoke parsers (their
|
||||
semantics — colon-split Path resolution, comma + `recommended`/`all`
|
||||
sentinels, the PTC/startup-mode allowlists — do not compress into a generic
|
||||
coercion). `THEME_DELEGATE` is resolved separately at the top of
|
||||
`resolve_scalar` and never reaches the inline coercers. `STRUCTURED` marks
|
||||
user-defined tables that the scalar resolver only passes through for
|
||||
display.
|
||||
"""
|
||||
|
||||
BOOL = "bool"
|
||||
@@ -101,6 +102,9 @@ class OptionKind(Enum):
|
||||
PTC_DELEGATE = "ptc"
|
||||
"""Delegates to `config._parse_interpreter_ptc`."""
|
||||
|
||||
STARTUP_MODE_DELEGATE = "startup_mode"
|
||||
"""Delegates to the `[startup].mode` runtime allowlist."""
|
||||
|
||||
THEME_DELEGATE = "theme"
|
||||
"""Delegates to the app theme-preference loader semantics."""
|
||||
|
||||
@@ -117,6 +121,7 @@ _KIND_TYPE_LABEL: dict[OptionKind, str] = {
|
||||
OptionKind.SHELL_LIST_DELEGATE: "list[str]",
|
||||
OptionKind.SKILLS_DIRS_DELEGATE: "list[path]",
|
||||
OptionKind.PTC_DELEGATE: "str | list[str]",
|
||||
OptionKind.STARTUP_MODE_DELEGATE: "str",
|
||||
OptionKind.THEME_DELEGATE: "theme",
|
||||
OptionKind.STRUCTURED: "table",
|
||||
}
|
||||
@@ -137,6 +142,7 @@ _KIND_DEFAULT_TYPES: dict[OptionKind, tuple[type, ...]] = {
|
||||
OptionKind.INT: (int,),
|
||||
OptionKind.FLOAT: (int, float),
|
||||
OptionKind.STR: (str,),
|
||||
OptionKind.STARTUP_MODE_DELEGATE: (str,),
|
||||
}
|
||||
|
||||
|
||||
@@ -420,6 +426,17 @@ def _coerce_env(option: ConfigOption, raw: str, name: str) -> object:
|
||||
# validation. Falling back to the validated default is the safe choice.
|
||||
logger.warning("%s is not env-backed; ignoring %s=%r", option.key, name, raw)
|
||||
return _INVALID
|
||||
if kind is OptionKind.STARTUP_MODE_DELEGATE:
|
||||
from deepagents_code.model_config import VALID_STARTUP_MODES
|
||||
|
||||
if raw in VALID_STARTUP_MODES:
|
||||
return raw
|
||||
logger.warning(
|
||||
"Ignoring %s=%r (expected 'manual' or 'dangerously-auto')",
|
||||
name,
|
||||
raw,
|
||||
)
|
||||
return _INVALID
|
||||
assert_never(kind)
|
||||
|
||||
|
||||
@@ -467,6 +484,17 @@ def _coerce_toml(option: ConfigOption, raw: object) -> object:
|
||||
except ValueError as exc:
|
||||
logger.warning("Ignoring %s in config.toml: %s", label, exc)
|
||||
return _INVALID
|
||||
elif kind is OptionKind.STARTUP_MODE_DELEGATE:
|
||||
from deepagents_code.model_config import VALID_STARTUP_MODES
|
||||
|
||||
if isinstance(raw, str) and raw in VALID_STARTUP_MODES:
|
||||
return raw
|
||||
logger.warning(
|
||||
"Ignoring %s=%r in config.toml (expected 'manual' or 'dangerously-auto')",
|
||||
label,
|
||||
raw,
|
||||
)
|
||||
return _INVALID
|
||||
elif kind is OptionKind.STRUCTURED:
|
||||
# Passed through verbatim for display; parsed by a dedicated loader.
|
||||
return raw
|
||||
@@ -1188,6 +1216,16 @@ _STATIC_OPTIONS: tuple[ConfigOption, ...] = (
|
||||
default="managed",
|
||||
env_var=_env_vars.RIPGREP_INSTALLER,
|
||||
),
|
||||
# --- Startup --------------------------------------------------------
|
||||
ConfigOption(
|
||||
key="startup.mode",
|
||||
group="Startup",
|
||||
summary="Default approval mode at launch ('manual' or 'dangerously-auto').",
|
||||
kind=OptionKind.STARTUP_MODE_DELEGATE,
|
||||
default="manual",
|
||||
toml_keys=("startup", "mode"),
|
||||
cli_flag="--auto-approve",
|
||||
),
|
||||
# --- Debug / Development -------------------------------------------
|
||||
ConfigOption(
|
||||
key="debug.enabled",
|
||||
|
||||
@@ -601,6 +601,27 @@ def _resolve_interpreter_enabled(args: argparse.Namespace) -> bool:
|
||||
return _resolve_enable_interpreter(args.interpreter, args.sandbox)
|
||||
|
||||
|
||||
def _resolve_auto_approve(args: argparse.Namespace) -> bool:
|
||||
"""Return whether tool calls should be auto-approved for these CLI args.
|
||||
|
||||
An explicit `-y`/`--auto-approve` wins; when the flag is omitted
|
||||
(`args.auto_approve is None`), the persistent `[startup].mode` config
|
||||
default decides — `dangerously-auto` enables auto-approval, anything else
|
||||
(including missing/invalid config) keeps human-in-the-loop approvals on.
|
||||
|
||||
Extracted from the `cli_main` body so it is unit-testable without
|
||||
constructing the full arg tree, matching `_resolve_interpreter_enabled`.
|
||||
"""
|
||||
if args.auto_approve is not None:
|
||||
return args.auto_approve
|
||||
from deepagents_code.model_config import (
|
||||
STARTUP_MODE_DANGEROUSLY_AUTO,
|
||||
load_startup_mode,
|
||||
)
|
||||
|
||||
return load_startup_mode() == STARTUP_MODE_DANGEROUSLY_AUTO
|
||||
|
||||
|
||||
def _warn_if_interpreter_disabled_by_sandbox(args: argparse.Namespace) -> None:
|
||||
"""Warn that a remote sandbox suppressed the otherwise-default interpreter.
|
||||
|
||||
@@ -1616,11 +1637,14 @@ def parse_args() -> argparse.Namespace:
|
||||
"-y",
|
||||
"--auto-approve",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help=(
|
||||
"Auto-approve all tool calls without prompting "
|
||||
"(disables human-in-the-loop). Affected tools: shell "
|
||||
"execution, file writes/edits, web search, and URL fetch. "
|
||||
"Use with caution — the agent can execute arbitrary commands."
|
||||
"Use with caution — the agent can execute arbitrary commands. "
|
||||
"When omitted, the launch default comes from [startup].mode in "
|
||||
"~/.deepagents/config.toml ('manual' or 'dangerously-auto')."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -3644,10 +3668,14 @@ def cli_main() -> None:
|
||||
# advisory as a startup notification instead (see
|
||||
# `DeepAgentsApp._notify_interpreter_tools_without_interpreter`).
|
||||
|
||||
# An explicit -y/--auto-approve wins; otherwise the persistent
|
||||
# [startup].mode config default decides the launch mode.
|
||||
auto_approve = _resolve_auto_approve(args)
|
||||
|
||||
result = asyncio.run(
|
||||
run_textual_cli_async(
|
||||
assistant_id=assistant_id,
|
||||
auto_approve=args.auto_approve,
|
||||
auto_approve=auto_approve,
|
||||
sandbox_type=args.sandbox,
|
||||
sandbox_id=args.sandbox_id,
|
||||
sandbox_snapshot_name=args.sandbox_snapshot_name,
|
||||
|
||||
@@ -3554,6 +3554,57 @@ def load_thread_sort_order(config_path: Path | None = None) -> str:
|
||||
return "updated_at"
|
||||
|
||||
|
||||
STARTUP_MODE_MANUAL = "manual"
|
||||
"""Startup approval mode that keeps human-in-the-loop approvals enabled."""
|
||||
|
||||
STARTUP_MODE_DANGEROUSLY_AUTO = "dangerously-auto"
|
||||
"""Startup approval mode that auto-approves gated tool calls at launch."""
|
||||
|
||||
VALID_STARTUP_MODES = frozenset({STARTUP_MODE_MANUAL, STARTUP_MODE_DANGEROUSLY_AUTO})
|
||||
"""Accepted values for the `[startup].mode` config option."""
|
||||
|
||||
DEFAULT_STARTUP_MODE = STARTUP_MODE_MANUAL
|
||||
"""Fallback startup mode when `[startup].mode` is missing, unreadable, or invalid."""
|
||||
|
||||
|
||||
def load_startup_mode(config_path: Path | None = None) -> str:
|
||||
"""Load the default startup approval mode from config.toml.
|
||||
|
||||
Reads `[startup].mode`, which controls whether the interactive TUI launches
|
||||
with human-in-the-loop approvals enabled (`manual`) or auto-approved
|
||||
(`dangerously-auto`). The explicit `-y`/`--auto-approve` flag overrides this.
|
||||
|
||||
Args:
|
||||
config_path: Path to config file.
|
||||
|
||||
Returns:
|
||||
`"manual"` or `"dangerously-auto"`; falls back to `"manual"` when unset,
|
||||
unreadable, or invalid.
|
||||
"""
|
||||
if config_path is None:
|
||||
config_path = DEFAULT_CONFIG_PATH
|
||||
try:
|
||||
if not config_path.exists():
|
||||
return DEFAULT_STARTUP_MODE
|
||||
with config_path.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
startup = data.get("startup")
|
||||
value = startup.get("mode") if isinstance(startup, dict) else None
|
||||
# `value` may be any TOML type; guard against non-strings (e.g. an
|
||||
# array or table) before the frozenset membership test, which would
|
||||
# otherwise raise `TypeError: unhashable type` and crash startup.
|
||||
if isinstance(value, str) and value in VALID_STARTUP_MODES:
|
||||
return value
|
||||
if value is not None:
|
||||
logger.warning(
|
||||
"Ignoring [startup].mode=%r (expected 'manual' or 'dangerously-auto')",
|
||||
value,
|
||||
)
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
logger.debug("Could not read startup mode config", exc_info=True)
|
||||
return DEFAULT_STARTUP_MODE
|
||||
|
||||
|
||||
def save_thread_sort_order(sort_order: str, config_path: Path | None = None) -> bool:
|
||||
"""Save the sort order preference for the thread selector.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ from deepagents_code.config_manifest import (
|
||||
resolve_interpreter_kwargs,
|
||||
resolve_scalar,
|
||||
)
|
||||
from deepagents_code.model_config import PROVIDER_API_KEY_ENV
|
||||
from deepagents_code.model_config import DEFAULT_STARTUP_MODE, PROVIDER_API_KEY_ENV
|
||||
|
||||
# Most unit tests set `DEEPAGENTS_CODE_NO_UPDATE_CHECK=1` to avoid accidental
|
||||
# PyPI/DNS work. This module checks whether update settings came from the env,
|
||||
@@ -1448,6 +1448,42 @@ def test_resolve_toml_str_success_and_type_mismatch(caplog) -> None:
|
||||
assert any("sort_order" in r.getMessage() for r in caplog.records)
|
||||
|
||||
|
||||
def test_startup_mode_option_definition() -> None:
|
||||
"""`startup.mode` is config.toml-only and uses runtime mode validation."""
|
||||
opt = get_option("startup.mode")
|
||||
assert opt is not None
|
||||
assert opt.group == "Startup"
|
||||
assert opt.kind is OptionKind.STARTUP_MODE_DELEGATE
|
||||
assert opt.default == DEFAULT_STARTUP_MODE
|
||||
assert opt.toml_keys == ("startup", "mode")
|
||||
assert opt.env_var is None
|
||||
|
||||
|
||||
def test_resolve_startup_mode_from_toml(caplog) -> None:
|
||||
"""`startup.mode` resolves only valid runtime modes from config.toml."""
|
||||
import logging
|
||||
|
||||
opt = get_option("startup.mode")
|
||||
assert opt is not None
|
||||
assert resolve_scalar(opt, toml_data={"startup": {"mode": "dangerously-auto"}}) == (
|
||||
"dangerously-auto",
|
||||
"config.toml",
|
||||
)
|
||||
with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"):
|
||||
value, source = resolve_scalar(opt, toml_data={"startup": {"mode": "yolo"}})
|
||||
assert (value, source) == (DEFAULT_STARTUP_MODE, "default")
|
||||
assert any("[startup].mode='yolo'" in r.getMessage() for r in caplog.records)
|
||||
|
||||
for raw in (["manual"], {"name": "manual"}):
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"):
|
||||
value, source = resolve_scalar(opt, toml_data={"startup": {"mode": raw}})
|
||||
assert (value, source) == (DEFAULT_STARTUP_MODE, "default")
|
||||
assert any("[startup].mode" in r.getMessage() for r in caplog.records)
|
||||
|
||||
assert resolve_scalar(opt, toml_data={}) == (DEFAULT_STARTUP_MODE, "default")
|
||||
|
||||
|
||||
def test_resolve_toml_float_success_non_bool() -> None:
|
||||
"""A FLOAT option reads a real number from TOML and coerces an int to float."""
|
||||
opt = get_option("interpreter.timeout_seconds")
|
||||
|
||||
@@ -118,6 +118,60 @@ def test_shell_allow_list_combined_with_other_args(mock_argv: MockArgvType) -> N
|
||||
assert parsed_args.auto_approve is True
|
||||
|
||||
|
||||
class TestAutoApproveArgument:
|
||||
"""Tests for -y / --auto-approve parsing and its config.toml default."""
|
||||
|
||||
def test_flag_sets_true(self, mock_argv: MockArgvType) -> None:
|
||||
"""Passing -y sets auto_approve to True."""
|
||||
with mock_argv("-y"):
|
||||
assert parse_args().auto_approve is True
|
||||
|
||||
def test_long_flag_sets_true(self, mock_argv: MockArgvType) -> None:
|
||||
"""Passing --auto-approve sets auto_approve to True."""
|
||||
with mock_argv("--auto-approve"):
|
||||
assert parse_args().auto_approve is True
|
||||
|
||||
def test_omitted_is_none(self, mock_argv: MockArgvType) -> None:
|
||||
"""Omitting the flag leaves auto_approve as None so config.toml decides."""
|
||||
with mock_argv():
|
||||
assert parse_args().auto_approve is None
|
||||
|
||||
|
||||
class TestResolveAutoApprove:
|
||||
"""Tests for `_resolve_auto_approve` (flag vs. `[startup].mode` precedence)."""
|
||||
|
||||
def test_explicit_flag_wins_without_consulting_config(self) -> None:
|
||||
"""An explicit -y (True) resolves True and never reads config."""
|
||||
from deepagents_code.main import _resolve_auto_approve
|
||||
|
||||
args = argparse.Namespace(auto_approve=True)
|
||||
with patch("deepagents_code.model_config.load_startup_mode") as mock_load:
|
||||
assert _resolve_auto_approve(args) is True
|
||||
mock_load.assert_not_called()
|
||||
|
||||
def test_omitted_flag_manual_config_resolves_false(self) -> None:
|
||||
"""No flag + `[startup].mode = manual` keeps approvals on (False)."""
|
||||
from deepagents_code.main import _resolve_auto_approve
|
||||
|
||||
args = argparse.Namespace(auto_approve=None)
|
||||
with patch(
|
||||
"deepagents_code.model_config.load_startup_mode",
|
||||
return_value="manual",
|
||||
):
|
||||
assert _resolve_auto_approve(args) is False
|
||||
|
||||
def test_omitted_flag_dangerously_auto_config_resolves_true(self) -> None:
|
||||
"""No flag + `[startup].mode = dangerously-auto` auto-approves (True)."""
|
||||
from deepagents_code.main import _resolve_auto_approve
|
||||
|
||||
args = argparse.Namespace(auto_approve=None)
|
||||
with patch(
|
||||
"deepagents_code.model_config.load_startup_mode",
|
||||
return_value="dangerously-auto",
|
||||
):
|
||||
assert _resolve_auto_approve(args) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input_str", "expected"),
|
||||
[
|
||||
|
||||
@@ -12,11 +12,14 @@ import pytest
|
||||
|
||||
from deepagents_code import model_config
|
||||
from deepagents_code.model_config import (
|
||||
DEFAULT_STARTUP_MODE,
|
||||
IMPLICIT_AUTH_PROVIDERS,
|
||||
NO_AUTH_REQUIRED_PROVIDERS,
|
||||
PROVIDER_API_KEY_ENV,
|
||||
PROVIDER_BASE_URL_ENV,
|
||||
RETRY_PARAM_BY_PROVIDER,
|
||||
STARTUP_MODE_DANGEROUSLY_AUTO,
|
||||
STARTUP_MODE_MANUAL,
|
||||
THREAD_COLUMN_DEFAULTS,
|
||||
McpServerTrustLists,
|
||||
ModelConfig,
|
||||
@@ -43,6 +46,7 @@ from deepagents_code.model_config import (
|
||||
load_mcp_server_trust_lists,
|
||||
load_recent_agent,
|
||||
load_recent_models,
|
||||
load_startup_mode,
|
||||
load_thread_columns,
|
||||
save_default_agent,
|
||||
save_recent_agent,
|
||||
@@ -5881,3 +5885,66 @@ enabled = false
|
||||
assert not any(
|
||||
spec.startswith(f"{model_config.CODEX_PROVIDER}:") for spec in profiles
|
||||
)
|
||||
|
||||
|
||||
class TestLoadStartupMode:
|
||||
"""Tests for `load_startup_mode` reading `[startup].mode` from config.toml."""
|
||||
|
||||
def test_missing_file_returns_default(self, tmp_path: Path) -> None:
|
||||
"""A nonexistent config file falls back to the default mode."""
|
||||
assert load_startup_mode(tmp_path / "missing.toml") == DEFAULT_STARTUP_MODE
|
||||
assert DEFAULT_STARTUP_MODE == STARTUP_MODE_MANUAL
|
||||
|
||||
def test_unset_option_returns_default(self, tmp_path: Path) -> None:
|
||||
"""A config file without `[startup].mode` falls back to the default."""
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text("[threads]\nsort_order = 'created_at'\n")
|
||||
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
|
||||
|
||||
def test_explicit_manual(self, tmp_path: Path) -> None:
|
||||
"""`mode = 'manual'` is returned verbatim."""
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text("[startup]\nmode = 'manual'\n")
|
||||
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
|
||||
|
||||
def test_explicit_dangerously_auto(self, tmp_path: Path) -> None:
|
||||
"""`mode = 'dangerously-auto'` is returned verbatim."""
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text("[startup]\nmode = 'dangerously-auto'\n")
|
||||
assert load_startup_mode(config) == STARTUP_MODE_DANGEROUSLY_AUTO
|
||||
|
||||
def test_invalid_value_returns_default(
|
||||
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An unrecognized mode logs a warning and falls back to the default."""
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text("[startup]\nmode = 'yolo'\n")
|
||||
with caplog.at_level(logging.WARNING, logger="deepagents_code.model_config"):
|
||||
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
|
||||
assert any("startup" in r.getMessage().lower() for r in caplog.records)
|
||||
|
||||
def test_malformed_startup_table_returns_default(self, tmp_path: Path) -> None:
|
||||
"""A non-table `startup` value does not crash and falls back."""
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text("startup = 'oops'\n")
|
||||
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
|
||||
|
||||
def test_non_scalar_mode_returns_default(self, tmp_path: Path) -> None:
|
||||
"""A non-string `mode` (e.g. array) falls back instead of raising.
|
||||
|
||||
`value in VALID_STARTUP_MODES` (a frozenset) would raise `TypeError:
|
||||
unhashable type` on a list/dict; the isinstance guard must prevent that.
|
||||
"""
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text("[startup]\nmode = ['dangerously-auto']\n")
|
||||
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
|
||||
|
||||
def test_unparseable_file_returns_default(self, tmp_path: Path) -> None:
|
||||
"""Syntactically invalid TOML is swallowed and falls back to default.
|
||||
|
||||
Exercises the `except (OSError, tomllib.TOMLDecodeError)` branch, which
|
||||
must fail closed (to `manual`) rather than propagate and abort startup.
|
||||
"""
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text("this is not valid toml [[[\n")
|
||||
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
|
||||
|
||||
Reference in New Issue
Block a user