Skip to content

Commit 724f767

Browse files
authored
Prompt for human feedback in LangGraph Functional API HITL sample (#368)
* Prompt for human feedback in LangGraph Functional API HITL sample * Make LangGraph HITL samples model-free Rewrite both graph_api and functional_api human-in-the-loop samples to be deliberately model-free without requiring an Anthropic API key. Draft generation and revision use deterministic templates while preserving the interactive terminal review workflow. Update unit tests and documentation accordingly. * Fix mypy attribute error in functional HITL test fixture
1 parent f7ae31e commit 724f767

7 files changed

Lines changed: 125 additions & 104 deletions

File tree

langgraph_plugin/functional_api/human_in_the_loop/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,19 @@ Demonstrates pausing an entrypoint with LangGraph's `interrupt()` and waiting in
2222
Prerequisites: `uv sync --group langgraph` and a running Temporal dev server (`temporal server start-dev`).
2323

2424
```bash
25-
# Terminal 1
25+
# Terminal 1: start the worker
2626
uv run langgraph_plugin/functional_api/human_in_the_loop/run_worker.py
2727

28-
# Terminal 2
28+
# Terminal 2: start the workflow (polls for the draft, then prompts you for feedback)
2929
uv run langgraph_plugin/functional_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 revised incorporating your notes.
33+
3234
## Files
3335

3436
| File | Description |
3537
|------|-------------|
3638
| `workflow.py` | `@task` functions, `@entrypoint`, and `ChatbotFunctionalWorkflow` |
3739
| `run_worker.py` | Registers tasks and entrypoint 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/functional_api/human_in_the_loop/run_workflow.py

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

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

31-
# Send approval via signal
32-
await handle.signal(ChatbotFunctionalWorkflow.provide_feedback, "approve")
31+
# Prompt for human feedback instead of auto-approving.
32+
feedback = await asyncio.to_thread(
33+
input, "Enter 'approve' to accept, or type revision feedback: "
34+
)
35+
await handle.signal(ChatbotFunctionalWorkflow.provide_feedback, feedback)
3336

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

langgraph_plugin/functional_api/human_in_the_loop/workflow.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818

1919
@task
20-
def generate_draft(message: str) -> str:
20+
async def generate_draft(message: str) -> str:
2121
"""Generate a draft response. Replace with an LLM call in production."""
2222
return (
2323
f"Here's my response to '{message}': "
@@ -26,16 +26,16 @@ def generate_draft(message: str) -> str:
2626

2727

2828
@task
29-
def request_human_review(draft: str) -> str:
30-
"""Pause execution to request human review of the draft."""
29+
async def request_human_review(draft: str) -> str:
30+
"""Present draft to human for review via interrupt; revise on feedback."""
3131
feedback = interrupt(draft)
3232
if feedback == "approve":
3333
return draft
3434
return f"[Revised] {draft} (incorporating feedback: {feedback})"
3535

3636

3737
@entrypoint()
38-
async def chatbot_entrypoint(user_message: str) -> dict:
38+
async def chatbot_entrypoint(user_message: str) -> dict[str, Any]:
3939
"""Chatbot entrypoint: generate a draft, get human review, return result."""
4040
draft = await generate_draft(user_message)
4141
final_response = await request_human_review(draft)

langgraph_plugin/graph_api/human_in_the_loop/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ uv run langgraph_plugin/graph_api/human_in_the_loop/run_worker.py
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.
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 revised incorporating your notes.
3333

3434
## Files
3535

langgraph_plugin/graph_api/human_in_the_loop/workflow.py

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

77
from datetime import timedelta
88

9-
from langchain.chat_models import init_chat_model
109
from langchain_core.runnables import RunnableConfig
1110
from langgraph.checkpoint.memory import InMemorySaver
1211
from langgraph.graph import START, StateGraph
@@ -21,25 +20,21 @@ class State(TypedDict):
2120

2221

2322
async def generate_draft(state: State) -> dict[str, str]:
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)}
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+
}
2930

3031

3132
async def human_review(state: State) -> dict[str, str]:
32-
"""Present draft to human for review via interrupt; revise with LLM on feedback."""
33+
"""Present draft to human for review via interrupt; revise on feedback."""
3334
feedback = interrupt(state["value"])
3435
if feedback == "approve":
3536
return {"value": state["value"]}
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)}
37+
return {"value": f"[Revised] {state['value']} (incorporating feedback: {feedback})"}
4338

4439

4540
def make_chatbot_graph() -> StateGraph:

tests/langgraph_plugin/functional_human_in_the_loop_test.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@
2020
)
2121

2222

23+
@pytest.fixture(autouse=True)
24+
def _restore_tasks():
25+
original_funcs = [getattr(t, "func") for t in all_tasks]
26+
try:
27+
yield
28+
finally:
29+
for t, orig in zip(all_tasks, original_funcs):
30+
setattr(t, "func", orig)
31+
32+
2333
async def test_functional_human_in_the_loop_approve(client: Client) -> None:
2434
task_queue = f"functional-hitl-test-{uuid.uuid4()}"
2535
plugin = LangGraphPlugin(
@@ -56,3 +66,43 @@ async def test_functional_human_in_the_loop_approve(client: Client) -> None:
5666
result = await handle.result()
5767

5868
assert result["response"] == draft
69+
70+
71+
async def test_functional_human_in_the_loop_revise(client: Client) -> None:
72+
task_queue = f"functional-hitl-revise-test-{uuid.uuid4()}"
73+
plugin = LangGraphPlugin(
74+
entrypoints={"chatbot": chatbot_entrypoint},
75+
tasks=all_tasks,
76+
activity_options=activity_options,
77+
)
78+
79+
async with Worker(
80+
client,
81+
task_queue=task_queue,
82+
workflows=[ChatbotFunctionalWorkflow],
83+
plugins=[plugin],
84+
):
85+
handle = await client.start_workflow(
86+
ChatbotFunctionalWorkflow.run,
87+
"test message",
88+
id=f"functional-hitl-revise-{uuid.uuid4()}",
89+
task_queue=task_queue,
90+
)
91+
92+
# Poll for draft
93+
draft = None
94+
for _ in range(40):
95+
await asyncio.sleep(0.25)
96+
draft = await handle.query(ChatbotFunctionalWorkflow.get_draft)
97+
if draft is not None:
98+
break
99+
assert draft is not None
100+
101+
# Send revision feedback
102+
await handle.signal(
103+
ChatbotFunctionalWorkflow.provide_feedback, "please be more concise"
104+
)
105+
result = await handle.result()
106+
107+
assert "[Revised]" in result["response"]
108+
assert "please be more concise" in result["response"]
Lines changed: 51 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import asyncio
22
import sys
33
import uuid
4-
from unittest.mock import patch
54

65
import pytest
76
from temporalio.client import Client
@@ -19,59 +18,36 @@
1918
)
2019

2120

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-
4421
async def test_human_in_the_loop_approve(client: Client) -> None:
4522
task_queue = f"hitl-test-{uuid.uuid4()}"
4623
plugin = LangGraphPlugin(graphs={"chatbot": make_chatbot_graph()})
4724

48-
with _patch_llm():
49-
async with Worker(
50-
client,
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()}",
5135
task_queue=task_queue,
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()
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()
7551

7652
assert result == draft # approved draft returned as-is
7753

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

83-
with _patch_llm():
84-
async with Worker(
85-
client,
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()}",
8669
task_queue=task_queue,
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.
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
11486
assert "please be more concise" in result
115-
assert "test message" in result

0 commit comments

Comments
 (0)