mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* feat(authz): extension tool authorization PR2 — middleware-declared tool path Layer 1 now covers tools declared by middlewares, which LangChain merges into the bound tool set after the host's explicit-list filter: - new agents/middlewares/tool_declarations.py: record the ordinary pass's verdict, collect declarations from the built stack, decide only the delta seeded by the first pass (a name denied for this build stays denied), narrow the stack build-locally on independent state-preserving copies (direct __dict__ write, refused up front for tools data descriptors, never mutating caller-owned instances), and verify the exact bound stack after the final normalization copy. - IsolatedMiddleware ships a state-preserving __copy__ so narrowing survives full/delta normalization and copies of copies. - TodoMiddleware degrades when write_todos is denied: no system-prompt injection, no completion/context-loss reminders, no jump_to. - Layer 2 forwards host-resolved tool provenance into AuthzRequest.context and binds the infrastructure exemption to the concrete host-created tool object instead of a name set (tool=None goes to the provider). - Wired into all four assembly paths (lead x2, native subagent with the decision offloaded via asyncio.to_thread, DeerFlowClient). Tests: new test_middleware_declared_tools.py (35) and test_layer2_tool_provenance.py (7); blocking-IO anchor pinning the subagent offload (teeth verified red->green); placement-guarantee and enforcement suites extended; executor test doubles converted for the async _create_agent. Behavior change called out in CHANGELOG: with authorization enabled and a tools policy that excludes write_todos, plan-mode builds no longer bind it (built-in RBAC default unchanged). * fix(authz): close residual gaps from review — narrow non-collectable declarations, dedupe candidates, warn on skipped pass - apply_declared_tool_view now removes non-BaseTool declarations from the bound view when authorization is enabled (they would otherwise bind via LangChain's create_tool auto-conversion with no provider decision); verify_declared_tool_view fails loudly if one survives normalization - decide_declared_tools dedupes never-submitted names so the provider sees each candidate once - subagent executor logs a warning when the provider is set but the seeded Layer-1 state is missing, instead of skipping the declaration pass silently - docs: middleware-declared tools must be BaseTool instances with usable names * fix(authz): fail loudly on non-list/tuple middleware tools declarations _iter_declared warned and returned () for a 'tools' attribute that is not a list/tuple, but LangChain's factory iterates the attribute as-is — so a set/generator/dict_values declaration bound with no Layer-1 decision. Raise DeclaredToolViewError instead, matching the module's fail-loud standard for every other un-narrowable shape. Disabled path remains a strict no-op. Docs updated to match.
141 lines
5.3 KiB
Python
141 lines
5.3 KiB
Python
"""Regression test: subagent _create_agent() must isolate from parent run checkpointer.
|
|
|
|
When a parent run carries a synchronous checkpointer (e.g. SqliteSaver via
|
|
DeerFlowClient), the subagent's ``agent.astream()`` inherits it through
|
|
``copy_context()`` + ``ensure_config()``. Without ``checkpointer=False``
|
|
at compile time, LangGraph's resolution prioritizes the inherited value
|
|
and calls the sync checkpointer's async methods, raising NotImplementedError.
|
|
|
|
The subagent is a one-shot delegation — it rebuilds state, calls astream
|
|
once, and extracts the last AIMessage. It never resumes, so persistence
|
|
is unnecessary and inheriting the parent checkpointer is harmful.
|
|
"""
|
|
|
|
import asyncio
|
|
import sys
|
|
from types import ModuleType, SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
# Module names mocked to break circular imports (same set as test_subagent_executor.py)
|
|
_MOCKED_MODULE_NAMES = [
|
|
"deerflow.agents",
|
|
"deerflow.agents.thread_state",
|
|
"deerflow.agents.middlewares",
|
|
"deerflow.agents.middlewares.thread_data_middleware",
|
|
"deerflow.sandbox",
|
|
"deerflow.sandbox.middleware",
|
|
"deerflow.sandbox.security",
|
|
"deerflow.models",
|
|
"deerflow.skills.storage",
|
|
]
|
|
|
|
|
|
def _default_app_config():
|
|
return SimpleNamespace(tool_search=SimpleNamespace(enabled=False))
|
|
|
|
|
|
def _clear_stale_executor_package_attr() -> None:
|
|
subagents_pkg = sys.modules.get("deerflow.subagents")
|
|
if subagents_pkg is not None and hasattr(subagents_pkg, "executor"):
|
|
delattr(subagents_pkg, "executor")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup_executor_module():
|
|
"""Set up mocked modules and import the real executor (same pattern as test_subagent_executor.py)."""
|
|
original_modules = {name: sys.modules.get(name) for name in _MOCKED_MODULE_NAMES}
|
|
original_executor = sys.modules.get("deerflow.subagents.executor")
|
|
|
|
if "deerflow.subagents.executor" in sys.modules:
|
|
del sys.modules["deerflow.subagents.executor"]
|
|
_clear_stale_executor_package_attr()
|
|
|
|
for name in _MOCKED_MODULE_NAMES:
|
|
sys.modules[name] = MagicMock()
|
|
storage_module = ModuleType("deerflow.skills.storage")
|
|
storage_module.get_or_new_skill_storage = lambda **kwargs: SimpleNamespace(load_skills=lambda *, enabled_only: [])
|
|
sys.modules["deerflow.skills.storage"] = storage_module
|
|
|
|
from deerflow.subagents.config import SubagentConfig
|
|
from deerflow.subagents.executor import SubagentExecutor
|
|
|
|
executor_module = sys.modules["deerflow.subagents.executor"]
|
|
executor_module.get_app_config = _default_app_config
|
|
|
|
yield {
|
|
"SubagentConfig": SubagentConfig,
|
|
"SubagentExecutor": SubagentExecutor,
|
|
"executor_module": executor_module,
|
|
}
|
|
|
|
for name in _MOCKED_MODULE_NAMES:
|
|
if original_modules[name] is not None:
|
|
sys.modules[name] = original_modules[name]
|
|
elif name in sys.modules:
|
|
del sys.modules[name]
|
|
|
|
if original_executor is not None:
|
|
sys.modules["deerflow.subagents.executor"] = original_executor
|
|
elif "deerflow.subagents.executor" in sys.modules:
|
|
del sys.modules["deerflow.subagents.executor"]
|
|
|
|
|
|
class TestSubagentCheckpointerIsolation:
|
|
"""Verify _create_agent() unconditionally passes checkpointer=False to create_agent()."""
|
|
|
|
def test_create_agent_receives_checkpointer_false(
|
|
self,
|
|
_setup_executor_module,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""Assert checkpointer=False is always passed to create_agent()."""
|
|
SubagentConfig = _setup_executor_module["SubagentConfig"]
|
|
SubagentExecutor = _setup_executor_module["SubagentExecutor"]
|
|
executor_module = _setup_executor_module["executor_module"]
|
|
|
|
captured_kwargs: dict = {}
|
|
|
|
def fake_create_agent(**kwargs):
|
|
captured_kwargs.update(kwargs)
|
|
agent = MagicMock()
|
|
agent.checkpointer = False
|
|
return agent
|
|
|
|
def fake_build_subagent_runtime_middlewares(**kwargs):
|
|
return []
|
|
|
|
monkeypatch.setattr(executor_module, "create_agent", fake_create_agent)
|
|
mw_module = ModuleType("deerflow.agents.middlewares.tool_error_handling_middleware")
|
|
mw_module.build_subagent_runtime_middlewares = fake_build_subagent_runtime_middlewares
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"deerflow.agents.middlewares.tool_error_handling_middleware",
|
|
mw_module,
|
|
)
|
|
|
|
executor = SubagentExecutor(
|
|
config=SubagentConfig(
|
|
name="test",
|
|
description="test",
|
|
system_prompt="You are a test agent.",
|
|
),
|
|
tools=[],
|
|
)
|
|
|
|
# Simulate lazy model_name resolution
|
|
def fake_create_chat_model(**kwargs):
|
|
return MagicMock()
|
|
|
|
executor.model_name = "test-model"
|
|
executor._base_tools = []
|
|
|
|
monkeypatch.setattr(executor_module, "create_chat_model", fake_create_chat_model)
|
|
monkeypatch.setattr(executor_module, "resolve_subagent_model_name", lambda config, parent, app_config=None: "test-model")
|
|
|
|
result = asyncio.run(executor._create_agent())
|
|
|
|
assert captured_kwargs.get("checkpointer") is False, f"Expected checkpointer=False in create_agent() kwargs, got: {captured_kwargs.get('checkpointer')!r}"
|
|
assert result.checkpointer is False
|