Skip to content

shared/a2a: graph entry partial state sanity — cancel 후 dangling user / tool 안전망 #73

@hagyutae

Description

@hagyutae

배경

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 정책과 정합).

동작

  1. messages 순회하며 인접 pair 검사
  2. Human → Human 연속 발견 시 그 사이에 SystemMessage("[이전 응답이 중단되어 사용자가 추가로 발화함]") 삽입 — LLM 이 컨텍스트 인지 가능
  3. AIMessage(tool_calls=[…]) 뒤에 짝 안 맞는 메시지 발견 시 각 tool_call 마다 placeholder ToolMessage("[tool call interrupted]", tool_call_id=..., name=...) 채움
  4. last message 가 ToolMessage 면 끝에 AIMessage("[response was interrupted]") 추가
  5. 결과를 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}

작업 항목

  1. shared/agent_graph/messages_sanity helper 추가 (위 코드 + LangGraph state reducer 정합 검증)
  2. Primary graph.pySTART → messages_sanity → llm_call 로 wiring
  3. Librarian graph.py — 동일 wiring
  4. 단위 테스트 — 3 dangling 패턴 + clean case + multi tool_calls fixture
  5. 통합 검증 — cancel 후 다음 turn 의 LLM 호출이 422 없이 정상 완주

Architect (#45, M4) 는 graph 자체가 도입되는 시점에 같은 패턴으로 wiring. 본 이슈 scope 아님.

관련

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status

    In Progress

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions