샌드박스 어댑터 빌드
에이전트는 샌드박스에서 실행되며, 서버 재시작이나 두 번째 복제본 실행 또는 사용자가 탭을 닫은 뒤에도 무엇이 남을지 결정해야 합니다. 답은 스위치 하나가 아닙니다. 샌드박스 실행에는 별도로 영속되는 두 부분이 있습니다.
- 샌드박스 측. 스레드에서 재개할 프로바이더 샌드박스, 실행이 아직 진행 중인지 여부, 실행의 이벤트 로그입니다.
- 대화. 누군가 스레드를 다시 열었을 때 UI가 표시하는 메시지입니다.
둘 다, 하나만 또는 어느 것도 유지할 수 있습니다. 이 페이지에서는 샌드박스 측과 두 부분이 만나는 한 지점을 다룹니다. 어댑터 안내의 세 번째 문서로, chat 및 generation과 나란히 있으며 두 문서의 스토어 계약은 필요하지 않습니다.
프로바이더 샌드박스가 사라진 뒤 완료된 작업공간 파일을 다시 빌드하려면 Reload 후 파일 유지를 사용합니다. withPersistence가 사용하는 것과 동일한 영속성 객체를 전달합니다.
저장할 항목 결정
| 유지하는 항목 | 연결 방식 | 얻는 것 | 포기하는 것 |
|---|---|---|---|
| 모두 | withPersistence + withSandbox with instances, runs, durability | 어떤 기기에서든 스레드를 다시 열어 대화 기록, 도구 카드 및 아직 실행 중인 실행을 확인합니다 | 스토어 크기: 한 실행의 도구 출력이 수백 킬로바이트일 수 있습니다 |
| 샌드박스만 | withSandbox를 instances, 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을 반환합니다. 오류를 발생시키지 않습니다. |
upsert | record.key 기준 전체 교체입니다. 생략된 선택 필드는 이전 값을 지웁니다. |
delete | 없는 키에 대해서는 아무 작업도 하지 않습니다. |
| timestamps | updatedAt은 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: 아무도 돌아오지 않은 실행은 완료되거나 만료되고, 아직 생성 중인 실행은 그대로 둡니다. 정리 및 보존을 참조합니다.
다음 단계
- 샌드박스 인스턴스 내구성에서는 방금 빌드한 스토어의 연결 방식과 잠금 규칙을 설명합니다.
- 이벤트에서는 저장된 대화 기록에 포함되는 내용과 이를 다듬는 방법을 다룹니다.
- 내구성 있는 실행 설명은 같은 주제를 쉬운 언어와 코드 없이 설명하므로, 위의 방식이 갑작스럽게 느껴졌다면 참고할 수 있습니다.