본문으로 건너뛰기

샌드박스 어댑터 빌드

에이전트는 샌드박스에서 실행되며, 서버 재시작이나 두 번째 복제본 실행 또는 사용자가 탭을 닫은 뒤에도 무엇이 남을지 결정해야 합니다. 답은 스위치 하나가 아닙니다. 샌드박스 실행에는 별도로 영속되는 두 부분이 있습니다.

  • 샌드박스 측. 스레드에서 재개할 프로바이더 샌드박스, 실행이 아직 진행 중인지 여부, 실행의 이벤트 로그입니다.
  • 대화. 누군가 스레드를 다시 열었을 때 UI가 표시하는 메시지입니다.

둘 다, 하나만 또는 어느 것도 유지할 수 있습니다. 이 페이지에서는 샌드박스 측과 두 부분이 만나는 한 지점을 다룹니다. 어댑터 안내의 세 번째 문서로, chatgeneration과 나란히 있으며 두 문서의 스토어 계약은 필요하지 않습니다.

프로바이더 샌드박스가 사라진 뒤 완료된 작업공간 파일을 다시 빌드하려면 Reload 후 파일 유지를 사용합니다. withPersistence가 사용하는 것과 동일한 영속성 객체를 전달합니다.

저장할 항목 결정

유지하는 항목연결 방식얻는 것포기하는 것
모두withPersistence + withSandbox with instances, runs, durability어떤 기기에서든 스레드를 다시 열어 대화 기록, 도구 카드 및 아직 실행 중인 실행을 확인합니다스토어 크기: 한 실행의 도구 출력이 수백 킬로바이트일 수 있습니다
샌드박스만withSandboxinstances, runs, durability와 함께 사용하고 메시지 저장소는 사용하지 않음새로고침 후에도 실행이 유지되고 제어권을 넘겨받을 수 있음. 샌드박스를 다시 빌드하는 대신 재사용함대화 기록이 없음. 복귀한 클라이언트에는 기록이 아니라 남은 실시간 내용이 표시됨
채팅만withPersistence alone대화가 다시 표시됩니다샌드박스를 재사용하거나 인계할 수 없습니다. 연결이 끊기면 샌드박스가 삭제됩니다
어느 것도 아님미들웨어 없음운영할 것이 없습니다모든 실행이 초기 상태의 샌드박스에서 시작하고 소켓과 함께 종료됩니다

텍스트 자체가 민감할 때는 "샌드박스만" 방식을 선택합니다. 실행 레코드와 인스턴스 맵에는 ID와 타임스탬프만 있으며, 누군가 입력한 내용은 저장하지 않습니다.

저장된 대화 기록 안에는 더 세밀한 설정이 있습니다. 하네스의 도구 호출은 메시지로 저장되므로, 이를 유지하면 스레드를 다시 열 때 도구 카드가 재구성되고, 버리면 대화만 유지됩니다. 유지할 항목 다듬기를 참조합니다.

모두 유지

withPersistence는 대화를 관리합니다. withSandbox는 샌드박스를 관리합니다. 둘은 한 지점에서 만납니다. 동일한 RunStore를 둘 다에 전달하여, 서로 불일치하는 두 레코드가 아니라 하나의 레코드가 실행을 설명하도록 합니다.

import { chat } from '@tanstack/ai'
import { withLocks } from '@tanstack/ai/locks'
import { claudeCodeText } from '@tanstack/ai-claude-code'
import { withPersistence } from '@tanstack/ai-persistence'
import { withSandbox } from '@tanstack/ai-sandbox'
// Your stores and your `defineSandbox(...)` result.
import { persistence } from './persistence'
import { instances } from './instances'
import { locks } from './locks'
import { sandbox } from './sandbox'

export function agentRun(input: {
messages: Array<{ role: 'user'; content: string }>
threadId: string
runId: string
}) {
return chat({
adapter: claudeCodeText('claude-opus-4-8'),
messages: input.messages,
threadId: input.threadId,
runId: input.runId,
middleware: [
withPersistence(persistence),
// Before `withSandbox`: it serializes resume-or-create for one key.
withLocks(locks),
withSandbox(sandbox, {
instances,
// The SAME store chat persistence uses.
runs: persistence.stores.runs,
}),
],
})
}

실행을 분리 가능하고 재생 가능하게 만들려면 durability를 추가합니다. 자세한 내용은 인계 및 분리된 실행을 참조합니다.

샌드박스 측만 유지

withPersistence를 제거해도 샌드박스 절반은 계속 작동합니다. 자체 스토어 두 개가 필요하며 둘 다 채팅 스토어가 아닙니다.

  • RunStore: persistence 패키지의 계약이 아닌 코어 계약입니다.
  • SandboxInstanceStore: 샌드박스 자체의 계약입니다(아래 참조).
import { chat } from '@tanstack/ai'
import { withLocks } from '@tanstack/ai/locks'
import { claudeCodeText } from '@tanstack/ai-claude-code'
import { withSandbox } from '@tanstack/ai-sandbox'
import { instances } from './instances'
import { locks } from './locks'
// Your own `RunStore`. Nothing here stores a message.
import { runs } from './runs'
import { sandbox } from './sandbox'

export function agentRun(input: {
messages: Array<{ role: 'user'; content: string }>
threadId: string
runId: string
}) {
return chat({
adapter: claudeCodeText('claude-opus-4-8'),
messages: input.messages,
threadId: input.threadId,
runId: input.runId,
middleware: [
withLocks(locks),
withSandbox(sandbox, { instances, runs }),
],
})
}

돌아온 클라이언트는 실행을 찾고(findActiveRun) 남은 부분을 이어서 받을 수 있습니다. 저장된 내용이 없으므로 이전에 표시된 내용을 다시 그릴 수는 없습니다.

대화만 유지

withPersistence만 사용합니다. withSandbox에서 instances, runs, durability를 제외하면 가장 단순한 현재 동작이 유지됩니다. 실행마다 새 샌드박스를 만들고 소켓이 닫히면 삭제합니다. 샌드박스 측에서 구현할 것은 전혀 없습니다.

SandboxInstanceStore 구현

이는 샌드박스 자체의 계약입니다. 복합 키를 재개해야 하는 프로바이더 샌드박스에 매핑하는 맵입니다. 메서드는 세 개이며, 각각에 적합성 테스트 모음이 확인하는 불변 조건이 있습니다.

import { defineSandboxInstanceStore } from '@tanstack/ai-sandbox'
import { db } from './db'

export const instances = defineSandboxInstanceStore({
// A missing key returns null. It never throws.
get: (key) => db.sandboxInstances.findByKey(key),

// A FULL replace, not a merge: omitted optional fields must clear the stored
// value, or a create-without-snapshot leaves a stale `latestSnapshotId` behind
// and `ensure` resumes from a snapshot that no longer describes the workspace.
upsert: (record) => db.sandboxInstances.replace(record),

// Deleting a key that is not there is a no-op.
delete: (key) => db.sandboxInstances.remove(key),
})
메서드불변 조건
get없는 키는 null을 반환합니다. 오류를 발생시키지 않습니다.
upsertrecord.key 기준 전체 교체입니다. 생략된 선택 필드는 이전 값을 지웁니다.
delete없는 키에 대해서는 아무 작업도 하지 않습니다.
timestampsupdatedAt은 epoch 밀리초입니다.

키마다 한 행이면 충분합니다.

CREATE TABLE sandbox_instances (
key TEXT PRIMARY KEY,
provider TEXT NOT NULL,
provider_sandbox_id TEXT NOT NULL,
latest_snapshot_id TEXT,
thread_id TEXT NOT NULL,
latest_run_id TEXT,
updated_at INTEGER NOT NULL
);

원한다면 채팅 테이블과 같은 데이터베이스에 배치합니다. 이는 요구 사항이 아니라 선택 사항입니다. 샌드박스는 채팅 테이블을 읽지 않고 채팅도 이 테이블을 읽지 않습니다.

updated_at을 언제 정리할지는 사용자가 결정합니다. 라이브러리는 일정에 따라 행을 삭제하지 않으므로 오래된 배치는 사용자가 수집해야 하는 가비지입니다(정리 및 보존 참조).

플랫폼 계층이 연결을 관리한다면 미들웨어에 전달하거나 해당 계층에서 암묵적으로 제공합니다.

import { withSandbox } from '@tanstack/ai-sandbox'
import { instances } from './instances'
import { sandbox } from './sandbox'

export const middleware = [withSandbox(sandbox, { instances })]

네 가지 실행 필드

내구성 있는 실행은 채팅에 이미 사용하는 RunStore 레코드에 선택적 필드 네 개를 추가합니다. 샌드박스 패키지 외부에서는 이 필드에 쓰지 않으며, 채팅만 사용하는 앱은 스키마에서 해당 열을 완전히 제외해도 됩니다.

필드기록 주체제외하면 발생하는 문제
sandboxKey분리 시 withSandbox분리된 샌드박스를 다시 찾을 수 없어 회수되지 않습니다
detachedSince분리 시 withSandbox, 재연결 시 삭제리퍼가 아무도 지켜보지 않는 실행과 활성 실행을 구분할 수 없습니다
cancelRequested대역 외에서 requestRunCancel중지 버튼이 다른 복제본이 구동하는 실행에 도달할 수 없습니다
driverEpoch실행을 확보하는 각 호스트인계 시 펜스가 없어 두 호스트가 하나의 실행을 구동할 수 있습니다

네 가지 이름보다 중요한 규칙이 하나 있습니다. update생략된 키와 **undefined**를 담은 키를 다르게 처리해야 합니다. 생략은 "열을 그대로 둠"을 뜻합니다. 명시적 undefined는 "지움"을 뜻하며, 재연결하는 뷰어가 detachedSince를 지우는 방식입니다. SET 절에서 undefined를 필터링하는 백엔드는 다른 모든 테스트를 통과한 뒤 정상 실행을 영구적으로 분리된 상태로 보고합니다.

import type { RunRecord } from '@tanstack/ai'

// One branch of your own `update(runId, patch)`. Key presence, not a value check.
export function detachedSinceColumn(
patch: Partial<RunRecord>,
sets: Array<string>,
params: Array<unknown>,
): void {
if ('detachedSince' in patch) {
sets.push('detached_since = ?')
params.push(patch.detachedSince ?? null)
}
}

테이블을 두 번 읽는 대신 이를 검증합니다. 대부분의 앱에는 이 기능이 필요하지 않기 때문에 이 테스트 모음은 runPersistenceConformance와 분리되어 있습니다.

import { runDurableRunFieldsConformance } from '@tanstack/ai-sandbox/testkit'
import { persistence } from './persistence'

runDurableRunFieldsConformance('my postgres runs', () => persistence.stores.runs)

같은 스토어의 listReclaimable도 샌드박스 전용이며 선택 사항입니다. 이를 생략하면 reapDetachedRuns가 해당 기능의 부재를 감지하고 한 줄을 기록한 뒤 아무것도 정리하지 않습니다. 정리 및 보존을 참조합니다.

적합성 테스트 모음으로 검증

이 어설션을 직접 작성하지 않습니다. @tanstack/ai-sandbox/testkit은 위의 모든 불변 조건을 고정하는 테스트 모음을 제공합니다. 여기에는 INSERT ... ON CONFLICT DO UPDATE에서 실수하기 쉬운 전체 교체 규칙도 포함됩니다.

import { runSandboxInstanceStoreConformance } from '@tanstack/ai-sandbox/testkit'
import { freshDb } from './test-db'

runSandboxInstanceStoreConformance('postgres', async () => {
const db = await freshDb()
return db.sandboxInstances
})

내구성 있는 실행 경로에는 세 가지 테스트 모음이 더 있으며, 각각 수동으로 찾기 어려운 한 가지 오류를 대상으로 합니다.

  • runJournalConformance: 에이전트의 출력이 후속 에이전트가 재생할 수 있는 파일로 유지됩니다. 실행 저널을 참조합니다.
  • runTakeoverConformance: 두 번째 호스트가 분리된 실행을 인계하고 남은 부분을 정확히 한 번 전달합니다. 인계 및 분리된 실행을 참조합니다.
  • runReaperConformance: 아무도 돌아오지 않은 실행은 완료되거나 만료되고, 아직 생성 중인 실행은 그대로 둡니다. 정리 및 보존을 참조합니다.

다음 단계

  • 샌드박스 인스턴스 내구성에서는 방금 빌드한 스토어의 연결 방식과 잠금 규칙을 설명합니다.
  • 이벤트에서는 저장된 대화 기록에 포함되는 내용과 이를 다듬는 방법을 다룹니다.
  • 내구성 있는 실행 설명은 같은 주제를 쉬운 언어와 코드 없이 설명하므로, 위의 방식이 갑작스럽게 느껴졌다면 참고할 수 있습니다.