Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions libs/code/deepagents_code/_env_vars.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,15 @@
upgrade/restart loop. Set and read internally across `os.execv`.
"""

RESUME_TERM_PROGRAM = "DEEPAGENTS_CODE_RESUME_TERM_PROGRAM"
"""Include launch-time `TERM_PROGRAM` in teardown resume commands.

Disabled by default and enabled by default in experimental or debug mode. An
explicit boolean (`1`/`true`/`yes`/`on`, or `0`/`false`/`no`/`off`) overrides
that mode-dependent default, as does an empty value, which reads as false. Also
settable as `[features].resume_term_program` in config.toml.
"""

RIPGREP_INSTALLER = "DEEPAGENTS_CODE_RIPGREP_INSTALLER"
"""Select how ripgrep is provisioned: `managed` (default) or `system`.

Expand Down
61 changes: 54 additions & 7 deletions libs/code/deepagents_code/config_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ class OptionKind(Enum):
"""How an option's raw env/TOML value is coerced to a typed value.

All kinds flow through `resolve_scalar`. The scalar kinds (`BOOL`,
`BOOL_PRESENCE`, `INT`, `FLOAT`, `STR`) are coerced inline by
`_coerce_env`/`_coerce_toml`. `LOG_LEVEL_DELEGATE`, `SHELL_LIST_DELEGATE`,
`BOOL_MODE_DEFAULT`, `BOOL_PRESENCE`, `INT`, `FLOAT`, `STR`) are coerced
inline by `_coerce_env`/`_coerce_toml`. `LOG_LEVEL_DELEGATE`, `SHELL_LIST_DELEGATE`,
`SKILLS_DIRS_DELEGATE`, `PTC_DELEGATE`, and `STARTUP_MODE_DELEGATE` defer to
bespoke parsers (their semantics — dynamic debug fallback, colon-split Path
resolution, comma + `recommended`/`all` sentinels, and the PTC/startup-mode
Expand All @@ -153,6 +153,11 @@ class OptionKind(Enum):
"""Recognized truthy (`1`/`true`/`yes`/`on`) or falsy (`0`/`false`/`no`/`off`)
tokens; an unrecognized value is logged and skipped to the next layer."""

BOOL_MODE_DEFAULT = "bool_mode_default"
"""Same token handling as `BOOL`, but with no static default: when no env or
TOML value applies, `resolve_scalar` derives the default from debug or
experimental mode. Declaring a `default` is rejected at construction."""

BOOL_PRESENCE = "bool_presence"
"""Any non-empty env value enables the flag (e.g. debug injectors)."""

Expand Down Expand Up @@ -189,6 +194,7 @@ class OptionKind(Enum):

_KIND_TYPE_LABEL: dict[OptionKind, str] = {
OptionKind.BOOL: "bool",
OptionKind.BOOL_MODE_DEFAULT: "bool",
OptionKind.BOOL_PRESENCE: "bool",
OptionKind.INT: "int",
OptionKind.FLOAT: "float",
Expand All @@ -213,6 +219,8 @@ class OptionKind(Enum):
# Python types accepted for a `ConfigOption.default` of each scalar kind,
# enforced by `ConfigOption.__post_init__`. Delegate kinds accept their parser's
# output shape and are validated by those parsers, so they are omitted here.
# `BOOL_MODE_DEFAULT` is omitted for the opposite reason: it must not declare a
# default at all, so there is no value here to type-check.
_KIND_DEFAULT_TYPES: dict[OptionKind, tuple[type, ...]] = {
OptionKind.BOOL: (bool,),
OptionKind.BOOL_PRESENCE: (bool,),
Expand Down Expand Up @@ -336,7 +344,10 @@ def __post_init__(self) -> None:
f"strings, got {self.fallback_env_vars!r}"
)
raise TypeError(msg)
if self.empty_env_is_false and self.kind is not OptionKind.BOOL:
if self.empty_env_is_false and self.kind not in {
OptionKind.BOOL,
OptionKind.BOOL_MODE_DEFAULT,
}:
msg = f"{self.key}: empty_env_is_false requires a bool option kind"
raise TypeError(msg)

Expand All @@ -354,6 +365,16 @@ def __post_init__(self) -> None:
if self.kind is OptionKind.STRUCTURED:
msg = f"{self.key}: STRUCTURED options must not declare a default"
raise TypeError(msg)
if self.kind is OptionKind.BOOL_MODE_DEFAULT:
# `resolve_scalar` computes this kind's default from debug/experimental
# mode and returns before reading `default`, so a declared value would
# be dead -- yet `dcode config` still renders it, advertising a default
# that contradicts the real one.
msg = (
f"{self.key}: BOOL_MODE_DEFAULT options must not declare a "
"default; the default follows debug/experimental mode"
)
raise TypeError(msg)
if self.invert_toml_bool:
self._validate_invert_toml_bool()
expected = _KIND_DEFAULT_TYPES.get(self.kind)
Expand All @@ -377,7 +398,11 @@ def _validate_invert_toml_bool(self) -> None:
Raises:
TypeError: When the marker is used without a boolean TOML source.
"""
if self.kind not in {OptionKind.BOOL, OptionKind.BOOL_PRESENCE}:
if self.kind not in {
OptionKind.BOOL,
OptionKind.BOOL_MODE_DEFAULT,
OptionKind.BOOL_PRESENCE,
}:
msg = f"{self.key}: invert_toml_bool requires a boolean option kind"
raise TypeError(msg)
if self.toml_keys is None:
Expand Down Expand Up @@ -459,7 +484,7 @@ def _coerce_env(option: ConfigOption, raw: str, name: str) -> object:
The typed value, or `_INVALID` when the raw value cannot be coerced.
"""
kind = option.kind
if kind is OptionKind.BOOL:
if kind in {OptionKind.BOOL, OptionKind.BOOL_MODE_DEFAULT}:
classified = classify_env_bool(raw)
if classified is None:
# Unrecognized boolean token: log and fall through like every other
Expand Down Expand Up @@ -557,7 +582,11 @@ def _coerce_toml(option: ConfigOption, raw: object) -> object:
kind = option.kind
label = option.toml_path or option.key

if kind in {OptionKind.BOOL, OptionKind.BOOL_PRESENCE}:
if kind in {
OptionKind.BOOL,
OptionKind.BOOL_MODE_DEFAULT,
OptionKind.BOOL_PRESENCE,
}:
if isinstance(raw, bool):
return not raw if option.invert_toml_bool else raw
elif kind is OptionKind.INT:
Expand Down Expand Up @@ -684,7 +713,8 @@ def resolve_scalar(

Resolution order is: the prefixed primary `env_var`, then each
`fallback_env_vars` name in declaration order, then `config.toml`, then the
typed `default`.
typed `default` -- or, for `BOOL_MODE_DEFAULT`, a default derived from debug
or experimental mode rather than from `option.default`.

Returns:
`(value, source)`, where `source` is `env (<name>)`, `config.toml`, or
Expand Down Expand Up @@ -731,6 +761,11 @@ def resolve_scalar(
if value is not _INVALID:
return value, "config.toml"

if option.kind is OptionKind.BOOL_MODE_DEFAULT:
from deepagents_code._env_vars import DEBUG, EXPERIMENTAL, is_env_truthy

return is_env_truthy(DEBUG) or is_env_truthy(EXPERIMENTAL), "default"

if option.kind is OptionKind.LOG_LEVEL_DELEGATE:
from deepagents_code._env_vars import DEBUG, is_env_truthy

Expand Down Expand Up @@ -1431,6 +1466,18 @@ def _credential_options() -> tuple[ConfigOption, ...]:
default=False,
env_var=_env_vars.EXPERIMENTAL,
),
ConfigOption(
key="features.resume_term_program",
group="Tools",
summary=(
"Include launch-time TERM_PROGRAM in resume hints; defaults on in "
"experimental or debug mode."
),
kind=OptionKind.BOOL_MODE_DEFAULT,
env_var=_env_vars.RESUME_TERM_PROGRAM,
empty_env_is_false=True,
toml_keys=("features", "resume_term_program"),
),
ConfigOption(
key="events.external_socket",
group="Tools",
Expand Down
65 changes: 43 additions & 22 deletions libs/code/deepagents_code/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,25 +266,42 @@ def _should_check_teardown_thread(


def _resume_term_program() -> str | None:
"""Return a `TERM_PROGRAM` value safe to echo inside the resume hint.
"""Return the `TERM_PROGRAM` value to echo in the resume hint, if any.

The value is read from `LAUNCH_TERM_PROGRAM` — the snapshot `cli_main`
takes at process entry — rather than live `TERM_PROGRAM`, so only a value
the launch environment supplied (inline prefix, terminal export, or shell
alias) is echoed back. A `TERM_PROGRAM` that appears later, from a project
or global `.env` file, never reaches the hint.
Gated on `features.resume_term_program` (off unless the user opts in, on by
default in debug or experimental mode), so this reads `config.toml` from
disk. The value comes from `LAUNCH_TERM_PROGRAM` -- the snapshot `cli_main`
takes at process entry -- so a `TERM_PROGRAM` that only appears later, from
a project or global `.env` file, never reaches the hint.

Returns:
The launch-time value when it is set and fully printable, else `None`.
The printable launch-time value when the feature is enabled, else `None`.
A value carrying control characters is dropped rather than stripped:
stripping would both write raw escape sequences into teardown output
and name a terminal the environment never actually contained. Native
Windows shells also return `None`: VS Code and WezTerm set
`TERM_PROGRAM` on every platform, so its presence under `win32` does
not imply a POSIX shell, and the `VAR=value` prefix would be executed
as a command by `cmd.exe`/PowerShell. POSIX markers (`SHELL` from
git-bash/MSYS, `MSYSTEM`, `WSL_DISTRO_NAME`) restore the prefix there.
stripping would both write raw escape sequences into teardown output and
name a terminal the environment never actually contained. Native Windows
shells also return `None` because they cannot parse the POSIX
`VAR=value` prefix. POSIX markers (`SHELL` from git-bash/MSYS,
`MSYSTEM`, `WSL_DISTRO_NAME`) restore the prefix there.
"""
from deepagents_code.config_manifest import (
get_option,
load_config_toml,
resolve_scalar,
)

option = get_option("features.resume_term_program")
if option is None:
# Unreachable unless the manifest key is renamed without updating this
# literal; log so that mismatch surfaces instead of silently defaulting.
logger.warning(
"Unknown config option %r; omitting TERM_PROGRAM from the resume hint",
"features.resume_term_program",
)
return None
enabled, _ = resolve_scalar(option, toml_data=load_config_toml())
if not enabled:
return None

raw = os.environ.get(LAUNCH_TERM_PROGRAM, "").strip()
if not raw or not raw.isprintable():
return None
Expand Down Expand Up @@ -354,14 +371,18 @@ def _render_teardown_thread_hints(
resume_command = shlex.join([invoked_name(), "-r", str(thread_id)])
# A shell alias that exports `TERM_PROGRAM` (to select a theme, say) is
# invisible to `invoked_name`, since an alias does not change `argv[0]`, so
# the bare command would resume without it. Carry the launch-time value as
# an env prefix to keep the line pasteable as-is; the launch snapshot (not
# the live variable) is what keeps a `.env`-supplied `TERM_PROGRAM` out of
# the hint. The prefix uses POSIX syntax, so `_resume_term_program`
# withholds it on native Windows, where terminals (VS Code, WezTerm) set
# the variable even under `cmd.exe`/PowerShell and those shells cannot
# parse a `VAR=value` command prefix.
term_program = _resume_term_program()
# the bare command would resume without it. Carrying the launch-time value
# as an env prefix keeps the line pasteable as-is. Guarded because this
# reads `config.toml`: unlike the rest of this function, it can raise, and
# an exception here would replace whatever is already unwinding.
try:
term_program = _resume_term_program()
except Exception:
logger.debug(
"Could not resolve resume TERM_PROGRAM on teardown",
exc_info=True,
)
term_program = None
if term_program is not None:
resume_command = f"TERM_PROGRAM={shlex.quote(term_program)} {resume_command}"
console.print(Text(resume_command, style="cyan"))
Expand Down
103 changes: 103 additions & 0 deletions libs/code/tests/unit_tests/test_config_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,109 @@ def test_invalid_goal_auto_accept_env_falls_through(
assert resolve_scalar(option, toml_data=toml_data) == expected


@pytest.mark.parametrize(
("mode", "expected"),
[(None, False), (_env_vars.DEBUG, True), (_env_vars.EXPERIMENTAL, True)],
)
def test_resume_term_program_resolves_mode_default(
monkeypatch: pytest.MonkeyPatch,
mode: str | None,
expected: bool,
) -> None:
"""The resume prefix defaults on only in experimental or debug mode."""
option = get_option("features.resume_term_program")
assert option is not None
monkeypatch.delenv(_env_vars.RESUME_TERM_PROGRAM, raising=False)
monkeypatch.delenv(_env_vars.DEBUG, raising=False)
monkeypatch.delenv(_env_vars.EXPERIMENTAL, raising=False)
if mode is not None:
monkeypatch.setenv(mode, "1")

assert resolve_scalar(option, toml_data={}) == (expected, "default")


@pytest.mark.parametrize(("raw", "expected"), [("1", True), ("0", False), ("", False)])
def test_resume_term_program_env_overrides_mode_default(
monkeypatch: pytest.MonkeyPatch,
raw: str,
expected: bool,
) -> None:
"""An explicit feature env value wins over mode and TOML values."""
option = get_option("features.resume_term_program")
assert option is not None
monkeypatch.setenv(_env_vars.DEBUG, "1")
monkeypatch.setenv(_env_vars.EXPERIMENTAL, "1")
monkeypatch.setenv(_env_vars.RESUME_TERM_PROGRAM, raw)

assert resolve_scalar(
option,
toml_data={"features": {"resume_term_program": not expected}},
) == (expected, f"env ({_env_vars.RESUME_TERM_PROGRAM})")


@pytest.mark.parametrize(
("configured", "mode", "expected"),
[
(True, None, True),
(False, _env_vars.DEBUG, False),
(False, _env_vars.EXPERIMENTAL, False),
],
)
def test_resume_term_program_toml_overrides_mode_default(
monkeypatch: pytest.MonkeyPatch,
configured: bool,
mode: str | None,
expected: bool,
) -> None:
"""An explicit config.toml value wins over the mode-dependent default."""
option = get_option("features.resume_term_program")
assert option is not None
monkeypatch.delenv(_env_vars.RESUME_TERM_PROGRAM, raising=False)
monkeypatch.delenv(_env_vars.DEBUG, raising=False)
monkeypatch.delenv(_env_vars.EXPERIMENTAL, raising=False)
if mode is not None:
monkeypatch.setenv(mode, "1")

assert resolve_scalar(
option,
toml_data={"features": {"resume_term_program": configured}},
) == (expected, "config.toml")


def test_resume_term_program_unrecognized_env_falls_through_to_mode_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A typo'd feature flag must not silently defeat debug mode."""
option = get_option("features.resume_term_program")
assert option is not None
monkeypatch.delenv(_env_vars.EXPERIMENTAL, raising=False)
monkeypatch.setenv(_env_vars.DEBUG, "1")
monkeypatch.setenv(_env_vars.RESUME_TERM_PROGRAM, "maybe")

assert resolve_scalar(option, toml_data={}) == (True, "default")


def test_bool_mode_default_rejects_declared_default() -> None:
"""A declared default is dead for this kind, so it is rejected up front.

`resolve_scalar` derives the default from debug/experimental mode and
returns before reading `default` -- but `dcode config` still renders the
declared value, advertising a default that contradicts the real one.
"""
option = get_option("features.resume_term_program")
assert option is not None
assert option.default is None

with pytest.raises(TypeError, match="must not declare a default"):
ConfigOption(
key="features.example",
group="Tools",
summary="Example.",
kind=OptionKind.BOOL_MODE_DEFAULT,
default=False,
)


def test_debug_log_level_resolves_dynamic_default(monkeypatch) -> None:
"""The effective log level follows debug mode when no level is explicit."""
option = get_option("debug.log_level")
Expand Down
Loading