Skip to content

Commit db61eef

Browse files
committed
Prompt for human feedback in LangGraph HITL sample
The graph_api human-in-the-loop sample previously auto-approved the draft and used a hardcoded placeholder response, so running it didn't actually involve a human. Now the draft is generated by an LLM, the runner prompts interactively at the terminal for approval or revision feedback, and the review node revises the draft with the LLM based on that feedback. Tests mock the chat model so they stay deterministic and offline.
1 parent 4d453de commit db61eef

4 files changed

Lines changed: 104 additions & 65 deletions

File tree

‎langgraph_plugin/graph_api/human_in_the_loop/README.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Demonstrates pausing a graph with LangGraph's `interrupt()` and waiting indefini
1414
1. The Workflow starts and the `generate_draft` node produces a response.
1515
2. The `human_review` node calls `interrupt(draft)`, pausing execution.
1616
3. The Workflow stores the draft (visible via the query) and calls `workflow.wait_condition()` — blocking durably until the signal sets `_human_input`. This can wait indefinitely; Temporal persists the state.
17-
4. An external process (UI, CLI, etc.) queries the draft and sends approval via signal.
17+
4. An external process (UI, CLI, etc.) queries the draft and sends the human's feedback via signal.
1818
5. The graph resumes — `interrupt()` returns the signal value and the node completes.
1919

2020
## Running the Sample
@@ -25,14 +25,16 @@ Prerequisites: `uv sync --group langgraph` and a running Temporal dev server (`t
2525
# Terminal 1: start the worker
2626
uv run langgraph_plugin/graph_api/human_in_the_loop/run_worker.py
2727

28-
# Terminal 2: start the workflow (polls for draft, then auto-approves)
28+
# Terminal 2: start the workflow (polls for the draft, then prompts you for feedback)
2929
uv run langgraph_plugin/graph_api/human_in_the_loop/run_workflow.py
3030
```
3131

32+
When the draft is ready, you'll be prompted at the terminal. Type `approve` to accept it as-is, or type revision feedback and the draft will be regenerated by an LLM incorporating your notes.
33+
3234
## Files
3335

3436
| File | Description |
3537
|------|-------------|
3638
| `workflow.py` | Graph node functions, graph definition, and `ChatbotWorkflow` definition |
3739
| `run_worker.py` | Builds graph, registers with `LangGraphPlugin`, starts worker |
38-
| `run_workflow.py` | Starts workflow, polls draft via query, sends approval via signal |
40+
| `run_workflow.py` | Starts workflow, polls draft via query, prompts for human feedback, sends it via signal |

‎langgraph_plugin/graph_api/human_in_the_loop/run_workflow.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@ async def main() -> None:
2727

2828
print(f"Draft for review: {draft}")
2929

30-
# Send approval via signal (a UI would trigger this)
31-
await handle.signal(ChatbotWorkflow.provide_feedback, "approve")
30+
# Prompt for human feedback instead of auto-approving.
31+
feedback = await asyncio.to_thread(
32+
input, "Enter 'approve' to accept, or type revision feedback: "
33+
)
34+
await handle.signal(ChatbotWorkflow.provide_feedback, feedback)
3235

3336
result = await handle.result()
3437
print(f"Final response: {result}")

‎langgraph_plugin/graph_api/human_in_the_loop/workflow.py‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from datetime import timedelta
88

9+
from langchain.chat_models import init_chat_model
910
from langchain_core.runnables import RunnableConfig
1011
from langgraph.checkpoint.memory import InMemorySaver
1112
from langgraph.graph import START, StateGraph
@@ -20,21 +21,25 @@ class State(TypedDict):
2021

2122

2223
async def generate_draft(state: State) -> dict[str, str]:
23-
"""Generate a draft response. Replace with an LLM call in production."""
24-
return {
25-
"value": (
26-
f"Here's my response to '{state['value']}': "
27-
"The answer is 42. Let me know if this helps!"
28-
)
29-
}
24+
"""Generate a draft response with an LLM."""
25+
response = await init_chat_model("claude-sonnet-4-6").ainvoke(
26+
f"Please respond concisely to: {state['value']}"
27+
)
28+
return {"value": str(response.content)}
3029

3130

3231
async def human_review(state: State) -> dict[str, str]:
33-
"""Present draft to human for review via interrupt."""
32+
"""Present draft to human for review via interrupt; revise with LLM on feedback."""
3433
feedback = interrupt(state["value"])
3534
if feedback == "approve":
3635
return {"value": state["value"]}
37-
return {"value": f"[Revised] {state['value']} (incorporating feedback: {feedback})"}
36+
response = await init_chat_model("claude-sonnet-4-6").ainvoke(
37+
"Revise the following draft according to the reviewer's feedback. "
38+
"Output only the revised draft, with no preamble.\n\n"
39+
f"Draft:\n{state['value']}\n\n"
40+
f"Feedback:\n{feedback}"
41+
)
42+
return {"value": str(response.content)}
3843

3944

4045
def make_chatbot_graph() -> StateGraph:
Lines changed: 80 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import sys
33
import uuid
4+
from unittest.mock import patch
45

56
import pytest
67
from temporalio.client import Client
@@ -18,36 +19,59 @@
1819
)
1920

2021

22+
class _FakeMessage:
23+
def __init__(self, content: str) -> None:
24+
self.content = content
25+
26+
27+
class _EchoModel:
28+
"""Stand-in for a chat model that echoes the prompt back as its response."""
29+
30+
async def ainvoke(self, prompt: str) -> _FakeMessage:
31+
return _FakeMessage(prompt)
32+
33+
34+
def _fake_init_chat_model(*args: object, **kwargs: object) -> _EchoModel:
35+
return _EchoModel()
36+
37+
38+
_patch_llm = lambda: patch(
39+
"langgraph_plugin.graph_api.human_in_the_loop.workflow.init_chat_model",
40+
_fake_init_chat_model,
41+
)
42+
43+
2144
async def test_human_in_the_loop_approve(client: Client) -> None:
2245
task_queue = f"hitl-test-{uuid.uuid4()}"
2346
plugin = LangGraphPlugin(graphs={"chatbot": make_chatbot_graph()})
2447

25-
async with Worker(
26-
client,
27-
task_queue=task_queue,
28-
workflows=[ChatbotWorkflow],
29-
plugins=[plugin],
30-
):
31-
handle = await client.start_workflow(
32-
ChatbotWorkflow.run,
33-
"test message",
34-
id=f"hitl-{uuid.uuid4()}",
48+
with _patch_llm():
49+
async with Worker(
50+
client,
3551
task_queue=task_queue,
36-
)
37-
38-
# Poll for draft to be ready
39-
draft = None
40-
for _ in range(40):
41-
await asyncio.sleep(0.25)
42-
draft = await handle.query(ChatbotWorkflow.get_draft)
43-
if draft is not None:
44-
break
45-
assert draft is not None
46-
assert "test message" in draft
47-
48-
# Approve
49-
await handle.signal(ChatbotWorkflow.provide_feedback, "approve")
50-
result = await handle.result()
52+
workflows=[ChatbotWorkflow],
53+
plugins=[plugin],
54+
):
55+
handle = await client.start_workflow(
56+
ChatbotWorkflow.run,
57+
"test message",
58+
id=f"hitl-{uuid.uuid4()}",
59+
task_queue=task_queue,
60+
)
61+
62+
# Poll for draft to be ready
63+
draft = None
64+
for _ in range(40):
65+
await asyncio.sleep(0.25)
66+
draft = await handle.query(ChatbotWorkflow.get_draft)
67+
if draft is not None:
68+
break
69+
assert draft is not None
70+
assert "test message" in draft
71+
72+
# Approve
73+
await handle.signal(ChatbotWorkflow.provide_feedback, "approve")
74+
result = await handle.result()
5175

5276
assert result == draft # approved draft returned as-is
5377

@@ -56,31 +80,36 @@ async def test_human_in_the_loop_revise(client: Client) -> None:
5680
task_queue = f"hitl-revise-test-{uuid.uuid4()}"
5781
plugin = LangGraphPlugin(graphs={"chatbot": make_chatbot_graph()})
5882

59-
async with Worker(
60-
client,
61-
task_queue=task_queue,
62-
workflows=[ChatbotWorkflow],
63-
plugins=[plugin],
64-
):
65-
handle = await client.start_workflow(
66-
ChatbotWorkflow.run,
67-
"test message",
68-
id=f"hitl-revise-{uuid.uuid4()}",
83+
with _patch_llm():
84+
async with Worker(
85+
client,
6986
task_queue=task_queue,
70-
)
71-
72-
# Poll for draft
73-
draft = None
74-
for _ in range(40):
75-
await asyncio.sleep(0.25)
76-
draft = await handle.query(ChatbotWorkflow.get_draft)
77-
if draft is not None:
78-
break
79-
assert draft is not None
80-
81-
# Send revision feedback
82-
await handle.signal(ChatbotWorkflow.provide_feedback, "please be more concise")
83-
result = await handle.result()
84-
85-
assert "[Revised]" in result
87+
workflows=[ChatbotWorkflow],
88+
plugins=[plugin],
89+
):
90+
handle = await client.start_workflow(
91+
ChatbotWorkflow.run,
92+
"test message",
93+
id=f"hitl-revise-{uuid.uuid4()}",
94+
task_queue=task_queue,
95+
)
96+
97+
# Poll for draft
98+
draft = None
99+
for _ in range(40):
100+
await asyncio.sleep(0.25)
101+
draft = await handle.query(ChatbotWorkflow.get_draft)
102+
if draft is not None:
103+
break
104+
assert draft is not None
105+
106+
# Send revision feedback
107+
await handle.signal(
108+
ChatbotWorkflow.provide_feedback, "please be more concise"
109+
)
110+
result = await handle.result()
111+
112+
# The revision node feeds the draft and feedback into the LLM; the echo
113+
# stand-in returns the revision prompt, which contains both.
86114
assert "please be more concise" in result
115+
assert "test message" in result

0 commit comments

Comments
 (0)