Interrupts

Interrupts (일시중지·재개)

Agent User Interaction Protocol에서 인간-개입(human-in-the-loop) 일시중지와 재개를 설명드릴게요.

출처: 문서

본문

에이전트는 가끔 일시중지해야 할 때가 있어요: 민감한 작업을 실행하기 전에 인간의 승인을 받는다거나, 구조화된 입력을 요청한다거나, out-of-band 정책 결정을 기다려야 할 때죠. AG-UI는 이를 **인터럽트 인지 실행 수명주기(interrupt-aware run lifecycle)**로 노출합니다 — 실행이 인터럽트 결과(outcome)로 끝나는 종단 모델로, 클라이언트는 각 인터럽트에 대한 응답을 담아 새 실행을 시작해요.

수명주기 (Lifecycle)

sequenceDiagram
  participant Agent
  participant Client as Client App

  Note over Agent,Client: Run 1 begins
  Agent-->>Client: RunStarted (runId: r1)
  Agent-->>Client: ...ToolCall* / TextMessage* / StateSnapshot...
  Note over Agent,Client: Agent needs user input — emit snapshot, then interrupt
  Agent-->>Client: RunFinished { outcome: { type: "interrupt", interrupts: [...] } }

  Note over Agent,Client: User resolves interrupts
  Client-->>Agent: RunAgentInput { threadId, resume: [{interruptId, status, payload?}, ...] }

  Note over Agent,Client: Run 2 begins; resume[].interruptId links back to run 1's interrupts
  Agent-->>Client: RunStarted (runId: r2)
  Agent-->>Client: ...continue / ToolCallResult / ...
  Agent-->>Client: RunFinished { outcome: { type: "success" }, result }

실행 결과 (Run outcomes)

RunFinished는 선택적 outcome 필드를 담아요 — 변형별 데이터가 안에 중첩된 판별 유니언(discriminated union)입니다:

  • 생략 (omitted) — 레거시/하위 호환. 정상 완료로 취급해요. 인터럽트를 아직 모르던 기존 AG-UI 클라이언트가 여전히 이 형태를 발행하며, 새 리더는 이를 받아들여야 해요.
  • { type: "success" } — 실행이 정상 완료됨. 선택적 result는 하위 호환을 위해 이벤트 루트에 남아요.
  • { type: "interrupt", interrupts: [...] } — 실행이 사용자 입력을 위해 일시중지됨. interrupts는 비어 있지 않은 배열이며, 필요로 하는 변형과 함께 이동하도록 outcome 안에 위치해요.
type RunFinishedOutcome =
  | { type: "success" }
  | { type: "interrupt"; interrupts: Interrupt[] }

type RunFinishedEvent = {
  type: "RUN_FINISHED"
  threadId: string
  runId: string
  result?: unknown
  outcome?: RunFinishedOutcome
}

outcome이 선택적이기 때문에, 인터럽트를 들어본 적 없는 옛 프로듀서(outcome 필드 없음)도 새 스키마 아래에서 RunFinished 이벤트로 유효하게 검증돼요 — 클라이언트는 인터럽트 인지 변형에 관심이 있을 때만 outcome을 검사하면 됩니다.

Interrupt 타입 (The Interrupt type)

type Interrupt = {
  id: string
  reason: string
  message?: string
  toolCallId?: string
  responseSchema?: JsonSchema
  expiresAt?: string
  metadata?: Record<string, any>
  subagentRunId?: string
}
Field Purpose
id 인터럽트·재개·멱등성·감사 전반에 걸친 상관 키(correlation key).
reason 범주형 라우팅 힌트 — Reason 분류 참고.
message 인간이 읽을 수 있는 프롬프트. 보편적 폴백 UI 콘텐츠.
toolCallId 인터럽트를 이전 ToolCall* 시퀀스에 바인딩.
responseSchema 예상되는 resume.payload에 대한 JSON Schema.
expiresAt 선택적 ISO-8601 TTL. 지연된 재개는 RunError를 생성.
metadata 자유 형식의 프레임워크별 데이터.
subagentRunId 이 인터럽트를 발생시킨 작업을 수행한 서브에이전트 — 루트가 발생시킨 것이면 부재. 귀속(attribution)은 실행 하나가 여러 서브에이전트의 인터럽트를 담을 수 있으므로 인터럽트별로 존재. 서브에이전트 수명주기 이벤트를 발행하는 프로듀서는 outcome: { type: "suspended" }로도 서브에이전트를 닫습니다 — 수명주기 이벤트는 선택적이므로 여기서의 귀속은 그 자체로 유효합니다 (참고: Subagents).

실행 재개하기 (Resuming a run)

같은 스레드의 다음 RunAgentInput은 resume 배열을 담아요:

type RunAgentInput = {
  // ... existing fields
  resume?: Array<{
    interruptId: string
    status: "resolved" | "cancelled"
    payload?: any
    metadata?: Record<string, any>
  }>
}
  • resolved — 사용자가 응답함. payload가 응답을 담으며, 인터럽트의 responseSchema에 대해 검증돼요. 거부(denial)는 별도 상태가 아니라 payload 안에 표현됩니다(예: { approved: false }).
  • cancelled — 사용자가 의미 있는 입력을 제공하지 않고 포기함. payload는 생략해야 해요.
  • metadata — 응답에 관한 선택적 봉투(envelope) 데이터(예: 인간 결정이 변조되지 않았음을 증명하는 서명, 라우팅 키)로, 에이전트가 요청하고 실행할 응답인 payload와 구분됩니다. 키별로 개방되며, 키 아래에는 null을 포함해 어떤 JSON 값도 허용돼요. 객체 자체는 부재하거나 객체이며, 결코 null이 아니에요. ag-ui 키는 AG-UI 자체 용도로 예약되어 있어요. 두 상태 모두에서 허용됩니다.

계약 규칙 (Contract rules)

  1. 같은 스레드. 재개 요청은 인터럽트된 실행과 같은 threadId를 사용해야 해요.
  2. 재개 연결 (Resume linkage). resume[].interruptId는 인터럽트된 실행의 interrupts[]에 있는 id를 참조해야 해요. parentRunId는 직교합니다 — 기존 AG-UI 분기/타임트래블 의미를 유지해요.
  3. 모든 열린 인터럽트를 커버. 단일 resume 배열은 인터럽트된 실행의 모든 열린 인터럽트를 다뤄야 해요. 부분 재개는 지원되지 않아요.
  4. 대기 중 인터럽트는 새 입력을 차단. 스레드에 해결되지 않은 인터럽트가 있으면, 그 스레드의 어떤 RunAgentInput도 이들을 다루는 resume을 포함해야 해요. 비준수 입력을 받은 에이전트는 RunError를 발행해야 해요.
  5. 멱등성 (Idempotency). 같은 (threadId, interruptId, status, payload)의 재개는 안전하게 재생될 수 있어야 해요.
  6. Payload 검증. 인터럽트가 responseSchema를 선언하면, 에이전트는 해당 resume payload를 검증하고 불일치 시 RunError를 발행할 수 있어요. 클라이언트는 제출 전에 검증해야 해요.
  7. 만료 강제. 클라이언트는 인터럽트의 expiresAt이 지난 재개를 제출해서는 안 돼요. 지연된 재개는 RunError를 생성해요.
  8. 우아한 처리. 에이전트는 누락되거나 잘못된 재개 payload를 조용한 실패가 아닌 RunError로 처리해야 해요.

인터럽트 경계에서의 상태 (State at the interrupt boundary)

인터럽트 시점에, 에이전트는 인터럽트를 담은 RunFinished 이벤트 이전에 StateSnapshot와 MessagesSnapshot 이벤트로 재개에 필요한 상태를 발행해야 해요.

이 규칙은 프로토콜을 재개 모드 무관(resume-mode-agnostic)하게 만들어요: replay 스타일 연속(messages + state로 컨텍스트 재구축)과 checkpoint 스타일 연속(일시중지된 코루틴 복원) 둘 다 재개 시 동일한 관측 가능한 동작을 생산해야 해요. 프레임워크 네이티브 checkpointing은 구현 최적화일 뿐, 프로토콜 계약이 아니에요.

오류 처리 (Error handling)

RunError는 유일한 오류 이벤트예요. outcome 열거형에는 "error" 값이 없어요. RunError를 생성하는 인터럽트 특정 오류 조건:

  • 인터럽트의 expiresAt이 지난 재개가 도착함.
  • 재개 payload가 responseSchema에 대한 검증에 실패함.
  • 재개가 에이전트가 상관시키지 못하는 interruptId를 참조함.
  • 재개가 모든 열린 인터럽트를 다루지 못함 (규칙 3 위반).
  • 대기 중 인터럽트가 있는 스레드의 RunAgentInput이 resume을 빠뜨림 (규칙 4 위반).

Reason 분류 (Reason taxonomy)

reason은 필수 문자열이에요. 소수의 핵심 값은 스펙이 정의하며, 다른 어떤 문자열도 유효한 확장입니다.

핵심 값 (Core values)

Value Semantics Typical companion fields
tool_call 결정을 기다리는 특정 도구 호출에 바인딩된 인터럽트. toolCallId must be set.
input_required 에이전트가 계속하려면 구조화된 입력이 필요함. responseSchema should be set.
confirmation 도구에 바인딩되지 않은 독립형 예/아니오 결정. responseSchema optional; boolean default.

커스텀 이유 (Custom reasons)

다른 어떤 문자열도 유효해요. 에이전트는 커스텀 이유를 <framework>:<name>으로 네임스페이스해야 해요 (예: langgraph:database_modification, mastra:workflow_suspend). core: 접두사는 향후 스펙 추가를 위해 예약되어 있어요.

클라이언트 라우팅 (Client routing)

  • 클라이언트는 전용 UI를 위해 알고 있는 핵심 값에 대해 스위치해야 해요.
  • 알 수 없는 이유에 대해 클라이언트는 오류를 내면 안 돼요. message, responseSchema, metadata로부터 렌더링하세요.

도구 바인딩 인터럽트 (Tool-bound interrupts)

인터럽트가 reason: "tool_call"과 toolCallId를 담으면, 도구 호출과 그 해결은 두 실행에 걸쳐 있어요. 전체 감사 추적(audit trail)은:

  1. 인터럽트된 실행의 ToolCallArgs (에이전트의 제안).
  2. 재개된 실행의 RunAgentInput.resume payload (사용자 결정과 편집).
  3. 재개된 실행의 ToolCallResult (실제 실행 결과).

에이전트는 재개된 실행에서 ToolCallStart/ToolCallArgs/ToolCallEnd를 다시 발행하지 않고, 원래 toolCallId에 대해 ToolCallResult를 발행해요.

편집과 함께 승인 (Approve with edits)

편집-승인(approve-with-edits)을 지원하는 도구 바인딩 인터럽트를 위한 권장 responseSchema 패턴:

{
  "type": "object",
  "properties": {
    "approved": { "type": "boolean" },
    "editedArgs": {
      "type": "object",
      "description": "Full replacement of the tool args. Not merged."
    }
  },
  "required": ["approved"]
}

editedArgs는 부분 병합이 아닌 전체 교체예요. 스키마에 이 필드가 있다는 것이 클라이언트가 편집 UI를 제공할 수 있다는 기능 신호(capability signal) 입니다.

예시 (Examples)

최소 도구 승인 (Minimal tool approval)

에이전트는 sendEmail을 제안한 후 인터럽트합니다:

{
  "type": "RUN_FINISHED",
  "threadId": "thread-1",
  "runId": "run-1",
  "outcome": {
    "type": "interrupt",
    "interrupts": [
      {
        "id": "int-abc123",
        "reason": "tool_call",
        "message": "Send email to [email protected] with subject 'Hi'?",
        "toolCallId": "tc-001",
        "responseSchema": {
          "type": "object",
          "properties": { "approved": { "type": "boolean" } },
          "required": ["approved"]
        }
      }
    ]
  }
}

클라이언트가 재개를 제출합니다:

{
  "threadId": "thread-1",
  "runId": "run-2",
  "resume": [
    { "interruptId": "int-abc123", "status": "resolved", "payload": { "approved": true } }
  ]
}

에이전트는 run-2에서 계속되며 tc-001에 대해 ToolCallResult를 발행한 뒤 RunFinished { outcome: { type: "success" } }를 발행해요.

편집과 함께 승인 (전체 감사) — Approve with edits (full audit)

{
  "type": "RUN_FINISHED",
  "threadId": "thread-2",
  "runId": "run-10",
  "outcome": {
    "type": "interrupt",
    "interrupts": [
      {
        "id": "int-email-edit",
        "reason": "tool_call",
        "message": "Send email to [email protected]? You can edit the body before approving.",
        "toolCallId": "tc-42",
        "responseSchema": {
          "type": "object",
          "properties": {
            "approved": { "type": "boolean" },
            "editedArgs": {
              "type": "object",
              "properties": {
                "to": { "type": "string", "format": "email" },
                "subject": { "type": "string" },
                "body": { "type": "string" }
              }
            }
          },
          "required": ["approved"]
        },
        "metadata": {
          "langgraph": {
            "checkpointId": "ckpt-xyz",
            "nodeId": "tool_executor"
          }
        }
      }
    ]
  }
}

편집과 함께하는 클라이언트 재개:

{
  "threadId": "thread-2",
  "runId": "run-11",
  "resume": [
    {
      "interruptId": "int-email-edit",
      "status": "resolved",
      "payload": {
        "approved": true,
        "editedArgs": {
          "to": "[email protected]",
          "subject": "Hi",
          "body": "Hi (revised per my note)"
        }
      }
    }
  ]
}

tc-42에 대한 감사 추적:

  • run-10 ToolCallArgs — 원래 제안.
  • run-11 RunAgentInput.resume[0].payload.editedArgs — 사용자 편집.
  • run-11 ToolCallResult — 실제 결과.

병렬 인터럽트 (Parallel interrupts)

{
  "type": "RUN_FINISHED",
  "threadId": "thread-3",
  "runId": "run-20",
  "outcome": {
    "type": "interrupt",
    "interrupts": [
      { "id": "i-1", "reason": "tool_call", "toolCallId": "tc-a", "message": "Approve sendEmail to [email protected]?" },
      { "id": "i-2", "reason": "tool_call", "toolCallId": "tc-b", "message": "Approve sendEmail to [email protected]?" },
      { "id": "i-3", "reason": "tool_call", "toolCallId": "tc-c", "message": "Approve sendEmail to [email protected]?" }
    ]
  }
}

클라이언트가 두 개는 승인하고 하나는 취소합니다:

{
  "threadId": "thread-3",
  "runId": "run-21",
  "resume": [
    { "interruptId": "i-1", "status": "resolved", "payload": { "approved": true } },
    { "interruptId": "i-2", "status": "resolved", "payload": { "approved": true } },
    { "interruptId": "i-3", "status": "cancelled" }
  ]
}

run-21에서 에이전트는 tc-a와 tc-b에 대해 ToolCallResult를 발행하고 tc-c를 실행되지 않은 것으로 취급해요.

비도구 입력 요청 (Non-tool input request)

{
  "type": "RUN_FINISHED",
  "threadId": "thread-4",
  "runId": "run-30",
  "outcome": {
    "type": "interrupt",
    "interrupts": [
      {
        "id": "int-form",
        "reason": "input_required",
        "message": "Please provide the quarterly filing details.",
        "responseSchema": {
          "type": "object",
          "properties": {
            "quarter": { "type": "string", "enum": ["Q1", "Q2", "Q3", "Q4"] },
            "year": { "type": "integer", "minimum": 2000 },
            "revenue": { "type": "number" }
          },
          "required": ["quarter", "year", "revenue"]
        },
        "expiresAt": "2026-04-20T17:00:00Z"
      }
    ]
  }
}

클라이언트 응답:

{
  "threadId": "thread-4",
  "runId": "run-31",
  "resume": [
    {
      "interruptId": "int-form",
      "status": "resolved",
      "payload": { "quarter": "Q1", "year": 2026, "revenue": 4200000 }
    }
  ]
}

프레임워크 통합 (Framework integrations)

Framework Package Interrupt support
LangGraph @ag-ui/langgraph / ag-ui-langgraph ✅ RunAgentInput.resume[] 수용. RunFinishedEvent.outcome = {type:"interrupt"} 발행 가능 — emitInterruptOutcome / emit_interrupt_outcome로 옵트인(기본 꺼짐; command.resume으로 재개하던 레거시 클라이언트는 구조화된 outcome을 보는 순간 재개를 멈춤). 기본적으로 레거시 CustomEvent(name="on_interrupt") 발행; enableLegacyOnInterruptEvent: false로 비활성화. 커스텀 HITL 변환을 위한 하위클래스 훅 제공.
AWS Strands @ag-ui/aws-strands ✅ RunFinishedEvent.outcome을 통해 네이티브 Strands 인터럽트를 전달.
  • Events — RunFinished가 더 넓은 이벤트 스트림에 어떻게 맞는지.
  • Capabilities — 에이전트가 선언하는 humanInTheLoop.interrupts와 humanInTheLoop.approveWithEdits 플래그.

더 알아보기 (Learn more)