mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 14:00:35 +00:00
* feat(hitl): add human-in-the-loop approval for high-risk tool calls * fix(hitl): address approval review findings * docs(hitl): keep agent guidance under size limit * fix(hitl): guard approval pause recovery * docs(hitl): shrink inherited guidance * fix(hitl): harden approval admission and recovery * fix(approvals): address review safety and reconciliation gaps * ci: retry approval checks after runner timeout --------- Co-authored-by: wangzeren <1004695029@qq.com>
249 lines
12 KiB
Python
249 lines
12 KiB
Python
"""Drive real graph pause/resume; a card is not proof that the tool was gated."""
|
|
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from _agent_e2e_helpers import build_single_tool_call_model
|
|
from langchain.agents import create_agent
|
|
from langchain_core.messages import ToolMessage
|
|
from langchain_core.tools import tool
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
from langgraph.types import Command
|
|
|
|
from deerflow.approvals.middleware import ToolApprovalMiddleware
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.config.hitl_approval_config import HitlApprovalConfig
|
|
|
|
|
|
def graph_fixture(*, mode="ask", reviewer=None, policy_overrides=None, inner=None):
|
|
calls = []
|
|
|
|
@tool
|
|
def external_write(value: str) -> str:
|
|
"""Write externally."""
|
|
calls.append(value)
|
|
return "written"
|
|
|
|
policy = HitlApprovalConfig(enabled=True, tools=["external_write"], default_mode=mode, **{"allowed_modes": ["ask", "auto", "full"], **(policy_overrides or {})})
|
|
middleware = ToolApprovalMiddleware(policy, app_config=AppConfig(sandbox={"use": "deerflow.sandbox.local:LocalSandboxProvider"}, hitl_approval=policy), reviewer=reviewer)
|
|
model = build_single_tool_call_model(tool_name="external_write", tool_args={"value": "original"}, tool_call_id="call")
|
|
graph = create_agent(model, tools=[external_write], middleware=[middleware, *([inner] if inner else [])], checkpointer=InMemorySaver())
|
|
return graph, {"configurable": {"thread_id": "approval-thread"}}, calls
|
|
|
|
|
|
def response(paused, option, **extra):
|
|
payload = paused["__interrupt__"][0].value
|
|
action = payload["actions"][0]
|
|
return {"responses": [{"tool_call_id": action["tool_call_id"], "request_id": action["request_id"], "option_id": option, **extra}]}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ask_really_stops_then_executes_exact_call_once():
|
|
graph, config, calls = graph_fixture()
|
|
paused = await graph.ainvoke({"messages": [("user", "write original")]}, config)
|
|
assert not calls
|
|
await graph.ainvoke(Command(resume=response(paused, "approve")), config)
|
|
assert calls == ["original"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejection_and_stale_response_never_execute():
|
|
for decision in ("reject", "approve"):
|
|
graph, config, calls = graph_fixture()
|
|
paused = await graph.ainvoke({"messages": [("user", "write original")]}, config)
|
|
value = response(paused, decision)
|
|
if decision == "approve":
|
|
value["responses"][0]["request_id"] = "stale"
|
|
result = await graph.ainvoke(Command(resume=value), config)
|
|
assert not calls
|
|
assert any(isinstance(m, ToolMessage) and m.status == "error" for m in result["messages"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_uses_separate_reviewer_and_never_rereviews_on_resume():
|
|
reviewer = AsyncMock(return_value={"outcome": "ask", "risk": "high", "authorization": "explicit", "reason": "External change"})
|
|
graph, config, calls = graph_fixture(mode="auto", reviewer=reviewer)
|
|
paused = await graph.ainvoke({"messages": [("user", "write original")]}, config)
|
|
assert not calls
|
|
assert reviewer.await_count == 1
|
|
await graph.ainvoke(Command(resume=response(paused, "approve")), config)
|
|
assert calls == ["original"]
|
|
assert reviewer.await_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_skips_reviewer_but_still_executes_inner_middleware():
|
|
from langchain.agents.middleware import AgentMiddleware
|
|
|
|
class MandatoryGuard(AgentMiddleware):
|
|
async def awrap_tool_call(self, request, handler):
|
|
return ToolMessage(content="Denied by account permission", tool_call_id=request.tool_call["id"], status="error")
|
|
|
|
reviewer = AsyncMock(side_effect=AssertionError("Full must not invoke the reviewer"))
|
|
graph, config, calls = graph_fixture(mode="full", reviewer=reviewer, inner=MandatoryGuard())
|
|
await graph.ainvoke({"messages": [("user", "write original")]}, config)
|
|
assert calls == []
|
|
reviewer.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("mode", ["ask", "auto", "full"])
|
|
async def test_operator_deny_applies_in_every_mode(mode):
|
|
graph, config, calls = graph_fixture(mode=mode, policy_overrides={"deny_tools": ["external_write"]})
|
|
result = await graph.ainvoke({"messages": [("user", "write original")]}, config)
|
|
assert not calls and not result.get("__interrupt__")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_edit_and_injected_runtime_fields_never_execute():
|
|
for args in ({"value": 99}, {"value": "edited", "runtime": {"is_admin": True}}):
|
|
graph, config, calls = graph_fixture()
|
|
paused = await graph.ainvoke({"messages": [("user", "write original")]}, config)
|
|
await graph.ainvoke(Command(resume=response(paused, "edit", args=args)), config)
|
|
assert not calls
|
|
|
|
|
|
def test_sync_graph_suspends_before_execution():
|
|
graph, config, calls = graph_fixture()
|
|
paused = graph.invoke({"messages": [("user", "write original")]}, config)
|
|
assert not calls
|
|
graph.invoke(Command(resume=response(paused, "approve")), config)
|
|
assert calls == ["original"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("cancel", [False, True])
|
|
async def test_child_in_an_isolated_loop_waits_for_parent_decision(cancel):
|
|
import asyncio
|
|
import threading
|
|
from types import SimpleNamespace
|
|
|
|
from deerflow.approvals.runtime import ApprovalRuntime
|
|
from deerflow.approvals.store import ApprovalStore
|
|
|
|
store = ApprovalStore()
|
|
scope = ("owner", "parent-thread", "incarnation")
|
|
bridge = ApprovalRuntime(store, scope=scope, run_id="parent-run", mode="ask", timeout=5, record=SimpleNamespace(ownership_lost=False, abort_event=threading.Event()))
|
|
bridge.set_user_messages(["write original"])
|
|
graph, config, calls = graph_fixture()
|
|
context = {"is_subagent": True, "__tool_approval_runtime": bridge.child(), "user_id": "owner", "thread_id": "parent-thread"}
|
|
running = asyncio.create_task(asyncio.to_thread(lambda: asyncio.run(graph.ainvoke({"messages": [("user", "delegated work")]}, config, context=context))))
|
|
async with asyncio.timeout(5):
|
|
while not (rows := await store.list(scope=scope, status="pending")):
|
|
await asyncio.sleep(0.01)
|
|
row = rows[0]
|
|
assert row["payload"]["transport"] == "live"
|
|
assert calls == []
|
|
if cancel:
|
|
await bridge.close()
|
|
else:
|
|
action = row["payload"]["actions"][0]
|
|
await store.decide(row["request_id"], {"responses": [{"request_id": action["request_id"], "tool_call_id": action["tool_call_id"], "option_id": "approve"}]}, scope=scope, fingerprint=row["fingerprint"])
|
|
await running
|
|
assert calls == ([] if cancel else ["original"])
|
|
assert (await store.get(row["request_id"], scope=scope))["status"] == ("cancelled" if cancel else "consumed")
|
|
await bridge.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exact_thread_grant_is_reused_then_revoked():
|
|
import threading
|
|
from types import SimpleNamespace
|
|
|
|
from deerflow.approvals.runtime import ApprovalRuntime
|
|
from deerflow.approvals.store import ApprovalStore
|
|
|
|
bridge = ApprovalRuntime(ApprovalStore(), scope=("owner", "thread", "incarnation"), run_id="run", mode="ask", timeout=5, record=SimpleNamespace(ownership_lost=False, abort_event=threading.Event()))
|
|
context = {"__tool_approval_runtime": bridge, "user_id": "owner", "thread_id": "thread"}
|
|
graph, config, calls = graph_fixture()
|
|
paused = await graph.ainvoke({"messages": [("user", "write original")]}, config, context=context)
|
|
assert paused["__interrupt__"][0].value["actions"][0]["grant_allowed"]
|
|
await graph.ainvoke(Command(resume=response(paused, "approve_thread")), config, context=context)
|
|
assert calls == ["original"]
|
|
fresh, config, fresh_calls = graph_fixture()
|
|
result = await fresh.ainvoke({"messages": [("user", "write original")]}, config, context=context)
|
|
assert not result.get("__interrupt__") and fresh_calls == ["original"]
|
|
await bridge.store.revoke(scope=bridge.scope)
|
|
fresh, config, fresh_calls = graph_fixture()
|
|
result = await fresh.ainvoke({"messages": [("user", "write original")]}, config, context=context)
|
|
assert result.get("__interrupt__") and fresh_calls == []
|
|
await bridge.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edited_arguments_are_schema_checked_before_tool_execution():
|
|
graph, config, calls = graph_fixture()
|
|
paused = await graph.ainvoke({"messages": [("user", "write original")]}, config)
|
|
await graph.ainvoke(Command(resume=response(paused, "edit", args={"value": "edited"})), config)
|
|
assert calls == ["edited"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_noninteractive_auto_uncertainty_fails_closed_without_parking():
|
|
reviewer = AsyncMock(return_value={"outcome": "ask", "risk": "high", "authorization": "unknown", "reason": "Need authorization"})
|
|
graph, config, calls = graph_fixture(mode="auto", reviewer=reviewer)
|
|
result = await graph.ainvoke({"messages": [("user", "write original")]}, config, context={"non_interactive": True})
|
|
assert not calls
|
|
assert not result.get("__interrupt__")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parallel_approval_uses_each_interrupt_identity_without_replaying_sibling():
|
|
from _approval_test_helpers import StateAwareToolModel
|
|
|
|
calls = []
|
|
|
|
@tool
|
|
def external_write(value: str) -> str:
|
|
"""Write externally."""
|
|
calls.append(value)
|
|
return "written"
|
|
|
|
policy = HitlApprovalConfig(enabled=True)
|
|
app = AppConfig(sandbox={"use": "deerflow.sandbox.local:LocalSandboxProvider"}, hitl_approval=policy)
|
|
graph = create_agent(
|
|
StateAwareToolModel(responses=[], calls=[{"name": "external_write", "args": {"value": value}, "id": value} for value in ("a", "b")]),
|
|
tools=[external_write],
|
|
middleware=[ToolApprovalMiddleware(policy, app_config=app)],
|
|
checkpointer=InMemorySaver(),
|
|
)
|
|
config = {"configurable": {"thread_id": "parallel"}}
|
|
paused = await graph.ainvoke({"messages": [("user", "write a and b")]}, config)
|
|
assert len(paused["__interrupt__"]) == 2
|
|
assert not calls
|
|
resume = {}
|
|
for item in paused["__interrupt__"]:
|
|
action = item.value["actions"][0]
|
|
resume[item.id] = {"responses": [{"request_id": action["request_id"], "tool_call_id": action["tool_call_id"], "option_id": "approve"}]}
|
|
await graph.ainvoke(Command(resume=resume), config)
|
|
assert sorted(calls) == ["a", "b"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_approved_parallel_sibling_is_not_reexecuted_on_human_resume():
|
|
from _approval_test_helpers import StateAwareToolModel
|
|
|
|
calls = []
|
|
|
|
@tool
|
|
def external_write(value: str) -> str:
|
|
"""Record a write."""
|
|
calls.append(value)
|
|
return "written"
|
|
|
|
async def review(action, instructions):
|
|
return {"outcome": "allow" if action["arguments"]["value"] == "a" else "ask", "risk": "medium", "authorization": "explicit", "reason": "review"}
|
|
|
|
reviewer = AsyncMock(side_effect=review)
|
|
policy = HitlApprovalConfig(enabled=True, default_mode="auto")
|
|
app = AppConfig(sandbox={"use": "deerflow.sandbox.local:LocalSandboxProvider"}, hitl_approval=policy)
|
|
model = StateAwareToolModel(responses=[], calls=[{"name": "external_write", "args": {"value": value}, "id": value} for value in ("a", "b")])
|
|
graph = create_agent(model, tools=[external_write], middleware=[ToolApprovalMiddleware(policy, app_config=app, reviewer=reviewer)], checkpointer=InMemorySaver())
|
|
config = {"configurable": {"thread_id": "mixed"}}
|
|
paused = await graph.ainvoke({"messages": [("user", "write both")]}, config)
|
|
assert calls == ["a"]
|
|
assert len(paused["__interrupt__"]) == 1
|
|
await graph.ainvoke(Command(resume=response(paused, "approve")), config)
|
|
assert calls == ["a", "b"]
|
|
assert reviewer.await_count == 2
|