Latest Results
feat(langchain): add opt-in per-call interrupts to HITL middleware (#41062)
Closes #41007
`HumanInTheLoopMiddleware` can now pause once per gated tool call
instead of once per model turn. Pass `interrupt_mode="per_call"` to opt
in. The default stays `"batched"`, so existing agents behave exactly as
before. Per-call is planned to become the default in the next major
release.
Today one interrupt carries every gated call of a turn, with the allowed
decisions in a separate list matched by position, so approval UIs have
to infer what they're looking at, and a bad edit is only caught when the
tool runs.
In per-call mode, each interrupt has type `tool_approval` and carries a
`response_schema` listing that tool's allowed decisions. Reviewers
answer by interrupt ID, so each call can be answered on its own. This
suits approval UIs that show one card or message per tool call, and
clients that build a typed form from the schema instead of hardcoding
the decisions. LangGraph checks every answer against the schema before
saving it:
- an edit can't switch tools, and its args must match the tool's
Pydantic schema. Args the tool doesn't declare are ignored, as they are
when the model sends them. Edits to tools defined by a raw JSON schema
are shown but not checked
- a bad answer raises a `ValidationError` pointing at the problem, and
nothing is saved, so the call can be answered again
- if one of several answers sent in one resume is invalid, the others'
tools may already have run even if they still show as pending (a
LangGraph checkpointing race), so answering them again would run them
twice: check answers against `response_schema` first, or send them one
at a time
A gated call with no tool call ID raises before pausing. In per-call
mode, list this middleware before tool retry or error-handling
middleware so it wraps them. On resume, middleware listed before it runs
again up to the pause, so it must not have side effects before calling
`handler`.
The config's `args_schema` isn't sent in either mode; in per-call mode
`response_schema` shows the tool's own argument schema instead. As in
batched mode, running the agent asynchronously needs Python 3.11 or
later.
Also raise the `langgraph` floor to 1.2.12, the first release with
interrupt `response_schema`. Drop the `# type: ignore[attr-defined]`
comments the newer `langgraph` made redundant.
### Before: `batched` (default)
```mermaid
sequenceDiagram
autonumber
participant App as Client app
participant HITL as Agent + HITL<br/>after_model
participant Tools
App->>HITL: invoke(user message)
Note over HITL,Tools: model asks for send_email, delete_file, lookup<br/>ONE pause for the whole turn, no tool has run yet
HITL-->>App: 1 interrupt: action_requests [send_email, delete_file]<br/>review_configs [...], response_schema = None
App->>HITL: resume {decisions: [edit to=5, reject]}<br/>matched by position
Note over HITL,Tools: nothing checks the answer, it is saved as-is
HITL->>Tools: delete_file: skipped (rejected)
HITL->>Tools: lookup(weather)
HITL->>Tools: send_email(to=5)
Tools-->>HITL: error: the model must fix a call it never made
Note over App,Tools: Wrong order [reject, edit]: the edit lands on delete_file, ValueError.<br/>The bad answer is already saved, so the corrected one fails too. Thread stuck.
```
### After: `per_call` (opt-in)
```mermaid
sequenceDiagram
autonumber
participant App as Client app
participant Agent as Agent<br/>after_model: no-op
participant T1 as task c1<br/>send_email
participant T2 as task c2<br/>delete_file
participant T3 as task c3<br/>lookup
App->>Agent: invoke(user message)
par one task per tool call
Agent->>T1: run
and
Agent->>T2: run
and
Agent->>T3: run
end
T3-->>Agent: found (not gated, runs right away)
T1-->>App: interrupt 68fc: tool_approval send_email(to=alice)<br/>schema: approve | edit(to: string) | reject | respond
T2-->>App: interrupt 834e: tool_approval delete_file(report.txt)<br/>schema: approve | reject
App->>Agent: resume {68fc: edit to=5}
Note over Agent,T2: LangGraph checks 68fc's schema first<br/>ValidationError at edit.edited_action.args.to, nothing saved, both still paused
App->>Agent: resume {68fc: edit to=bob, 834e: reject "keep it"}
Agent->>T1: resume with its answer
T1-->>Agent: sent to bob (+ edit notice for the model)
Agent->>T2: resume with its answer
T2-->>Agent: rejected: keep it (tool not run)
Note over Agent,T3: model runs once with all 3 results, lookup did not run again
```
## Release note
`HumanInTheLoopMiddleware` accepts `interrupt_mode="per_call"`, which
pauses once per gated tool call instead of once per model turn. Each
interrupt has type `tool_approval` and a `response_schema` listing the
decisions that tool allows, is answered with a single decision keyed by
interrupt ID, and LangGraph checks the answer against the schema before
saving it. The default stays `"batched"`.
https://github.com/langchain-ai/langgraph/pull/9265 Latest Branches
0%
0%
richardscarrott/core/tool-call-delta-consumers +57%
fix/hitl-per-call-interrupts © 2026 CodSpeed Technology