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
40 changes: 25 additions & 15 deletions libs/code/deepagents_code/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2458,17 +2458,21 @@ 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`).
from deepagents_code.hooks.server_middleware import ServerHooksMiddleware
# 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

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,
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()
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
Expand Down Expand Up @@ -2842,13 +2846,19 @@ def _subagent_cli_middleware(
agent_middleware.append(AsyncApprovalHITLMiddleware(resolved_interrupt_on))

# Server-owned Hooks v2 lifecycle events (Pre/Post tool, Stop, subagent).
# 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
# 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

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 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)
)

if fs_tools is not None:
# `fs_tools` is an explicit allowlist here (`--allow-fs-tools all` and an
Expand Down
10 changes: 9 additions & 1 deletion libs/code/deepagents_code/hooks/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ 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.

Expand Down Expand Up @@ -543,10 +547,14 @@ def _load_runtime(cwd: Path, *, trust: WorkspaceTrust) -> HooksRuntime | None:
trust: Policy deciding whether project hooks may load.

Returns:
The loaded runtime, or `None` when configuration could not be loaded.
The loaded runtime, `None` when configuration could not be loaded, and
`None` whenever `DEEPAGENTS_CODE_EXPERIMENTAL` is not truthy.
"""
from deepagents_code._env_vars import EXPERIMENTAL, is_env_truthy
from deepagents_code.hooks.runtime import HooksRuntime

if not is_env_truthy(EXPERIMENTAL):
return None
try:
return HooksRuntime.create(cwd=cwd, workspace_trusted=trust.allows(cwd))
except Exception:
Expand Down
7 changes: 6 additions & 1 deletion libs/code/deepagents_code/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3590,11 +3590,14 @@ 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.
abort startup. Nothing is trusted, and no prompt is shown, unless
`DEEPAGENTS_CODE_EXPERIMENTAL` is truthy, since hooks stay off without
it.
"""
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,
Expand All @@ -3603,6 +3606,8 @@ 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
Expand Down
7 changes: 6 additions & 1 deletion libs/code/tests/unit_tests/hooks/test_server_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,12 @@ 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) -> None:
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")
model = GenericFakeChatModel(messages=iter([AIMessage(content="done")]))
model.profile = {"max_input_tokens": 200000}
graph, _backend = create_cli_agent(
Expand Down
7 changes: 7 additions & 0 deletions libs/code/tests/unit_tests/hooks/test_trust.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

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 (
Expand All @@ -30,6 +31,12 @@
from pathlib import Path


@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) -> Path:
(root / ".git").mkdir(parents=True)
hooks_dir = root / ".deepagents"
Expand Down
7 changes: 6 additions & 1 deletion libs/code/tests/unit_tests/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3517,18 +3517,20 @@ def test_adds_configurable_model_middleware_to_implicit_model_subagents(
), f"Unexpected shell middleware on subagent {name!r}"

def test_subagent_middleware_combines_shell_and_configurable_model(
self, tmp_path: Path
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Restrictive shell + implicit model should yield both middlewares.

Explicitly pinned subagents keep shell restriction but 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.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
Expand Down Expand Up @@ -4846,6 +4848,7 @@ def test_auto_mode_enabled_wires_middleware(self, tmp_path: Path) -> None:
def test_single_hitl_slot_precedes_server_hooks(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
*,
auto_mode_enabled: bool,
) -> None:
Expand All @@ -4857,8 +4860,10 @@ 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
)
Expand Down
17 changes: 15 additions & 2 deletions libs/code/tests/unit_tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -26240,9 +26240,15 @@ 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) -> None:
async def test_session_init_keeps_mode_changed_during_construction(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
from deepagents_code._env_vars import EXPERIMENTAL
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:
Expand All @@ -26257,12 +26263,19 @@ 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) -> None:
async def test_session_init_builds_one_state_for_concurrent_callers(
self, monkeypatch: pytest.MonkeyPatch
) -> 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

Expand Down
7 changes: 6 additions & 1 deletion libs/code/tests/unit_tests/test_non_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
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,
Expand Down Expand Up @@ -379,8 +380,11 @@ 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) -> None:
async def test_permission_hooks_override_headless_yolo_bypass(
self, monkeypatch: pytest.MonkeyPatch
) -> 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}
Expand Down Expand Up @@ -1676,6 +1680,7 @@ 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()
Expand Down