여러 인터럽트
하나의 실행이 여러 결정에서 동시에 일시 중지될 수 있습니다. 도구 승인과 일반 미들웨어 요청이 같은 배치에 들어올 수 있습니다. 전체 큐를 표시하고 각 요청마다 왕복하지 않고 답변을 함께 보내야 합니다.
해결하는 두 가지 방법
도구 승인 페이지에서 첫 번째 방법인 항목 자체의 메서드 호출을 이미 확인했습니다.
// Per item: resolve each one where you render it.
interrupt.resolveInterrupt(true)
여러 클라이언트 소유 항목이 보류 중이면 한 곳에서 모두 답변하는 편이 더 쉽습니다. useChat 훅은 이러한 항목을 위한 루트 헬퍼를 제공합니다.
// All at once: one callback decides every pending item.
resolveInterrupts((interrupt) => {
if (interrupt.kind === 'tool-approval') {
interrupt.resolveInterrupt(true)
return
}
interrupt.cancel()
})
두 방법 모두 로컬 초안을 준비합니다. 모든 보류 항목에 답변이 있을 때까지 서버로 전송하지 않습니다. 그런 다음 클라이언트가 전체 집합을 한 번에 제출합니다. 서버는 항목을 하나라도 수락하기 전에 전체 집합을 검증합니다.
영속성이 활성화되면 InterruptStore.commitBatch()가 하나의 데이터베이스 트랜잭션으로 검증된 배치를 커밋할 수 있습니다. commitBatch()가 없으면 호환성 폴백이 항목을 순서대로 기록합니다. 이 폴백은 원자적이지 않습니다.
혼합 배치에서는 kind를 기준으로 분기합니다. 등록된 일반 항목은 정의 ID를 사용해 페이로드와 응답의 타입을 좁힙니다. 도구 승인에는 자체 컨트롤이 있습니다. 유효한 원시 바인딩이 있는 일반 항목은 타입이 지정되지 않은 컨트롤을 가지며 재개 배치에 참여합니다. unbound 항목은 바인딩이 없거나 잘못되었거나 지원되지 않는 경우입니다. 계속 표시되지만 컨트롤이 없고 재개 가능한 항목을 차단하지 않습니다.
큐 렌더링
interrupts를 매핑하고 kind를 기준으로 분기합니다. 각 항목에는 자체
canResolve and errors:
// app/decision-queue.tsx
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import { transferTool } from '../tools/transfer'
export function DecisionQueue() {
const { interrupts, resolveInterrupts, cancelInterrupts, resuming } = useChat({
threadId: 'account-42',
connection: fetchServerSentEvents('/api/chat'),
tools: [transferTool] as const,
})
if (interrupts.length === 0) return null
return (
<section>
<p>{interrupts.length} decision(s) needed</p>
{interrupts.map((interrupt) => {
if (
interrupt.kind === 'tool-approval' &&
interrupt.toolName === 'transfer'
) {
return (
<article key={interrupt.id}>
<p>
{interrupt.originalArgs.amount} to{' '}
{interrupt.originalArgs.recipient}
</p>
<button
disabled={!interrupt.canResolve || resuming}
onClick={() => interrupt.resolveInterrupt(true)}
>
Approve
</button>
<button
disabled={!interrupt.canResolve || resuming}
onClick={() => interrupt.resolveInterrupt(false)}
>
Reject
</button>
</article>
)
}
return <article key={interrupt.id}>Unsupported: {interrupt.kind}</article>
})}
<button onClick={() => resolveInterrupts(true)} disabled={resuming}>
Approve all
</button>
<button onClick={() => cancelInterrupts()} disabled={resuming}>
Cancel all
</button>
</section>
)
}
하나의 콜백에서 모든 항목 해결
resolveInterrupts(callback)은 재개 가능한 항목마다 콜백을 한 번씩 단일 동기 트랜잭션에서 실행합니다. 여기에는 유효한 원시 외부 일반 바인딩도 포함됩니다. 이러한 항목을 모두 해결하거나 취소해야 합니다. 예외가 발생하거나 하나라도 답변하지 않은 채 남으면 아무것도 제출되지 않습니다.
resolveInterrupts((interrupt) => {
if (interrupt.kind === 'tool-approval') {
interrupt.resolveInterrupt(true, { payload: { note: 'Batch review' } })
return
}
interrupt.cancel()
})
일반적인 경우에는 다음 두 가지 축약형을 사용합니다.
resolveInterrupts(true)/resolveInterrupts(false)는 전체 큐를 승인하거나 거부합니다. 모든 항목이 페이로드나 편집이 필요 없는 도구 승인인 경우에만 작동합니다. 일반 항목, 혼합 큐 또는 필수 페이로드는 거부됩니다.cancelInterrupts()는interrupts에 표시되지 않는 클라이언트 도구 실행 단계도 포함해 페이로드 없는 모든 재개 가능한 항목을 취소합니다.
onInterruptStateChange 소스를 사용해 복원된(hydrate) 배치와 현재 세션(live) 배치에 서로 다른 정책을 적용합니다. 취소는 배치의 모든 재개 가능한 항목에 계속 적용됩니다.
답변이 잘못된 경우
잘못된 답변이 큐를 해제하지는 않습니다. 항목은 마지막으로 유효한 초안을 유지하고, 문제를 표시하며, 수정 후 다시 제출할 수 있게 합니다. 오류는 두 곳에서 발생하므로 모두 렌더링합니다.
각 항목에는 자체 errors가 있습니다. 잘못된 페이로드, 유효하지 않은 편집 인수 또는 만료된 항목을 나타냅니다. 루트 interruptErrors에는 전체 배치의 실패, 즉 전송 문제, 서버 거부 및 자체 항목으로 표시되지 않는 내부 클라이언트 도구 단계의 오류가 포함됩니다.
이 컴포넌트는 두 오류를 모두 렌더링하고 버튼을 올바르게 제어하며 두 가지 복구 경로를 제공합니다.
// app/robust-queue.tsx
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
const transferTool = toolDefinition({
name: 'transfer',
description: 'Move money between accounts',
needsApproval: true,
inputSchema: z.object({
recipient: z.string(),
amount: z.number(),
}),
outputSchema: z.object({ receiptId: z.string() }),
}).client()
export function RobustQueue() {
const { interrupts, interruptErrors, retryInterrupts, resuming } = useChat({
threadId: 'account-42',
connection: fetchServerSentEvents('/api/chat'),
tools: [transferTool] as const,
})
// Retry only helps a transport failure. Expired or stale batches cannot
// be retried, so do not offer it for those.
const canRetry = interruptErrors.some((error) => error.code === 'transport')
return (
<section>
{interrupts.map((interrupt) => {
if (
interrupt.kind !== 'tool-approval' ||
interrupt.toolName !== 'transfer'
) {
return null
}
// canResolve reflects the schema and binding, not the live phase, so
// also gate on the item's status and the run being busy.
const busy = interrupt.status === 'submitting' || resuming
return (
<article key={interrupt.id}>
<p>
{interrupt.originalArgs.amount} to{' '}
{interrupt.originalArgs.recipient}
</p>
<button
disabled={!interrupt.canResolve || busy}
onClick={() => interrupt.resolveInterrupt(true)}
>
Approve
</button>
<button disabled={busy} onClick={() => interrupt.clearResolution()}>
Start over
</button>
{/* Item errors: bad payload, bad edited args, expired. */}
{interrupt.errors.map((error) => (
<p key={`${error.code}:${error.path?.join('.') ?? ''}`}>
{error.message}
</p>
))}
</article>
)
})}
{/* Batch errors: transport, server, and hidden client-tool steps. */}
{interruptErrors.map((error) => (
<p key={error.code}>{error.message}</p>
))}
{canRetry ? (
<button onClick={() => retryInterrupts()} disabled={resuming}>
Retry
</button>
) : null}
</section>
)
}
두 가지 복구 경로는 다음과 같습니다.
interrupt.clearResolution()은 한 항목의 초안을 삭제하므로 처음부터 다시 답변할 수 있습니다. 양식을 수정하고resolveInterrupt를 다시 호출해도 되며, 초안은 누적되지 않고 교체됩니다.retryInterrupts()는 전송 실패 후 준비된 배치를 재시도합니다. 순차적 영속 저장소에서는 실패한 요청이 이전 항목을 이미 커밋했을 수 있습니다. 현재 보류 레코드를 다시 로드한 다음 남은 배치만 재시도합니다. 만료되었거나 오래된 배치에는 아무 작업도 하지 않으므로, 해당 항목에 대한 새 인터럽트 집합을 얻으려면 새 실행을 시작합니다.