From 0944a437849be2742036ef3cec18ba2cde79f473 Mon Sep 17 00:00:00 2001 From: Mason Daugherty <61371264+mdrxy@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:07:31 +0000 Subject: [PATCH 1/4] feat(code): gate dropping `TodoListMiddleware` behind `DEEPAGENTS_CODE_EXPERIMENTAL` `create_deep_agent` always injects `TodoListMiddleware` (and its `write_todos` tool) with no parameter to disable it. When `DEEPAGENTS_CODE_EXPERIMENTAL` is truthy, dcode now threads a tool-less no-op middleware named `TodoListMiddleware` into the agent and subagent middleware lists; the SDK's name-based merge replaces the real middleware, dropping `write_todos`. Off by default, so behavior is unchanged unless the flag is set. Co-authored-by: open-swe[bot] --- libs/code/deepagents_code/_env_vars.py | 9 +++++ libs/code/deepagents_code/agent.py | 37 +++++++++++++++++++ .../tests/unit_tests/test_tool_catalog.py | 21 +++++++++++ 3 files changed, 67 insertions(+) diff --git a/libs/code/deepagents_code/_env_vars.py b/libs/code/deepagents_code/_env_vars.py index 95c606fbba..c7b4dcb191 100644 --- a/libs/code/deepagents_code/_env_vars.py +++ b/libs/code/deepagents_code/_env_vars.py @@ -129,6 +129,15 @@ is never silently emptied.) """ +EXPERIMENTAL = "DEEPAGENTS_CODE_EXPERIMENTAL" +"""Opt into experimental, unstable dcode behavior. + +Off by default. Parsed by `is_env_truthy`: accepts `1`, `true`, `yes`, `on` +(case-insensitive) as enabled. Currently gates dropping the SDK's +`TodoListMiddleware` (and its `write_todos` tool) from the agent and its +subagents. Behavior behind this flag may change or be removed without notice. +""" + EXTERNAL_EVENT_SOCKET = "DEEPAGENTS_CODE_EXTERNAL_EVENT_SOCKET" """Enable the local Unix-socket external event listener. diff --git a/libs/code/deepagents_code/agent.py b/libs/code/deepagents_code/agent.py index 942e0bc557..846aeaad8f 100644 --- a/libs/code/deepagents_code/agent.py +++ b/libs/code/deepagents_code/agent.py @@ -66,6 +66,7 @@ from deepagents_code import theme from deepagents_code._cli_context import CLIContextSchema from deepagents_code._constants import DEFAULT_AGENT_NAME +from deepagents_code._env_vars import EXPERIMENTAL, is_env_truthy from deepagents_code.config import ( _INHERITED_PYTHONPATH_ENV, _ShellAllowAll, @@ -101,6 +102,36 @@ REQUIRE_COMPACT_TOOL_APPROVAL: bool = True """When `True`, `compact_conversation` requires HITL approval like other gated tools.""" + +class _NoTodoListMiddleware(AgentMiddleware): + """No-op stand-in that drops the SDK's `TodoListMiddleware` by name. + + `create_deep_agent` always injects `TodoListMiddleware` and exposes no + parameter to disable it. Its `_apply_custom_middleware` merge replaces a + default middleware in place when a caller-supplied middleware shares its + `.name`, so threading this tool-less stand-in (which matches + `TodoListMiddleware.name`) into the agent and subagent middleware lists + removes the real middleware — and its `write_todos` tool — without touching + the SDK. Gated behind `DEEPAGENTS_CODE_EXPERIMENTAL`; see + `_todo_list_middleware_override`. + """ + + name = "TodoListMiddleware" + + +def _todo_list_middleware_override() -> list[AgentMiddleware]: + """Return the middleware needed to strip `TodoListMiddleware`, if enabled. + + Returns a single-element list with `_NoTodoListMiddleware` when the + experimental flag is set, else an empty list. Callers splice the result + into the middleware list they pass to `create_deep_agent` so the SDK's + name-based merge drops the real `TodoListMiddleware`. + """ + if is_env_truthy(EXPERIMENTAL): + return [_NoTodoListMiddleware()] + return [] + + _RUBRIC_GRADER_READ_FILE_PREFIX = "/large_tool_results/" _RUBRIC_GRADER_SYSTEM_PROMPT = ( GRADER_SYSTEM_PROMPT @@ -1506,6 +1537,9 @@ def create_cli_agent( def _subagent_cli_middleware(*, has_explicit_model: bool) -> list[AgentMiddleware]: middleware: list[AgentMiddleware] = [] + # Experimental: mirror the main agent and drop TodoListMiddleware / + # write_todos from subagent stacks too. No-op unless the flag is set. + middleware.extend(_todo_list_middleware_override()) if not has_explicit_model: middleware.append(ConfigurableModelMiddleware(persist_model_state=False)) if restrictive_shell_allow_list is not None: @@ -1567,6 +1601,9 @@ def _subagent_cli_middleware(*, has_explicit_model: bool) -> list[AgentMiddlewar # Build middleware stack based on enabled features agent_middleware: list[AgentMiddleware[Any, Any]] = [ ConfigurableModelMiddleware(), + # Experimental: drop the SDK's TodoListMiddleware / write_todos tool. + # No-op unless DEEPAGENTS_CODE_EXPERIMENTAL is truthy. + *_todo_list_middleware_override(), ] # Resume state: declares private checkpoint channels used on resume. diff --git a/libs/code/tests/unit_tests/test_tool_catalog.py b/libs/code/tests/unit_tests/test_tool_catalog.py index 7edde79754..6198b21e5d 100644 --- a/libs/code/tests/unit_tests/test_tool_catalog.py +++ b/libs/code/tests/unit_tests/test_tool_catalog.py @@ -8,6 +8,7 @@ import pytest +from deepagents_code._env_vars import EXPERIMENTAL from deepagents_code.config import Settings from deepagents_code.mcp_tools import MCPServerInfo, MCPToolInfo from deepagents_code.tool_catalog import ( @@ -119,6 +120,26 @@ def test_raises_when_compiled_agent_not_inspectable(self) -> None: collect_built_in_tools() +class TestExperimentalTodoRemoval: + """`DEEPAGENTS_CODE_EXPERIMENTAL` drops the SDK `write_todos` tool.""" + + def test_write_todos_bound_by_default( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv(EXPERIMENTAL, raising=False) + names = {tool.name for tool in collect_built_in_tools()} + assert "write_todos" in names + + def test_write_todos_removed_when_experimental( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv(EXPERIMENTAL, "1") + names = {tool.name for tool in collect_built_in_tools()} + assert "write_todos" not in names + # Only the todo tool is dropped; the rest of the core set stays bound. + assert names >= _CORE_BUILT_IN - {"write_todos"} + + class TestCollectToolsFromAgent: """Tests for inspecting the tool node of an already-running local graph.""" From f9be4afc87c234454a8a60819a864fca359c752a Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Mon, 13 Jul 2026 11:25:39 -0400 Subject: [PATCH 2/4] cr --- libs/code/deepagents_code/config_manifest.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/libs/code/deepagents_code/config_manifest.py b/libs/code/deepagents_code/config_manifest.py index 9e0c1ad56e..a59a4847fb 100644 --- a/libs/code/deepagents_code/config_manifest.py +++ b/libs/code/deepagents_code/config_manifest.py @@ -1036,6 +1036,14 @@ def _credential_options() -> tuple[ConfigOption, ...]: default=True, env_var=_env_vars.OLLAMA_DISCOVERY, ), + ConfigOption( + key="features.experimental", + group="Tools", + summary="Opt into experimental, unstable dcode behavior.", + kind=OptionKind.BOOL, + default=False, + env_var=_env_vars.EXPERIMENTAL, + ), ConfigOption( key="events.external_socket", group="Tools", From ecec73d974149f1936d0955d7eaed0e3c51c304b Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Mon, 13 Jul 2026 11:44:18 -0400 Subject: [PATCH 3/4] cr --- libs/code/deepagents_code/_env_vars.py | 9 +- libs/code/deepagents_code/agent.py | 33 +++- libs/code/deepagents_code/system_prompt.md | 13 +- libs/code/deepagents_code/todo_list_prompt.md | 12 ++ libs/code/tests/unit_tests/test_agent.py | 164 ++++++++++++++++++ 5 files changed, 206 insertions(+), 25 deletions(-) create mode 100644 libs/code/deepagents_code/todo_list_prompt.md diff --git a/libs/code/deepagents_code/_env_vars.py b/libs/code/deepagents_code/_env_vars.py index c7b4dcb191..fcb68c3568 100644 --- a/libs/code/deepagents_code/_env_vars.py +++ b/libs/code/deepagents_code/_env_vars.py @@ -132,10 +132,11 @@ EXPERIMENTAL = "DEEPAGENTS_CODE_EXPERIMENTAL" """Opt into experimental, unstable dcode behavior. -Off by default. Parsed by `is_env_truthy`: accepts `1`, `true`, `yes`, `on` -(case-insensitive) as enabled. Currently gates dropping the SDK's -`TodoListMiddleware` (and its `write_todos` tool) from the agent and its -subagents. Behavior behind this flag may change or be removed without notice. +Off by default; parsed by `is_env_truthy` (see there for the accepted truthy +values). Currently gates dropping the SDK's `TodoListMiddleware` (and its +`write_todos` tool) from the agent and its subagents, along with the matching +todo-list prompt guidance. Behavior behind this flag may change or be removed +without notice. """ EXTERNAL_EVENT_SOCKET = "DEEPAGENTS_CODE_EXTERNAL_EVENT_SOCKET" diff --git a/libs/code/deepagents_code/agent.py b/libs/code/deepagents_code/agent.py index 846aeaad8f..b1644ae56b 100644 --- a/libs/code/deepagents_code/agent.py +++ b/libs/code/deepagents_code/agent.py @@ -57,6 +57,7 @@ from deepagents_code.mcp_tools import MCPServerInfo from deepagents_code.output import OutputFormat +from langchain.agents.middleware import TodoListMiddleware from langchain.agents.middleware.types import AgentMiddleware from langchain.tools import ( ToolRuntime, # noqa: TC002 # LangChain inspects this annotation for runtime injection. @@ -107,16 +108,21 @@ class _NoTodoListMiddleware(AgentMiddleware): """No-op stand-in that drops the SDK's `TodoListMiddleware` by name. `create_deep_agent` always injects `TodoListMiddleware` and exposes no - parameter to disable it. Its `_apply_custom_middleware` merge replaces a - default middleware in place when a caller-supplied middleware shares its - `.name`, so threading this tool-less stand-in (which matches - `TodoListMiddleware.name`) into the agent and subagent middleware lists - removes the real middleware — and its `write_todos` tool — without touching - the SDK. Gated behind `DEEPAGENTS_CODE_EXPERIMENTAL`; see - `_todo_list_middleware_override`. + per-call parameter to disable it (only a globally registered + `HarnessProfile.excluded_middleware` can strip it, which dcode does not use + here). Its `_apply_custom_middleware` merge replaces a default middleware in + place when a caller-supplied middleware shares its `.name`, so threading + this tool-less stand-in into the agent and subagent middleware lists removes + the real middleware — and its `write_todos` tool — without touching the SDK. + + `name` is derived from `TodoListMiddleware.__name__` rather than hard-coded, + so a rename of the SDK class trips an `ImportError`/attribute error here + instead of silently turning the override into a no-op. The behavioral guard + against a `.name` override lives in `test_agent.py`. Gated behind + `DEEPAGENTS_CODE_EXPERIMENTAL`; see `_todo_list_middleware_override`. """ - name = "TodoListMiddleware" + name: str = TodoListMiddleware.__name__ def _todo_list_middleware_override() -> list[AgentMiddleware]: @@ -128,6 +134,10 @@ def _todo_list_middleware_override() -> list[AgentMiddleware]: name-based merge drops the real `TodoListMiddleware`. """ if is_env_truthy(EXPERIMENTAL): + logger.debug( + "%s set: dropping TodoListMiddleware / write_todos from this stack", + EXPERIMENTAL, + ) return [_NoTodoListMiddleware()] return [] @@ -856,7 +866,11 @@ def get_system_prompt( ... {CONDITIONAL SECTIONS} ... ``` """ - template = (Path(__file__).parent / "system_prompt.md").read_text() + prompt_dir = Path(__file__).parent + template = (prompt_dir / "system_prompt.md").read_text() + todo_list_section = "" + if not is_env_truthy(EXPERIMENTAL): + todo_list_section = (prompt_dir / "todo_list_prompt.md").read_text().rstrip() skills_path = f"~/.deepagents/{assistant_id}/skills" @@ -968,6 +982,7 @@ def get_system_prompt( template.replace("{mode_description}", mode_description) .replace("{interactive_preamble}", interactive_preamble) .replace("{ambiguity_guidance}", ambiguity_guidance) + .replace("{todo_list_section}", todo_list_section) .replace("{todo_guidance}", todo_guidance) .replace("{model_identity_section}", model_identity_section) .replace("{working_dir_section}", working_dir_section) diff --git a/libs/code/deepagents_code/system_prompt.md b/libs/code/deepagents_code/system_prompt.md index 3e7da8ed2c..40b0b297d8 100644 --- a/libs/code/deepagents_code/system_prompt.md +++ b/libs/code/deepagents_code/system_prompt.md @@ -201,15 +201,4 @@ When you use the web_search tool: The user only sees your text responses - not tool results. Always provide a complete, natural language answer after using web_search. -### Todo List Management - -When using the write_todos tool: - -1. Use todos for any task with 2+ steps — they give the user visibility -2. Mark tasks `in_progress` before starting, `completed` immediately after -3. Don't batch completions — mark each item done as you finish it -4. If a task reveals sub-tasks, add them right away -5. For simple 1-step tasks, just do them directly -{todo_guidance} - -The todo list is a planning tool - use it judiciously to avoid overwhelming the user with excessive task tracking. +{todo_list_section} diff --git a/libs/code/deepagents_code/todo_list_prompt.md b/libs/code/deepagents_code/todo_list_prompt.md new file mode 100644 index 0000000000..ea6f81cc36 --- /dev/null +++ b/libs/code/deepagents_code/todo_list_prompt.md @@ -0,0 +1,12 @@ +### Todo List Management + +When using the write_todos tool: + +1. Use todos for any task with 2+ steps — they give the user visibility +2. Mark tasks `in_progress` before starting, `completed` immediately after +3. Don't batch completions — mark each item done as you finish it +4. If a task reveals sub-tasks, add them right away +5. For simple 1-step tasks, just do them directly +{todo_guidance} + +The todo list is a planning tool - use it judiciously to avoid overwhelming the user with excessive task tracking. diff --git a/libs/code/tests/unit_tests/test_agent.py b/libs/code/tests/unit_tests/test_agent.py index 4e9ab4bba6..3d4a8315ef 100644 --- a/libs/code/tests/unit_tests/test_agent.py +++ b/libs/code/tests/unit_tests/test_agent.py @@ -10,6 +10,7 @@ from unittest.mock import Mock, patch import pytest +from langchain.agents.middleware import TodoListMiddleware from langchain_core.language_models.fake_chat_models import GenericFakeChatModel from langchain_core.messages import AIMessage @@ -20,6 +21,7 @@ from langgraph.runtime import Runtime from deepagents_code._cli_context import CLIContext, CLIContextSchema +from deepagents_code._env_vars import EXPERIMENTAL from deepagents_code.agent import ( DEFAULT_AGENT_NAME, _add_interrupt_on, @@ -916,6 +918,44 @@ def test_non_interactive_todo_section_does_not_wait_for_user(self) -> None: assert "do NOT ask the user to approve your plan" in prompt assert "mark the first item `in_progress` immediately" in prompt + def test_experimental_prompt_omits_todo_section( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Experimental mode must not reference its removed todo tool.""" + monkeypatch.setenv(EXPERIMENTAL, "1") + mock_settings = Mock() + mock_settings.model_name = None + + with patch("deepagents_code.agent.settings", mock_settings): + prompt = get_system_prompt("test-agent") + + assert "Todo List Management" not in prompt + assert "write_todos" not in prompt + # `{todo_guidance}` lives only inside the gated section, so dropping the + # section must not leave the placeholder unresolved. + assert "{todo_list_section}" not in prompt + assert "{todo_guidance}" not in prompt + + def test_default_prompt_resolves_todo_placeholders( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Default mode keeps the todo section with no unresolved placeholders. + + Guards the `.replace` ordering in `get_system_prompt`: `{todo_guidance}` + is nested inside the todo section, so the section must be substituted + before the guidance placeholder is filled. + """ + monkeypatch.delenv(EXPERIMENTAL, raising=False) + mock_settings = Mock() + mock_settings.model_name = None + + with patch("deepagents_code.agent.settings", mock_settings): + prompt = get_system_prompt("test-agent") + + assert "Todo List Management" in prompt + assert "{todo_list_section}" not in prompt + assert "{todo_guidance}" not in prompt + class TestGetSystemPromptCwdOSError: """Tests for Path.cwd() OSError handling in get_system_prompt.""" @@ -3086,6 +3126,130 @@ def test_preserves_explicit_subagent_model_without_configurable_middleware( ) +class TestExperimentalTodoMiddlewareWiring: + """`DEEPAGENTS_CODE_EXPERIMENTAL` drops TodoListMiddleware from every stack. + + `collect_built_in_tools` (see `test_tool_catalog.py`) only inspects the main + agent's bound tools, so the subagent splice needs its own coverage: these + tests capture the `create_deep_agent` kwargs and assert the stand-in reaches + the main agent, custom subagents, and the auto general-purpose subagent. + """ + + @staticmethod + def _build_mock_settings(tmp_path: Path) -> Mock: + agent_dir = tmp_path / "agent" + agent_dir.mkdir() + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + + mock_settings = Mock() + mock_settings.ensure_agent_dir.return_value = agent_dir + mock_settings.ensure_user_skills_dir.return_value = skills_dir + mock_settings.get_project_skills_dir.return_value = None + mock_settings.get_built_in_skills_dir.return_value = ( + Settings.get_built_in_skills_dir() + ) + mock_settings.get_user_agent_md_path.return_value = agent_dir / "AGENTS.md" + mock_settings.get_project_agent_md_path.return_value = [] + mock_settings.get_user_agents_dir.return_value = tmp_path / "agents" + mock_settings.get_project_agents_dir.return_value = None + mock_settings.model_name = None + mock_settings.model_provider = None + mock_settings.model_unsupported_modalities = frozenset() + mock_settings.model_context_limit = None + mock_settings.project_root = None + mock_settings.shell_allow_list = ["ls", "cat"] + return mock_settings + + def _capture_create_deep_agent_kwargs(self, tmp_path: Path) -> dict[str, Any]: + """Build a default agent + custom subagent; capture `create_deep_agent` kwargs. + + Returns the kwargs dcode forwards to `create_deep_agent` so callers can + assert on both the main `middleware` list and each `subagents` spec. + """ + mock_settings = self._build_mock_settings(tmp_path) + mock_agent = Mock() + mock_agent.with_config.return_value = mock_agent + fake_model = _make_fake_chat_model() + + subagent_meta = { + "name": "researcher", + "description": "Researches things", + "system_prompt": "Investigate the task thoroughly.", + "model": None, + } + + with ( + patch("deepagents_code.agent.settings", mock_settings), + patch("deepagents_code.agent.SkillsMiddleware"), + patch("deepagents_code.agent.MemoryMiddleware"), + patch( + "deepagents_code.agent.list_subagents", + return_value=[subagent_meta], + ), + patch( + "deepagents_code.agent.create_deep_agent", + return_value=mock_agent, + ) as mock_create, + patch( + "deepagents._models.init_chat_model", + return_value=fake_model, + ), + ): + create_cli_agent( + model="fake-model", + assistant_id="test", + enable_memory=False, + enable_skills=False, + enable_shell=True, + ) + + _, kwargs = mock_create.call_args + return kwargs + + @staticmethod + def _has_todo_standin(middleware: list[Any]) -> bool: + return any( + getattr(mw, "name", None) == TodoListMiddleware.__name__ + for mw in middleware + ) + + def test_standin_name_matches_sdk_middleware(self) -> None: + """The stand-in must impersonate the real middleware's `.name`. + + The name-based merge keys on the instance `.name`, so this guards a + hypothetical SDK `.name` override that `__name__`-derivation would miss. + """ + from deepagents_code.agent import _NoTodoListMiddleware + + assert _NoTodoListMiddleware().name == TodoListMiddleware().name + + def test_dropped_from_main_and_subagents_when_experimental( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv(EXPERIMENTAL, "1") + kwargs = self._capture_create_deep_agent_kwargs(tmp_path) + + assert self._has_todo_standin(kwargs["middleware"]) + + subagents_by_name = {sa["name"]: sa for sa in kwargs["subagents"]} + assert {"researcher", "general-purpose"} <= set(subagents_by_name) + for name, spec in subagents_by_name.items(): + assert self._has_todo_standin(spec.get("middleware", [])), ( + f"Expected TodoListMiddleware stand-in on subagent {name!r}" + ) + + def test_absent_from_all_stacks_by_default( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv(EXPERIMENTAL, raising=False) + kwargs = self._capture_create_deep_agent_kwargs(tmp_path) + + assert not self._has_todo_standin(kwargs["middleware"]) + for spec in kwargs["subagents"]: + assert not self._has_todo_standin(spec.get("middleware", [])) + + def _mock_agents_dir(agents_dir: Path) -> Mock: mock_settings = Mock() mock_settings.user_deepagents_dir = agents_dir From a7fc1bb838f0036cd8228a882d0d536dcf91dbac Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Mon, 13 Jul 2026 17:09:11 -0400 Subject: [PATCH 4/4] cr --- libs/code/deepagents_code/agent.py | 50 ++++++++++++++++++------ libs/code/tests/unit_tests/test_agent.py | 34 ++++++++++++++-- 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/libs/code/deepagents_code/agent.py b/libs/code/deepagents_code/agent.py index b1644ae56b..38074b465b 100644 --- a/libs/code/deepagents_code/agent.py +++ b/libs/code/deepagents_code/agent.py @@ -115,14 +115,24 @@ class _NoTodoListMiddleware(AgentMiddleware): this tool-less stand-in into the agent and subagent middleware lists removes the real middleware — and its `write_todos` tool — without touching the SDK. - `name` is derived from `TodoListMiddleware.__name__` rather than hard-coded, - so a rename of the SDK class trips an `ImportError`/attribute error here - instead of silently turning the override into a no-op. The behavioral guard - against a `.name` override lives in `test_agent.py`. Gated behind - `DEEPAGENTS_CODE_EXPERIMENTAL`; see `_todo_list_middleware_override`. + Deriving `name` from `TodoListMiddleware.__name__` makes a *rename* or + removal of the SDK class fail loudly (`ImportError`) at the top-of-module + import. It does not, on its own, guard a `.name` *override* on an unrenamed + class: the merge keys on the instance `.name`, not `__name__`, so such an + override would slip past the import and silently turn this into a no-op. + That case is caught two ways — `_todo_list_middleware_override` re-checks the + match at build time and raises, and `test_agent.py` guards it in CI. Gated + behind `DEEPAGENTS_CODE_EXPERIMENTAL`; see `_todo_list_middleware_override`. """ name: str = TodoListMiddleware.__name__ + tools: Sequence[BaseTool] = () + """No tools — replacing the real `TodoListMiddleware` drops its `write_todos`. + + Declared explicitly (mirroring the base's `transformers = ()` default) so a + bare instance is self-contained rather than relying on the SDK's + `getattr(mw, "tools", [])` fallback. + """ def _todo_list_middleware_override() -> list[AgentMiddleware]: @@ -132,14 +142,32 @@ def _todo_list_middleware_override() -> list[AgentMiddleware]: experimental flag is set, else an empty list. Callers splice the result into the middleware list they pass to `create_deep_agent` so the SDK's name-based merge drops the real `TodoListMiddleware`. + + Raises: + RuntimeError: If the stand-in's `.name` no longer matches the SDK + middleware's instance `.name`. The merge replaces by name, so a + mismatch would silently *append* the tool-less stand-in instead of + replacing the real middleware, leaving `write_todos` bound. Failing + fast here converts that silent no-op into a loud, actionable error + (only ever runs when the flag is on). """ - if is_env_truthy(EXPERIMENTAL): - logger.debug( - "%s set: dropping TodoListMiddleware / write_todos from this stack", - EXPERIMENTAL, + if not is_env_truthy(EXPERIMENTAL): + return [] + stand_in = _NoTodoListMiddleware() + sdk_name = TodoListMiddleware().name + if stand_in.name != sdk_name: + msg = ( + f"{EXPERIMENTAL} is set but the TodoListMiddleware override would be " + f"a silent no-op: stand-in name {stand_in.name!r} no longer matches " + f"the SDK middleware's instance name {sdk_name!r}. The SDK likely " + f"overrode TodoListMiddleware.name; update _NoTodoListMiddleware." ) - return [_NoTodoListMiddleware()] - return [] + raise RuntimeError(msg) + logger.info( + "%s set: dropping TodoListMiddleware / write_todos from this stack", + EXPERIMENTAL, + ) + return [stand_in] _RUBRIC_GRADER_READ_FILE_PREFIX = "/large_tool_results/" diff --git a/libs/code/tests/unit_tests/test_agent.py b/libs/code/tests/unit_tests/test_agent.py index 3d4a8315ef..3428a0af99 100644 --- a/libs/code/tests/unit_tests/test_agent.py +++ b/libs/code/tests/unit_tests/test_agent.py @@ -3132,7 +3132,8 @@ class TestExperimentalTodoMiddlewareWiring: `collect_built_in_tools` (see `test_tool_catalog.py`) only inspects the main agent's bound tools, so the subagent splice needs its own coverage: these tests capture the `create_deep_agent` kwargs and assert the stand-in reaches - the main agent, custom subagents, and the auto general-purpose subagent. + the main agent, custom subagents, and the general-purpose subagent that + dcode auto-adds. """ @staticmethod @@ -3161,11 +3162,15 @@ def _build_mock_settings(tmp_path: Path) -> Mock: mock_settings.shell_allow_list = ["ls", "cat"] return mock_settings - def _capture_create_deep_agent_kwargs(self, tmp_path: Path) -> dict[str, Any]: + def _capture_create_deep_agent_kwargs( + self, tmp_path: Path, *, subagent_model: str | None = None + ) -> dict[str, Any]: """Build a default agent + custom subagent; capture `create_deep_agent` kwargs. Returns the kwargs dcode forwards to `create_deep_agent` so callers can assert on both the main `middleware` list and each `subagents` spec. + `subagent_model` sets the custom subagent's `model:` frontmatter, which + drives the `has_explicit_model` branch in `_subagent_cli_middleware`. """ mock_settings = self._build_mock_settings(tmp_path) mock_agent = Mock() @@ -3176,7 +3181,7 @@ def _capture_create_deep_agent_kwargs(self, tmp_path: Path) -> dict[str, Any]: "name": "researcher", "description": "Researches things", "system_prompt": "Investigate the task thoroughly.", - "model": None, + "model": subagent_model, } with ( @@ -3239,6 +3244,29 @@ def test_dropped_from_main_and_subagents_when_experimental( f"Expected TodoListMiddleware stand-in on subagent {name!r}" ) + def test_dropped_from_explicit_model_subagent_when_experimental( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The splice must survive the `has_explicit_model` branch. + + `_subagent_cli_middleware` extends the stand-in in *before* the + `if not has_explicit_model:` model-middleware check, so a subagent with + an explicit `model:` in frontmatter must still receive it. The other + wiring cases only exercise the `model: None` branch, so without this a + regression that moved the splice inside that `if` would pass unnoticed. + """ + monkeypatch.setenv(EXPERIMENTAL, "1") + kwargs = self._capture_create_deep_agent_kwargs( + tmp_path, subagent_model="fake-model" + ) + + subagents_by_name = {sa["name"]: sa for sa in kwargs["subagents"]} + researcher = subagents_by_name["researcher"] + # Guards the premise: an explicit model must reach the spec, else the + # subagent would take the `model: None` path and the test proves nothing. + assert researcher.get("model"), "explicit subagent model was not forwarded" + assert self._has_todo_standin(researcher.get("middleware", [])) + def test_absent_from_all_stacks_by_default( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: