From f81a8b086ef9464bbcb74013ebbcf66cb1954b68 Mon Sep 17 00:00:00 2001 From: Xi Zhang <106144707+X-iZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:41:12 +0100 Subject: [PATCH] feat(deps): upgrade deepagents to 0.7.0 with todos restore and delete gating- #395 - Introduced TodoListMiddleware to the middleware stack for better task management. - Updated HITL interrupt configuration to include 'delete' operations requiring approval. - Implemented error handling for delete operations in read-only and memory backends. - Enhanced approval prompt formatting to display file paths for delete actions. - Added tests to ensure delete operations are correctly blocked or prompted for approval. - Updated dependencies to use deepagents 0.7.0 and langchain 1.5.3 for improved functionality. --- EvoScientist/EvoScientist.py | 39 ++++----- EvoScientist/backends.py | 19 +++++ EvoScientist/channels/interaction.py | 13 ++- EvoScientist/cli/widgets/approval_widget.py | 14 +++- EvoScientist/config/settings.py | 6 +- EvoScientist/memory/agents/_factory.py | 1 + EvoScientist/memory/agents/autoskills.py | 2 +- EvoScientist/memory/agents/memory_worker.py | 2 +- EvoScientist/stream/display.py | 10 ++- pyproject.toml | 6 +- tests/test_backends.py | 90 +++++++++++++++++++++ tests/test_hitl.py | 47 +++++++++++ tests/test_hitl_interrupt_config.py | 12 +++ tests/test_interaction_grammar.py | 9 +++ tests/test_middleware_name_collision.py | 26 ++++++ tests/test_subagent_summarize.py | 11 +-- tests/test_todos_middleware_restore.py | 27 +++++++ tests/test_tui_widgets.py | 32 ++++++++ uv.lock | 42 +++++----- 19 files changed, 346 insertions(+), 62 deletions(-) create mode 100644 tests/test_hitl_interrupt_config.py create mode 100644 tests/test_middleware_name_collision.py create mode 100644 tests/test_todos_middleware_restore.py diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py index 0ecc52f..6f54c20 100644 --- a/EvoScientist/EvoScientist.py +++ b/EvoScientist/EvoScientist.py @@ -22,7 +22,11 @@ import os from pathlib import Path from typing import TYPE_CHECKING -from langchain.agents.middleware import AgentMiddleware, HumanInTheLoopMiddleware +from langchain.agents.middleware import ( + AgentMiddleware, + HumanInTheLoopMiddleware, + TodoListMiddleware, +) from . import paths as _paths_mod from .config import ( @@ -52,6 +56,15 @@ SUBAGENTS_CONFIG = Path(__file__).parent / "subagents" SKILLS_DIR = str(Path(__file__).parent / "skills") DEFAULT_SKILL_SOURCES = ("/skills/",) +# Tools requiring human approval on attended agents (deepagents 0.7.0 ships a +# recursive `delete` FS tool that would otherwise bypass the execute blocklist). +HITL_INTERRUPT_ON: dict[str, bool] = { + "execute": True, + "run_in_background": True, + "schedule_task": True, + "delete": True, +} + # ============================================================================= # Lazy state — initialized on first use, not at import time # ============================================================================= @@ -359,6 +372,7 @@ def _inject_subagent_middleware( create_context_editing_middleware(chat_model), create_runtime_context_middleware(), ToolErrorHandlerMiddleware(), + TodoListMiddleware(), ContextOverflowMapperMiddleware(), ] if memory_controls.memory_enabled: @@ -790,6 +804,9 @@ def _get_default_middleware( ModelFallbackMiddleware(events=events), ContextOverflowMapperMiddleware(), ToolErrorHandlerMiddleware(), + # deepagents 0.7.0 dropped TodoListMiddleware from its defaults; + # EXPERIMENT_WORKFLOW planning and the todo UI pipeline require it. + TodoListMiddleware(), *create_tool_selector_middleware( model=tool_selector_model, events=events, @@ -876,15 +893,7 @@ def _get_default_agent(): # breaks parallel execute calls (multi-pending-interrupt LangGraph # error). See PR #202. if not cfg.auto_approve: - mw.append( - HumanInTheLoopMiddleware( - interrupt_on={ - "execute": True, - "run_in_background": True, - "schedule_task": True, - } - ) - ) + mw.append(HumanInTheLoopMiddleware(interrupt_on=dict(HITL_INTERRUPT_ON))) if os.environ.get("EVOSCIENTIST_DEPLOY_MODE", "").lower() == "stripped": kwargs = _build_base_kwargs( @@ -1042,15 +1051,7 @@ def create_cli_agent( # would propagate it to every subagent, breaking parallel execute calls # (multi-pending-interrupt LangGraph error). if not cfg.auto_approve: - mw.append( - HumanInTheLoopMiddleware( - interrupt_on={ - "execute": True, - "run_in_background": True, - "schedule_task": True, - } - ) - ) + mw.append(HumanInTheLoopMiddleware(interrupt_on=dict(HITL_INTERRUPT_ON))) # Re-load MCP tools from current config (picks up /mcp add changes) kwargs = load_mcp_and_build_kwargs( diff --git a/EvoScientist/backends.py b/EvoScientist/backends.py index 61902b0..f5115f7 100644 --- a/EvoScientist/backends.py +++ b/EvoScientist/backends.py @@ -15,6 +15,7 @@ from pathlib import Path from deepagents.backends import FilesystemBackend, LocalShellBackend from deepagents.backends.protocol import ( BackendProtocol, + DeleteResult, EditResult, ExecuteResponse, FileDownloadResponse, @@ -904,6 +905,11 @@ class ReadOnlyFilesystemBackend(FilesystemBackend): for file_path, _ in files ] + def delete(self, file_path: str) -> DeleteResult: + return DeleteResult( + error="This directory is read-only. Delete operations are not permitted here." + ) + class MemoryFilesystemBackend(FilesystemBackend): """Filesystem backend for memory files with structured-write enforcement. @@ -920,6 +926,10 @@ class MemoryFilesystemBackend(FilesystemBackend): "Raw edits under /memories are limited to existing " "/memories/profile/... files. Use memory tools for observations." ) + _RAW_DELETE_ERROR = ( + "Deletes under /memories are blocked. Manage memory files through " + "memory tools instead." + ) @staticmethod def _is_profile_path(file_path: str) -> bool: @@ -946,6 +956,9 @@ class MemoryFilesystemBackend(FilesystemBackend): for file_path, _ in files ] + def delete(self, file_path: str) -> DeleteResult: + return DeleteResult(error=self._RAW_DELETE_ERROR) + def build_memory_agent_backend( *, @@ -1552,6 +1565,12 @@ class AutoskillProposalSandboxBackend(CustomSandboxBackend): for file_path, _ in files ] + def delete(self, file_path: str) -> DeleteResult: + return DeleteResult( + error="Deletes are blocked for AutoSkills. Manage proposal files " + "under /autoskill-proposals/ instead." + ) + def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse: return super().execute( self._rewrite_autoskill_mount(command), diff --git a/EvoScientist/channels/interaction.py b/EvoScientist/channels/interaction.py index a45fdf0..79eece7 100644 --- a/EvoScientist/channels/interaction.py +++ b/EvoScientist/channels/interaction.py @@ -102,7 +102,10 @@ def format_approval_prompt( name = req.get("name", "") args = req.get("args", {}) if isinstance(args, dict): - command = args.get("command", args.get("path", "")) + # deepagents 0.7.0's `delete` tool uses `file_path`, not + # `command`/`path` — without this fallback the prompt shows + # only "delete" with no target. + command = args.get("command", args.get("path", args.get("file_path", ""))) else: command = "" if command: @@ -217,7 +220,11 @@ def config_auto_approve(action_requests: list[dict]) -> bool: return True try: - from ..config.settings import HITL_SHELL_TOOLS, load_config + from ..config.settings import ( + HITL_ALWAYS_PROMPT_TOOLS, + HITL_SHELL_TOOLS, + load_config, + ) cfg = load_config() except Exception: @@ -234,6 +241,8 @@ def config_auto_approve(action_requests: list[dict]) -> bool: for req in action_requests: name = req.get("name", "") + if name in HITL_ALWAYS_PROMPT_TOOLS: + return False if name not in HITL_SHELL_TOOLS: continue args = req.get("args", {}) diff --git a/EvoScientist/cli/widgets/approval_widget.py b/EvoScientist/cli/widgets/approval_widget.py index 53f0fc9..07ad73c 100644 --- a/EvoScientist/cli/widgets/approval_widget.py +++ b/EvoScientist/cli/widgets/approval_widget.py @@ -101,6 +101,15 @@ class ApprovalWidget(Widget): self._selected = 0 self._option_widgets: list[Static] = [] + @staticmethod + def _extract_command(args: dict) -> str: + """Pull the display-worthy target out of a tool's args dict. + + Checks `command`/`path` first, then deepagents 0.7.0's `delete` + tool key `file_path` — without it, `delete` shows no target. + """ + return args.get("command", args.get("path", args.get("file_path", ""))) + def compose(self) -> ComposeResult: self._option_widgets = [] count = len(self._action_requests) @@ -115,10 +124,7 @@ class ApprovalWidget(Widget): for req in self._action_requests: name = req.get("name", "") args = req.get("args", {}) - if isinstance(args, dict): - command = args.get("command", args.get("path", "")) - else: - command = "" + command = self._extract_command(args) if isinstance(args, dict) else "" if command: cmd_str = str(command) if len(cmd_str) > _COMMAND_TRUNCATE_LENGTH: diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index 6f987a1..1626d2d 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -21,10 +21,14 @@ from dotenv import dotenv_values, find_dotenv # Tools that run shell commands and need manual HITL approval (subject to # shell_allow_list). Single source of truth for every interrupt consumer -# (stream/display.py, channels/consumer.py) — keep aligned with the agent's +# (stream/display.py, channels/interaction.py) — keep aligned with the agent's # `interrupt_on` set in EvoScientist.py. HITL_SHELL_TOOLS = ("execute", "run_in_background") +# Armed non-shell destructive tools must always prompt — no allow-list carve-outs +# (their args carry paths, not commands). Keep aligned with HITL_INTERRUPT_ON. +HITL_ALWAYS_PROMPT_TOOLS = ("delete",) + class MemoryObservationTarget(StrEnum): """Runtime locations that can receive `record_observation`.""" diff --git a/EvoScientist/memory/agents/_factory.py b/EvoScientist/memory/agents/_factory.py index 8373929..4962c3a 100644 --- a/EvoScientist/memory/agents/_factory.py +++ b/EvoScientist/memory/agents/_factory.py @@ -18,6 +18,7 @@ from ... import paths as _paths MEMORY_AGENT_RECURSION_LIMIT = 100 MEMORY_MAINTENANCE_EXCLUDED_TOOLS = frozenset( { + "delete", "edit_file", "execute", "task", diff --git a/EvoScientist/memory/agents/autoskills.py b/EvoScientist/memory/agents/autoskills.py index 05598dc..6c8f57c 100644 --- a/EvoScientist/memory/agents/autoskills.py +++ b/EvoScientist/memory/agents/autoskills.py @@ -21,7 +21,7 @@ from ._factory import ( resolve_memory_agent_paths, ) -_AUTOSKILLS_EXCLUDED_TOOLS = frozenset({"task", "write_todos"}) +_AUTOSKILLS_EXCLUDED_TOOLS = frozenset({"delete", "task", "write_todos"}) def _autoskills_system_prompt() -> str: diff --git a/EvoScientist/memory/agents/memory_worker.py b/EvoScientist/memory/agents/memory_worker.py index 5009d72..804d6eb 100644 --- a/EvoScientist/memory/agents/memory_worker.py +++ b/EvoScientist/memory/agents/memory_worker.py @@ -34,7 +34,7 @@ from ._factory import ( logger = logging.getLogger(__name__) _MEMORY_WORKER_EXCLUDED_TOOLS = frozenset( - {"execute", "task", "write_file", "write_todos"} + {"delete", "execute", "task", "write_file", "write_todos"} ) diff --git a/EvoScientist/stream/display.py b/EvoScientist/stream/display.py index 4d817ac..6218244 100644 --- a/EvoScientist/stream/display.py +++ b/EvoScientist/stream/display.py @@ -1185,7 +1185,11 @@ def _resolve_hitl_approval( return [{"type": "approve"} for _ in action_requests] # Config-level auto-approve - from ..config.settings import HITL_SHELL_TOOLS, load_config + from ..config.settings import ( + HITL_ALWAYS_PROMPT_TOOLS, + HITL_SHELL_TOOLS, + load_config, + ) cfg = load_config() if cfg.auto_approve: @@ -1203,6 +1207,10 @@ def _resolve_hitl_approval( name = req.get("name", "") args = req.get("args", {}) + if name in HITL_ALWAYS_PROMPT_TOOLS: + needs_prompt = True + break + if name not in HITL_SHELL_TOOLS: continue # Only shell-running tools need manual approval diff --git a/pyproject.toml b/pyproject.toml index 67e22f1..8f34198 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,13 +16,13 @@ classifiers = [ "Programming Language :: Python :: 3", ] dependencies = [ - "deepagents[quickjs]~=0.6.12", + "deepagents[quickjs]~=0.7.0", "langchain>=1.3", - "langchain-anthropic>=1.4", + "langchain-anthropic>=1.5", "langchain-openai>=1.2", "langchain-deepseek>=1.1", "langchain-nvidia-ai-endpoints>=1.2", - "langchain-google-genai>=4.2", + "langchain-google-genai>=4.3", "langchain-ollama>=1.1", "langchain-openrouter>=0.2.5", # 0.11.0+ SSE regressions: teardown noise + mid-turn ReadTimeout diff --git a/tests/test_backends.py b/tests/test_backends.py index 6200c1e..11fbff8 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -11,6 +11,7 @@ import pytest from EvoScientist import backends, paths from EvoScientist.backends import ( + AutoskillProposalSandboxBackend, CustomSandboxBackend, MemoryFilesystemBackend, MergedSkillsBackend, @@ -959,6 +960,52 @@ class TestMemoryFilesystemBackend: assert not (workspace / "created.txt").exists() +# === delete blocking (deepagents 0.7.0 recursive delete tool) === + + +class TestDeleteBlocked: + """deepagents 0.7.0 adds a recursive delete tool; guarded backends must refuse it.""" + + def test_readonly_backend_blocks_delete(self, tmp_path): + (tmp_path / "f.txt").write_text("x") + backend = ReadOnlyFilesystemBackend(root_dir=str(tmp_path), virtual_mode=True) + result = backend.delete("/f.txt") + assert result.error is not None + assert (tmp_path / "f.txt").exists() + + async def test_readonly_backend_blocks_adelete(self, tmp_path): + (tmp_path / "f.txt").write_text("x") + backend = ReadOnlyFilesystemBackend(root_dir=str(tmp_path), virtual_mode=True) + result = await backend.adelete("/f.txt") + assert result.error is not None + assert (tmp_path / "f.txt").exists() + + def test_memory_backend_blocks_delete_everywhere(self, tmp_path): + profile = tmp_path / "profile" + profile.mkdir() + (profile / "USER_PROFILE.md").write_text("x") + backend = MemoryFilesystemBackend(root_dir=str(tmp_path), virtual_mode=True) + result = backend.delete("/profile/USER_PROFILE.md") + assert result.error is not None + assert (profile / "USER_PROFILE.md").exists() + + def test_autoskill_backend_blocks_delete(self, tmp_path): + (tmp_path / "f.txt").write_text("x") + backend = AutoskillProposalSandboxBackend( + root_dir=str(tmp_path), virtual_mode=True + ) + result = backend.delete("/f.txt") + assert result.error is not None + assert (tmp_path / "f.txt").exists() + + def test_sandbox_backend_delete_enabled_by_default(self, tmp_path): + (tmp_path / "f.txt").write_text("x") + backend = CustomSandboxBackend(root_dir=str(tmp_path), virtual_mode=True) + result = backend.delete("/f.txt") + assert result.error is None + assert not (tmp_path / "f.txt").exists() + + # === CustomSandboxBackend._resolve_path === @@ -1918,3 +1965,46 @@ class TestPlatformQuote: backends._platform_quote(r"C:\path\%TEMP%\file.py") == r"C:\path\%TEMP%\file.py" ) + + +def test_memory_maintenance_excludes_delete_tool(): + from EvoScientist.memory.agents._factory import MEMORY_MAINTENANCE_EXCLUDED_TOOLS + + assert "delete" in MEMORY_MAINTENANCE_EXCLUDED_TOOLS + + +def test_autoskill_composite_route_blocks_or_excludes_delete(): + """Codex finding: /autoskill-proposals/ routes to a plain FilesystemBackend whose + delete works; the agent-level tool exclusion is the guard that must cover it.""" + from EvoScientist.memory.agents.autoskills import _AUTOSKILLS_EXCLUDED_TOOLS + + assert "delete" in _AUTOSKILLS_EXCLUDED_TOOLS + + +def test_autoskill_proposals_route_delete_is_not_backend_blocked(tmp_path): + """Documents WHY the tool exclusion above is the enforcement layer: the raw + composite backend's /autoskill-proposals/ route has no backend-level delete + guard (unlike /memories/ and the default proposal-root sandbox), so a bare + `delete("/autoskill-proposals/...")` call still succeeds at the backend level.""" + from EvoScientist.backends import build_autoskill_agent_backend + + memory_dir = tmp_path / "memories" + proposals_dir = tmp_path / "proposals" + memory_dir.mkdir() + proposals_dir.mkdir() + (proposals_dir / "some-skill").mkdir() + (proposals_dir / "some-skill" / "SKILL.md").write_text("x", encoding="utf-8") + + backend = build_autoskill_agent_backend( + memory_dir=memory_dir, proposals_dir=proposals_dir + ) + result = backend.delete("/autoskill-proposals/some-skill") + + assert result.error is None + assert not (proposals_dir / "some-skill").exists() + + +def test_memory_worker_excludes_delete_tool(): + from EvoScientist.memory.agents.memory_worker import _MEMORY_WORKER_EXCLUDED_TOOLS + + assert "delete" in _MEMORY_WORKER_EXCLUDED_TOOLS diff --git a/tests/test_hitl.py b/tests/test_hitl.py index 400ca71..b8c4be5 100644 --- a/tests/test_hitl.py +++ b/tests/test_hitl.py @@ -329,6 +329,37 @@ class TestResolveHitlApproval: finally: disp._session_auto_approve = original + def test_delete_only_request_prompts_not_auto_approved(self): + """delete is armed in HITL_INTERRUPT_ON but has no `command` arg, so the + shell_allow_list carve-out must never silently clear it (C1).""" + import EvoScientist.stream.display as disp + from EvoScientist.stream.display import _resolve_hitl_approval + + original = disp._session_auto_approve + try: + disp._session_auto_approve = False + mock_cfg = MagicMock() + mock_cfg.auto_approve = False + mock_cfg.shell_allow_list = "" + with patch( + "EvoScientist.config.settings.load_config", return_value=mock_cfg + ): + with patch( + "EvoScientist.stream.display._prompt_hitl_approval" + ) as mock_prompt: + mock_prompt.return_value = [{"type": "approve"}] + result = _resolve_hitl_approval( + { + "action_requests": [ + {"name": "delete", "args": {"file_path": "/f.txt"}} + ], + } + ) + assert result == [{"type": "approve"}] + mock_prompt.assert_called_once() # must be prompted, not auto-approved + finally: + disp._session_auto_approve = original + # ============================================================================= # Config fields @@ -556,6 +587,22 @@ class TestConsumerHitlHelpers: ) assert result is True + def test_should_auto_approve_delete_not_cleared(self): + """delete has no `command` arg, so shell_allow_list must never clear it + the way it clears execute (C1).""" + from EvoScientist.channels.interaction import config_auto_approve + + mock_cfg = MagicMock() + mock_cfg.auto_approve = False + mock_cfg.shell_allow_list = "ls,python" + with patch("EvoScientist.config.settings.load_config", return_value=mock_cfg): + result = config_auto_approve( + [ + {"name": "delete", "args": {"file_path": "/f.txt"}}, + ] + ) + assert result is False + # ============================================================================= # Channel reply-interception mechanism (channel.py PendingReplyRegistry) diff --git a/tests/test_hitl_interrupt_config.py b/tests/test_hitl_interrupt_config.py new file mode 100644 index 0000000..6129c88 --- /dev/null +++ b/tests/test_hitl_interrupt_config.py @@ -0,0 +1,12 @@ +"""HITL interrupt policy: which tools require approval on attended agents.""" + + +def test_hitl_interrupt_on_arms_expected_tools(): + from EvoScientist.EvoScientist import HITL_INTERRUPT_ON + + assert HITL_INTERRUPT_ON == { + "execute": True, + "run_in_background": True, + "schedule_task": True, + "delete": True, + } diff --git a/tests/test_interaction_grammar.py b/tests/test_interaction_grammar.py index 95703a7..059a028 100644 --- a/tests/test_interaction_grammar.py +++ b/tests/test_interaction_grammar.py @@ -252,6 +252,15 @@ class TestApprovalPromptFormat: got = I.format_approval_prompt([{"name": "ask_user", "args": {}}]) assert "ask_user" in got + def test_delete_shows_file_path(self): + # deepagents 0.7.0's `delete` tool uses `file_path`, not `command`/ + # `path` — the prompt must still show the target, not just the name. + got = I.format_approval_prompt( + [{"name": "delete", "args": {"file_path": "/results/run-3"}}] + ) + assert "delete" in got + assert "/results/run-3" in got + def test_metadata_no_buttons(self): assert I.approval_prompt_metadata({"k": "v"}, with_buttons=False) == {"k": "v"} diff --git a/tests/test_middleware_name_collision.py b/tests/test_middleware_name_collision.py new file mode 100644 index 0000000..59e4b5b --- /dev/null +++ b/tests/test_middleware_name_collision.py @@ -0,0 +1,26 @@ +"""deepagents 0.7.0 merges caller middleware into its default stack by `.name`: +a name match silently REPLACES the built-in. None of EvoScientist's middleware +may collide unintentionally. TodoListMiddleware is deliberately absent from the +forbidden set: we pass it on purpose and replacing a profile-added instance +(e.g. the Codex harness profile's) with our identical one is desired dedup. +""" + +DEEPAGENTS_BASE_STACK_NAMES = { + "SkillsMiddleware", + "FilesystemMiddleware", + "SubAgentMiddleware", + "SummarizationMiddleware", + "PatchToolCallsMiddleware", + "AsyncSubAgentMiddleware", + "AnthropicPromptCachingMiddleware", +} + + +def test_no_name_collision_with_deepagents_base_stack(): + from EvoScientist.EvoScientist import _get_default_middleware + + ours = {m.name for m in _get_default_middleware()} + assert not ours & DEEPAGENTS_BASE_STACK_NAMES + + ours_async = {m.name for m in _get_default_middleware(for_async_subagent=True)} + assert not ours_async & DEEPAGENTS_BASE_STACK_NAMES diff --git a/tests/test_subagent_summarize.py b/tests/test_subagent_summarize.py index d10d42e..3bf0cdb 100644 --- a/tests/test_subagent_summarize.py +++ b/tests/test_subagent_summarize.py @@ -683,12 +683,5 @@ class TestDelegationPromptSummarize: """The upstream TASK_TOOL_DESCRIPTION already instructs the LLM to summarize.""" from deepagents.middleware.subagents import TASK_TOOL_DESCRIPTION - assert "not visible to the user" in TASK_TOOL_DESCRIPTION - assert "summary of the result" in TASK_TOOL_DESCRIPTION - - def test_framework_task_system_prompt_contains_reconcile_step(self): - """The upstream TASK_SYSTEM_PROMPT includes a reconcile/synthesize step.""" - from deepagents.middleware.subagents import TASK_SYSTEM_PROMPT - - assert "Reconcile" in TASK_SYSTEM_PROMPT - assert "synthesize" in TASK_SYSTEM_PROMPT.lower() + assert "not shown to the user" in TASK_TOOL_DESCRIPTION + assert "relay a summary yourself" in TASK_TOOL_DESCRIPTION diff --git a/tests/test_todos_middleware_restore.py b/tests/test_todos_middleware_restore.py new file mode 100644 index 0000000..d0ece62 --- /dev/null +++ b/tests/test_todos_middleware_restore.py @@ -0,0 +1,27 @@ +"""deepagents 0.7.0 made TodoListMiddleware opt-in; EvoScientist opts back in everywhere.""" + + +def _names(middleware_list): + return {m.name for m in middleware_list} + + +def test_default_middleware_includes_todos(): + from EvoScientist.EvoScientist import _get_default_middleware + + assert "TodoListMiddleware" in _names(_get_default_middleware()) + + +def test_async_subagent_middleware_includes_todos(): + from EvoScientist.EvoScientist import _get_default_middleware + + assert "TodoListMiddleware" in _names( + _get_default_middleware(for_async_subagent=True) + ) + + +def test_injected_subagent_middleware_includes_todos(tmp_path): + from EvoScientist.EvoScientist import _inject_subagent_middleware + + subs = [{"name": "research-agent"}] + _inject_subagent_middleware(subs, workspace_dir=str(tmp_path)) + assert "TodoListMiddleware" in _names(subs[0]["middleware"]) diff --git a/tests/test_tui_widgets.py b/tests/test_tui_widgets.py index 8bf7173..b8d0793 100644 --- a/tests/test_tui_widgets.py +++ b/tests/test_tui_widgets.py @@ -285,6 +285,38 @@ class TestToolCallWidget(unittest.TestCase): assert w._should_collapse() is True +@unittest.skipUnless(_has_textual, "textual not installed") +class TestApprovalWidgetCommandExtraction(unittest.TestCase): + """ApprovalWidget's compact-line command extraction. + + ``compose()`` itself needs a mounted App (it opens a Container context + manager), so — per the widget's existing style of unit-testing its + private helpers directly (see TestToolCallWidget) — these exercise the + extraction helper `compose()` calls rather than the full render. + """ + + def test_extract_command_prefers_command(self): + from EvoScientist.cli.widgets.approval_widget import ApprovalWidget + + assert ApprovalWidget._extract_command({"command": "ls", "path": "/x"}) == "ls" + + def test_extract_command_falls_back_to_path(self): + from EvoScientist.cli.widgets.approval_widget import ApprovalWidget + + assert ApprovalWidget._extract_command({"path": "/out.txt"}) == "/out.txt" + + def test_extract_command_falls_back_to_file_path(self): + # deepagents 0.7.0's `delete` tool uses `file_path`, not `command`/ + # `path` — without this fallback the approval prompt shows only the + # tool name and hides the deletion target. + from EvoScientist.cli.widgets.approval_widget import ApprovalWidget + + assert ( + ApprovalWidget._extract_command({"file_path": "/results/run-3"}) + == "/results/run-3" + ) + + @unittest.skipUnless(_has_textual, "textual not installed") class TestSubAgentWidget(unittest.TestCase): """SubAgentWidget construction and name display.""" diff --git a/uv.lock b/uv.lock index ea0a46b..8ca7d51 100644 --- a/uv.lock +++ b/uv.lock @@ -186,7 +186,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.117.0" +version = "0.120.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -198,9 +198,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/0d/8f71d535edb0d438f023bd825fb65f67c14fa88a2bd6b75f292a58a63de4/anthropic-0.117.0.tar.gz", hash = "sha256:98107f2b76439641e0ae2a1754087534b8f178dbab99d6eb1bc4b7bc8c744496", size = 989933, upload-time = "2026-07-16T19:36:13.07Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/10/4ca013cb166f226bd89e0aeb0fcaff94f45ddf716d4925ce89475d3c587b/anthropic-0.120.2.tar.gz", hash = "sha256:9722efc10c27a30a69f5338ddacdb35bc6a64297a4e4ba729bf83af873d5fb3a", size = 1008421, upload-time = "2026-07-28T17:38:26.986Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/4c/917d21d6619a4475cdafc6d13a69fdb3b901ddac57e76caca5a25c117b6d/anthropic-0.117.0-py3-none-any.whl", hash = "sha256:451a0a6905f11dff7663d13e4ee5dbf909eb8942b1d049803c7b937a13ac47ec", size = 998327, upload-time = "2026-07-16T19:36:11.225Z" }, + { url = "https://files.pythonhosted.org/packages/63/af/0f5db57b9397a0f3b7fc204cbef143401a7cadaf982330f97f1ce3d39f34/anthropic-0.120.2-py3-none-any.whl", hash = "sha256:0f0bc2b381dc0eb41c8d886b815d79c2041cd2374f83aed36f574b6dc9c579c1", size = 1022851, upload-time = "2026-07-28T17:38:25.466Z" }, ] [[package]] @@ -859,7 +859,7 @@ wheels = [ [[package]] name = "deepagents" -version = "0.6.12" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain" }, @@ -869,9 +869,9 @@ dependencies = [ { name = "langsmith" }, { name = "wcmatch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/db/a6acdc72a9e90c3f07ed10de35c951734a02d4facb693bb59684ad368801/deepagents-0.6.12.tar.gz", hash = "sha256:1f281c0bc5a63132f62e2ee345c1dc593b23188da6e23016401f6879fbe54b5f", size = 211364, upload-time = "2026-06-25T17:26:52.775Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/6b/f61d30d45e25e8dc720b4928da029162329e8f22173bbbe190083e19a75e/deepagents-0.7.0.tar.gz", hash = "sha256:36c49bced5de9ef0deb1e9c05a449adf931706e25ba5f99449d7436eccbc694b", size = 263583, upload-time = "2026-07-29T16:27:29.241Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/49/af7219b3c13520fee047bb807cfaefba17f8e4584c551d946773589a4f08/deepagents-0.6.12-py3-none-any.whl", hash = "sha256:28b8fa0119ca0a689e3e18e288c4634e4046062acfc87a1cb34289d3af3a1c88", size = 236120, upload-time = "2026-06-25T17:26:51.736Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/ee3a0518a90bae0ee3f48e23061fe185804d0d3476cb75f21ab834f4698c/deepagents-0.7.0-py3-none-any.whl", hash = "sha256:c0ceb3c12f44638cdb81ea95652d6d6fa8ec76a2372ae8016aa2dd9a493762b1", size = 289723, upload-time = "2026-07-29T16:27:28.178Z" }, ] [package.optional-dependencies] @@ -1060,16 +1060,16 @@ requires-dist = [ { name = "certifi", marker = "extra == 'wechat'", specifier = ">=2024.0" }, { name = "cryptography", marker = "extra == 'all-channels'", specifier = ">=41.0" }, { name = "cryptography", marker = "extra == 'qq'", specifier = ">=41.0" }, - { name = "deepagents", extras = ["quickjs"], specifier = "~=0.6.12" }, + { name = "deepagents", extras = ["quickjs"], specifier = "~=0.7.0" }, { name = "discord-py", marker = "extra == 'all-channels'", specifier = ">=2.3" }, { name = "discord-py", marker = "extra == 'discord'", specifier = ">=2.3" }, { name = "faster-whisper", marker = "extra == 'stt'", specifier = ">=1.0" }, { name = "filelock", specifier = ">=3.16" }, { name = "httpx", specifier = ">=0.28" }, { name = "langchain", specifier = ">=1.3" }, - { name = "langchain-anthropic", specifier = ">=1.4" }, + { name = "langchain-anthropic", specifier = ">=1.5" }, { name = "langchain-deepseek", specifier = ">=1.1" }, - { name = "langchain-google-genai", specifier = ">=4.2" }, + { name = "langchain-google-genai", specifier = ">=4.3" }, { name = "langchain-mcp-adapters", specifier = ">=0.2" }, { name = "langchain-nvidia-ai-endpoints", specifier = ">=1.2" }, { name = "langchain-ollama", specifier = ">=1.1" }, @@ -2038,21 +2038,21 @@ wheels = [ [[package]] name = "langchain-anthropic" -version = "1.4.8" +version = "1.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic" }, { name = "langchain-core" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/22/40ab129b08329ca295b391aa1d48267692b42594757084c6918e22b655ac/langchain_anthropic-1.4.8.tar.gz", hash = "sha256:c76891b2044d56105ff13c106ed12650637b53bd598a4bdf15b4796eefa2a4ec", size = 708524, upload-time = "2026-06-26T21:28:46.916Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/5b/25ffc9cc66a8260db9e2da2836ee33b260dfbb874d1ea2f99d70023f2b1d/langchain_anthropic-1.5.3.tar.gz", hash = "sha256:48a7e4fc3856c42f9dcb4396ba112afcdd8dee21b70a402bc43283c970de3941", size = 713962, upload-time = "2026-07-28T17:02:55.903Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/14/746235c4da89d9bc6a608c5f489f628e03feb8f697195c146e452c8f23c8/langchain_anthropic-1.4.8-py3-none-any.whl", hash = "sha256:778e9301b6fd517824f76ec1776975ce8add97a1f6a36c50ae3c2f4b03a66f7f", size = 52366, upload-time = "2026-06-26T21:28:45.535Z" }, + { url = "https://files.pythonhosted.org/packages/2a/19/584ab8517718f48bb411d051be713ebd8b58351741f8a4ca23d6a305cb1b/langchain_anthropic-1.5.3-py3-none-any.whl", hash = "sha256:b1b72b7c9e4bf5c044660629ebbcb79cef18c140fdc80174750eb86de13ec8bc", size = 54034, upload-time = "2026-07-28T17:02:54.594Z" }, ] [[package]] name = "langchain-core" -version = "1.4.9" +version = "1.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -2065,9 +2065,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/b9/e937d0a90b26540bff07e7a7c64349f3b29c2dcc36257cd1cd3fdce17f2a/langchain_core-1.4.9.tar.gz", hash = "sha256:f8078901145bed0466755277500a5a22822a7b628808c4c0a28d4fc88895fcf2", size = 967294, upload-time = "2026-07-08T20:06:54.191Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/58/3ad53096eee1e07728e8219ded30ae308b0f2b8b7b26b8ebb6371917c0f5/langchain_core-1.5.2.tar.gz", hash = "sha256:2d13ab35b42eec63d4669a483776b8cdd778ee764107149369fb369d84c08c41", size = 972322, upload-time = "2026-07-28T16:38:37.977Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/70/ade2fada52772798ef815b6352b59e71b116aa0c32c3aef5be3dc2cbed12/langchain_core-1.4.9-py3-none-any.whl", hash = "sha256:28e3909e2a10cc81504952d795ac0a9e014c0018121ef89d48dd396fa09ec624", size = 558293, upload-time = "2026-07-08T20:06:52.382Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e4/024e402a65f5ee2eb6ae9667b5ffc5f08776cb4e712f5a11ee1997d56083/langchain_core-1.5.2-py3-none-any.whl", hash = "sha256:a687dd7c3b22c6c1294e1c1eeb61fb6f3a308e6015a1d75b576b3836ad5b5aed", size = 561643, upload-time = "2026-07-28T16:38:36.38Z" }, ] [[package]] @@ -2085,7 +2085,7 @@ wheels = [ [[package]] name = "langchain-google-genai" -version = "4.2.7" +version = "4.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filetype" }, @@ -2093,9 +2093,9 @@ dependencies = [ { name = "langchain-core" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/0c/bc60dabc362ca7c6ffe8c4bcc2f724c7e566b43eb230cee51419f88f784c/langchain_google_genai-4.2.7.tar.gz", hash = "sha256:03b1463ffe4d42435f43c7870467f2215f684bb46400d2543435d10157c80ac7", size = 281605, upload-time = "2026-07-06T13:51:58.724Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/2f/e03b63ad3a61fd1aa479bbc0f3df5d27abb8f9159d111cba96629df844ef/langchain_google_genai-4.3.2.tar.gz", hash = "sha256:6471769a4463fedb10d2d19a9b56c31de1cde505edf7fffd8cdbf98af8c1d7da", size = 286018, upload-time = "2026-07-27T16:27:39.214Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/f9/d73d1e712591723aaddb7a7b1e94978cd2320c29acfe0d26b6169a2f26f0/langchain_google_genai-4.2.7-py3-none-any.whl", hash = "sha256:0d9c388d0e6c629718fca6abb19c6fdca728a9a7873d0324c1ec821288b5b571", size = 70702, upload-time = "2026-07-06T13:51:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/01/cb/4a2eb187b108a240d57cf8dcf67e818ca75365f769444bf5716e2823cd98/langchain_google_genai-4.3.2-py3-none-any.whl", hash = "sha256:f3b1c09b264612fd1735a9590987bfa0cccca0bc0111691543decb5a03b8667d", size = 72770, upload-time = "2026-07-27T16:27:38.052Z" }, ] [[package]] @@ -2355,7 +2355,7 @@ wheels = [ [[package]] name = "langsmith" -version = "0.10.6" +version = "0.10.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2373,9 +2373,9 @@ dependencies = [ { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/7e/05db6baaed1383386afd34e5d8fbf0b6cff1e87ed6b5c98d444511af6490/langsmith-0.10.6.tar.gz", hash = "sha256:6ec5b26bb57ee41363c07eb2884f24d69910b8d6bc26cae1f9e367af15259f1b", size = 4729044, upload-time = "2026-07-17T17:14:02.897Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c3/7203f18c6ca0ff4c65087fe8694511fadc8a0037c37010e43d25b9ecacd8/langsmith-0.10.12.tar.gz", hash = "sha256:e27cace529a5c54546ecdc2d3fa7af6d8a84a5b9333e99b497f47cafffa16ef5", size = 4749918, upload-time = "2026-07-29T14:22:52.335Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/d5/2d8d84f5330aa773bae18644aaf7e3288b2f082e31640080784a7d908a24/langsmith-0.10.6-py3-none-any.whl", hash = "sha256:094285b755224f49fd396c3672b0f747294c7b8d9e8278a81d76b487b5cfdb56", size = 661257, upload-time = "2026-07-17T17:14:01.118Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/de3b66a9b9212ed41541814d8e062909c009837e650a4c2e43425ee1b43c/langsmith-0.10.12-py3-none-any.whl", hash = "sha256:d280fff7a03ecf612c3fdb172bf716467cfb9c0b9d90661b3523c2d8ee1b9a6e", size = 681040, upload-time = "2026-07-29T14:22:50.372Z" }, ] [package.optional-dependencies]