Azure / Azure/azure-sdk-for-python

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

Open
#45,875 1 comment 0 reactions 2 assignees Claimed by @lusu-msft View on GitHub
bug Client customer-reported Hosted Agents needs-team-attention Service Attention
Dominant language
Python
Stars
5.6k
Forks
3.4k
Avg merge
1d 21h
Merged PRs (30d)
193

Description

## 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`):

```python
# 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:

```python
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)

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.