From 40ed1e83d23c156a572892f4ccb45915f2fb00d9 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Sun, 3 May 2026 14:54:49 -0400 Subject: [PATCH] feat(langchain): `rejection_response` override for `HumanInTheLoopMiddleware` --- .../agents/middleware/human_in_the_loop.py | 176 +++++- .../implementations/test_human_in_the_loop.py | 588 ++++++++++++++++++ libs/langchain_v1/uv.lock | 2 +- 3 files changed, 755 insertions(+), 11 deletions(-) diff --git a/libs/langchain_v1/langchain/agents/middleware/human_in_the_loop.py b/libs/langchain_v1/langchain/agents/middleware/human_in_the_loop.py index ac1a01fa50f..5d80f52b125 100644 --- a/libs/langchain_v1/langchain/agents/middleware/human_in_the_loop.py +++ b/libs/langchain_v1/langchain/agents/middleware/human_in_the_loop.py @@ -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: diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_human_in_the_loop.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_human_in_the_loop.py index 5e03b0bed55..8433b418e1f 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_human_in_the_loop.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_human_in_the_loop.py @@ -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, + } + } + ) diff --git a/libs/langchain_v1/uv.lock b/libs/langchain_v1/uv.lock index d252394e7de..6053d05c094 100644 --- a/libs/langchain_v1/uv.lock +++ b/libs/langchain_v1/uv.lock @@ -2136,7 +2136,7 @@ typing = [ [[package]] name = "langchain-anthropic" -version = "1.4.2" +version = "1.4.3" source = { editable = "../partners/anthropic" } dependencies = [ { name = "anthropic" },