본문으로 건너뛰기

일반 인터럽트

서버에 클라이언트의 데이터가 필요하지만 도구 호출로 요청이 발생한 것은 아닐 때 generic interrupt를 사용합니다. 예를 들어 모델이 실행되기 전에 사용자에게 계획을 선택하도록 요청할 수 있습니다.

defineInterrupt는 클라이언트가 확인할 데이터와 반환해야 할 데이터를 정의합니다. 같은 정의를 chat()useChat()에 등록하면 양쪽에서 동일한 타입을 사용하게 됩니다.

인터럽트 정의 및 발생

라우트와 클라이언트 모두에서 가져올 수 있는 모듈에 정의를 작성합니다. 스키마는 JSON Schema를 내보내야 합니다.

// app/interrupts.ts
import { defineInterrupt } from '@tanstack/ai'
import { z } from 'zod'

export const reviewPlan = defineInterrupt({
id: 'review-plan',
payloadSchema: z.object({ title: z.string(), changes: z.array(z.string()) }),
responseSchema: z.object({ approved: z.boolean(), note: z.string().optional() }),
})

payloadSchema는 서버에서 클라이언트로 전달되는 표시 데이터를 설명합니다. responseSchema는 클라이언트에서 서버로 전달되는 데이터를 설명합니다. 표시 페이로드는 선택 사항입니다. 응답 스키마는 필수입니다.

onInterruptBoundary에서 요청을 반환합니다. 이 훅은 INTERRUPT_BOUNDARY_PHASES의 각 값에서 실행될 수 있습니다.

  • beforeModel: 어댑터 호출 전
  • afterModel: 모델 스트림 종료 후
  • beforeTools: 도구 실행 전
  • afterTools: 도구 결과가 messages에 들어온 후

같은 경계의 모든 미들웨어에서 보낸 요청은 하나의 인터럽트 배치를 구성합니다.

ctx.phase 가드로 단계를 선택합니다. ctx.parentRunId가 설정되어 있으면 발생을 건너뜁니다. 그렇게 하지 않으면 동일한 일시 중지가 다시 발생합니다.

각 단계를 언제 사용해야 하는지와 해당 단계에서 ctx에 무엇이 포함되는지는 Lifecycle Boundaries.

// app/chat-middleware.ts
import type { ChatMiddleware } from '@tanstack/ai'
import { reviewPlan } from './interrupts'

export const requestReview: ChatMiddleware<unknown, typeof reviewPlan> = {
name: 'request-review',
onInterruptBoundary(ctx) {
if (ctx.phase !== 'beforeModel') return
if (ctx.parentRunId) return
if (ctx.iteration !== 0) return
return {
interrupts: [
reviewPlan.interrupt({
key: 'initial-plan',
reason: 'review-required',
message: 'Review the proposed plan.',
payload: { title: 'Release plan', changes: ['Add search', 'Add tests'] },
}),
],
}
},
}

interrupt()key, reason, message, expiresAt, 그리고 선언된 경우 payload만 허용합니다. 변경할 수 없는 요청을 반환합니다. 클라이언트가 페이로드를 받고 영속성에 저장될 수 있으므로 페이로드에 비밀을 넣지 마세요.

서버에 등록

모든 정의를 chat({ interrupts })에 등록합니다. 중복된 정의 ID가 있으면 어댑터가 시작되기 전에 실패합니다.

// app/api/chat/route.ts
import { chat, chatParamsFromRequest, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { requestReview } from '../../chat-middleware'
import { reviewPlan } from '../../interrupts'

export async function POST(request: Request) {
const params = await chatParamsFromRequest(request)
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
...(params.parentRunId ? { parentRunId: params.parentRunId } : {}),
...(params.resume ? { resume: params.resume } : {}),
interrupts: [reviewPlan],
middleware: [requestReview],
})
return toServerSentEventsResponse(stream)
}

인터럽트는 결과가 interrupt인 하나의 RUN_FINISHED 이벤트와 함께 현재 AG-UI 실행을 종료합니다. 이를 해결하면 중단된 실행으로 설정된 parentRunId와 함께 새 실행이 시작됩니다. 연속 실행은 응답을 등록된 미들웨어로 전달합니다.

React에서 해결

같은 정의를 useChat에 등록합니다. 바인딩된 일반 인터럽트에는 정의 ID, 타입이 지정된 표시 페이로드, 타입이 지정된 resolveInterrupt 메서드가 있습니다.

kinddefinitionId를 확인합니다. 그런 다음 항목을 다음을 받는 카드에 전달합니다: GenericInterrupt<typeof reviewPlan>.

// app/plan-review.tsx
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import type { GenericInterrupt } from '@tanstack/ai-react'
import { reviewPlan } from './interrupts'

function ReviewCard({
interrupt,
}: {
interrupt: GenericInterrupt<typeof reviewPlan>
}) {
return (
<article>
<h2>{interrupt.payload?.title}</h2>
<button onClick={() => interrupt.resolveInterrupt({ approved: true })}>
Approve
</button>
<button onClick={() => interrupt.cancel()}>Cancel</button>
</article>
)
}

export function PlanReview() {
const { interrupts } = useChat({
threadId: 'release-42',
connection: fetchServerSentEvents('/api/chat'),
interrupts: [reviewPlan],
})

return (
<>
{interrupts.map((interrupt) => {
if (interrupt.kind !== 'generic') return null
if (!('definitionId' in interrupt)) return null
if (interrupt.definitionId !== reviewPlan.id) return null
return <ReviewCard key={interrupt.id} interrupt={interrupt} />
})}
</>
)
}

resolveInterrupt는 답변을 준비합니다. 클라이언트는 배치의 모든 바인딩된 인터럽트가 해결되거나 취소된 후에만 하나의 연속 실행을 보냅니다. 사용자가 데이터 제공을 거부하면 cancel()을 사용합니다.

미들웨어에서 재개된 값 읽기

onInterruptResolution은 일시 중지된 chat() 호출에서는 실행되지 않습니다. 클라이언트가 답변한 후 다음 chat() 호출이 시작될 때 한 번 실행됩니다.

두 번째 호출은 새 실행입니다. useChatparentRunIdresume을 보냅니다. 각 일반 재개 항목은 원래 요청을 metadata에 포함합니다. 이 훅은 init onConfig 이후, onStart 이전에 실행됩니다. ctx.phase는 여전히 'init'입니다.

for(definition)은 해당 정의의 응답 타입을 유지합니다. all()은 등록된 모든 정의를 읽습니다. all(definitionA, definitionB)는 결과를 해당 정의들로 좁힙니다.

import type { ChatMiddleware } from '@tanstack/ai'
import { reviewPlan } from './interrupts'

export const applyReview: ChatMiddleware<unknown, typeof reviewPlan> = {
name: 'apply-review',
onInterruptResolution(_ctx, resumedInterrupts) {
for (const result of resumedInterrupts.for(reviewPlan)) {
if (result.status === 'resolved' && !result.response.approved) {
return { toolResume: 'stop' }
}
}
},
}

미들웨어는 toolResume: 'continue', 'cancel', 또는 'stop'을 반환할 수 있습니다. 둘 이상의 미들웨어가 값을 반환하면 stopcancel보다 우선하고, cancelcontinue보다 우선합니다.

이 훅은 프롬프트, 도구 또는 메시지를 변경할 수 없습니다. 답변을 capability에 저장한 다음, ctx.phase === 'beforeModel'일 때 onConfig에서 해당 필드를 반환합니다. 전체 순서와 작동하는 예제는 Apply Answers.

네 라이프사이클 단계 사용해 보기

React 채팅 예제에는 beforeModel, afterModel, beforeTools, afterTools를 위한 플레이그라운드가 있습니다. 각 일시 중지에는 타입이 지정된 카드 두 개(reviewPlanchooseAudience)가 표시됩니다.

  1. examples/ts-react-chat을 시작합니다.
  2. /generic-interrupts를 엽니다.
  3. 단계를 선택하고 두 카드를 모두 해결한 뒤 선택한 정책을 확인합니다.

외부 generic interrupt

외부 시스템은 표준 AG-UI 일반 인터럽트를 발생시킬 수 있습니다. 유효한 TanStack 바인딩이 없으면 TanStack AI는 이를 kind: 'unbound'로 표시합니다. 계속 표시되지만 해결 또는 취소 메서드는 없습니다. 이를 통해 다른 시스템이 TanStack AI가 소유한 연속 실행을 받지 못하게 합니다.

도구 승인과 일반 인터럽트를 함께 사용하는 방법은 여러 인터럽트를 참조합니다. 인터럽트가 재시작 후에도 유지되어야 할 때는 채팅 영속성을 참조합니다.

원하는 작업페이지
beforeModel, afterModel, beforeTools, afterTools 중 하나 선택라이프사이클 경계
사용자 답변을 프롬프트, 도구 또는 toolResume에 적용답변 적용
일반 인터럽트와 도구 승인 함께 사용여러 인터럽트