Skip to content

Commit 2671fee

Browse files
feat(langchain_v1): description generator for HITL middleware (#33195)
Need to decide - what information should we feed to this description factory? Right now, feeding: * state * runtime * tool call (so the developer doesn't have to search through the state's messages for the corresponding tool call) I can see a case for just passing tool call. But again, this abstraction is semi-bound to interrupts for tools... though we pretend it's more abstract than that. Right now: ```py def custom_description(state: AgentState, runtime: Runtime, tool_call: ToolCall) -> str: """Generate a custom description.""" return f"Custom: {tool_call['name']} with args {tool_call['args']}" middleware = HumanInTheLoopMiddleware( interrupt_on={ "tool_with_callable": {"allow_accept": True, "description": custom_description}, "tool_with_string": {"allow_accept": True, "description": "Static description"}, } ) ```
1 parent 010ed5d commit 2671fee

File tree

2 files changed

+105
-12
lines changed

2 files changed

+105
-12
lines changed

libs/langchain_v1/langchain/agents/middleware/human_in_the_loop.py

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Human in the loop middleware."""
22

3-
from typing import Any, Literal
3+
from typing import Any, Literal, Protocol
44

55
from langchain_core.messages import AIMessage, ToolCall, ToolMessage
66
from langgraph.runtime import Runtime
@@ -95,6 +95,14 @@ class EditPayload(TypedDict):
9595
"""Aggregated response type for all possible human in the loop responses."""
9696

9797

98+
class _DescriptionFactory(Protocol):
99+
"""Callable that generates a description for a tool call."""
100+
101+
def __call__(self, tool_call: ToolCall, state: AgentState, runtime: Runtime) -> str:
102+
"""Generate a description for a tool call."""
103+
...
104+
105+
98106
class ToolConfig(TypedDict):
99107
"""Configuration for a tool requiring human in the loop."""
100108

@@ -104,8 +112,40 @@ class ToolConfig(TypedDict):
104112
"""Whether the human can approve the current action with edited content."""
105113
allow_respond: NotRequired[bool]
106114
"""Whether the human can reject the current action with feedback."""
107-
description: NotRequired[str]
108-
"""The description attached to the request for human input."""
115+
description: NotRequired[str | _DescriptionFactory]
116+
"""The description attached to the request for human input.
117+
118+
Can be either:
119+
- A static string describing the approval request
120+
- A callable that dynamically generates the description based on agent state,
121+
runtime, and tool call information
122+
123+
Example:
124+
.. code-block:: python
125+
126+
# Static string description
127+
config = ToolConfig(
128+
allow_accept=True,
129+
description="Please review this tool execution"
130+
)
131+
132+
# Dynamic callable description
133+
def format_tool_description(
134+
tool_call: ToolCall,
135+
state: AgentState,
136+
runtime: Runtime
137+
) -> str:
138+
import json
139+
return (
140+
f"Tool: {tool_call['name']}\\n"
141+
f"Arguments:\\n{json.dumps(tool_call['args'], indent=2)}"
142+
)
143+
144+
config = ToolConfig(
145+
allow_accept=True,
146+
description=format_tool_description
147+
)
148+
"""
109149

110150

111151
class HumanInTheLoopMiddleware(AgentMiddleware):
@@ -122,12 +162,15 @@ def __init__(
122162
Args:
123163
interrupt_on: Mapping of tool name to allowed actions.
124164
If a tool doesn't have an entry, it's auto-approved by default.
125-
* `True` indicates all actions are allowed: accept, edit, and respond.
126-
* `False` indicates that the tool is auto-approved.
127-
* ToolConfig indicates the specific actions allowed for this tool.
165+
166+
* ``True`` indicates all actions are allowed: accept, edit, and respond.
167+
* ``False`` indicates that the tool is auto-approved.
168+
* ``ToolConfig`` indicates the specific actions allowed for this tool.
169+
The ToolConfig can include a ``description`` field (str or callable) for
170+
custom formatting of the interrupt description.
128171
description_prefix: The prefix to use when constructing action requests.
129172
This is used to provide context about the tool call and the action being requested.
130-
Not used if a tool has a description in its ToolConfig.
173+
Not used if a tool has a ``description`` in its ToolConfig.
131174
"""
132175
super().__init__()
133176
resolved_tool_configs: dict[str, ToolConfig] = {}
@@ -146,7 +189,7 @@ def __init__(
146189
self.interrupt_on = resolved_tool_configs
147190
self.description_prefix = description_prefix
148191

149-
def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None: # noqa: ARG002
192+
def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
150193
"""Trigger interrupt flows for relevant tool calls after an AIMessage."""
151194
messages = state["messages"]
152195
if not messages:
@@ -179,10 +222,15 @@ def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | N
179222
tool_name = tool_call["name"]
180223
tool_args = tool_call["args"]
181224
config = self.interrupt_on[tool_name]
182-
description = (
183-
config.get("description")
184-
or f"{self.description_prefix}\n\nTool: {tool_name}\nArgs: {tool_args}"
185-
)
225+
226+
# Generate description using the description field (str or callable)
227+
description_value = config.get("description")
228+
if callable(description_value):
229+
description = description_value(tool_call, state, runtime)
230+
elif description_value is not None:
231+
description = description_value
232+
else:
233+
description = f"{self.description_prefix}\n\nTool: {tool_name}\nArgs: {tool_args}"
186234

187235
request: HumanInTheLoopRequest = {
188236
"action_request": ActionRequest(

libs/langchain_v1/tests/unit_tests/agents/test_middleware_agent.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,51 @@ def test_human_in_the_loop_middleware_sequence_mismatch() -> None:
892892
middleware.after_model(state, None)
893893

894894

895+
def test_human_in_the_loop_middleware_description_as_callable() -> None:
896+
"""Test that description field accepts both string and callable."""
897+
898+
def custom_description(tool_call: ToolCall, state: AgentState, runtime: Runtime) -> str:
899+
"""Generate a custom description."""
900+
return f"Custom: {tool_call['name']} with args {tool_call['args']}"
901+
902+
middleware = HumanInTheLoopMiddleware(
903+
interrupt_on={
904+
"tool_with_callable": {"allow_accept": True, "description": custom_description},
905+
"tool_with_string": {"allow_accept": True, "description": "Static description"},
906+
}
907+
)
908+
909+
ai_message = AIMessage(
910+
content="I'll help you",
911+
tool_calls=[
912+
{"name": "tool_with_callable", "args": {"x": 1}, "id": "1"},
913+
{"name": "tool_with_string", "args": {"y": 2}, "id": "2"},
914+
],
915+
)
916+
state = {"messages": [HumanMessage(content="Hello"), ai_message]}
917+
918+
captured_requests = []
919+
920+
def mock_capture_requests(requests):
921+
captured_requests.extend(requests)
922+
return [{"type": "accept"}, {"type": "accept"}]
923+
924+
with patch(
925+
"langchain.agents.middleware.human_in_the_loop.interrupt", side_effect=mock_capture_requests
926+
):
927+
middleware.after_model(state, None)
928+
929+
assert len(captured_requests) == 2
930+
931+
# Check callable description
932+
assert (
933+
captured_requests[0]["description"] == "Custom: tool_with_callable with args {'x': 1}"
934+
)
935+
936+
# Check string description
937+
assert captured_requests[1]["description"] == "Static description"
938+
939+
895940
# Tests for AnthropicPromptCachingMiddleware
896941
def test_anthropic_prompt_caching_middleware_initialization() -> None:
897942
"""Test AnthropicPromptCachingMiddleware initialization."""

0 commit comments

Comments
 (0)