본문으로 건너뛰기

승인 흐름 처리 아키텍처

도구 승인은 인터럽트 및 재개 프로토콜입니다. 사용자 입력이 필요한 실행은 하나의 표준 이벤트로 종료됩니다.

const interruptTerminal = {
type: 'RUN_FINISHED',
runId: 'run-1',
threadId: 'thread-1',
timestamp: Date.now(),
outcome: {
type: 'interrupt',
interrupts: [
{
id: 'approval-1',
reason: 'tool_call',
toolCallId: 'call-1',
responseSchema: {
oneOf: [
{ type: 'object', properties: { approved: { const: true } } },
{ type: 'object', properties: { approved: { const: false } } },
],
},
},
],
},
}

표준 이벤트 스트림은 유일한 네이티브 승인 이벤트 스트림입니다. 영속성 없이도 일시적으로 작동합니다. 서버 상태 영속성이 구성되면 종료 이벤트가 노출되기 전에 완전한 디스크립터/바인딩 배치를 저장합니다. SSE 전달 영속성은 전달된 이벤트에 불투명한 재개 오프셋을 별도로 할당합니다. 네이티브 경로는 approval-requested 또는 tool-input-available 사용자 지정 이벤트를 내보내지 않습니다.

공개 서버/클라이언트 가이드는 인터럽트를, 지원 중단 예정인 리더는 AG-UI 인터럽트로 마이그레이션을 참조하세요.

책임

계층책임
도구 정의민감한 작업에 needsApproval: true를 선언합니다.
채팅 엔진도구 실행 전에 중지하고 인터럽트 결과를 내보냅니다.
채팅 클라이언트디스크립터를 타입이 지정된 메서드에 바인딩하고, 초안을 준비하며, 정확히 하나의 재개 배치를 제출합니다.
애플리케이션 UI작업을 설명하고 resolveInterrupt, cancel 또는 루트 배치 컨트롤을 사용합니다.
전달 어댑터선택적으로 어댑터가 소유한 불투명 오프셋으로 SSE 이벤트를 재생합니다.

디스크립터에서 연속 처리로 이어지는 파이프라인

불변식은 디스크립터 → 모두 검증 → 연속 처리 → 기록입니다.

  1. 엔진이 공개 디스크립터와 바인딩을 빌드합니다. 출력에는 MESSAGES_SNAPSHOT, 선택적 STATE_SNAPSHOT, 인터럽트 RUN_FINISHED 종료 이벤트가 포함됩니다.
  2. 클라이언트는 이유, 도구 ID, 호출 ID, 스키마 해시, 인터럽트된 실행 및 세대가 도구 레지스트리와 일치하는 디스크립터만 바인딩합니다. 신뢰할 수 없는 항목은 타입이 지정된 도구 리졸버를 얻는 대신 generic으로 처리됩니다.
  3. 항목 메서드가 로컬 초안을 검증하고 준비합니다. 제출 경계에는 대기 중인 모든 인터럽트 ID가 정확히 한 번씩 포함됩니다.
  4. 클라이언트가 현재 전체 메시지 기록, 인터럽트된 parentRunId, 완전한 재개 배치를 포함한 새 실행을 제출합니다.
  5. 서버는 무엇이든 실행하기 전에 모든 페이로드, 편집된 입력, 출력, 해시 및 상관관계를 검증하고, 클라이언트가 제공한 기록과 현재 도구 정의에서 예상 배치를 재구성합니다.
  6. 재개된 도구 호출은 결과만 내보내며, 합성된 도구 호출 시작/인수 이벤트를 재생하지 않습니다. 성공한 기록은 연속 처리 실행에 속합니다.

배치는 클라이언트가 제공한 기록에서 다시 빌드되므로 일시적 모드는 재생, 정확히 한 번, 재시작 또는 인스턴스 간 보장을 제공하지 않습니다. 메시지 기록은 검증되지만 클라이언트가 제공한 입력으로 남습니다.

서버 설정

도구를 일반적으로 정의합니다. 다음 라우트는 일시적 흐름(영속성 미들웨어 없음)입니다. chat + chatParamsFromRequest가 클라이언트 메시지 기록과 도구 정의에서 인터럽트 배치를 재개합니다. 내구성 있는 복구는 별도의 선택적 계층이며 도구 승인에 필요하지 않습니다.

// tools.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'

export const deleteProjectDefinition = toolDefinition({
name: 'delete_project',
description: 'Delete a project permanently',
inputSchema: z.object({ projectId: z.string() }),
outputSchema: z.object({ deleted: z.boolean() }),
needsApproval: true,
})

export const deleteProject = deleteProjectDefinition.server(async ({ projectId }) => {
await deleteProjectFromDatabase(projectId)
return { deleted: true }
})

declare function deleteProjectFromDatabase(projectId: string): Promise<void>
// app/api/chat/route.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { deleteProject } from './tools'

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,
parentRunId: params.parentRunId,
...(params.resume ? { resume: params.resume } : {}),
tools: [deleteProject],
})

return toServerSentEventsResponse(stream)
}

인터럽트를 내보내거나 해결하는 데 서버 저장소는 필요하지 않습니다. 위 라우트가 전체 흐름입니다. 브라우저가 연속 처리 요청에서 전체 메시지 기록을 다시 보내고, 엔진이 그 기록에서 일시 중지된 호출을 다시 빌드합니다.

클라이언트 상태 머신

단일 승인은 다음 순서를 따릅니다.

  1. 모델이 도구 호출을 방출합니다.
  2. 클라이언트 도구 호출 부분이 approval-requested 에 도달합니다.
  3. RUN_FINISHED.outcome.type === 'interrupt' 로 실행이 종료됩니다.
  4. useChatinterrupts 에서 바운드 항목을 노출합니다.
  5. UI 가 resolveInterrupt(...) 또는 cancel() 를 호출하면 단일 항목은 즉시 제출되고, 다중 항목 배치 는 모든 유효한 초안을 기다립니다.
  6. 다음 요청에는 새로운 runId, 인터럽트된 parentRunId, 및 정확한 AG-UI resume 배열이 포함됩니다.
  7. 엔진이 도구 호출을 계속하기 전에 서버가 전체 세트를 유효성 검사합니다.

일반 입력은 4단계에서 거부됩니다. 이를 통해 기존 실행이 결정을 기다리는 동안 두 번째 분기가 생성되는 것을 방지합니다.

React 승인 UI

useChat이 반환한 바인딩된 값을 사용합니다. interrupts를 렌더링하면 ID, 도구 유형, 초안 및 오류가 실행을 소유한 훅에 연결된 상태로 유지됩니다.

import type { ItemInterruptError } from '@tanstack/ai'
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import { deleteProjectDefinition } from './tools'

export function ApprovalQueue() {
const chat = useChat({
id: 'project-chat',
threadId: 'project-thread',
connection: fetchServerSentEvents('/api/chat'),
tools: [deleteProjectDefinition] as const,
})

return (
<section>
{chat.interrupts.map((interrupt) => (
<article key={interrupt.id}>
<p>Approval required: {interrupt.reason}</p>
{interrupt.kind === 'tool-approval' ? (
<button onClick={() => interrupt.resolveInterrupt(true)}>
Approve
</button>
) : null}
<button onClick={() => interrupt.cancel()}>Cancel</button>
{interrupt.errors.map((error: ItemInterruptError) => (
<p key={`${error.code}:${error.path?.join('.') ?? ''}`}>
{error.message}
</p>
))}
</article>
))}
</section>
)
}

배치의 경우 하나의 동기식 루트 콜백에서 모든 해결을 준비합니다.

function ResolveAll({ approved }: { approved: boolean }) {
const chat = useChat({
threadId: 'project-thread',
connection: fetchServerSentEvents('/api/chat'),
tools: [deleteProjectDefinition] as const,
})

return (
<button
onClick={() =>
void chat.resolveInterrupts((interrupt) => {
if (interrupt.kind === 'tool-approval') {
if (approved) {
interrupt.resolveInterrupt(true)
} else {
interrupt.resolveInterrupt(false)
}
return
}
interrupt.cancel()
})
}
>
Resolve all
</button>
)
}

상태 영속성과 전달 영속성 비교

인터럽트는 일시적으로 실행됩니다. 브라우저가 연속 처리 요청에서 재생하는 메시지 기록에서 일시 중지된 호출을 다시 빌드합니다. 이는 연결이 끊긴 후 라이브 바이트 스트림을 재생할 수 있게 하는 전달 영속성과는 별개입니다. 전달 영속성은 toServerSentEventsResponse에서 구성되며 청크마다 하나의 불투명한 SSE ID를 할당합니다(NDJSON에서는 사용할 수 없습니다). 재개 가능한 스트림을 참조하세요.