Skip to content

[azure-ai-agentserver-langgraph] Incomplete tool-call sequences sent to LLM when non-HITL message arrives during pending interrupt #45875

@niallkeys

Description

@niallkeys

Bug Report

Package: azure-ai-agentserver-langgraph
Version: 1.0.0b8

Summary

When a LangGraph interrupt is pending (e.g. a HITL form or confirmation is awaiting a response) and the client sends a regular human message instead of the expected function_call_output response, _convert_request_input_with_history takes the checkpoint path, which skips _filter_incomplete_tool_calls. LangGraph then merges the new HumanMessage into the checkpoint state, producing a message sequence like:

[SystemMessage, HumanMessage, AIMessage(tool_calls=[...]), HumanMessage]

OpenAI rejects this with HTTP 400:

An assistant message with 'tool_calls' must be followed by tool messages responding
to each 'tool_call_id'. The following tool_call_ids did not have response messages:
call_F4JleIVATKlDQ0CGvrDOf9T5

Root Cause

In response_api_default_converter.py, _convert_request_input_with_history applies _filter_incomplete_tool_calls only on the no-checkpoint path (when historical items are fetched from AIProjectClient):

# No checkpoint path — filter IS applied ✓
messages = self._filter_incomplete_tool_calls(messages)

# Checkpoint path — filter is NOT applied ✗
has_checkpoint = prev_state is not None and prev_state.values is not None and len(prev_state.values) > 0
if has_checkpoint:
    return current_input  # ← returns bare HumanMessage, no filtering

The checkpoint path returns current_input (the new HumanMessage) trusting the checkpointed state to be clean. However, when an interrupt is pending the checkpoint contains an AIMessage with tool_calls and no corresponding ToolMessage. Merging a new HumanMessage onto that state creates an invalid sequence.

Steps to Reproduce

  1. Build a LangGraph agent that uses interrupt() inside a tool (e.g. to show a form).
  2. Send a message that triggers the tool → the agent returns a HITL interrupt.
  3. Without responding to the form, send a new human message (e.g. the user types something new).
  4. HumanInTheLoopJsonHelper._validate_input_format logs "Invalid interrupt input item type: None, expected FUNCTION_CALL_OUTPUT" and returns None.
  5. _convert_request_input_with_history sees has_checkpoint=True and returns only the new HumanMessage.
  6. LangGraph merges [..., AIMessage(tool_calls=[...]), HumanMessage] → OpenAI 400.

Evidence from Application Logs (App Insights)

11:16:56  FunctionCallArgumentEventGenerator did not process message: Interrupt(value={'action': 'form', ...})
11:17:05  Checkpoint found for conversation conv_8bdcdbb1a8e1dfe8009..., using existing state
11:17:05  Invalid interrupt input item type: None, expected FUNCTION_CALL_OUTPUT.
11:17:05  Retrieved interrupt from state, validating and converting human feedback.
          → graph invoked with HumanMessage merged into interrupted checkpoint
          → OpenAI 400: tool_call_id call_F4JleIVATKlDQ0CGvrDOf9T5 has no response

Expected Behaviour

When a non-HITL message is received while an interrupt is pending, _convert_request_input_with_history should either:

Option A (minimal fix): Apply _filter_incomplete_tool_calls to the merged message list before returning, even on the checkpoint path.

Option B (explicit handling): When has_interrupt(state)=True but validate_and_convert_human_feedback returns None, return an error to the client (e.g. a 400 or a synthetic agent message) rather than proceeding with the corrupt message list.

Workaround

We are currently working around this in our own llm_call node by filtering before every LLM invocation:

messages = _filter_incomplete_tool_call_sequences(messages)
response = llm.invoke(messages)

where _filter_incomplete_tool_call_sequences mirrors the logic of the SDK's _filter_incomplete_tool_calls.

Environment

  • azure-ai-agentserver-langgraph==1.0.0b8
  • azure-ai-agentserver-core==1.0.0b8
  • langgraph==0.3.18
  • langchain-openai==0.3.9
  • Azure AI Foundry hosted agent (Linux, multi-instance, MemorySaver checkpointer)

Metadata

Metadata

Assignees

No one assigned

    Labels

    customer-reportedIssues that are reported by GitHub users external to the Azure organization.needs-triageWorkflow: This is a new issue that needs to be triaged to the appropriate team.questionThe issue doesn't require a change to the product in order to be resolved. Most issues start as that

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions