deer-flow/backend/tests/test_hitl_approval_middleware.py
一片一片 989b20b1dc
fix(hitl): never pause on an approval call without a usable id (#8070)
* fix(hitl): never pause on an approval call without a usable id

A provider can emit a tool call with no id (langchain_core keeps `id=None`),
an empty id, or the same id twice in one response. `HitlApprovalMiddleware`
copied that id into the interrupt payload, but the Gateway resume validator
requires unique non-empty strings: every resume answered 422 and every new run
stayed 409 `approval_pending`, so the thread was wedged permanently — the
all-reject escape is validated after that check. Denial paths also built
`ToolMessage(tool_call_id=None)`, which raises inside `after_model`, so a
non-interactive run failed instead of denying the call.

Treat such calls as unaddressable: strip them from the model response with a
visible rejection note instead of pausing, and deny by removal whenever a
`ToolMessage` could not pair. The tool never executes and the thread stays
usable.

Fixes #8069

* docs(hitl): state the empty-id rule accurately and track legacy recovery

Review follow-up on the unaddressable-approval guard:

- An empty tool-call id was technically resumable end to end (langchain-core
  accepts `ToolMessage("")`, and the Gateway validator accepts `""`), so only a
  null or duplicated id can wedge a pause. The rule still denies empty ids
  because strict providers reject them when the conversation is replayed; the
  docstring, CHANGELOG and interaction policy now say exactly that, and
  `_process_responses` uses the same `_addressable` predicate as the pause
  guard so the two checks cannot drift.
- Add an operator note for pauses stored before this fix: they are not cleared
  here, so continue in a new thread; in-place all-reject recovery is tracked in
  #8071.

* fix(hitl): drop a colliding call id from every serialized surface

Review follow-up: `_strip_calls` removed the unaddressable approval calls from
`tool_calls`, but `clone_ai_message_with_tool_calls` filters the raw
`additional_kwargs["tool_calls"]` payload and content tool-call blocks by id.
A non-target call sharing a removed id kept that id in `kept_ids`, so the
removed calls stayed in the raw provider payload and the next request would
advertise calls that have no result.

Drop the whole colliding id group: an id shared with a denied approval call
cannot be answered unambiguously either. The regression covers a mixed response
(duplicate approval ids plus an ordinary call with the same id) and asserts the
raw payload, content blocks, execution and visible note.

* fix(hitl): reject an id shared with an ordinary call before pausing

Review follow-up: `_addressable(targets)` only asked whether a `ToolMessage`
can answer the targeted calls. A single approval-required call whose id is
shared by an ordinary call in the same response still paused, even though
approving or rejecting it would also answer the ordinary call by id.

Add `_unambiguous_ids`: every targeted id must also be unique across the whole
response. A collision denies the colliding group (strip + visible note) before
the pause, and the resume path applies the same check to a legacy pause. New
regression: one approval-required call and one ordinary call sharing an id.

* fix(hitl): strip a deny target whose id an ordinary call shares

`_deny_all` routed only on `_addressable`, which checks uniqueness among the
targeted calls. A target whose id an ordinary non-target call also carried was
therefore answered with a deny `ToolMessage` while the ordinary call stayed
serialized, so the next request advertised the id twice with two results.

`_deny_all` now requires `_unambiguous_ids` and strips the whole colliding id
group, matching the pause path. The tool still never executes.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-10 21:30:43 +08:00

622 lines
26 KiB
Python

"""Unit tests for HitlApprovalMiddleware (after_model pattern)."""
from __future__ import annotations
import json
from typing import Any
import pytest
from _agent_e2e_helpers import build_single_tool_call_model
from langchain.agents import create_agent
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from deerflow.agents.middlewares.hitl_approval_middleware import (
MAX_PAYLOAD_BYTES,
MAX_REASON_CHARS,
HitlApprovalMiddleware,
summarize_args,
)
from deerflow.config.hitl_approval_config import HitlApprovalConfig
@tool
def write_file(path: str, content: str) -> str:
"""Write a file."""
return "OK"
class _StateAwareToolModel(FakeMessagesListChatModel):
calls: list[dict[str, Any]]
def bind_tools(self, tools: Any, **kwargs: Any) -> Any:
return self
def _generate(self, messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any) -> ChatResult:
if any(isinstance(message, ToolMessage) for message in messages):
message = AIMessage(content="final answer")
else:
message = AIMessage(content="", tool_calls=self.calls)
return ChatResult(generations=[ChatGeneration(message=message)])
def build_agent(middleware, *, tool_calls=None, tool_args=None, checkpointer=None):
if tool_calls is not None:
model = _StateAwareToolModel(responses=[], calls=tool_calls)
else:
model = build_single_tool_call_model(
tool_name="write_file",
tool_args=tool_args or {"path": "/tmp/a.txt", "content": "hello"},
tool_call_id="call_0",
)
return create_agent(model, tools=[write_file], middleware=[middleware], checkpointer=checkpointer or InMemorySaver())
def messages_of(out):
return out["messages"]
def run_pause(middleware, *, metadata=None, context=None, thread="t", tool_calls=None, tool_args=None, checkpointer=None):
agent = build_agent(middleware, tool_calls=tool_calls, tool_args=tool_args, checkpointer=checkpointer)
config = {"configurable": {"thread_id": thread}}
if metadata is not None:
config["metadata"] = metadata
out = agent.invoke({"messages": [{"role": "user", "content": "write"}]}, config, context=context)
return agent, config, out
def approval_response(out, option_id, tool_call_id="call_0", **extra):
payload = out["__interrupt__"][0].value
action = next(action for action in payload["actions"] if action["tool_call_id"] == tool_call_id)
return {"option_id": option_id, "tool_call_id": tool_call_id, "request_id": action["request_id"], **extra}
def test_outside_tool_list_passes_through():
middleware = HitlApprovalMiddleware(tool_names={"bash"})
_, _, out = run_pause(middleware)
assert "__interrupt__" not in out
assert any(isinstance(m, ToolMessage) and m.content == "OK" for m in messages_of(out))
def test_approve_executes_tool():
agent, config, out = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}))
interrupts = out["__interrupt__"]
payload = interrupts[0].value
assert payload["kind"] == "hitl_approval"
assert payload["pending_tool_call_ids"] == ["call_0"]
out = agent.invoke(Command(resume={"responses": [approval_response(out, "approve")]}), config)
assert any(isinstance(m, ToolMessage) and m.content == "OK" for m in messages_of(out))
def test_stale_request_id_denies_all_and_never_executes():
calls = [
{"name": "write_file", "args": {"path": "/tmp/a.txt", "content": "a"}, "id": "call_0"},
{"name": "write_file", "args": {"path": "/tmp/b.txt", "content": "b"}, "id": "call_1"},
]
agent, config, paused = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}), tool_calls=calls)
stale = approval_response(paused, "approve", tool_call_id="call_1")
stale["request_id"] = "hitl_approval:stale-message:call_1"
out = agent.invoke(Command(resume={"responses": [approval_response(paused, "approve"), stale]}), config)
tool_messages = [message for message in messages_of(out) if isinstance(message, ToolMessage)]
assert len(tool_messages) == 2
assert {message.tool_call_id for message in tool_messages} == {"call_0", "call_1"}
assert all(message.status == "error" for message in tool_messages)
assert not any(message.content == "OK" for message in tool_messages)
def test_reject_produces_named_error_tool_message_and_no_execution():
agent, config, paused = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}))
out = agent.invoke(
Command(resume={"responses": [approval_response(paused, "reject", value="too risky")]}),
config,
)
tool_messages = [m for m in messages_of(out) if isinstance(m, ToolMessage)]
assert len(tool_messages) == 1
message = tool_messages[0]
assert message.name == "write_file"
assert message.status == "error"
assert "too risky" in message.content
assert "The tool was not executed" in message.content
assert not any(m.content == "OK" for m in tool_messages)
def test_approve_after_tool_removal_denies_resumed_call():
middleware = HitlApprovalMiddleware(tool_names={"write_file"})
agent, config, paused = run_pause(middleware)
middleware.tool_names.clear()
out = agent.invoke(
Command(resume={"responses": [approval_response(paused, "approve")]}),
config,
context={"_hitl_resume_tool_names": {"write_file"}},
)
tool_messages = [message for message in messages_of(out) if isinstance(message, ToolMessage)]
assert len(tool_messages) == 1
assert tool_messages[0].status == "error"
assert "approval configuration changed" in tool_messages[0].content
assert not any(message.content == "OK" for message in tool_messages)
@pytest.mark.asyncio
async def test_removed_tool_approval_recovers_gate_after_head_moving_write():
from deerflow.runtime.runs.worker import _pending_hitl_resume_tool_names
middleware = HitlApprovalMiddleware(tool_names={"write_file"})
agent, config, paused = run_pause(middleware)
agent.update_state(config, {"messages": [AIMessage(content="head move", id="head-move")]}, as_node="model")
command = Command(resume={"responses": [approval_response(paused, "approve")]})
names = await _pending_hitl_resume_tool_names(agent.checkpointer, config, command)
middleware.tool_names.clear()
assert names == {"write_file"}
@pytest.mark.asyncio
async def test_disabled_reject_escape_recovers_gate_after_head_moving_write():
from deerflow.runtime.runs.worker import _is_all_reject_hitl_resume, _pending_hitl_resume_tool_names
agent, config, paused = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}))
agent.update_state(config, {"messages": [AIMessage(content="head move", id="head-move")]}, as_node="model")
command = Command(resume={"responses": [approval_response(paused, "reject")]})
names = await _pending_hitl_resume_tool_names(agent.checkpointer, config, command)
assert _is_all_reject_hitl_resume(command)
assert names == {"write_file"}
@pytest.mark.asyncio
async def test_pending_resume_names_fall_back_to_sync_saver_history():
from deerflow.runtime.runs.worker import _pending_hitl_resume_tool_names
class SyncOnlySaver(InMemorySaver):
async def alist(self, *args, **kwargs):
raise NotImplementedError
yield # pragma: no cover
async def aget_tuple(self, *args, **kwargs):
raise NotImplementedError
saver = SyncOnlySaver()
agent, config, paused = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}), checkpointer=saver)
agent.update_state(config, {"messages": [AIMessage(content="head move", id="head-move")]}, as_node="model")
command = Command(resume={"responses": [approval_response(paused, "approve")]})
assert await _pending_hitl_resume_tool_names(saver, config, command) == {"write_file"}
@pytest.mark.asyncio
async def test_pending_resume_names_tuple_only_saver_handles_empty_and_paused_heads():
from deerflow.runtime.runs.worker import _pending_hitl_resume_tool_names
class TupleOnlySaver(InMemorySaver):
async def alist(self, *args, **kwargs):
raise NotImplementedError
yield # pragma: no cover
def list(self, *args, **kwargs):
raise NotImplementedError
async def aget_tuple(self, *args, **kwargs):
raise NotImplementedError
saver = TupleOnlySaver()
command = Command(resume={"responses": [{"option_id": "approve", "tool_call_id": "call_0"}]})
config = {"configurable": {"thread_id": "t"}}
assert await _pending_hitl_resume_tool_names(saver, config, command) == set()
agent, config, paused = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}), checkpointer=saver)
command = Command(resume={"responses": [approval_response(paused, "approve")]})
assert await _pending_hitl_resume_tool_names(saver, config, command) == {"write_file"}
@pytest.mark.asyncio
async def test_pending_resume_names_newer_resume_wins_over_old_interrupt():
from deerflow.runtime.runs.worker import _pending_hitl_resume_tool_names
agent, config, paused = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}))
command = Command(resume={"responses": [approval_response(paused, "reject")]})
agent.invoke(command, config)
assert await _pending_hitl_resume_tool_names(agent.checkpointer, config, command) == set()
def test_request_id_stable_across_replay_and_contains_message_id():
agent, config, out = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}))
first = out["__interrupt__"][0].value["request_id"]
agent.invoke(Command(resume={"responses": [approval_response(out, "approve")]}), config)
# The payload is rebuilt identically on replay; id contains the AI message id.
ai_id = next(m.id for m in messages_of(out) if isinstance(m, AIMessage))
assert first == f"hitl_approval:{ai_id}:call_0"
def test_non_interactive_denies_without_interrupt():
_, _, out = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}), context={"non_interactive": True})
assert "__interrupt__" not in out
assert any(isinstance(m, ToolMessage) and m.status == "error" for m in messages_of(out))
assert not any(isinstance(m, ToolMessage) and m.content == "OK" for m in messages_of(out))
def test_im_origin_allow_executes_without_pause():
_, _, out = run_pause(
HitlApprovalMiddleware(tool_names={"write_file"}, non_interactive="allow"),
metadata={"deerflow_origin": {"kind": "im_channel"}},
)
assert "__interrupt__" not in out
assert any(isinstance(m, ToolMessage) and m.content == "OK" for m in messages_of(out))
def test_im_origin_deny_rejects_without_pause():
_, _, out = run_pause(
HitlApprovalMiddleware(tool_names={"write_file"}),
metadata={"deerflow_origin": {"kind": "im_channel"}},
)
assert "__interrupt__" not in out
assert any(isinstance(m, ToolMessage) and m.status == "error" for m in messages_of(out))
def test_subagent_fails_closed_even_with_allow():
_, _, out = run_pause(
HitlApprovalMiddleware(tool_names={"write_file"}, non_interactive="allow"),
context={"is_subagent": True},
)
assert "__interrupt__" not in out
assert any(isinstance(m, ToolMessage) and m.status == "error" for m in messages_of(out))
def test_supports_resume_false_fails_closed():
_, _, out = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}, supports_resume=False))
assert "__interrupt__" not in out
assert any(isinstance(m, ToolMessage) and m.status == "error" for m in messages_of(out))
def test_response_count_mismatch_denies_all_and_can_resume_again():
agent, config, _ = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}))
out = agent.invoke(Command(resume={"responses": []}), config)
assert any(isinstance(m, ToolMessage) and m.status == "error" for m in messages_of(out))
assert not any(isinstance(m, ToolMessage) and m.content == "OK" for m in messages_of(out))
def test_unknown_tool_call_id_denies_all():
agent, config, _ = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}))
out = agent.invoke(Command(resume={"responses": [{"option_id": "approve", "tool_call_id": "nope", "request_id": "hitl_approval:unknown:nope"}]}), config)
assert any(isinstance(m, ToolMessage) and m.status == "error" for m in messages_of(out))
assert not any(isinstance(m, ToolMessage) and m.content == "OK" for m in messages_of(out))
def test_unknown_option_rejects_item():
agent, config, paused = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}))
out = agent.invoke(Command(resume={"responses": [approval_response(paused, "edit")]}), config)
assert any(isinstance(m, ToolMessage) and m.status == "error" for m in messages_of(out))
assert not any(isinstance(m, ToolMessage) and m.content == "OK" for m in messages_of(out))
def test_duplicate_resume_ids_deny_all_targets():
calls = [
{"name": "write_file", "args": {"path": "/tmp/a.txt", "content": "a"}, "id": "call_0"},
{"name": "write_file", "args": {"path": "/tmp/b.txt", "content": "b"}, "id": "call_1"},
]
agent, config, paused = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}), tool_calls=calls)
out = agent.invoke(
Command(
resume={
"responses": [
approval_response(paused, "approve"),
approval_response(paused, "reject"),
]
}
),
config,
)
tool_messages = [message for message in messages_of(out) if isinstance(message, ToolMessage)]
assert len(tool_messages) == 2
assert {message.tool_call_id for message in tool_messages} == {"call_0", "call_1"}
assert all(message.status == "error" for message in tool_messages)
assert not any(message.content == "OK" for message in tool_messages)
def test_mixed_approve_and_reject_requires_and_covers_each_target_once():
calls = [
{"name": "write_file", "args": {"path": "/tmp/a.txt", "content": "a"}, "id": "call_0"},
{"name": "write_file", "args": {"path": "/tmp/b.txt", "content": "b"}, "id": "call_1"},
]
agent, config, paused = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}), tool_calls=calls)
out = agent.invoke(
Command(
resume={
"responses": [
approval_response(paused, "approve"),
approval_response(paused, "reject", tool_call_id="call_1", value="not this one"),
]
}
),
config,
)
tool_messages = [message for message in messages_of(out) if isinstance(message, ToolMessage)]
assert len(tool_messages) == 2
by_id = {message.tool_call_id: message for message in tool_messages}
assert by_id["call_0"].content == "OK"
assert by_id["call_1"].status == "error"
assert "not this one" in by_id["call_1"].content
def test_non_string_rejection_reason_is_rendered_as_bounded_text():
agent, config, paused = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}))
out = agent.invoke(
Command(resume={"responses": [approval_response(paused, "reject", value={"why": "unsafe"})]}),
config,
)
tool_messages = [message for message in messages_of(out) if isinstance(message, ToolMessage)]
assert len(tool_messages) == 1
assert tool_messages[0].status == "error"
assert "unsafe" in tool_messages[0].content
def test_rejection_reason_is_truncated():
agent, config, paused = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}))
out = agent.invoke(
Command(resume={"responses": [approval_response(paused, "reject", value="r" * (MAX_REASON_CHARS + 100))]}),
config,
)
message = next(message for message in messages_of(out) if isinstance(message, ToolMessage))
assert message.content.endswith("r" * MAX_REASON_CHARS)
assert "r" * (MAX_REASON_CHARS + 1) not in message.content
def test_unknown_option_reason_is_bounded():
agent, config, paused = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}))
out = agent.invoke(
Command(resume={"responses": [approval_response(paused, "x" * (MAX_REASON_CHARS * 2))]}),
config,
)
message = next(message for message in messages_of(out) if isinstance(message, ToolMessage))
prefix = "The tool was not executed. Do not retry unless the user explicitly asks. User reason: "
assert message.status == "error"
assert len(message.content) <= len(prefix) + MAX_REASON_CHARS
def _approval_calls(count: int) -> list[dict[str, Any]]:
return [{"name": "write_file", "args": {"path": f"/tmp/{index}.txt", "content": "x"}, "id": f"call_{index}"} for index in range(count)]
def test_more_than_configured_max_actions_denies_every_call_without_pausing():
_, _, out = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}, max_actions=2), tool_calls=_approval_calls(3))
tool_messages = [message for message in messages_of(out) if isinstance(message, ToolMessage)]
assert "__interrupt__" not in out
assert len(tool_messages) == 3
assert all(message.status == "error" for message in tool_messages)
assert all("3 > 2" in message.content for message in tool_messages)
def test_max_actions_boundary_still_pauses_for_approval():
_, _, out = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}, max_actions=2), tool_calls=_approval_calls(2))
assert len(out["__interrupt__"][0].value["actions"]) == 2
def test_zero_max_actions_disables_count_limit():
_, _, out = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}, max_actions=0), tool_calls=_approval_calls(33))
assert len(out["__interrupt__"][0].value["actions"]) == 33
def test_max_actions_config_rejects_negative_values():
assert HitlApprovalConfig(max_actions=0).max_actions == 0
with pytest.raises(ValueError, match="max_actions"):
HitlApprovalConfig(max_actions=-1)
def test_oversized_approval_payload_denies_without_pausing():
_, _, out = run_pause(
HitlApprovalMiddleware(tool_names={"write_file"}),
tool_args={"path": "/tmp/" + "x" * MAX_PAYLOAD_BYTES, "content": "hello"},
)
tool_messages = [message for message in messages_of(out) if isinstance(message, ToolMessage)]
assert "__interrupt__" not in out
assert len(tool_messages) == 1
assert tool_messages[0].status == "error"
def test_summarize_args_preserves_high_semantic_fields():
long_content = "x" * 5000
summary = summarize_args({"path": "/tmp/a.txt", "content": long_content, "old_str": "a", "new_str": "b"})
assert summary["path"] == "/tmp/a.txt"
assert summary["old_str"] == "a"
assert summary["new_str"] == "b"
assert "chars omitted" in summary["content"]
assert summary["content"].startswith("xxxx")
assert summary["content"].endswith("xxxx")
def _visible_text(content):
if isinstance(content, str):
return content
return chr(10).join(block.get("text", "") for block in content if isinstance(block, dict))
def _assert_stripped_without_pausing(out):
assert "__interrupt__" not in out
messages = messages_of(out)
# No result can be paired with an unaddressable call, so the call is removed
# instead of answered: nothing may execute and nothing may pause.
assert not any(isinstance(message, ToolMessage) for message in messages)
assert not any("OK" in _visible_text(message.content) for message in messages)
answers = [message for message in messages if isinstance(message, AIMessage)]
assert all(not message.tool_calls for message in answers)
assert any("no usable id" in _visible_text(message.content) for message in answers)
def test_null_tool_call_id_is_denied_instead_of_pausing():
"""A provider can emit a tool call without an id; pausing would wedge the thread.
The Gateway resume validator rejects a payload whose ``pending_tool_call_ids``
contains a non-string with 422, while a pending pause blocks new runs with
409, so an interrupt created here could never be cleared.
"""
calls = [{"name": "write_file", "args": {"path": "/tmp/a.txt", "content": "a"}, "id": None}]
_, _, out = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}), tool_calls=calls)
_assert_stripped_without_pausing(out)
def test_duplicate_tool_call_ids_are_denied_instead_of_pausing():
calls = [
{"name": "write_file", "args": {"path": "/tmp/a.txt", "content": "a"}, "id": "call_dup"},
{"name": "write_file", "args": {"path": "/tmp/b.txt", "content": "b"}, "id": "call_dup"},
]
_, _, out = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}), tool_calls=calls)
_assert_stripped_without_pausing(out)
def test_empty_tool_call_id_is_denied_instead_of_pausing():
calls = [{"name": "write_file", "args": {"path": "/tmp/a.txt", "content": "a"}, "id": ""}]
_, _, out = run_pause(HitlApprovalMiddleware(tool_names={"write_file"}), tool_calls=calls)
_assert_stripped_without_pausing(out)
class _RawPayloadApprovalModel(FakeMessagesListChatModel):
"""Emit structured tool calls plus the raw OpenAI-style provider payload."""
calls: list[dict[str, Any]]
def bind_tools(self, tools: Any, **kwargs: Any) -> Any:
return self
def _generate(self, messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any) -> ChatResult:
if any(isinstance(message, ToolMessage) for message in messages):
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="final answer"))])
raw = [{"id": call["id"], "type": "function", "function": {"name": call["name"], "arguments": json.dumps(call.get("args", {}))}} for call in self.calls]
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="", tool_calls=self.calls, additional_kwargs={"tool_calls": raw}))])
def test_stripping_a_shared_id_clears_the_raw_provider_payload_too():
"""A non-target call sharing the removed id must not keep the removed call serialized.
``clone_ai_message_with_tool_calls`` filters ``additional_kwargs["tool_calls"]``
and content blocks by id, so a surviving ordinary call with the same id would
keep the denied approval calls in the raw provider payload; the next request
would advertise calls that have no result.
"""
executed: list[str] = []
@tool
def echo(text: str = "") -> str:
"""Echo text."""
executed.append("echo")
return "echoed"
calls = [
{"name": "write_file", "args": {"path": "/tmp/a.txt", "content": "a"}, "id": "call_shared"},
{"name": "write_file", "args": {"path": "/tmp/b.txt", "content": "b"}, "id": "call_shared"},
{"name": "echo", "args": {"text": "hi"}, "id": "call_shared"},
]
model = _RawPayloadApprovalModel(responses=[], calls=calls)
agent = create_agent(
model,
tools=[write_file, echo],
middleware=[HitlApprovalMiddleware(tool_names={"write_file"})],
checkpointer=InMemorySaver(),
)
out = agent.invoke({"messages": [{"role": "user", "content": "go"}]}, {"configurable": {"thread_id": "shared-raw"}})
assert "__interrupt__" not in out
messages = messages_of(out)
assert not any(isinstance(message, ToolMessage) for message in messages)
assert executed == []
answer = messages[-1]
assert answer.tool_calls == []
assert "tool_calls" not in answer.additional_kwargs
assert "no usable id" in _visible_text(answer.content)
def test_shared_id_with_an_ordinary_call_is_denied_instead_of_pausing():
"""An id shared with a non-target call makes the approval ambiguous.
Approving or rejecting the targeted call would also answer the ordinary one
by id, so the colliding group is rejected before any pause is created.
"""
executed: list[str] = []
@tool
def echo(text: str = "") -> str:
"""Echo text."""
executed.append("echo")
return "echoed"
calls = [
{"name": "write_file", "args": {"path": "/tmp/a.txt", "content": "a"}, "id": "call_shared"},
{"name": "echo", "args": {"text": "hi"}, "id": "call_shared"},
]
model = _RawPayloadApprovalModel(responses=[], calls=calls)
agent = create_agent(
model,
tools=[write_file, echo],
middleware=[HitlApprovalMiddleware(tool_names={"write_file"})],
checkpointer=InMemorySaver(),
)
out = agent.invoke({"messages": [{"role": "user", "content": "go"}]}, {"configurable": {"thread_id": "shared-mixed"}})
assert "__interrupt__" not in out
messages = messages_of(out)
assert not any(isinstance(message, ToolMessage) for message in messages)
assert executed == []
answer = messages[-1]
assert answer.tool_calls == []
assert "tool_calls" not in answer.additional_kwargs
assert "no usable id" in _visible_text(answer.content)
def test_deny_path_strips_a_target_sharing_an_id_with_an_ordinary_call():
"""Denying by result answers a call by id, so a shared id must be stripped.
``_deny_all`` routed on ``_addressable`` (uniqueness within the targets), so
a target whose id an ordinary call also carried got a deny ``ToolMessage``
while the ordinary call stayed serialized. The next request then advertised
the id twice with two results, which strict providers reject.
"""
executed: list[str] = []
@tool
def echo(text: str = "") -> str:
"""Echo text."""
executed.append("echo")
return "echoed"
calls = [
{"name": "write_file", "args": {"path": "/tmp/a.txt", "content": "a"}, "id": "call_shared"},
{"name": "echo", "args": {"text": "hi"}, "id": "call_shared"},
]
model = _RawPayloadApprovalModel(responses=[], calls=calls)
agent = create_agent(
model,
tools=[write_file, echo],
middleware=[HitlApprovalMiddleware(tool_names={"write_file"}, supports_resume=False)],
checkpointer=InMemorySaver(),
)
out = agent.invoke({"messages": [{"role": "user", "content": "go"}]}, {"configurable": {"thread_id": "shared-deny"}})
assert "__interrupt__" not in out
messages = messages_of(out)
assert not any(isinstance(message, ToolMessage) for message in messages)
assert executed == []
answer = messages[-1]
assert answer.tool_calls == []
assert "tool_calls" not in answer.additional_kwargs
assert "cannot collect approval" in _visible_text(answer.content)