mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 08:03:46 +00:00
* chore: recover hamburg workspace state * chore: drop workspace context files * refactor: generalize permissions example * refactor: parse permissions example flags * docs: clarify why fs and terminal stay native * feat: add interactive permission prompt UI to Inspector Add permission request handling to the Inspector UI so users can Allow, Always Allow, or Reject tool calls that require permissions instead of having them auto-cancelled. Wires up SDK onPermissionRequest/respondPermission through App → ChatPanel → ChatMessages with proper toolCallId-to-pendingId mapping. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: prevent permission reply from silently escalating "once" to "always" Remove allow_always from the fallback chain when the user replies "once", aligning with the ACP spec which says "map by option kind first" with no fallback for allow_once. Also fix Inspector to use rawSend, revert hydration guard to accept empty configOptions, and handle respondPermission errors by rejecting the pending promise. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
263 lines
6.7 KiB
Text
263 lines
6.7 KiB
Text
---
|
|
title: "Manage Sessions"
|
|
description: "Persist and replay agent transcripts across connections."
|
|
icon: "database"
|
|
---
|
|
|
|
Sandbox Agent stores sessions in memory only. When the server restarts or the sandbox is destroyed, all session data is lost. It's your responsibility to persist events to your own database.
|
|
|
|
## Recommended approach
|
|
|
|
1. Store events to your database as they arrive
|
|
2. On reconnect, get the last event's `sequence` and pass it as `offset`
|
|
3. The API returns events where `sequence > offset`
|
|
|
|
This prevents duplicate writes and lets you recover from disconnects.
|
|
|
|
## Receiving Events
|
|
|
|
Two ways to receive events: streaming (recommended) or polling.
|
|
|
|
### Streaming
|
|
|
|
Use streaming for real-time events with automatic reconnection support.
|
|
|
|
```typescript
|
|
import { SandboxAgentClient } from "sandbox-agent";
|
|
|
|
const client = new SandboxAgentClient({
|
|
baseUrl: "http://127.0.0.1:2468",
|
|
agent: "mock",
|
|
});
|
|
|
|
// Get offset from last stored event (0 returns all events)
|
|
const lastEvent = await db.getLastEvent("my-session");
|
|
const offset = lastEvent?.sequence ?? 0;
|
|
|
|
// Stream from where you left off
|
|
for await (const event of client.streamEvents("my-session", { offset })) {
|
|
await db.insertEvent("my-session", event);
|
|
}
|
|
```
|
|
|
|
### Polling
|
|
|
|
If you can't use streaming, poll the events endpoint:
|
|
|
|
```typescript
|
|
const lastEvent = await db.getLastEvent("my-session");
|
|
let offset = lastEvent?.sequence ?? 0;
|
|
|
|
while (true) {
|
|
const { events } = await client.getEvents("my-session", {
|
|
offset,
|
|
limit: 100
|
|
});
|
|
|
|
for (const event of events) {
|
|
await db.insertEvent("my-session", event);
|
|
offset = event.sequence;
|
|
}
|
|
|
|
await sleep(1000);
|
|
}
|
|
```
|
|
|
|
## Database options
|
|
|
|
Choose where to persist events based on your requirements. For most use cases, we recommend Rivet Actors.
|
|
|
|
| | Durable | Real-time | Multiplayer | Scaling | Throughput | Complexity |
|
|
|---------|:-------:|:---------:|:-----------:|---------|------------|------------|
|
|
| Rivet Actors | ✓ | ✓ | ✓ | Auto-sharded, one actor per session | Millions of concurrent sessions | Zero infrastructure |
|
|
| PostgreSQL | ✓ | | | Manual sharding | Connection pool limited | Connection pools, migrations |
|
|
| Redis | | ✓ | | Redis Cluster | High, in-memory | Memory management, Sentinel for failover |
|
|
|
|
### Rivet Actors
|
|
|
|
For production workloads, [Rivet Actors](https://rivet.gg) provide a managed solution for:
|
|
|
|
- **Persistent state**: Events survive crashes and restarts
|
|
- **Real-time streaming**: Built-in WebSocket support for clients
|
|
- **Horizontal scaling**: Run thousands of concurrent sessions
|
|
- **Observability**: Built-in logging and metrics
|
|
|
|
#### Actor
|
|
|
|
```typescript
|
|
import { actor } from "rivetkit";
|
|
import { Daytona } from "@daytonaio/sdk";
|
|
import { SandboxAgent, SandboxAgentClient, AgentEvent } from "sandbox-agent";
|
|
|
|
interface CodingSessionState {
|
|
sandboxId: string;
|
|
baseUrl: string;
|
|
sessionId: string;
|
|
events: AgentEvent[];
|
|
}
|
|
|
|
interface CodingSessionVars {
|
|
client: SandboxAgentClient;
|
|
}
|
|
|
|
const daytona = new Daytona();
|
|
|
|
const codingSession = actor({
|
|
createState: async (): Promise<CodingSessionState> => {
|
|
const sandbox = await daytona.create({
|
|
snapshot: "sandbox-agent-ready",
|
|
envVars: {
|
|
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
|
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
|
},
|
|
autoStopInterval: 0,
|
|
});
|
|
|
|
await sandbox.process.executeCommand(
|
|
"nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 &"
|
|
);
|
|
|
|
const baseUrl = (await sandbox.getSignedPreviewUrl(3000)).url;
|
|
const sessionId = crypto.randomUUID();
|
|
|
|
return {
|
|
sandboxId: sandbox.id,
|
|
baseUrl,
|
|
sessionId,
|
|
events: [],
|
|
};
|
|
},
|
|
|
|
createVars: async (c): Promise<CodingSessionVars> => {
|
|
const client = new SandboxAgentClient({
|
|
baseUrl: c.state.baseUrl,
|
|
agent: "mock",
|
|
});
|
|
await client.createSession(c.state.sessionId, { agent: "claude" });
|
|
return { client };
|
|
},
|
|
|
|
onDestroy: async (c) => {
|
|
const sandbox = await daytona.get(c.state.sandboxId);
|
|
await sandbox.delete();
|
|
},
|
|
|
|
run: async (c) => {
|
|
for await (const event of c.vars.client.streamEvents(c.state.sessionId)) {
|
|
c.state.events.push(event);
|
|
c.broadcast("agentEvent", event);
|
|
}
|
|
},
|
|
|
|
actions: {
|
|
postMessage: async (c, message: string) => {
|
|
await c.vars.client.postMessage(c.state.sessionId, message);
|
|
},
|
|
|
|
getTranscript: (c) => c.state.events,
|
|
},
|
|
});
|
|
```
|
|
|
|
#### Client
|
|
|
|
<CodeGroup>
|
|
|
|
```typescript TypeScript
|
|
import { createClient } from "rivetkit/client";
|
|
|
|
const client = createClient();
|
|
const session = client.codingSession.getOrCreate(["my-session"]);
|
|
|
|
const conn = session.connect();
|
|
conn.on("agentEvent", (event) => {
|
|
console.log(event.type, event.data);
|
|
});
|
|
|
|
await conn.postMessage("Create a new React component for user profiles");
|
|
|
|
const transcript = await conn.getTranscript();
|
|
```
|
|
|
|
```typescript React
|
|
import { createRivetKit } from "@rivetkit/react";
|
|
|
|
const { useActor } = createRivetKit();
|
|
|
|
function CodingSession() {
|
|
const [messages, setMessages] = useState<AgentEvent[]>([]);
|
|
const session = useActor({ name: "codingSession", key: ["my-session"] });
|
|
|
|
session.useEvent("agentEvent", (event) => {
|
|
setMessages((prev) => [...prev, event]);
|
|
});
|
|
|
|
const sendPrompt = async (prompt: string) => {
|
|
await session.connection?.postMessage(prompt);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{messages.map((msg, i) => (
|
|
<div key={i}>{JSON.stringify(msg)}</div>
|
|
))}
|
|
<button onClick={() => sendPrompt("Build a login page")}>
|
|
Send Prompt
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
</CodeGroup>
|
|
|
|
### PostgreSQL
|
|
|
|
```sql
|
|
CREATE TABLE agent_events (
|
|
event_id TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
native_session_id TEXT,
|
|
sequence INTEGER NOT NULL,
|
|
time TIMESTAMPTZ NOT NULL,
|
|
type TEXT NOT NULL,
|
|
source TEXT NOT NULL,
|
|
synthetic BOOLEAN NOT NULL DEFAULT FALSE,
|
|
data JSONB NOT NULL,
|
|
UNIQUE(session_id, sequence)
|
|
);
|
|
|
|
CREATE INDEX idx_events_session ON agent_events(session_id, sequence);
|
|
```
|
|
|
|
### Redis
|
|
|
|
```typescript
|
|
// Append event to list
|
|
await redis.rpush(`session:${sessionId}`, JSON.stringify(event));
|
|
|
|
// Get events from offset
|
|
const events = await redis.lrange(`session:${sessionId}`, offset, -1);
|
|
```
|
|
|
|
## Handling disconnects
|
|
|
|
The event stream may disconnect due to network issues. Handle reconnection gracefully:
|
|
|
|
```typescript
|
|
async function streamWithRetry(sessionId: string) {
|
|
while (true) {
|
|
try {
|
|
const lastEvent = await db.getLastEvent(sessionId);
|
|
const offset = lastEvent?.sequence ?? 0;
|
|
|
|
for await (const event of client.streamEvents(sessionId, { offset })) {
|
|
await db.insertEvent(sessionId, event);
|
|
}
|
|
} catch (error) {
|
|
console.error("Stream disconnected, reconnecting...", error);
|
|
await sleep(1000);
|
|
}
|
|
}
|
|
}
|
|
```
|