mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-12 04:37:51 +02:00
feature: Implement post_model_hook and HumanInterruptNode (#4583)
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import pytest
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
from langgraph.prebuilt.interrupt import HumanInterruptConfig, InterruptToolNode
|
||||
from langgraph.types import Command
|
||||
from tests.model import FakeToolCallingModel
|
||||
|
||||
|
||||
def hello_tool(name: str) -> str:
|
||||
"""Return a greeting for the provided person."""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
|
||||
post_model_hook = InterruptToolNode(
|
||||
hello_tool=HumanInterruptConfig(
|
||||
allow_accept=True,
|
||||
allow_edit=True,
|
||||
allow_ignore=True,
|
||||
allow_respond=True,
|
||||
)
|
||||
)
|
||||
|
||||
default_model = FakeToolCallingModel(
|
||||
tool_calls=[
|
||||
[
|
||||
{
|
||||
"name": "hello_tool",
|
||||
"args": {"name": "lady gaga"},
|
||||
"id": "some-random-id",
|
||||
}
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_interrupt_surfaced(
|
||||
request: pytest.FixtureRequest,
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
agent = create_react_agent(
|
||||
default_model,
|
||||
[hello_tool],
|
||||
checkpointer=sync_checkpointer,
|
||||
post_model_hook=post_model_hook,
|
||||
)
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "1"}}
|
||||
result = agent.invoke({"messages": [("user", "Say hi to lady gaga!")]}, config)
|
||||
|
||||
interrupt_data = result["__interrupt__"]
|
||||
assert interrupt_data[0].value == [
|
||||
{
|
||||
"action_request": {"action": "hello_tool", "args": {"name": "lady gaga"}},
|
||||
"config": {
|
||||
"allow_accept": True,
|
||||
"allow_edit": True,
|
||||
"allow_ignore": True,
|
||||
"allow_respond": True,
|
||||
},
|
||||
"description": "Please review tool call for `hello_tool` before execution.",
|
||||
}
|
||||
]
|
||||
|
||||
response = agent.invoke(Command(resume={"type": "accept"}), config=config)
|
||||
tool_message: ToolMessage = response["messages"][-2]
|
||||
assert tool_message.content == "Hello, lady gaga!"
|
||||
assert tool_message.name == "hello_tool"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resume, expected_content",
|
||||
[
|
||||
({"type": "accept"}, "Hello, lady gaga!"),
|
||||
(
|
||||
{"type": "ignore"},
|
||||
"User ignored the tool call for `hello_tool` with id some-random-id",
|
||||
),
|
||||
(
|
||||
{
|
||||
"type": "edit",
|
||||
"args": {"action": "hello_tool", "args": {"name": "bruno mars"}},
|
||||
},
|
||||
"Hello, bruno mars!",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_interrupt_resume_variants(
|
||||
request: pytest.FixtureRequest,
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
resume: dict,
|
||||
expected_content: str,
|
||||
) -> None:
|
||||
agent = create_react_agent(
|
||||
default_model,
|
||||
[hello_tool],
|
||||
checkpointer=sync_checkpointer,
|
||||
post_model_hook=post_model_hook,
|
||||
)
|
||||
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "1"}}
|
||||
agent.invoke({"messages": [("user", "Say hi to lady gaga!")]}, config)
|
||||
|
||||
response = agent.invoke(Command(resume=resume), config=config)
|
||||
tool_message: ToolMessage = response["messages"][-2]
|
||||
assert tool_message.name == "hello_tool"
|
||||
assert tool_message.content == expected_content
|
||||
|
||||
if resume["type"] == "edit":
|
||||
ai_msg = response["messages"][-1]
|
||||
assert ai_msg.tool_calls == [
|
||||
{
|
||||
"name": "hello_tool",
|
||||
"args": {"name": "lady gaga"},
|
||||
"id": "some-random-id",
|
||||
"type": "tool_call",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_resume_with_response(
|
||||
request: pytest.FixtureRequest,
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
model = FakeToolCallingModel(
|
||||
tool_calls=[
|
||||
[
|
||||
{
|
||||
"name": "hello_tool",
|
||||
"args": {"name": "lady gaga"},
|
||||
"id": "some-random-id",
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"name": "hello_tool",
|
||||
"args": {"name": "bruno mars"},
|
||||
"id": "some-random-id-2",
|
||||
}
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
agent = create_react_agent(
|
||||
model,
|
||||
[hello_tool],
|
||||
checkpointer=sync_checkpointer,
|
||||
post_model_hook=post_model_hook,
|
||||
)
|
||||
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "1"}}
|
||||
agent.invoke({"messages": [("user", "Say hi to lady gaga!")]}, config)
|
||||
|
||||
# Provide user response
|
||||
agent.invoke(
|
||||
Command(
|
||||
resume={
|
||||
"type": "response",
|
||||
"args": "actually, please say hello to bruno mars",
|
||||
}
|
||||
),
|
||||
config=config,
|
||||
)
|
||||
|
||||
# Accept the updated call
|
||||
response = agent.invoke(Command(resume={"type": "accept"}), config=config)
|
||||
|
||||
assert len(response["messages"]) == 6
|
||||
tool_message: ToolMessage = response["messages"][-2]
|
||||
assert tool_message.name == "hello_tool"
|
||||
assert tool_message.content == "Hello, bruno mars!"
|
||||
|
||||
|
||||
def test_resume_with_type_not_allowed(sync_checkpointer: BaseCheckpointSaver) -> None:
|
||||
agent = create_react_agent(
|
||||
default_model,
|
||||
[hello_tool],
|
||||
checkpointer=sync_checkpointer,
|
||||
post_model_hook=post_model_hook,
|
||||
)
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "1"}}
|
||||
agent.invoke({"messages": [("user", "Say hi to lady gaga!")]}, config)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
agent.invoke(Command(resume={"type": "not-allowed"}), config=config)
|
||||
|
||||
assert (
|
||||
str(exc_info.value)
|
||||
== "Unexpected human response: {'type': 'not-allowed'}. Expected one with `'type'` in ['accept', 'edit', 'response', 'ignore'] based on hello_tool's interrupt configuration."
|
||||
)
|
||||
Reference in New Issue
Block a user