배경
LangGraph checkpoint 의 messages channel 은 graph 가 cancel / disconnect 로 raise 종료되어도 그 시점까지의 부분 state 가 그대로 남는다. 다음 invoke 시 그 잔재 위에 새 입력이 들어가 history 형태가 깨지면서 LLM 호출이 망가짐.
본 이슈는 graph entry 직후에 messages sanity 노드를 부착해 잔재를 정리한다.
Dangling 패턴 3 종
| 패턴 |
발생 조건 |
위험 |
Human → Human (assistant 누락) |
LLM call 노드 종료 전 cancel |
LLM 이 두 입력 의도를 합치거나 무시 |
AIMessage(tool_calls=[…]) 뒤에 짝 맞는 ToolMessage 없음 |
tool 실행 직전 / 중 cancel |
다음 turn 의 LLM 호출이 unanswered tool 보고 혼란 |
ToolMessage 로 끝, 닫는 AIMessage 없음 |
tool 실행 후 LLM 응답 전 cancel |
Anthropic API 의 tool_use → tool_result → assistant 검사에서 422 가능 |
본 결함은 chat tier (handler 의 thread_id = chat session_id) 와 A2A tier (RPCContext 의 thread_id = a2a_context_id) 양쪽 동일. sanity 노드도 양쪽 graph 에 동일 적용.
해결 방향 — graph entry sanity 노드
shared/agent_graph 에 building block 으로 helper 추가, 각 agent graph 가 START → messages_sanity → llm_call 로 명시 조립 (#75 D8 의 agent graph topology 정책과 정합).
동작
- messages 순회하며 인접 pair 검사
Human → Human 연속 발견 시 그 사이에 SystemMessage("[이전 응답이 중단되어 사용자가 추가로 발화함]") 삽입 — LLM 이 컨텍스트 인지 가능
AIMessage(tool_calls=[…]) 뒤에 짝 안 맞는 메시지 발견 시 각 tool_call 마다 placeholder ToolMessage("[tool call interrupted]", tool_call_id=..., name=...) 채움
- last message 가
ToolMessage 면 끝에 AIMessage("[response was interrupted]") 추가
- 결과를 state 의
messages 로 반환 (LangGraph add_messages reducer 와의 정합은 RemoveMessage 패턴 검토 — 본 노드는 단순 replace 가 아닌 add_messages 의 의미를 따라야 함)
코드 골격
async def messages_sanity(state: dict) -> dict:
msgs = state["messages"]
cleaned: list[BaseMessage] = []
for prev, cur in pairwise(msgs):
cleaned.append(prev)
if isinstance(prev, HumanMessage) and isinstance(cur, HumanMessage):
cleaned.append(SystemMessage(content="[이전 응답이 중단되어 사용자가 추가로 발화함]"))
if isinstance(prev, AIMessage) and prev.tool_calls:
unmatched = {tc["id"] for tc in prev.tool_calls}
if not isinstance(cur, ToolMessage) or cur.tool_call_id not in unmatched:
for tc in prev.tool_calls:
cleaned.append(ToolMessage(
content="[tool call interrupted]",
tool_call_id=tc["id"], name=tc["name"],
))
if msgs:
cleaned.append(msgs[-1])
if isinstance(msgs[-1], ToolMessage):
cleaned.append(AIMessage(content="[response was interrupted]"))
return {"messages": cleaned}
작업 항목
shared/agent_graph/ 에 messages_sanity helper 추가 (위 코드 + LangGraph state reducer 정합 검증)
- Primary
graph.py — START → messages_sanity → llm_call 로 wiring
- Librarian
graph.py — 동일 wiring
- 단위 테스트 — 3 dangling 패턴 + clean case + multi tool_calls fixture
- 통합 검증 — cancel 후 다음 turn 의 LLM 호출이 422 없이 정상 완주
Architect (#45, M4) 는 graph 자체가 도입되는 시점에 같은 패턴으로 wiring. 본 이슈 scope 아님.
관련
배경
LangGraph checkpoint 의
messageschannel 은 graph 가 cancel / disconnect 로 raise 종료되어도 그 시점까지의 부분 state 가 그대로 남는다. 다음 invoke 시 그 잔재 위에 새 입력이 들어가 history 형태가 깨지면서 LLM 호출이 망가짐.본 이슈는 graph entry 직후에 messages sanity 노드를 부착해 잔재를 정리한다.
Dangling 패턴 3 종
Human → Human(assistant 누락)AIMessage(tool_calls=[…])뒤에 짝 맞는ToolMessage없음ToolMessage로 끝, 닫는AIMessage없음tool_use → tool_result → assistant검사에서 422 가능본 결함은 chat tier (handler 의 thread_id = chat session_id) 와 A2A tier (RPCContext 의 thread_id = a2a_context_id) 양쪽 동일. sanity 노드도 양쪽 graph 에 동일 적용.
해결 방향 — graph entry sanity 노드
shared/agent_graph에 building block 으로 helper 추가, 각 agent graph 가START → messages_sanity → llm_call로 명시 조립 (#75 D8 의 agent graph topology 정책과 정합).동작
Human → Human연속 발견 시 그 사이에SystemMessage("[이전 응답이 중단되어 사용자가 추가로 발화함]")삽입 — LLM 이 컨텍스트 인지 가능AIMessage(tool_calls=[…])뒤에 짝 안 맞는 메시지 발견 시 각 tool_call 마다 placeholderToolMessage("[tool call interrupted]", tool_call_id=..., name=...)채움ToolMessage면 끝에AIMessage("[response was interrupted]")추가messages로 반환 (LangGraphadd_messagesreducer 와의 정합은 RemoveMessage 패턴 검토 — 본 노드는 단순 replace 가 아닌 add_messages 의 의미를 따라야 함)코드 골격
작업 항목
shared/agent_graph/에messages_sanityhelper 추가 (위 코드 + LangGraph state reducer 정합 검증)graph.py—START → messages_sanity → llm_call로 wiringgraph.py— 동일 wiring관련
Human → Human은 BatchQueueConcurrency 의 batch concat 으로 단일 HumanMessage 흡수. 본 sanity 는 cancel 케이스의 dangling 안전망 (책임 다름)