콘텐츠로 이동

A2A 히스토리 시드

워크플로우 오케스트레이터(VoltAgent 등)가 우리 A2A 에이전트를 자식으로 호출할 때, 호스트가 보유한 대화 history 를 LangGraph state 에 1회성 시드합니다. 호스트마다 자체 대화 상태를 보존하지만 우리 에이전트의 checkpointer 는 호스트가 부여한 새 contextId 에 대해 비어있습니다 (cold-start) — 이 갭을 메우는 채널입니다.

사용자 ──┐ (4턴 대화 누적)
▼ 워크플로우 호스트 (자체 history 보유)
┌──────────────┐
│ VoltAgent 등 │ contextId = 새 UUID, metadata.history = 4턴
└──────┬───────┘
▼ A2A 호출
┌──────────────┐
│ 우리 에이전트 │ checkpointer empty → history 시드 → LLM 맥락 살림
└──────────────┘

호스트가 매번 새 contextId 로 호출하면 우리 checkpointer 가 항상 cold-start. metadata.history 가 없으면 LLM 은 단발 질의로 인식하여 호스트의 의도(맥락 유지)와 다른 응답을 낼 수 있습니다.

호스트는 다음 두 위치 중 어디든 history 를 넣을 수 있습니다 (실제 동일):

{
"jsonrpc": "2.0",
"method": "message/stream",
"params": {
"message": {
"kind": "message",
"role": "user",
"parts": [{"kind": "text", "text": "현재 사용자 질의"}],
"contextId": "wf-session-abc",
"metadata": {
"history": [
{"role": "user", "parts": [{"kind": "text", "text": "이전 질의 1"}]},
{"role": "agent", "parts": [{"kind": "text", "text": "이전 응답 1"}]},
{"role": "user", "parts": [{"kind": "text", "text": "이전 질의 2"}]},
{"role": "agent", "parts": [{"kind": "text", "text": "이전 응답 2"}]}
]
}
},
"metadata": {}
}
}

또는 params.metadata.history 위치도 동일하게 동작 (executor 의 _merge_metadata 가 양쪽 흡수).

  • role: "user" | "agent" (A2A Role enum). 다른 값 ("assistant", "system" 등) 은 skip + 경고 로그.
  • parts: A2A 표준 part 리스트. 현재는 kind: "text" 만 변환 (file/data part 는 무시).
  • 순서는 그대로 보존되어 LangGraph state.messages 앞에 prepend.

contextId == LangGraph thread_id 1:1 매핑은 그대로 유지됩니다. metadata.historycheckpointer state 가 비어있을 때만 1회 시드되고, 이후 호출에서는 checkpointer 가 source of truth.

시나리오contextIdmetadata.historycheckpointer시드 동작
워크플로우 첫 호출새 UUID4턴empty✅ 시드
워크플로우 후속 호출 (동일 contextId, history 누적해서 또 보냄)같은 UUID6턴보유❌ 무시 (checkpointer 우선)
워크플로우 매번 새 contextId매번 새매번 history항상 empty✅ 매번 시드
개별 사용자 (history 없음)새 UUID없음empty빈 시작 (기존 동작)
개별 사용자 멀티턴같은 UUID없음보유checkpointer 자동 (기존 동작)
HITL interrupt → resume같은 UUID있을 수도보유 (interrupt)❌ 무시 (interrupt state 보존)

호스트는 history 를 항상 보내도 무방 — 두 번째 호출부터는 checkpointer 가 알아서 우선시합니다.

입력출력
{"role": "user", "parts": [{"kind": "text", "text": "..."}]}HumanMessage(content="...")
{"role": "agent", "parts": [{"kind": "text", "text": "..."}]}AIMessage(content="...")
다른 role / 잘못된 parts / 비어있는 textskip + 경고 로그
비-text part (file/data)text 부분만 추출
한 메시지에 text part 가 여러 개concat 단일 메시지

변환 헬퍼(llamon_agent.inbound.a2a.history.convert_a2a_history_to_lc) 는 어떤 입력에도 raise 하지 않습니다 — malformed 항목은 skip 처리되어 정상 항목만 시드됩니다.

길이 제한 정책 — 최대 10턴 cap (host 약속과 동일)

섹션 제목: “길이 제한 정책 — 최대 10턴 cap (host 약속과 동일)”

SDK 는 마지막 방어선으로 최대 10턴 cap 을 적용합니다 (_MAX_PREFILL_TURNS = 10, src/llamon_agent/inbound/a2a/history/converter.py).

호스트 송신시드 동작로그
0 ~ 10턴 (정상)전체 시드, cap 미트리거metadata.history 수신 — N개 (INFO)
11턴 이상가장 오래된 것부터 trim → 최근 10턴만 시드호스트 약속(10) 초과, 최근 10턴만 시드 (WARNING)
  • 명시적 계약: 호스트가 10턴 한계로 보낸다는 약속을 SDK 코드가 그대로 강제 → 코드가 spec 문서 역할
  • 운영 모니터링: cap 트리거 WARNING 이 1건이라도 발생하면 호스트 측 정책 위반/버그 조기 경보
  • 다른 호스트 보호: VoltAgent 외 다른 워크플로우 도구가 추가되어도 동일 cap 자동 적용
  • stateless 경로 보호: memory_manager 가 없는 호출에서도 cap 적용 (window_size 자동 trim 무관)

FIFO oldest-out (가장 오래된 메시지부터 제거):

입력 12턴: [m0, m1, m2, m3, m4, m5, m6, m7, m8, m9, m10, m11]
↓ cap 적용
시드 10턴: [m2, m3, m4, m5, m6, m7, m8, m9, m10, m11]
└ m0, m1 손실 (가장 오래된 정보)

가장 최근 정보가 LLM 응답에 더 가치 있다는 가정.

본 기능은 호스트를 신뢰 경계 내부로 가정합니다 (자체 운영 워크플로우). AIMessage 위조 가능성(호스트가 임의 응답을 우리 agent 의 과거 발언으로 주장)이 존재하므로, 외부 호스트가 호출하는 시나리오에서는 별도 host signing 검증 메커니즘이 필요합니다.

자체 invoke() 인터페이스를 가진 custom agent 가 prefill_messages 파라미터를 지원하지 않는 경우:

  • 호출 자체는 정상 진행 (시그니처 introspection 으로 판별)
  • metadata.history 시드는 silently skip
  • 경고 로그 1회 발행 (디버깅 가능하도록)

표준 LangGraphAgentinvoke(), stream(), invoke_with_hitl() 모두 prefill_messages 지원.