mirror of
https://github.com/hwchase17/langchain.git
synced 2026-07-12 11:21:37 +00:00
feat(langchain): rejection_response override for HumanInTheLoopMiddleware
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""Human in the loop middleware."""
|
||||
|
||||
from typing import Any, Literal, Protocol
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Literal, Protocol, TypeAlias
|
||||
|
||||
from langchain_core.messages import AIMessage, ToolCall, ToolMessage
|
||||
from langgraph.runtime import Runtime
|
||||
@@ -113,6 +114,30 @@ class RespondDecision(TypedDict):
|
||||
Decision = ApproveDecision | EditDecision | RejectDecision | RespondDecision
|
||||
|
||||
|
||||
RejectionResponseFactory: TypeAlias = Callable[[ToolCall, RejectDecision], ToolMessage]
|
||||
"""Factory that builds the synthetic `ToolMessage` returned for a `reject` decision.
|
||||
|
||||
!!! warning "Alpha"
|
||||
|
||||
This signature is alpha and subject to change or removal without notice.
|
||||
|
||||
Receives the original `ToolCall` and the human's `RejectDecision`; returns the
|
||||
`ToolMessage` paired with the call. See `InterruptOnConfig.rejection_response`
|
||||
for usage guidance and contract requirements.
|
||||
"""
|
||||
|
||||
RejectionResponse: TypeAlias = ToolMessage | RejectionResponseFactory
|
||||
"""Override for the `ToolMessage` produced by a `reject` decision.
|
||||
|
||||
!!! warning "Alpha"
|
||||
|
||||
This signature is alpha and subject to change or removal without notice.
|
||||
|
||||
Either a pre-built `ToolMessage` template or a `RejectionResponseFactory`
|
||||
callable. See `InterruptOnConfig.rejection_response` for full usage guidance.
|
||||
"""
|
||||
|
||||
|
||||
class HITLResponse(TypedDict):
|
||||
"""Response payload for a HITLRequest."""
|
||||
|
||||
@@ -178,6 +203,128 @@ class InterruptOnConfig(TypedDict):
|
||||
args_schema: NotRequired[dict[str, Any]]
|
||||
"""JSON schema for the args associated with the action, if edits are allowed."""
|
||||
|
||||
rejection_response: NotRequired[RejectionResponse]
|
||||
"""Override the synthetic `ToolMessage` produced when a human rejects this action.
|
||||
|
||||
!!! warning "Alpha"
|
||||
|
||||
This field is alpha and subject to change or removal without notice.
|
||||
|
||||
Two forms, with deliberately asymmetric strictness:
|
||||
|
||||
- A pre-built `ToolMessage` template — the middleware returns a copy with
|
||||
`tool_call_id` and `name` overwritten from the rejected call, so any
|
||||
values you set for those two fields on the template are treated as
|
||||
placeholders. Use this form when you want a generic message reused
|
||||
across many tools without threading per-call ids through.
|
||||
- A `RejectionResponseFactory` (a `(ToolCall, RejectDecision) -> ToolMessage`
|
||||
callable). The factory has access to the call, so it is responsible
|
||||
for setting `tool_call_id` and `name` correctly; mismatches raise
|
||||
`ValueError` and a non-`ToolMessage` return raises `TypeError`.
|
||||
|
||||
Only meaningful when `'reject'` is in `allowed_decisions`; setting it
|
||||
otherwise raises at middleware construction.
|
||||
|
||||
By default the middleware emits a `status="error"` `ToolMessage`. Override
|
||||
when you need custom copy, additional metadata for downstream consumers,
|
||||
or a different `status` — for example, models that interpret `error` as a
|
||||
transient failure and immediately re-emit the same tool call can be
|
||||
calmed down with `status="success"` and retry-discouraging content.
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Suppress retries on providers that re-emit on `status="error"`.
|
||||
config = InterruptOnConfig(
|
||||
allowed_decisions=["approve", "reject"],
|
||||
rejection_response=lambda tool_call, decision: ToolMessage(
|
||||
content=(
|
||||
f"`{tool_call['name']}` declined: "
|
||||
f"{decision.get('message', 'no reason provided')}. "
|
||||
"Do not retry."
|
||||
),
|
||||
name=tool_call["name"],
|
||||
tool_call_id=tool_call["id"],
|
||||
status="success",
|
||||
additional_kwargs={"hitl_decision": "reject"},
|
||||
),
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
def _default_rejection_message(tool_call: ToolCall, decision: RejectDecision) -> ToolMessage:
|
||||
"""Build the default `ToolMessage` returned when a tool call is rejected."""
|
||||
# An explicitly empty `decision["message"]` falls back to the canned text;
|
||||
# treating it as "no message" matches caller intent.
|
||||
content = decision.get("message") or (
|
||||
f"User rejected the tool call for `{tool_call['name']}` with id {tool_call['id']}"
|
||||
)
|
||||
return ToolMessage(
|
||||
content=content,
|
||||
name=tool_call["name"],
|
||||
tool_call_id=tool_call["id"],
|
||||
status="error",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_rejection_response(
|
||||
override: RejectionResponse | None,
|
||||
tool_call: ToolCall,
|
||||
decision: RejectDecision,
|
||||
) -> ToolMessage:
|
||||
"""Resolve a `rejection_response` override into a concrete `ToolMessage`.
|
||||
|
||||
Three branches:
|
||||
|
||||
- `None` — fall back to the default rejection message.
|
||||
- A `ToolMessage` template — return a copy with `tool_call_id` and `name`
|
||||
overwritten from the rejected call so providers can pair the result to
|
||||
the originating call without callers threading those fields through.
|
||||
The caller's template is not mutated.
|
||||
- A factory callable — invoke it and validate the result. The return must
|
||||
be a `ToolMessage` whose `tool_call_id` and `name` match the rejected
|
||||
call, so the `AIMessage` / `ToolMessage` pair stays well-formed for
|
||||
providers (Anthropic in particular rejects mismatched `tool_use_id` /
|
||||
`tool_result.tool_use_id`).
|
||||
|
||||
Validation is intentionally asymmetric: the template branch *stamps* the
|
||||
identity fields (template is meant to be generic and reusable), while the
|
||||
factory branch *validates* them (the factory has the call in hand and is
|
||||
expected to set them correctly).
|
||||
"""
|
||||
if override is None:
|
||||
return _default_rejection_message(tool_call, decision)
|
||||
if isinstance(override, ToolMessage):
|
||||
return override.model_copy(
|
||||
update={"tool_call_id": tool_call["id"], "name": tool_call["name"]}
|
||||
)
|
||||
message = override(tool_call, decision)
|
||||
# User-supplied callable: verify the runtime contract even though the
|
||||
# static type narrows to `ToolMessage`.
|
||||
if not isinstance(message, ToolMessage):
|
||||
msg = ( # type: ignore[unreachable]
|
||||
f"`rejection_response` callable for tool '{tool_call['name']}' returned "
|
||||
f"{type(message).__name__!r}; expected a `ToolMessage`."
|
||||
)
|
||||
raise TypeError(msg)
|
||||
if message.tool_call_id != tool_call["id"]:
|
||||
msg = (
|
||||
f"`rejection_response` callable for tool '{tool_call['name']}' returned a "
|
||||
f"`ToolMessage` with `tool_call_id={message.tool_call_id!r}`, but the "
|
||||
f"rejected call's id is {tool_call['id']!r}. The ids must match so "
|
||||
"providers can pair the tool result with the originating tool call."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
if message.name != tool_call["name"]:
|
||||
msg = (
|
||||
f"`rejection_response` callable for tool '{tool_call['name']}' returned a "
|
||||
f"`ToolMessage` with `name={message.name!r}`, but the rejected call's "
|
||||
f"name is {tool_call['name']!r}. Names must match so the tool result "
|
||||
"is correctly attributed to the originating tool call."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return message
|
||||
|
||||
|
||||
class HumanInTheLoopMiddleware(AgentMiddleware[StateT, ContextT, ResponseT]):
|
||||
"""Human in the loop middleware."""
|
||||
@@ -209,6 +356,11 @@ class HumanInTheLoopMiddleware(AgentMiddleware[StateT, ContextT, ResponseT]):
|
||||
requested.
|
||||
|
||||
Not used if a tool has a `description` in its `InterruptOnConfig`.
|
||||
|
||||
Raises:
|
||||
ValueError: If an `InterruptOnConfig` sets `rejection_response` without
|
||||
including `'reject'` in `allowed_decisions` (the override would
|
||||
never be used).
|
||||
"""
|
||||
super().__init__()
|
||||
resolved_configs: dict[str, InterruptOnConfig] = {}
|
||||
@@ -219,6 +371,17 @@ class HumanInTheLoopMiddleware(AgentMiddleware[StateT, ContextT, ResponseT]):
|
||||
allowed_decisions=["approve", "edit", "reject", "respond"]
|
||||
)
|
||||
elif tool_config.get("allowed_decisions"):
|
||||
if (
|
||||
tool_config.get("rejection_response") is not None
|
||||
and "reject" not in tool_config["allowed_decisions"]
|
||||
):
|
||||
msg = (
|
||||
f"Tool '{tool_name}' has a `rejection_response` configured but "
|
||||
"'reject' is not in `allowed_decisions`; the override would "
|
||||
"never be used. Add 'reject' to `allowed_decisions` or remove "
|
||||
"`rejection_response`."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
resolved_configs[tool_name] = tool_config
|
||||
self.interrupt_on = resolved_configs
|
||||
self.description_prefix = description_prefix
|
||||
@@ -282,15 +445,8 @@ class HumanInTheLoopMiddleware(AgentMiddleware[StateT, ContextT, ResponseT]):
|
||||
None,
|
||||
)
|
||||
if decision["type"] == "reject" and "reject" in allowed_decisions:
|
||||
# Create a tool message with the human's text response
|
||||
content = decision.get("message") or (
|
||||
f"User rejected the tool call for `{tool_call['name']}` with id {tool_call['id']}"
|
||||
)
|
||||
tool_message = ToolMessage(
|
||||
content=content,
|
||||
name=tool_call["name"],
|
||||
tool_call_id=tool_call["id"],
|
||||
status="error",
|
||||
tool_message = _resolve_rejection_response(
|
||||
config.get("rejection_response"), tool_call, decision
|
||||
)
|
||||
return tool_call, tool_message
|
||||
if decision["type"] == "respond" and "respond" in allowed_decisions:
|
||||
|
||||
@@ -10,6 +10,7 @@ from langchain.agents.middleware import InterruptOnConfig
|
||||
from langchain.agents.middleware.human_in_the_loop import (
|
||||
Action,
|
||||
HumanInTheLoopMiddleware,
|
||||
RejectionResponseFactory,
|
||||
)
|
||||
from langchain.agents.middleware.types import AgentState
|
||||
|
||||
@@ -148,6 +149,7 @@ def test_human_in_the_loop_middleware_single_tool_response() -> None:
|
||||
assert result["messages"][1].content == "Custom response message"
|
||||
assert result["messages"][1].name == "test_tool"
|
||||
assert result["messages"][1].tool_call_id == "1"
|
||||
assert result["messages"][1].status == "error"
|
||||
|
||||
|
||||
def test_human_in_the_loop_middleware_single_tool_respond() -> None:
|
||||
@@ -327,6 +329,7 @@ def test_human_in_the_loop_middleware_multiple_tools_mixed_responses() -> None:
|
||||
assert isinstance(tool_message, ToolMessage)
|
||||
assert tool_message.content == "User rejected this tool call"
|
||||
assert tool_message.name == "get_temperature"
|
||||
assert tool_message.status == "error"
|
||||
|
||||
|
||||
def test_human_in_the_loop_middleware_multiple_tools_edit_responses() -> None:
|
||||
@@ -883,3 +886,588 @@ def test_human_in_the_loop_middleware_preserves_order_with_rejections() -> None:
|
||||
assert isinstance(tool_message, ToolMessage)
|
||||
assert tool_message.content == "Rejected tool B"
|
||||
assert tool_message.tool_call_id == "id_b"
|
||||
assert tool_message.status == "error"
|
||||
|
||||
|
||||
def test_human_in_the_loop_middleware_reject_default_status_and_content() -> None:
|
||||
"""The default rejection message uses `status="error"` and the canned content.
|
||||
|
||||
Pinned so that any future flip of the default is intentional and visible
|
||||
rather than a silent wire-format change.
|
||||
"""
|
||||
middleware = HumanInTheLoopMiddleware(
|
||||
interrupt_on={"test_tool": {"allowed_decisions": ["reject"]}}
|
||||
)
|
||||
|
||||
ai_message = AIMessage(
|
||||
content="I'll help",
|
||||
tool_calls=[{"name": "test_tool", "args": {"input": "x"}, "id": "1"}],
|
||||
)
|
||||
state = AgentState[Any](messages=[HumanMessage(content="Hello"), ai_message])
|
||||
|
||||
with patch(
|
||||
"langchain.agents.middleware.human_in_the_loop.interrupt",
|
||||
return_value={"decisions": [{"type": "reject"}]},
|
||||
):
|
||||
result = middleware.after_model(state, Runtime())
|
||||
|
||||
assert result is not None
|
||||
tool_message = result["messages"][1]
|
||||
assert isinstance(tool_message, ToolMessage)
|
||||
assert tool_message.status == "error"
|
||||
assert tool_message.content == "User rejected the tool call for `test_tool` with id 1"
|
||||
assert tool_message.tool_call_id == "1"
|
||||
assert tool_message.name == "test_tool"
|
||||
|
||||
|
||||
def test_human_in_the_loop_middleware_reject_uses_human_message() -> None:
|
||||
"""A user-supplied `message` on the reject decision becomes the ToolMessage content."""
|
||||
middleware = HumanInTheLoopMiddleware(
|
||||
interrupt_on={"test_tool": {"allowed_decisions": ["reject"]}}
|
||||
)
|
||||
|
||||
ai_message = AIMessage(
|
||||
content="I'll help",
|
||||
tool_calls=[{"name": "test_tool", "args": {"input": "x"}, "id": "1"}],
|
||||
)
|
||||
state = AgentState[Any](messages=[HumanMessage(content="Hello"), ai_message])
|
||||
|
||||
with patch(
|
||||
"langchain.agents.middleware.human_in_the_loop.interrupt",
|
||||
return_value={"decisions": [{"type": "reject", "message": "no thanks"}]},
|
||||
):
|
||||
result = middleware.after_model(state, Runtime())
|
||||
|
||||
assert result is not None
|
||||
tool_message = result["messages"][1]
|
||||
assert isinstance(tool_message, ToolMessage)
|
||||
assert tool_message.content == "no thanks"
|
||||
assert tool_message.status == "error"
|
||||
|
||||
|
||||
def test_human_in_the_loop_middleware_rejection_response_callable() -> None:
|
||||
"""`rejection_response` callable receives the tool call and decision."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def factory(tool_call: ToolCall, decision: dict[str, Any]) -> ToolMessage:
|
||||
captured["tool_call"] = tool_call
|
||||
captured["decision"] = decision
|
||||
return ToolMessage(
|
||||
content=f"declined: {decision.get('message', '?')}",
|
||||
name=tool_call["name"],
|
||||
tool_call_id=tool_call["id"],
|
||||
status="success",
|
||||
additional_kwargs={"hitl_decision": "reject"},
|
||||
)
|
||||
|
||||
middleware = HumanInTheLoopMiddleware(
|
||||
interrupt_on={
|
||||
"test_tool": {
|
||||
"allowed_decisions": ["reject"],
|
||||
"rejection_response": factory,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ai_message = AIMessage(
|
||||
content="I'll help",
|
||||
tool_calls=[{"name": "test_tool", "args": {"input": "x"}, "id": "tc-1"}],
|
||||
)
|
||||
state = AgentState[Any](messages=[HumanMessage(content="Hello"), ai_message])
|
||||
|
||||
with patch(
|
||||
"langchain.agents.middleware.human_in_the_loop.interrupt",
|
||||
return_value={"decisions": [{"type": "reject", "message": "stop"}]},
|
||||
):
|
||||
result = middleware.after_model(state, Runtime())
|
||||
|
||||
assert result is not None
|
||||
tool_message = result["messages"][1]
|
||||
assert isinstance(tool_message, ToolMessage)
|
||||
assert tool_message.content == "declined: stop"
|
||||
assert tool_message.additional_kwargs == {"hitl_decision": "reject"}
|
||||
assert captured["tool_call"]["id"] == "tc-1"
|
||||
assert captured["decision"] == {"type": "reject", "message": "stop"}
|
||||
|
||||
|
||||
def test_human_in_the_loop_middleware_rejection_response_static_toolmessage() -> None:
|
||||
"""A static `ToolMessage` override is used verbatim with id/name stamped from the call."""
|
||||
template = ToolMessage(
|
||||
content="Rejected by policy.",
|
||||
name="placeholder",
|
||||
tool_call_id="placeholder",
|
||||
status="error",
|
||||
)
|
||||
|
||||
middleware = HumanInTheLoopMiddleware(
|
||||
interrupt_on={
|
||||
"test_tool": {
|
||||
"allowed_decisions": ["reject"],
|
||||
"rejection_response": template,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ai_message = AIMessage(
|
||||
content="I'll help",
|
||||
tool_calls=[{"name": "test_tool", "args": {"input": "x"}, "id": "tc-1"}],
|
||||
)
|
||||
state = AgentState[Any](messages=[HumanMessage(content="Hello"), ai_message])
|
||||
|
||||
with patch(
|
||||
"langchain.agents.middleware.human_in_the_loop.interrupt",
|
||||
return_value={"decisions": [{"type": "reject"}]},
|
||||
):
|
||||
result = middleware.after_model(state, Runtime())
|
||||
|
||||
assert result is not None
|
||||
tool_message = result["messages"][1]
|
||||
assert isinstance(tool_message, ToolMessage)
|
||||
# Content/status preserved from the template; identity fields stamped from the call.
|
||||
assert tool_message.content == "Rejected by policy."
|
||||
assert tool_message.status == "error"
|
||||
assert tool_message.name == "test_tool"
|
||||
assert tool_message.tool_call_id == "tc-1"
|
||||
# Template instance must not be mutated — callers may share one across tools.
|
||||
assert template.name == "placeholder"
|
||||
assert template.tool_call_id == "placeholder"
|
||||
|
||||
|
||||
def test_human_in_the_loop_middleware_rejection_response_callable_id_mismatch() -> None:
|
||||
"""A callable returning a `ToolMessage` with the wrong `tool_call_id` raises.
|
||||
|
||||
Providers (notably Anthropic) reject AI/Tool message pairs whose ids do not
|
||||
line up, so the middleware fails fast rather than letting the malformed
|
||||
pair reach the model.
|
||||
"""
|
||||
|
||||
def bad_factory(tool_call: ToolCall, decision: dict[str, Any]) -> ToolMessage:
|
||||
return ToolMessage(
|
||||
content="declined",
|
||||
name=tool_call["name"],
|
||||
tool_call_id="not-the-right-id",
|
||||
status="success",
|
||||
)
|
||||
|
||||
middleware = HumanInTheLoopMiddleware(
|
||||
interrupt_on={
|
||||
"test_tool": {
|
||||
"allowed_decisions": ["reject"],
|
||||
"rejection_response": bad_factory,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ai_message = AIMessage(
|
||||
content="I'll help",
|
||||
tool_calls=[{"name": "test_tool", "args": {"input": "x"}, "id": "tc-1"}],
|
||||
)
|
||||
state = AgentState[Any](messages=[HumanMessage(content="Hello"), ai_message])
|
||||
|
||||
with (
|
||||
patch(
|
||||
"langchain.agents.middleware.human_in_the_loop.interrupt",
|
||||
return_value={"decisions": [{"type": "reject"}]},
|
||||
),
|
||||
pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape(
|
||||
"`rejection_response` callable for tool 'test_tool' returned a "
|
||||
"`ToolMessage` with `tool_call_id='not-the-right-id'`, but the "
|
||||
"rejected call's id is 'tc-1'."
|
||||
),
|
||||
),
|
||||
):
|
||||
middleware.after_model(state, Runtime())
|
||||
|
||||
|
||||
def test_human_in_the_loop_middleware_reject_empty_message_falls_back_to_default() -> None:
|
||||
"""An empty `decision["message"]` falls back to the default canned content."""
|
||||
middleware = HumanInTheLoopMiddleware(
|
||||
interrupt_on={"test_tool": {"allowed_decisions": ["reject"]}}
|
||||
)
|
||||
|
||||
ai_message = AIMessage(
|
||||
content="I'll help",
|
||||
tool_calls=[{"name": "test_tool", "args": {"input": "x"}, "id": "1"}],
|
||||
)
|
||||
state = AgentState[Any](messages=[HumanMessage(content="Hello"), ai_message])
|
||||
|
||||
with patch(
|
||||
"langchain.agents.middleware.human_in_the_loop.interrupt",
|
||||
return_value={"decisions": [{"type": "reject", "message": ""}]},
|
||||
):
|
||||
result = middleware.after_model(state, Runtime())
|
||||
|
||||
assert result is not None
|
||||
tool_message = result["messages"][1]
|
||||
assert isinstance(tool_message, ToolMessage)
|
||||
assert tool_message.content == "User rejected the tool call for `test_tool` with id 1"
|
||||
assert tool_message.status == "error"
|
||||
|
||||
|
||||
def test_human_in_the_loop_middleware_per_tool_rejection_response() -> None:
|
||||
"""Each rejected tool call resolves its own `rejection_response` from the per-tool config.
|
||||
|
||||
Guards against a regression in `_process_decision` that misroutes the
|
||||
override (e.g. by reading `rejection_response` from the wrong entry in
|
||||
`interrupt_on` or by sharing it across calls).
|
||||
"""
|
||||
|
||||
def factory_a(tool_call: ToolCall, decision: dict[str, Any]) -> ToolMessage:
|
||||
return ToolMessage(
|
||||
content="A declined",
|
||||
name=tool_call["name"],
|
||||
tool_call_id=tool_call["id"],
|
||||
status="success",
|
||||
)
|
||||
|
||||
template_b = ToolMessage(
|
||||
content="B declined by policy.",
|
||||
name="placeholder",
|
||||
tool_call_id="placeholder",
|
||||
status="error",
|
||||
)
|
||||
|
||||
middleware = HumanInTheLoopMiddleware(
|
||||
interrupt_on={
|
||||
"tool_a": {"allowed_decisions": ["reject"], "rejection_response": factory_a},
|
||||
"tool_b": {"allowed_decisions": ["reject"], "rejection_response": template_b},
|
||||
}
|
||||
)
|
||||
|
||||
ai_message = AIMessage(
|
||||
content="Two calls",
|
||||
tool_calls=[
|
||||
{"name": "tool_a", "args": {}, "id": "id_a"},
|
||||
{"name": "tool_b", "args": {}, "id": "id_b"},
|
||||
],
|
||||
)
|
||||
state = AgentState[Any](messages=[HumanMessage(content="Hi"), ai_message])
|
||||
|
||||
with patch(
|
||||
"langchain.agents.middleware.human_in_the_loop.interrupt",
|
||||
return_value={"decisions": [{"type": "reject"}, {"type": "reject"}]},
|
||||
):
|
||||
result = middleware.after_model(state, Runtime())
|
||||
|
||||
assert result is not None
|
||||
# AI message + 2 synthetic ToolMessages, one per rejected call.
|
||||
assert len(result["messages"]) == 3
|
||||
|
||||
msg_a = result["messages"][1]
|
||||
assert isinstance(msg_a, ToolMessage)
|
||||
assert msg_a.content == "A declined"
|
||||
assert msg_a.tool_call_id == "id_a"
|
||||
assert msg_a.name == "tool_a"
|
||||
assert msg_a.status == "success"
|
||||
|
||||
msg_b = result["messages"][2]
|
||||
assert isinstance(msg_b, ToolMessage)
|
||||
assert msg_b.content == "B declined by policy."
|
||||
assert msg_b.tool_call_id == "id_b"
|
||||
assert msg_b.name == "tool_b"
|
||||
assert msg_b.status == "error"
|
||||
|
||||
|
||||
def test_human_in_the_loop_middleware_mixed_approve_and_reject_callable() -> None:
|
||||
"""An approve+reject pair routes the rejection through its `rejection_response` callable."""
|
||||
|
||||
def factory(tool_call: ToolCall, decision: dict[str, Any]) -> ToolMessage:
|
||||
return ToolMessage(
|
||||
content="declined",
|
||||
name=tool_call["name"],
|
||||
tool_call_id=tool_call["id"],
|
||||
status="success",
|
||||
additional_kwargs={"hitl_decision": "reject"},
|
||||
)
|
||||
|
||||
middleware = HumanInTheLoopMiddleware(
|
||||
interrupt_on={
|
||||
"approve_tool": {"allowed_decisions": ["approve"]},
|
||||
"reject_tool": {
|
||||
"allowed_decisions": ["reject"],
|
||||
"rejection_response": factory,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
ai_message = AIMessage(
|
||||
content="Two calls",
|
||||
tool_calls=[
|
||||
{"name": "approve_tool", "args": {}, "id": "id_ok"},
|
||||
{"name": "reject_tool", "args": {}, "id": "id_no"},
|
||||
],
|
||||
)
|
||||
state = AgentState[Any](messages=[HumanMessage(content="Hi"), ai_message])
|
||||
|
||||
with patch(
|
||||
"langchain.agents.middleware.human_in_the_loop.interrupt",
|
||||
return_value={"decisions": [{"type": "approve"}, {"type": "reject"}]},
|
||||
):
|
||||
result = middleware.after_model(state, Runtime())
|
||||
|
||||
assert result is not None
|
||||
# AI message + 1 synthetic ToolMessage for the reject decision.
|
||||
assert len(result["messages"]) == 2
|
||||
|
||||
updated_ai = result["messages"][0]
|
||||
assert len(updated_ai.tool_calls) == 2
|
||||
assert updated_ai.tool_calls[0]["name"] == "approve_tool"
|
||||
assert updated_ai.tool_calls[1]["name"] == "reject_tool"
|
||||
|
||||
tool_message = result["messages"][1]
|
||||
assert isinstance(tool_message, ToolMessage)
|
||||
assert tool_message.content == "declined"
|
||||
assert tool_message.tool_call_id == "id_no"
|
||||
assert tool_message.additional_kwargs == {"hitl_decision": "reject"}
|
||||
|
||||
|
||||
def test_rejection_response_factory_alias_is_callable_signature() -> None:
|
||||
"""`RejectionResponseFactory` is a public alias users can annotate factories against."""
|
||||
|
||||
def my_factory(tool_call: ToolCall, decision: dict[str, Any]) -> ToolMessage:
|
||||
return ToolMessage(
|
||||
content="ok",
|
||||
name=tool_call["name"],
|
||||
tool_call_id=tool_call["id"],
|
||||
status="success",
|
||||
)
|
||||
|
||||
factory: RejectionResponseFactory = my_factory
|
||||
msg = factory(ToolCall(name="t", args={}, id="x"), {"type": "reject"})
|
||||
assert isinstance(msg, ToolMessage)
|
||||
assert msg.tool_call_id == "x"
|
||||
|
||||
|
||||
def test_human_in_the_loop_middleware_rejection_response_template_status_preserved() -> None:
|
||||
"""A template's non-default `status` survives the `model_copy` stamp.
|
||||
|
||||
The documented use case for overriding is `status="success"` to suppress
|
||||
retry loops on providers that re-emit on `status="error"`. Pin that the
|
||||
copy only overwrites identity fields, not `status` or content.
|
||||
"""
|
||||
template = ToolMessage(
|
||||
content="Declined; do not retry.",
|
||||
name="placeholder",
|
||||
tool_call_id="placeholder",
|
||||
status="success",
|
||||
additional_kwargs={"hitl_decision": "reject"},
|
||||
)
|
||||
|
||||
middleware = HumanInTheLoopMiddleware(
|
||||
interrupt_on={
|
||||
"test_tool": {
|
||||
"allowed_decisions": ["reject"],
|
||||
"rejection_response": template,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ai_message = AIMessage(
|
||||
content="I'll help",
|
||||
tool_calls=[{"name": "test_tool", "args": {}, "id": "tc-1"}],
|
||||
)
|
||||
state = AgentState[Any](messages=[HumanMessage(content="Hello"), ai_message])
|
||||
|
||||
with patch(
|
||||
"langchain.agents.middleware.human_in_the_loop.interrupt",
|
||||
return_value={"decisions": [{"type": "reject"}]},
|
||||
):
|
||||
result = middleware.after_model(state, Runtime())
|
||||
|
||||
assert result is not None
|
||||
tool_message = result["messages"][1]
|
||||
assert isinstance(tool_message, ToolMessage)
|
||||
assert tool_message.status == "success"
|
||||
assert tool_message.content == "Declined; do not retry."
|
||||
assert tool_message.additional_kwargs == {"hitl_decision": "reject"}
|
||||
assert tool_message.tool_call_id == "tc-1"
|
||||
assert tool_message.name == "test_tool"
|
||||
|
||||
|
||||
def test_human_in_the_loop_middleware_heterogeneous_batch_routes_per_tool() -> None:
|
||||
"""A mixed approve/edit/respond/reject batch routes each decision to its tool's config.
|
||||
|
||||
Higher-leverage than a homogeneous reject batch: regressions in
|
||||
`_process_decision` (e.g. reading `rejection_response` from the wrong
|
||||
`interrupt_on` entry) only surface when decision *types* differ across
|
||||
calls, not just tools.
|
||||
"""
|
||||
|
||||
def reject_factory(tool_call: ToolCall, decision: dict[str, Any]) -> ToolMessage:
|
||||
return ToolMessage(
|
||||
content="declined by factory",
|
||||
name=tool_call["name"],
|
||||
tool_call_id=tool_call["id"],
|
||||
status="success",
|
||||
)
|
||||
|
||||
middleware = HumanInTheLoopMiddleware(
|
||||
interrupt_on={
|
||||
"edit_tool": {"allowed_decisions": ["edit"]},
|
||||
"respond_tool": {"allowed_decisions": ["respond"]},
|
||||
"reject_tool": {
|
||||
"allowed_decisions": ["reject"],
|
||||
"rejection_response": reject_factory,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
ai_message = AIMessage(
|
||||
content="Three calls",
|
||||
tool_calls=[
|
||||
{"name": "edit_tool", "args": {"x": 1}, "id": "id_edit"},
|
||||
{"name": "respond_tool", "args": {}, "id": "id_resp"},
|
||||
{"name": "reject_tool", "args": {}, "id": "id_rej"},
|
||||
],
|
||||
)
|
||||
state = AgentState[Any](messages=[HumanMessage(content="Hi"), ai_message])
|
||||
|
||||
with patch(
|
||||
"langchain.agents.middleware.human_in_the_loop.interrupt",
|
||||
return_value={
|
||||
"decisions": [
|
||||
{
|
||||
"type": "edit",
|
||||
"edited_action": {"name": "edit_tool", "args": {"x": 2}},
|
||||
},
|
||||
{"type": "respond", "message": "synthetic answer"},
|
||||
{"type": "reject"},
|
||||
]
|
||||
},
|
||||
):
|
||||
result = middleware.after_model(state, Runtime())
|
||||
|
||||
assert result is not None
|
||||
# AI message + 2 synthetic ToolMessages (respond + reject; edit produces no message).
|
||||
assert len(result["messages"]) == 3
|
||||
|
||||
updated_ai = result["messages"][0]
|
||||
assert isinstance(updated_ai, AIMessage)
|
||||
assert len(updated_ai.tool_calls) == 3
|
||||
edited_call = updated_ai.tool_calls[0]
|
||||
assert edited_call["args"] == {"x": 2}
|
||||
assert edited_call["id"] == "id_edit"
|
||||
|
||||
respond_msg = result["messages"][1]
|
||||
assert isinstance(respond_msg, ToolMessage)
|
||||
assert respond_msg.tool_call_id == "id_resp"
|
||||
assert respond_msg.content == "synthetic answer"
|
||||
assert respond_msg.status == "success"
|
||||
|
||||
reject_msg = result["messages"][2]
|
||||
assert isinstance(reject_msg, ToolMessage)
|
||||
assert reject_msg.tool_call_id == "id_rej"
|
||||
assert reject_msg.content == "declined by factory"
|
||||
assert reject_msg.status == "success"
|
||||
|
||||
|
||||
def test_human_in_the_loop_middleware_rejection_response_callable_returns_non_toolmessage() -> None:
|
||||
"""A factory returning something other than a `ToolMessage` raises `TypeError`."""
|
||||
|
||||
def bad_factory(tool_call: ToolCall, decision: dict[str, Any]) -> Any:
|
||||
return "not a tool message"
|
||||
|
||||
middleware = HumanInTheLoopMiddleware(
|
||||
interrupt_on={
|
||||
"test_tool": {
|
||||
"allowed_decisions": ["reject"],
|
||||
"rejection_response": bad_factory,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ai_message = AIMessage(
|
||||
content="I'll help",
|
||||
tool_calls=[{"name": "test_tool", "args": {}, "id": "tc-1"}],
|
||||
)
|
||||
state = AgentState[Any](messages=[HumanMessage(content="Hello"), ai_message])
|
||||
|
||||
with (
|
||||
patch(
|
||||
"langchain.agents.middleware.human_in_the_loop.interrupt",
|
||||
return_value={"decisions": [{"type": "reject"}]},
|
||||
),
|
||||
pytest.raises(
|
||||
TypeError,
|
||||
match=re.escape(
|
||||
"`rejection_response` callable for tool 'test_tool' returned 'str'; "
|
||||
"expected a `ToolMessage`."
|
||||
),
|
||||
),
|
||||
):
|
||||
middleware.after_model(state, Runtime())
|
||||
|
||||
|
||||
def test_human_in_the_loop_middleware_rejection_response_callable_name_mismatch() -> None:
|
||||
"""A factory returning a `ToolMessage` with the wrong `name` raises `ValueError`."""
|
||||
|
||||
def bad_factory(tool_call: ToolCall, decision: dict[str, Any]) -> ToolMessage:
|
||||
return ToolMessage(
|
||||
content="declined",
|
||||
name="wrong_name",
|
||||
tool_call_id=tool_call["id"],
|
||||
status="success",
|
||||
)
|
||||
|
||||
middleware = HumanInTheLoopMiddleware(
|
||||
interrupt_on={
|
||||
"test_tool": {
|
||||
"allowed_decisions": ["reject"],
|
||||
"rejection_response": bad_factory,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ai_message = AIMessage(
|
||||
content="I'll help",
|
||||
tool_calls=[{"name": "test_tool", "args": {}, "id": "tc-1"}],
|
||||
)
|
||||
state = AgentState[Any](messages=[HumanMessage(content="Hello"), ai_message])
|
||||
|
||||
with (
|
||||
patch(
|
||||
"langchain.agents.middleware.human_in_the_loop.interrupt",
|
||||
return_value={"decisions": [{"type": "reject"}]},
|
||||
),
|
||||
pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape(
|
||||
"`rejection_response` callable for tool 'test_tool' returned a "
|
||||
"`ToolMessage` with `name='wrong_name'`, but the rejected call's "
|
||||
"name is 'test_tool'."
|
||||
),
|
||||
),
|
||||
):
|
||||
middleware.after_model(state, Runtime())
|
||||
|
||||
|
||||
def test_human_in_the_loop_middleware_rejection_response_without_reject_raises() -> None:
|
||||
"""Setting `rejection_response` without `'reject'` in `allowed_decisions` raises.
|
||||
|
||||
Catches misconfiguration eagerly at construction rather than silently
|
||||
ignoring the override at runtime.
|
||||
"""
|
||||
template = ToolMessage(
|
||||
content="should not be reachable",
|
||||
name="placeholder",
|
||||
tool_call_id="placeholder",
|
||||
status="error",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape(
|
||||
"Tool 'test_tool' has a `rejection_response` configured but 'reject' is "
|
||||
"not in `allowed_decisions`"
|
||||
),
|
||||
):
|
||||
HumanInTheLoopMiddleware(
|
||||
interrupt_on={
|
||||
"test_tool": {
|
||||
"allowed_decisions": ["approve"],
|
||||
"rejection_response": template,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
2
libs/langchain_v1/uv.lock
generated
2
libs/langchain_v1/uv.lock
generated
@@ -2136,7 +2136,7 @@ typing = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-anthropic"
|
||||
version = "1.4.2"
|
||||
version = "1.4.3"
|
||||
source = { editable = "../partners/anthropic" }
|
||||
dependencies = [
|
||||
{ name = "anthropic" },
|
||||
|
||||
Reference in New Issue
Block a user