kmail.at
contact

elsewhere

EnglishEN
← learning

langchain · difficulty ◆◆

HumanInTheLoopMiddleware — frame custom rejection reasons

When a human rejects a tool call, the model must know it was rejected — not that the tool ran and returned your text.

A human says "no" to a tool call. The model must know it was rejected — not that the tool ran and returned your words.

fully AI-generatedThis text is fully AI-generated and was published without human editorial review.

2026-08-27 · 7 min read

$ pip install -U langchain==1.3.17
1.3.17
release
#39773
PR

Frame custom HITL rejection reasons

approve / edit / reject
decisions
bare rejection
fallback

What it does

HumanInTheLoopMiddleware pauses an agent before a sensitive tool runs and asks a human to approve, edit, reject, or respond. When a human rejects a tool call, the middleware feeds a ToolMessage back to the model explaining what happened. Before this fix, if you supplied a custom RejectDecision.message, that message replaced all the rejection framing — the model got your raw text with no context that the tool was rejected rather than executed. Now the middleware wraps your reason in a consistent frame: "User rejected the tool call for '<tool>' with reason: <your message>". If you omit the message, it falls back to the bare-rejection text (tool not executed, don't retry unless the user asks). Either way, the model now reliably understands the tool was rejected by a human, without the middleware prescribing whether it should retry.

Why it matters

In real agent deployments, human-in-the-loop gates protect irreversible or costly actions — sending emails, transferring money, deleting data, calling external APIs. When a human rejects such an action, the model's next decision depends entirely on understanding why. If your custom rejection reason ("the amount is wrong", "wrong recipient", "this is a test account") is passed through without framing, the model may think the tool ran and returned that text, then behave incorrectly. This fix makes rejection feedback unambiguous and consistent across all your tools, so the agent reliably stops, learns the reason, and only retries when the human actually asks. It's a small change with a big impact on agent reliability and safety.

Example

$ Gate a send_payment tool and simulate a human rejection with a custom reason
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI

@tool
def send_payment(amount: int, recipient: str) -> str:
    """Send a payment to a recipient."""
    return f"Paid {amount} to {recipient}"

hitl = HumanInTheLoopMiddleware(
    interrupt_on={
        "send_payment": {
            "allowed_decisions": ["approve", "edit", "reject"],
        }
    }
)

agent = create_agent(
    model=ChatOpenAI(model="gpt-4o", temperature=0),
    tools=[send_payment],
    middleware=[hitl],
)

# Simulate the human rejecting with a custom reason.
rejection = {"type": "reject", "message": "Amount is too high"}

# The ToolMessage the model now receives is framed as a user rejection:
print(
    "User rejected the tool call for `send_payment` "
    f"with reason: {rejection['message']}"
)

Expected output: User rejected the tool call for `send_payment` with reason: Amount is too high. The model sees this framed message and understands the tool was rejected by a human — not executed — and only retries if the user explicitly asks.

Common flags

RejectDecision
TypedDict for a human rejection; optional message is now framed as a user rejection reason
ApproveDecision
TypedDict signalling the tool call should proceed unchanged
EditDecision
TypedDict carrying an edited_action (new name/args) to run instead
interrupt()
The runtime call that pauses the agent and returns the human's decisions

History

Origin

The fix ships in langchain==1.3.17 (released 2026-08-25), a sparse meta-release whose substantive change is PR #39773: "fix(langchain): frame custom HITL rejection reasons". The changelog entry landed on 2026-08-27.

The framing problem

Before the fix, a custom RejectDecision.message replaced all rejection framing, so the model could mistake your reason for tool output. The fix wraps the reason in a consistent "User rejected the tool call for '<tool>' with reason: ..." frame, and falls back to bare-rejection text when no message is supplied.

Under the hood

Why framing beats raw text

The model's next decision depends on knowing the tool was rejected by a human, not executed. Raw text like "the amount is wrong" could be read as tool output. The framed message — "User rejected the tool call for 'send_payment' with reason: Amount is too high" — makes the rejection unambiguous, so the agent stops, learns the reason, and only retries when the human actually asks.

The fallback path

When you omit the message, the middleware falls back to the bare-rejection text: the tool was not executed and should not be retried unless the user explicitly requests it. Either way, the model reliably understands the tool was rejected by a human, without the middleware prescribing whether it should retry.

Fun facts

Pros & cons

pros

  • + Unambiguous rejection feedback across all your tools
  • + Custom reasons no longer mistaken for tool output
  • + Consistent framing regardless of which tool is gated
  • + Falls back cleanly when no message is supplied

cons

  • − Requires upgrading to langchain==1.3.17+
  • − Existing code that relied on raw rejection text needs a migration

Takeaways

  1. 1Upgrade to langchain==1.3.17 to get the framed rejection reasons
  2. 2Supply a custom RejectDecision.message and confirm the model sees "User rejected the tool call for ... with reason: ..."
  3. 3Omit the message to exercise the bare-rejection fallback
  4. 4Add an edit decision and verify the edited action preserves the original tool_call_id
  5. 5Test with a real interrupt() flow to confirm the agent stops and only retries when the human asks

Related commands

archive entry — written and published by a scheduled job, never editorially reviewed. Kept for reference: out of the site’s search index, and no ads run here. the reviewed library →

← all learning