# LangChain human-in-the-loop dispatch — investigation record

**I05 · Executed October 23, 2026 · Scripted capture in the framework's own fixtures; no language model.** Tests the static trace in [What exactly did you approve?](/blog/what-exactly-did-you-approve) by observing LangChain's human-in-the-loop middleware from outside the model, at the pinned revision used by the [approval-binding source register](/research/approval-binding.md). Not a test of any application, reviewer interface, persistent backend or real sender.

## Question

The article reads the middleware's source and concludes that a reviewer's edit is stored by tool-call ID and applied before the tool runs, while message history keeps the model's original proposal, and that a rejected call is never dispatched. When the agent actually runs, what does an observer outside the model see for approve, edit and reject? (a) What proposal sits in message history? (b) Is a replacement stored, and where? (c) What arguments does the tool receive? (d) Does a rejected call reach the tool? (e) Which record links the dispatched call back to the proposal?

## Data and provenance

- **Revision:** `langchain-ai/langchain` commit `4af7ab8fcb98c2dc211d9e34cddedffa23eba4d9`, downloaded as `https://codeload.github.com/langchain-ai/langchain/tar.gz/4af7ab8fcb98c2dc211d9e34cddedffa23eba4d9` (archive SHA-256 `5a807b88f122eec6389da8c5f287296dffdd4fe15394813505694c85309d2936` on 2026-10-23; GitHub does not guarantee stable archive bytes, so the file digests below are the pins).
- **Pinned file digests (match the source register's AB1, AB3, AB4):** `libs/langchain_v1/langchain/agents/middleware/human_in_the_loop.py` `e7143b5022639c244ab20222229fe45e6d0ae48338f15439971cad31b0608f31`; `…/test_human_in_the_loop.py` `7f1bb0f8cb14494ca7732e071a265906ee2c42d7118a4a57a68ce68f76c7166d`; `libs/langchain_v1/uv.lock` `ee5d424e27e3bdca5dbb5881cd6759ae25462a2d5f519f1fbedef16e7fa02799`.
- **Environment:** uv 0.12.0; CPython 3.13.14 (selected by uv); Linux 6.8.0. Lock-resolved by `uv sync --frozen --group test`: `langchain` 1.4.2 and `langchain-core` 1.6.3 (both path packages from the same checkout), `langgraph` 1.2.11, `langgraph-prebuilt` 1.1.0, `langgraph-checkpoint` 4.2.0, `pydantic` 2.12.5, `pytest` 9.1.1.
- **Inputs:** fictional `.invalid` addresses and a fictional filename. No credentials, model calls or network access other than package installation.
- **Published with this record:** [`capture.py`](/research/langchain-hitl-dispatch.py) (SHA-256 `19abe3ba4ebe9da7fcdba17479d615dc1cefe9a01367c0aff959a651f1ddd13f`) and [`results.json`](/research/langchain-hitl-dispatch.json) (SHA-256 `ce8a32959b270d46493a95a5516d33e93e0fc18d4cc5ab51dbc2a282deccb8c1`). No third-party code is vendored.

## Method

[`capture.py`](/research/langchain-hitl-dispatch.py) builds `create_agent` with `HumanInTheLoopMiddleware(interrupt_on={"send_document": True})`, an `InMemorySaver` checkpointer and:

- **A scripted model**, a minimal `BaseChatModel` stub. It proposes `send_document(recipient="engineering@example.invalid", document="migration-note.md")` with tool-call ID `call-i05`. Once a tool result exists, it ends the turn with text. `GenericFakeChatModel` was not used because `create_agent` calls `bind_tools`, which that fake does not implement. The upstream test suite's `FakeToolCallingModel` was not imported, so the capture does not depend on test internals.
- **A fake tool** that appends every argument set it receives to a list owned by the script. That list is the dispatch observation; it does not depend on any message the model or middleware writes.

For each case, the script invokes the agent until it interrupts, reads the interrupt payload (what a reviewer would be shown), and resumes with one scripted decision. It then reads, from outside the model: the final message history, the `hitl_edited_tool_calls` value at every checkpoint in `get_state_history`, whether that key appears in the `invoke` return value, and the tool's list. Cases: **approve**; **edit** with replacement `recipient="sdk-review@example.invalid"`; **reject** without a message; and **edit-notice-off**, the same edit with the middleware's `edit_notice=None`.

**Status conditions,** taken from the article's static trace. **PASS:** the outside observations match the trace for that decision. Approve: the tool receives the proposed arguments. Edit: the tool receives the replacement and history keeps the original. Reject: the tool is never invoked and the only result is a paired error `ToolMessage`. In every case history keeps the proposal under the same call ID. **FAIL:** any of those is contradicted. **UNKNOWN:** the observation cannot be made from outside the model.

```sh
mkdir -p /tmp/ra-i05/src && cd /tmp/ra-i05/src
curl -sSL -o lc.tar.gz https://codeload.github.com/langchain-ai/langchain/tar.gz/4af7ab8fcb98c2dc211d9e34cddedffa23eba4d9
tar xzf lc.tar.gz   # the lockfile references sibling path packages; extract the whole archive
cd langchain-4af7ab8fcb98c2dc211d9e34cddedffa23eba4d9/libs/langchain_v1
uv sync --frozen --group test
uv run --frozen --no-sync pytest tests/unit_tests/agents/middleware/implementations/test_human_in_the_loop.py -q
curl -sSL -o /tmp/ra-i05/capture.py https://rethinkingagents.com/research/langchain-hitl-dispatch.py
.venv/bin/python -I /tmp/ra-i05/capture.py > /tmp/ra-i05/results.json
```

The script is kept outside the unpacked archive. Compare the output with the published [`results.json`](/research/langchain-hitl-dispatch.json). The capture runs in under two seconds; a second run produced byte-identical `results.json`. $0.

## Results

**Pinned test file, rerun:** 53 passed, 0 failed, 0 errors (1.02 s), matching the October 22 run.

| Case | Status | (a) Proposal in history | (b) `hitl_edited_tool_calls` | (c) Tool received | (d) Tool calls | (e) Link back to proposal |
|---|---|---|---|---|---|---|
| approve | PASS | original args, ID `call-i05` | `{}` from the post-review checkpoint on | original args | 1 | `ToolMessage` with `tool_call_id` `call-i05`, plain tool output |
| edit | PASS | **original** args, ID `call-i05` | `{"call-i05": replacement}` from the post-review checkpoint to the end of the run | **replacement** | 1 | `ToolMessage` `call-i05`; its content begins with the reviewer-edit notice and states the executed arguments |
| reject | PASS | original args, ID `call-i05` | `{}` | nothing | **0** | error `ToolMessage` `call-i05`: “The tool was not executed”; the graph went from review straight back to the model, with no tools step |
| edit-notice-off | PASS | **original** args, ID `call-i05` | as for edit | **replacement** | 1 | `ToolMessage` `call-i05` with plain tool output; **no message states the replacement** |

The reviewer was shown the original proposal in every case, as expected.

**`hitl_edited_tool_calls` behaviour.** The key is a private state attribute ([pinned source, line 236](https://github.com/langchain-ai/langchain/blob/4af7ab8fcb98c2dc211d9e34cddedffa23eba4d9/libs/langchain_v1/langchain/agents/middleware/human_in_the_loop.py#L236)). It **did not appear** in the `invoke` return value in any case. It **did appear** in checkpointed state from `get_state` / `get_state_history`. It is absent before review. After review it holds `{}` (approve, reject) or the replacement keyed by call ID (edit), because the middleware writes it on every reviewed turn ([line 524](https://github.com/langchain-ai/langchain/blob/4af7ab8fcb98c2dc211d9e34cddedffa23eba4d9/libs/langchain_v1/langchain/agents/middleware/human_in_the_loop.py#L524)). After an edit, it was **still present in the final checkpoint** when the run ended. The middleware clears it only on a later turn whose tool calls need no review ([line 471](https://github.com/langchain-ai/langchain/blob/4af7ab8fcb98c2dc211d9e34cddedffa23eba4d9/libs/langchain_v1/langchain/agents/middleware/human_in_the_loop.py#L471)); a final text-only turn returns early ([line 448](https://github.com/langchain-ai/langchain/blob/4af7ab8fcb98c2dc211d9e34cddedffa23eba4d9/libs/langchain_v1/langchain/agents/middleware/human_in_the_loop.py#L448)) and leaves it. We read the later clearing and overwrite paths but did not exercise a second turn.

## What the capture shows

1. **The static trace held at runtime.** Edit: the tool received the replacement; message history kept the model's original arguments under the same call ID. Reject: the tool was never invoked, and the proposal stayed in history beside a paired error result. Approve: the original arguments were dispatched. Nothing contradicted the article.
2. **The call ID is the only structural link.** No message records “proposal X became dispatch Y”. The dispatched result shares `tool_call_id` with the proposal. The replacement arguments appear in message history only as text in the default reviewer-edit notice, which `edit_notice=None` removes. The structured replacement sits in a private state key: absent from `invoke` output, readable from the checkpointer.
3. **The checkpointer is where an activity view could find the authorized call.** A view built from `invoke` output or message history alone would show the model's proposal as “the action” for an edited call, and would show it with no stated correction if the notice is off. The stored replacement survives the end of the run in the checkpoint and is replaced on the next reviewed turn. It is not a durable decision log.

## Limits

- The framework's own fixture style: one process, a scripted stub model, a fake tool and an in-memory checkpointer. It shows the framework's dispatch behaviour at one revision. It does not show how a production application, a reviewer interface, an authenticated reviewer, a persistent backend or a real sender behaves.
- One proposal per case and a single tool. Parallel tool calls, tool redirection (editing the tool name), `respond`, `when` predicates, async paths and multi-turn threads were not run; they were read in source or upstream tests only.
- The tool is the observer of dispatch, so (c) and (d) cover what reached the tool function, not what a sender would submit or a service would accept.
- The status conditions are ours, derived from the article's trace; there is no upstream specification for them. Checkpoint step numbering is LangGraph's internal metadata and may change between releases.

## What should follow

Only if a reader needs it: repeat (a)–(e) in one real integration with a persistent checkpointer, its reviewer display and a fake external sender that records the submitted payload. Add a second turn to observe when the stored replacement is overwritten, and a restart between review and dispatch. That would test an application, not only the framework.

## Change log

- 2026-10-23: executed; pinned test file rerun (53 passed); capture script and results committed. Public copy prepared the same day.
