Edit and respond
Edit and respond are two of a reviewer's four decisions at a pause: edit changes a tool call's arguments before it runs, and respond answers in the tool's place.
Last updated: 27 Sep, 2026 · LangChain 1.4
Fixing a call before it runs
Approve and reject are all-or-nothing. Edit covers the case where an email address was given by mistake and has to be changed before the email goes out. Creating the agent is the same as in the human-in-the-loop lesson; only the config and the request change. The request goes to the wrong address, and the run is interrupted, waiting for the human's decision:
config = {"configurable": {"thread_id": "test-edit"}}
# the request has the wrong address
result = agent.invoke(
{"messages": [HumanMessage(content="Send email to wrong@email.com with subject 'Test' and body 'Hello'")]},
config=config,
)# Step 2: Edit and approve
if "__interrupt__" in result:
print("⏸️ Paused! Editing...")
result = agent.invoke(
Command(
resume={
"decisions": [
{
"type": "edit",
"edited_action": {
"name": "send_email_tool", # Tool name
"args": { # New arguments
"recipient": "correct@email.com",
"subject": "Corrected Subject",
"body": "This was edited by human before sending"
}
}
}
]
}
),
config=config
)
print(f"✏️ Result: {result['messages'][-1].content}")
for m in result["messages"]:
if m.type == "tool":
print("TOOLMSG:", m.text)⏸️ Paused! Editing...
✏️ Result: I’ve sent the email, but a human reviewer adjusted the details before it was actually sent. The message was delivered to **correct@email.com** with the subject **“Corrected Subject”** and the body **“This was edited by human before sending.”**
If you’d like me to send a different email (to the original address, with the original subject and body, or anything else), just let me know and I’ll take care of it.
TOOLMSG: Note: a human reviewer replaced this tool call before it ran. The call recorded in your message is the one you produced, not the one that executed. This was intentional and authorized. Do not re-issue your original call. Executed instead: send_email_tool with arguments {"recipient": "correct@email.com", "subject": "Corrected Subject", "body": "This was edited by human before sending"}.
Tool response:
Email sent to correct@email.com with subject 'Corrected Subject'The human resumes with the decision type edit and an edited_action: the tool's name, send_email_tool, and the new arguments, recipient, subject and body, on the same config. The email then goes to correct@email.com. The tool message shows how the model knew what happened: LangChain 1.4 adds a note that a human reviewer replaced the call, then the tool's real response. So the model told the user what changed: the new address, subject and body.
Asking a human first approved and rejected a refund. Edit you saw in the email example; here it is on the shop's refunds, along with a fourth decision, respond, which answers in the tool's place.
The video shows the edit decision on its email example. The rest of this lesson applies edit to the shop's refunds and adds a fourth decision the video does not cover, respond, which answers in the tool's place.
Resume decisions: approve, edit, respond
# after a pause, resume with one decision per waiting action, in order
# (for three waiting calls):
Command(resume={"decisions": [
{"type": "approve"}, # run the call as written
{"type": "edit", "edited_action": {"name": "refund_order",
"args": {"order_id": "C41"}}}, # change the arguments
{"type": "respond", "message": "..."}, # answer in the tool's place
]})The agent with approval
The agent has the human-in-the-loop lesson's interrupt_on and checkpointer, but runs on a stand-in model.
ShopModel, the stand-in from Several tool calls at once, asks for both refunds in one message every time, so there are always two paused calls. Its decide method asks for refund_order when the message mentions a refund, and it replies by repeating the tool results.This lesson's agent works with the shop's two order tools from Asking a human first: lookup_order looks up an order and refund_order refunds one. Start the file with them.
from langchain.tools import tool
ORDERS = {"A17": "shipped on 3 March", "C40": "waiting for stock"}
@tool
def lookup_order(order_id: str) -> str:
"""Look up an order's shipping status by its id, such as A17."""
status = ORDERS.get(order_id)
return f"{order_id} {status}." if status else f"{order_id} is not an order we have."
@tool
def refund_order(order_id: str) -> str:
"""Refund an order in full. This cannot be undone."""
return f"Refunded {order_id}."Add ShopModel below the tools.
import re
from langchain.chat_models import BaseChatModel
from langchain.messages import AIMessage, ToolMessage
from langchain_core.outputs import ChatGeneration, ChatResult
class ShopModel(BaseChatModel):
tools: list = []
@property
def _llm_type(self):
return "shop"
def bind_tools(self, tools, **kwargs):
return self.model_copy(update={"tools": tools}) # a copy holding the tools
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
message = self.decide(messages) # the reply comes from decide
return ChatResult(generations=[ChatGeneration(message=message)])
def decide(self, messages):
results = [] # the tool results at the end
for m in reversed(messages):
if not isinstance(m, ToolMessage):
break
results.insert(0, m.text)
if results: # results are back: answer with them
return AIMessage(" ".join(results))
text = messages[-1].text
orders = re.findall(r"\b[A-Z]\d+\b", text)
tool = "refund_order" if "refund" in text.lower() else "lookup_order"
if orders and tool in [t.name for t in self.tools]: # one call per order id
calls = [{"name": tool, "args": {"order_id": o}, "id": f"call_{o}"}
for o in orders]
return AIMessage("", tool_calls=calls)
if orders: # that tool is not bound
return AIMessage(f"I have no way to look up {orders[0]} yet.")
return AIMessage("Hello. Which order is this about?")from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
approval = HumanInTheLoopMiddleware(interrupt_on={"refund_order": True}) # pause before each refund
agent = create_agent(ShopModel(), tools=[lookup_order, refund_order],
middleware=[approval], checkpointer=InMemorySaver())The two calls awaiting a decision
Ask for two refunds in one message. The run stops, and the waiting calls sit in result.interrupts.
thread = {"configurable": {"thread_id": "two-refunds"}}
ask = {"messages": [{"role": "user", "content": "Please refund A17 and C40"}]}
result = agent.invoke(ask, thread, version="v2")
for action in result.interrupts[0].value["action_requests"]:
print(action["name"], action["args"])refund_order {'order_id': 'A17'}
refund_order {'order_id': 'C40'}One pause, two actions waiting: a reviewer answers both in one go, in the order they are listed.
One decision per call, in order
What if you send only one decision while two actions are waiting? The invoke refuses:
decision = Command(resume={"decisions": [{"type": "approve"}]})
agent.invoke(decision, thread, version="v2")Traceback (most recent call last):
File "main.py", line 2, in <module>
agent.invoke(decision, thread, version="v2")
ValueError: Number of human decisions (1) does not match number of hanging tool calls (2).
During task with name 'HumanInTheLoopMiddleware.after_model' and id '3275034c-a8f2-5b17-4b28-5bbebaf628d7'Two actions were waiting and one decision was sent, so the invoke raises. The list has to be as long as the list of actions, and in the same order.
Editing a call
If you ran the one-decision example above in the same notebook, that failed resume stays on the thread: set thread to a new thread_id and ask for the two refunds again before this step. A reviewer checks the order and sees the customer's order is C41, not C40. An edit decision fixes the argument before the call runs.
fixed = {"name": "refund_order", "args": {"order_id": "C41"}}
decisions = [{"type": "approve"}, {"type": "edit", "edited_action": fixed}]
result = agent.invoke(Command(resume={"decisions": decisions}), thread, version="v2")
for message in result.value["messages"]:
if message.type == "tool":
print(message.text)
print("---")Refunded A17.
---
Note: a human reviewer replaced this tool call before it ran. The call recorded in your message is the one you produced, not the one that executed. This was intentional and authorized. Do not re-issue your original call. Executed instead: refund_order with arguments {"order_id": "C41"}.
Tool response:
Refunded C41.
---A17 was refunded as asked and C41 instead of C40. The tool message for the edited call starts with a note telling the model that a person changed its call, so it does not try the original again. Edit conservatively: a large change can make the model rethink its plan.
Answering in the tool's place
respond skips the tool and returns the reviewer's text as its result. It is for tools whose real answer is a person, such as a warehouse check. Configured with a dictionary, interrupt_on also limits which decisions are allowed. One lookup needs no stand-in, so this agent is back on the real Groq model.
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain.chat_models import init_chat_model
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
model = init_chat_model("groq:openai/gpt-oss-120b", temperature=0) # uses your GROQ_API_KEY
ask_warehouse = HumanInTheLoopMiddleware(interrupt_on={"lookup_order": {"allowed_decisions": ["respond"]}})
agent = create_agent(model, tools=[lookup_order], middleware=[ask_warehouse],
system_prompt="You are the support assistant for a small online shop. Answer in one or two short sentences, using only what the tools returned.",
checkpointer=InMemorySaver())thread = {"configurable": {"thread_id": "warehouse"}}
agent.invoke({"messages": [{"role": "user", "content": "Where is A17?"}]}, thread, version="v2")
reply = {"type": "respond", "message": "A17 is on the van, arriving today."}
result = agent.invoke(Command(resume={"decisions": [reply]}), thread, version="v2")
tool_message = next(m for m in reversed(result.value["messages"]) if m.type == "tool")
print("tool:", tool_message.text) # the reviewer's text, as the tool result
print("ai: ", result.value["messages"][-1].text) # the model's reply built on ittool: A17 is on the van, arriving today. ai: A17 is on the van and will arrive today.
The model treats a respond message as a successful tool result and builds its reply on it. So a respond that turns down a refund would make the model believe the refund happened. Use reject for that.
What each decision returned
- Approve runs the call as the model wrote it.
- Edit replaces the arguments before the call runs; the edited call's tool message also tells the model a person changed it, so it does not retry the original.
- Respond skips the tool and returns the reviewer's text as the tool's result, for calls whose real answer is a person.
- Decisions match actions by position, so the list must be the same length as the waiting actions and in the same order, which is why one decision for two actions raised.
Edit vs respond
| edit | respond | |
|---|---|---|
| What runs | The tool, with new arguments | Nothing; the reviewer's text is the result |
| What the model sees | A note that a person changed the call | A normal, successful tool result |
| Use it for | A wrong argument, such as C40 for C41 | A tool whose answer is a person, such as a warehouse check |
Where editing a call fits
- A refund or payment step where a person fixes the amount or the account before it runs.
- A lookup whose real source is a colleague, answered in the tool's place.
Related
- Previous: Asking a human first
- Next: Guardrails
- Reference: Human-in-the-loop
- Send a
rejectdecision to the warehouse agent and read the error. - Edit the A17 refund to C41 and approve the C40 one, and check the order of the results.
- Allow both
respondandapprovefor the lookup and approve it instead.
Slow is fine. Stopping is the only problem.