HITL — 실행 중간에 사람 확인 받기
흐름은 한 줄로 요약됩니다: 노드가 HITLInterrupt 를 return → SDK 가 Task 를 input-required 상태로 내보냄 → 사용자가 같은 contextId 로 답변 → SDK 가 답변을 정규화해 같은 지점에서 그래프 재개.
기본 — 자유 입력 질문
섹션 제목: “기본 — 자유 입력 질문”from llamon_agent.core.hitl import HITLInterrupt
async def ask_birthday(state): birthday = state.get("birthday") if not birthday: # raise 하지 않고 return — SDK 가 input-required 로 전송 return HITLInterrupt( payload={"question": "생년월일을 알려주세요."}, thread_id=state["thread_id"], ) ...선택지가 있는 질문 — HITLQuestion / HITLOption v0.3.1+
섹션 제목: “선택지가 있는 질문 — HITLQuestion / HITLOption ”자유 입력 대신 정해진 선택지를 물을 땐 HITLQuestion/HITLOption(pydantic)으로 페이로드를 선언하세요. to_payload() 는 위 예제의 {"question": ...} 형태를 포함하는 superset 이라 기존 클라이언트·executor 가 그대로 동작하고, 신규 클라이언트는 구조화 선택지(choices)와 multi_select/free_text_allowed 플래그를 읽어 UI 를 렌더링할 수 있습니다.
from llamon_agent.core.hitl import HITLInterrupt, HITLOption, HITLQuestion
async def ask_document(state): if state.get("doc_type"): ... # 이미 받았으면 진행
question = HITLQuestion( prompt="어떤 서류를 검증할까요?", options=[ HITLOption(label="여권", value="passport"), HITLOption(label="주민등록증", value="id_card"), ], ) return HITLInterrupt(payload=question.to_payload(), thread_id=state["thread_id"])재개 시 답변 정규화는 SDK 가 처리합니다. 사용자가 "여권"(label) 또는 "passport"(value) 중 무엇으로 답해도 재개 경로가 옵션 value ("passport")로 정규화해 그래프를 재개합니다 — label·value 정확 매칭은 LLM 없이(폐쇄망 친화), 애매한 자유 텍스트일 때만 에이전트에 설정된 LLM 으로 분류합니다(별도 키 불필요). 의도 파악 실패 시 한 번 재질문하고, 그래도 불명확하면 첫 옵션으로 폴백합니다.
JSON-RPC 2.0 예시 — 요청과 재개
섹션 제목: “JSON-RPC 2.0 예시 — 요청과 재개”HITL을 유발하는 첫 요청은 일반 message/send와 같습니다. 같은 대화를 이어야 하므로 contextId를 넣습니다.
{ "jsonrpc": "2.0", "id": "hitl-001", "method": "message/send", "params": { "message": { "messageId": "msg-hitl-001", "contextId": "case-123", "role": "user", "parts": [ { "kind": "text", "text": "이 서류를 검증해줘" } ] }, "metadata": { "agentId": "document-review", "sessionId": "case-123" } }}노드가 HITLInterrupt(payload=HITLQuestion(...).to_payload(), thread_id="case-123")를 반환하면 응답은 input-required 상태로 끝납니다. 실제 응답에는 task id, timestamp 같은 필드가 더 붙을 수 있습니다.
질문 텍스트와 선택지 DataPart는 status.message.parts 에 담겨 나갑니다 — artifacts 가 아닙니다. HITL 은 아직 결과를 내놓지 않은 상태이므로 산출물이 아니라 상태 메시지로 전달됩니다.
{ "jsonrpc": "2.0", "id": "hitl-001", "result": { "kind": "task", "id": "task-abc", "contextId": "case-123", "status": { "state": "input-required", "message": { "kind": "message", "role": "agent", "messageId": "msg-agent-001", "taskId": "task-abc", "contextId": "case-123", "parts": [ { "kind": "text", "text": "어떤 서류를 검증할까요?" }, { "kind": "data", "data": { "hitl": { "schema": "llamon.hitl.question.v1", "question": "어떤 서류를 검증할까요?", "options": ["passport", "id_card"], "choices": [ { "label": "여권", "value": "passport", "description": null }, { "label": "주민등록증", "value": "id_card", "description": null } ], "multi_select": false, "free_text_allowed": true } } } ] } } }}사용자가 답하면 **같은 contextId**로 새 messageId를 보내 재개합니다. 선택지형 HITL이라면 사용자는 label("여권")이나 value("passport") 어느 쪽으로 답해도 SDK가 value로 정규화합니다.
아래 예시의 "여권"은 위 응답의 choices[0].label입니다. SDK는 이를 choices[0].value인 "passport"로 정규화해 그래프에 넘깁니다. 클라이언트 UI가 value를 보관한다면 "text": "passport"로 보내도 됩니다.
{ "jsonrpc": "2.0", "id": "hitl-002", "method": "message/send", "params": { "message": { "messageId": "msg-hitl-002", "contextId": "case-123", "role": "user", "parts": [ { "kind": "text", "text": "여권" } ] }, "metadata": { "agentId": "document-review", "sessionId": "case-123" } }}에러와의 관계 — input-required 는 에러가 아닙니다
섹션 제목: “에러와의 관계 — input-required 는 에러가 아닙니다”input-required · auth-required 는 정상 플로우 제어입니다. SDK 의 HITL 경로(invoke_with_hitl())가 별도로 처리하며, 예외 → failed 변환 경로를 타지 않습니다.
반대로 “에러 아닌 결과”(빈 결과·부적합 안내 등)를 state:"failed" 로 내보내면 Task 가 종료되어 HITL 재개·후속 턴을 이어 붙일 수 없습니다. 상태 선택 기준과 에러 매핑 전반은 A2A 에러 처리 를 보세요.
오케스트레이터에서 — 재개 지점은 resume 에
섹션 제목: “오케스트레이터에서 — 재개 지점은 resume 에”위 예시는 에이전트·플로우를 구성하는 그래프 노드(state)의 HITL 입니다. 오케스트레이터가 자식 에이전트의 input_required를 전달할 때는 run_turn(ctx) 단위로 멈췄다 이어지므로, “어느 child로 돌아갈지”를 resume 체크포인트 에 남깁니다. 다음 턴이 전체 히스토리를 되짚지 않고 한 번 읽어 분기하기 위한 앱 레벨 표식입니다.
여기서도 두 흐름을 나눠야 합니다.
- 재개가 필요한 HITL: child가
input_required를 반환했습니다.ctx.set_resume(...)으로 다음 턴 복귀 지점을 남깁니다. - 사람 검토 안내 후 종료: 담당자 확인이 필요하다고 안내하고
completed로 끝냅니다. 이 경우ctx.set_resume(...)을 남기지 않습니다.
async def run_turn(ctx): if (r := ctx.get_resume()) and r.get("next_action") == "await_approval": return ctx.emit(await ctx.call("finalize", data=ctx.data)) # 답변 받고 이어가기
risk = await ctx.call("risk", data=ctx.data) if risk.data.get("needs_human"): ctx.set_resume(intent="대출 심사", next_action="await_approval") return ctx.emit(risk) # 사람에게 질문 내보내고 턴 종료 return ctx.emit(await ctx.call("finalize", data=ctx.data))관련 문서
섹션 제목: “관련 문서”- 오케스트레이터 담당자 검수(
HumanReviewPort): 담당자 검수 - 에러 매핑·Task 상태 선택 기준: A2A 에러 처리
- 대화를 이어가는 요청 형태(
contextId): A2A 메시지 요청 - 노드에서 state 를 읽고 쓰는 규칙: 플로우 상태와 헬퍼