From 0382fe6186792f5a0cc3d5b10da746758e347c22 Mon Sep 17 00:00:00 2001 From: Johannes du Plessis <51395795+johannes117@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:00:41 +0000 Subject: [PATCH] feat(code): make Hooks v2 generally available Co-authored-by: open-swe[bot] --- libs/code/deepagents_code/agent.py | 40 +++++++------------ libs/code/deepagents_code/app.py | 5 --- libs/code/deepagents_code/hooks/manager.py | 10 +---- libs/code/deepagents_code/main.py | 7 +--- .../tests/unit_tests/hooks/test_manager.py | 11 +---- .../unit_tests/hooks/test_server_lifecycle.py | 7 +--- .../code/tests/unit_tests/hooks/test_trust.py | 7 ---- .../unit_tests/plugins/test_plugin_hooks.py | 2 - libs/code/tests/unit_tests/test_agent.py | 7 +--- libs/code/tests/unit_tests/test_app.py | 17 +------- .../tests/unit_tests/test_non_interactive.py | 7 +--- 11 files changed, 24 insertions(+), 96 deletions(-) diff --git a/libs/code/deepagents_code/agent.py b/libs/code/deepagents_code/agent.py index 1082e2b85d7..4fb6751e4a2 100644 --- a/libs/code/deepagents_code/agent.py +++ b/libs/code/deepagents_code/agent.py @@ -2474,21 +2474,17 @@ def _subagent_cli_middleware( # Server-owned hooks must wrap subagent tools too; otherwise Pre/Post # ToolUse only fire on the parent graph. Disable Stop so finishing a # subagent does not emit the main-agent Stop event (SubagentStop still - # fires from the parent wrap around `task`). Hooks v2 stays off unless - # experimental mode is on. - from deepagents_code._env_vars import EXPERIMENTAL, is_env_truthy - - if is_env_truthy(EXPERIMENTAL): - from deepagents_code.hooks.server_middleware import ServerHooksMiddleware + # fires from the parent wrap around `task`). + from deepagents_code.hooks.server_middleware import ServerHooksMiddleware - hooks_cwd = Path(effective_cwd) if effective_cwd is not None else Path.cwd() - middleware.append( - ServerHooksMiddleware( - cwd=hooks_cwd, - emit_stop=False, - mcp_tools=mcp_tools, - ) + hooks_cwd = Path(effective_cwd) if effective_cwd is not None else Path.cwd() + middleware.append( + ServerHooksMiddleware( + cwd=hooks_cwd, + emit_stop=False, + mcp_tools=mcp_tools, ) + ) # Subagents share the on-disk filesystem backend and can edit the user # AGENTS.md, so they get the same managed onboarding-name block guard as # the main agent. Gated on memory because the block only exists when @@ -2883,19 +2879,13 @@ def _subagent_cli_middleware( agent_middleware.append(AsyncApprovalHITLMiddleware(resolved_interrupt_on)) # Server-owned Hooks v2 lifecycle events (Pre/Post tool, Stop, subagent). - # Mounted only in experimental mode; when mounted, also gated at runtime by - # `hooks_server_events` on the per-run context so idle sessions without - # configured handlers pay no interrupt round-trip. Appended after the HITL - # middleware so `PreToolUse` resolves before approval routing. - from deepagents_code._env_vars import EXPERIMENTAL, is_env_truthy + # Gated at runtime by `hooks_server_events` on the per-run context so idle + # sessions without configured handlers pay no interrupt round-trip. Appended + # after the HITL middleware so `PreToolUse` resolves before approval routing. + from deepagents_code.hooks.server_middleware import ServerHooksMiddleware - if is_env_truthy(EXPERIMENTAL): - from deepagents_code.hooks.server_middleware import ServerHooksMiddleware - - hooks_cwd = Path(effective_cwd) if effective_cwd is not None else Path.cwd() - agent_middleware.append( - ServerHooksMiddleware(cwd=hooks_cwd, mcp_tools=mcp_tools) - ) + hooks_cwd = Path(effective_cwd) if effective_cwd is not None else Path.cwd() + agent_middleware.append(ServerHooksMiddleware(cwd=hooks_cwd, mcp_tools=mcp_tools)) if fs_tools is not None: # `fs_tools` is an explicit allowlist here (`--allow-fs-tools all` and an diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index 1ef5c652b55..32f8c872684 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -4746,11 +4746,6 @@ async def _retarget_hooks_after_cwd_switch( immediately. In-session resumes defer activation until the outgoing runtime has received `SessionEnd`. """ - from deepagents_code._env_vars import EXPERIMENTAL, is_env_truthy - - if not is_env_truthy(EXPERIMENTAL): - return - from deepagents_code.hooks.loading import project_hooks_path from deepagents_code.hooks.trust import project_root_for, trust_project_hooks from deepagents_code.tui.widgets.cwd_switch import HookTrustScreen diff --git a/libs/code/deepagents_code/hooks/manager.py b/libs/code/deepagents_code/hooks/manager.py index 559f7b7e562..7308d0ff7df 100644 --- a/libs/code/deepagents_code/hooks/manager.py +++ b/libs/code/deepagents_code/hooks/manager.py @@ -135,10 +135,6 @@ def create( ) -> HooksManager: """Load hook configuration and return a ready manager. - Hooks v2 is in progress, so it stays off unless - `DEEPAGENTS_CODE_EXPERIMENTAL` is truthy; without it the manager is - inert and no hook (client- or server-owned) fires. - Never raises: a failed load yields an inert manager whose lifecycle methods are all no-ops. @@ -614,16 +610,12 @@ def _load_runtime( plugins: Already-discovered plugins, or `None` to discover them. Returns: - The loaded runtime, `None` when configuration could not be loaded, and - `None` whenever `DEEPAGENTS_CODE_EXPERIMENTAL` is not truthy. + The loaded runtime, or `None` when configuration could not be loaded. """ - from deepagents_code._env_vars import EXPERIMENTAL, is_env_truthy from deepagents_code.hooks.runtime import HooksRuntime from deepagents_code.plugins.adapters.hooks import discover_plugin_hook_sources from deepagents_code.project_utils import ProjectContext - if not is_env_truthy(EXPERIMENTAL): - return None try: project_context = ProjectContext.from_user_cwd(cwd) plugin_sources, plugin_diagnostics = discover_plugin_hook_sources( diff --git a/libs/code/deepagents_code/main.py b/libs/code/deepagents_code/main.py index 5b6e7a86235..f03ae8e9c86 100644 --- a/libs/code/deepagents_code/main.py +++ b/libs/code/deepagents_code/main.py @@ -3714,14 +3714,11 @@ def _check_project_hooks_trust( Returns: The trust policy to run the session under, `INTERRUPTED` when the user presses Ctrl+C, or `CANCELLED` when the user presses Esc or Ctrl+D to - abort startup. Nothing is trusted, and no prompt is shown, unless - `DEEPAGENTS_CODE_EXPERIMENTAL` is truthy, since hooks stay off without - it. + abort startup. """ from rich.console import Console from rich.text import Text - from deepagents_code._env_vars import EXPERIMENTAL, is_env_truthy from deepagents_code.hooks.loading import project_hooks_path from deepagents_code.hooks.trust import ( WorkspaceTrust, @@ -3730,8 +3727,6 @@ def _check_project_hooks_trust( ) from deepagents_code.project_utils import ProjectContext - if not is_env_truthy(EXPERIMENTAL): - return WorkspaceTrust.none() try: context = ProjectContext.from_user_cwd(Path.cwd()) project_root = context.project_root or context.user_cwd diff --git a/libs/code/tests/unit_tests/hooks/test_manager.py b/libs/code/tests/unit_tests/hooks/test_manager.py index 5faacf54848..95bb08ff0b2 100644 --- a/libs/code/tests/unit_tests/hooks/test_manager.py +++ b/libs/code/tests/unit_tests/hooks/test_manager.py @@ -5,9 +5,6 @@ import json from typing import TYPE_CHECKING -import pytest - -from deepagents_code._env_vars import EXPERIMENTAL from deepagents_code.approval_mode import ApprovalMode from deepagents_code.hooks.manager import HookSessionIdentity, HooksManager from deepagents_code.hooks.models.domain import PermissionEffect @@ -15,13 +12,9 @@ if TYPE_CHECKING: from pathlib import Path - from deepagents_code.hooks.presenter import HookNoticeSeverity, HookPresenter - + import pytest -@pytest.fixture(autouse=True) -def _enable_hooks_v2(monkeypatch: pytest.MonkeyPatch) -> None: - """Hooks v2 only loads in experimental mode, which these tests exercise.""" - monkeypatch.setenv(EXPERIMENTAL, "1") + from deepagents_code.hooks.presenter import HookNoticeSeverity, HookPresenter def _write_project_hooks(root: Path) -> Path: diff --git a/libs/code/tests/unit_tests/hooks/test_server_lifecycle.py b/libs/code/tests/unit_tests/hooks/test_server_lifecycle.py index 05babf0de5f..515a190c228 100644 --- a/libs/code/tests/unit_tests/hooks/test_server_lifecycle.py +++ b/libs/code/tests/unit_tests/hooks/test_server_lifecycle.py @@ -911,12 +911,7 @@ def test_pre_tool_allow_bypasses_hitl_and_preserves_context( handler.assert_called_once_with(request) -def test_server_pre_tool_node_runs_before_stock_hitl( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - from deepagents_code._env_vars import EXPERIMENTAL - - monkeypatch.setenv(EXPERIMENTAL, "1") +def test_server_pre_tool_node_runs_before_stock_hitl(tmp_path: Path) -> None: model = GenericFakeChatModel(messages=iter([AIMessage(content="done")])) model.profile = {"max_input_tokens": 200000} graph, _backend = create_cli_agent( diff --git a/libs/code/tests/unit_tests/hooks/test_trust.py b/libs/code/tests/unit_tests/hooks/test_trust.py index 4a4c1a8ebc3..c7f0d0f0f0f 100644 --- a/libs/code/tests/unit_tests/hooks/test_trust.py +++ b/libs/code/tests/unit_tests/hooks/test_trust.py @@ -11,7 +11,6 @@ import pytest -from deepagents_code._env_vars import EXPERIMENTAL from deepagents_code.approval_mode import ApprovalMode from deepagents_code.hooks.manager import HookSessionIdentity, HooksManager from deepagents_code.hooks.models.domain import ( @@ -33,12 +32,6 @@ from deepagents_code.app import DeepAgentsApp -@pytest.fixture(autouse=True) -def _enable_hooks_v2(monkeypatch: pytest.MonkeyPatch) -> None: - """Hooks v2 only loads in experimental mode, which these tests exercise.""" - monkeypatch.setenv(EXPERIMENTAL, "1") - - def _write_project_hooks(root: Path, *, event: str = "Stop") -> Path: (root / ".git").mkdir(parents=True, exist_ok=True) hooks_dir = root / ".deepagents" diff --git a/libs/code/tests/unit_tests/plugins/test_plugin_hooks.py b/libs/code/tests/unit_tests/plugins/test_plugin_hooks.py index d5840600f62..de6311f2f7d 100644 --- a/libs/code/tests/unit_tests/plugins/test_plugin_hooks.py +++ b/libs/code/tests/unit_tests/plugins/test_plugin_hooks.py @@ -8,7 +8,6 @@ import pytest -from deepagents_code._env_vars import EXPERIMENTAL from deepagents_code.approval_mode import ApprovalMode from deepagents_code.hooks.manager import HookSessionIdentity, HooksManager from deepagents_code.hooks.models.domain import HookEvent, SessionStartCause @@ -39,7 +38,6 @@ def _stage_plugins( monkeypatch: pytest.MonkeyPatch, documents: Mapping[str, dict[str, object] | bytes], ) -> tuple[Path, Path]: - monkeypatch.setenv(EXPERIMENTAL, "1") user_dir = tmp_path / "config" user_dir.mkdir(parents=True, exist_ok=True) for module in ("model_config", "hooks.loading", "hooks.runtime"): diff --git a/libs/code/tests/unit_tests/test_agent.py b/libs/code/tests/unit_tests/test_agent.py index 07ffdb1a4c6..6d712cafb73 100644 --- a/libs/code/tests/unit_tests/test_agent.py +++ b/libs/code/tests/unit_tests/test_agent.py @@ -3544,7 +3544,7 @@ def test_adds_configurable_model_middleware_to_implicit_model_subagents( ), f"Unexpected shell middleware on subagent {name!r}" def test_subagent_middleware_combines_shell_configurable_model_and_cost( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + self, tmp_path: Path ) -> None: """Restrictive shell + implicit model should yield shell, model, and cost. @@ -3552,13 +3552,11 @@ def test_subagent_middleware_combines_shell_configurable_model_and_cost( must not gain `ConfigurableModelMiddleware`, which would let a runtime `/model` switch clobber the pinned model. """ - from deepagents_code._env_vars import EXPERIMENTAL from deepagents_code.agent import ShellAllowListMiddleware from deepagents_code.configurable_model import ConfigurableModelMiddleware from deepagents_code.cost_tracking import CostTrackingMiddleware from deepagents_code.hooks.server_middleware import ServerHooksMiddleware - monkeypatch.setenv(EXPERIMENTAL, "1") mock_settings = self._build_mock_settings(tmp_path) mock_agent = Mock() mock_agent.with_config.return_value = mock_agent @@ -4957,7 +4955,6 @@ def test_auto_classifier_model_defaults_to_inheriting(self, tmp_path: Path) -> N def test_single_hitl_slot_precedes_server_hooks( self, tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, *, auto_mode_enabled: bool, ) -> None: @@ -4969,10 +4966,8 @@ def test_single_hitl_slot_precedes_server_hooks( stay behind whichever one is installed so its `after_model` `PreToolUse` pass resolves before approval routing. """ - from deepagents_code._env_vars import EXPERIMENTAL from deepagents_code.hooks.server_middleware import ServerHooksMiddleware - monkeypatch.setenv(EXPERIMENTAL, "1") middleware = self._capture_middleware( tmp_path, auto_mode_enabled=auto_mode_enabled ) diff --git a/libs/code/tests/unit_tests/test_app.py b/libs/code/tests/unit_tests/test_app.py index 88bd3090336..7062895abb8 100644 --- a/libs/code/tests/unit_tests/test_app.py +++ b/libs/code/tests/unit_tests/test_app.py @@ -27660,15 +27660,9 @@ async def test_toggle_off_while_reconnecting_stages_manual(self) -> None: assert app._session_state.approval_mode is ApprovalMode.MANUAL assert app._approval_mode_blocked is False - async def test_session_init_keeps_mode_changed_during_construction( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - from deepagents_code._env_vars import EXPERIMENTAL + async def test_session_init_keeps_mode_changed_during_construction(self) -> None: from deepagents_code.approval_mode import ApprovalMode - # `HooksRuntime.create` is the construction seam probed below, and it - # only runs in experimental mode. - monkeypatch.setenv(EXPERIMENTAL, "1") app = DeepAgentsApp(approval_mode=ApprovalMode.MANUAL) def change_mode_during_construction(**_kwargs: object) -> None: @@ -27683,19 +27677,12 @@ def change_mode_during_construction(**_kwargs: object) -> None: assert app._session_state is not None assert app._session_state.approval_mode is ApprovalMode.AUTO - async def test_session_init_builds_one_state_for_concurrent_callers( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: + async def test_session_init_builds_one_state_for_concurrent_callers(self) -> None: """The startup worker and the inline startup fallback must not race. Idempotency rests on construction staying free of `await`; reintroducing one would let both callers pass the guard and build two states. """ - from deepagents_code._env_vars import EXPERIMENTAL - - # `HooksRuntime.create` is the construction seam counted below, and it - # only runs in experimental mode. - monkeypatch.setenv(EXPERIMENTAL, "1") app = DeepAgentsApp() creations = 0 diff --git a/libs/code/tests/unit_tests/test_non_interactive.py b/libs/code/tests/unit_tests/test_non_interactive.py index c62b023bbdf..98148e2ea96 100644 --- a/libs/code/tests/unit_tests/test_non_interactive.py +++ b/libs/code/tests/unit_tests/test_non_interactive.py @@ -19,7 +19,6 @@ from rich.style import Style from rich.text import Text -from deepagents_code._env_vars import EXPERIMENTAL from deepagents_code._tool_stream import ( TOOL_OUTPUT_TRUNCATION_MARKER, UNRENDERABLE_TOOL_OUTPUT, @@ -381,11 +380,8 @@ async def test_sandbox_type_passed_to_server(self) -> None: assert kwargs["profile_overrides"] == {"max_input_tokens": 32_000} assert kwargs["enable_interpreter"] is None - async def test_permission_hooks_override_headless_yolo_bypass( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: + async def test_permission_hooks_override_headless_yolo_bypass(self) -> None: """Permission hooks force client resolution while retaining YOLO context.""" - monkeypatch.setenv(EXPERIMENTAL, "1") runtime = MagicMock() runtime.configured_events.return_value = frozenset( {HookEvent.PERMISSION_REQUEST} @@ -1679,7 +1675,6 @@ async def test_run_agent_loop_trusts_project_hooks_when_opted_in( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """`--trust-project-hooks` loads repository hook handlers.""" - monkeypatch.setenv(EXPERIMENTAL, "1") monkeypatch.chdir(tmp_path) project_hooks = tmp_path / ".deepagents" project_hooks.mkdir()