From fc0a6595d78da014a1aec2b53210c12ea42fdfc7 Mon Sep 17 00:00:00 2001 From: Nick Hollon Date: Tue, 12 May 2026 15:30:33 -0700 Subject: [PATCH] feat(langchain): add `interrupt_when` field to `InterruptOnConfig` --- .../agents/middleware/human_in_the_loop.py | 31 +++++++++++++++++++ .../implementations/test_human_in_the_loop.py | 11 +++++++ 2 files changed, 42 insertions(+) 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..fccf36bcc1a 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 @@ -14,6 +14,7 @@ from langchain.agents.middleware.types import ( ResponseT, StateT, ) +from langchain.tools import ToolRuntime class Action(TypedDict): @@ -130,6 +131,18 @@ class _DescriptionFactory(Protocol): ... +class _InterruptWhen(Protocol): + """Predicate that decides whether a tool call should trigger an interrupt.""" + + def __call__( + self, + tool_call: ToolCall, + runtime: ToolRuntime[ContextT, Any], + ) -> bool: + """Return `True` to interrupt this tool call, `False` to auto-approve it.""" + ... + + class InterruptOnConfig(TypedDict): """Configuration for an action requiring human in the loop. @@ -178,6 +191,24 @@ class InterruptOnConfig(TypedDict): args_schema: NotRequired[dict[str, Any]] """JSON schema for the args associated with the action, if edits are allowed.""" + interrupt_when: NotRequired[_InterruptWhen] + """Optional predicate gating whether this tool call triggers an interrupt. + + Called with the proposed `ToolCall` and a `ToolRuntime` constructed by the + middleware. Return `True` to interrupt (with this config's `allowed_decisions`) + or `False` to auto-approve the call as if the tool were not listed in + `interrupt_on`. + + Predicates must be synchronous and deterministic — LangGraph interrupt replay + on resume requires the same interrupts to fire on each evaluation. Exceptions + propagate. + + Note: the `ToolRuntime` constructed for predicate evaluation has three + deviations from a tool-time `ToolRuntime`: `tools=[]`, `config={}`, and + `execution_info`/`server_info=None`. Predicates should rely on `state`, + `tool_call_id`, `context`, and `store` rather than these fields. + """ + class HumanInTheLoopMiddleware(AgentMiddleware[StateT, ContextT, ResponseT]): """Human in the loop middleware.""" 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..ca80a803f09 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 @@ -883,3 +883,14 @@ 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" + + +def test_interrupt_on_config_accepts_interrupt_when() -> None: + """`InterruptOnConfig` accepts an optional `interrupt_when` predicate.""" + from langchain.agents.middleware.human_in_the_loop import _InterruptWhen # noqa: F401 + + config: InterruptOnConfig = { + "allowed_decisions": ["approve", "reject"], + "interrupt_when": lambda _tc, _rt: True, + } + assert config["interrupt_when"](None, None) is True # type: ignore[arg-type]