콘텐츠로 이동

HITL — 실행 중간에 사람 확인 받기

흐름은 한 줄로 요약됩니다: 노드가 HITLInterruptreturn → SDK 가 Task 를 input-required 상태로 내보냄 → 사용자가 같은 contextId 로 답변 → SDK 가 답변을 정규화해 같은 지점에서 그래프 재개.

app/nodes.py — HITL
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 를 렌더링할 수 있습니다.

app/nodes.py — 타입드 HITL
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 으로 분류합니다(별도 키 불필요). 의도 파악 실패 시 한 번 재질문하고, 그래도 불명확하면 첫 옵션으로 폴백합니다.

HITL을 유발하는 첫 요청은 일반 message/send와 같습니다. 같은 대화를 이어야 하므로 contextId를 넣습니다.

1. 최초 요청
{
"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 은 아직 결과를 내놓지 않은 상태이므로 산출물이 아니라 상태 메시지로 전달됩니다.

2. input-required 응답 예시
{
"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"로 보내도 됩니다.

3. HITL 답변으로 재개
{
"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(...)을 남기지 않습니다.
app/orchestrator.py — run_turn
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))