승인 흐름 처리 아키텍처
도구 승인은 인터럽트 및 재개 프로토콜입니다. 사용자 입력이 필요한 실행은 하나의 표준 이벤트로 종료됩니다.
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 이벤트를 재생합니다. |
디스크립터에서 연속 처리로 이어지는 파이프라인
불변식은 디스크립터 → 모두 검증 → 연속 처리 → 기록입니다.
- 엔진이 공개 디스크립터와 바인딩을 빌드합니다. 출력에는
MESSAGES_SNAPSHOT, 선택적STATE_SNAPSHOT, 인터럽트RUN_FINISHED종료 이벤트가 포함됩니다. - 클라이언트는 이유, 도구 ID, 호출 ID,
스키마 해시, 인터럽트된 실행 및 세대가 도구 레지스트리와 일치하는
디스크립터만 바인딩합니다.
신뢰할 수 없는 항목은 타입이 지정된 도구 리졸버를 얻는 대신
generic으로 처리됩니다. - 항목 메서드가 로컬 초안을 검증하고 준비합니다. 제출 경계에는 대기 중인 모든 인터럽트 ID가 정확히 한 번씩 포함됩니다.
- 클라이언트가 현재 전체 메시지 기록, 인터럽트된
parentRunId, 완전한 재개 배치를 포함한 새 실행을 제출합니다. - 서버는 무엇이든 실행하기 전에 모든 페이로드, 편집된 입력, 출력, 해시 및 상관관계를 검증하고, 클라이언트가 제공한 기록과 현재 도구 정의에서 예상 배치를 재구성합니다.
- 재개된 도구 호출은 결과만 내보내며, 합성된 도구 호출 시작/인수 이벤트를 재생하지 않습니다. 성공한 기록은 연속 처리 실행에 속합니다.
배치는 클라이언트가 제공한 기록에서 다시 빌드되므로 일시적 모드는 재생, 정확히 한 번, 재시작 또는 인스턴스 간 보장을 제공하지 않습니다. 메시지 기록은 검증되지만 클라이언트가 제공한 입력으로 남습니다.
서버 설정
도구를 일반적으로 정의합니다. 다음 라우트는 일시적 흐름(영속성 미들웨어 없음)입니다. 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)
}
인터럽트를 내보내거나 해결하는 데 서버 저장소는 필요하지 않습니다. 위 라우트가 전체 흐름입니다. 브라우저가 연속 처리 요청에서 전체 메시지 기록을 다시 보내고, 엔진이 그 기록에서 일시 중지된 호출을 다시 빌드합니다.
클라이언트 상태 머신
단일 승인은 다음 순서를 따릅니다.
- 모델이 도구 호출을 방출합니다.
- 클라이언트 도구 호출 부분이
approval-requested에 도달합니다. RUN_FINISHED.outcome.type === 'interrupt'로 실행이 종료됩니다.useChat가interrupts에서 바운드 항목을 노출합니다.- UI 가
resolveInterrupt(...)또는cancel()를 호출하면 단일 항목은 즉시 제출되고, 다중 항목 배치 는 모든 유효한 초안을 기다립니다. - 다음 요청에는 새로운
runId, 인터럽트된parentRunId, 및 정확한 AG-UIresume배열이 포함됩니다. - 엔진이 도구 호출을 계속하기 전에 서버가 전체 세트를 유효성 검사합니다.
일반 입력은 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에서는 사용할 수 없습니다). 재개 가능한 스트림을 참조하세요.