ai-sdk-harness-sessions 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # ai-sdk-harness-sessions
2
2
 
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - d1448b3: Closing or deleting an `interrupted` session now discards its sandbox, as it does for a `suspended` one. A session the process left live when it stopped without suspending it kept its sandbox forever: with `sandboxPerSession()`, a session created again under the same id then failed, its sandbox's name already taken.
8
+ - 116b98c: A resume that fails no longer closes the session's sandbox lease: it hands it back with `suspend()`, as a suspension does, so the sandbox and the files the next try resumes from are kept. With `sandboxPerSession()`, a failed resume destroyed the session's sandbox while the session stayed `suspended`, waiting to resume from it.
9
+ - a1197a2: Several managers can share one store: the instances of an application behind a load balancer, scaled out, or a new version deployed beside the old one. A store that implements three more methods — `claimSession`, `renewLease` and `releaseSession`, the lease of each manager kept on the database's clock — lets each manager run the sessions it holds:
10
+
11
+ - a starting manager no longer marks `interrupted` the live sessions of the others, only those of a manager whose lease expired;
12
+ - a request for a session another manager runs fails with `SessionOwnedElsewhereError` (a `SessionConflictError`), which names that manager (`owner`, the new `managerId` option) so that the application can route the request to it;
13
+ - a suspended session is resumed by whichever manager receives its next message;
14
+ - once the lease of a manager that crashed has expired, the others mark its live sessions `interrupted`.
15
+
16
+ `leaseTtlMs` sets the lease (30 seconds by default). A store without these methods works as before, with one manager.
17
+ - 32e5121: The message sent after a turn cut short is answered. Claude Code goes on with a turn for a moment after it is told to stop, and the harness session handed what it still said to the next turn: that turn failed with "Received text-delta for missing text part", or ended at once without its answer. A turn cut short by `interrupt()`, `turnTimeoutMs` or the caller's `abortSignal` now starts its harness session again, in the same sandbox and the same conversation, before the next turn.
18
+
19
+ ## 0.2.0
20
+
21
+ ### Minor Changes
22
+
23
+ - 05ff36c: The `approve` option answers the approvals a turn asks for before anyone sees them — an approval policy, the grants of the session: a verdict is sent at once and the turn goes on in the same stream, the client seeing the answer; `undefined` leaves the approval to a person. When it answers part of a pause, `pendingInput.approvals` carries its answers in `decision`, and `continue()` sends them with the person's. An approval asked again right after it was answered, with the same input, is left to a person. `update(id, { metadata })` changes the metadata of a session, whatever its status. A session resumed without the turn it waited in tells the agent the answers to its questions in words.
24
+
3
25
  ## 0.1.0
4
26
 
5
27
  ### Minor Changes
package/README.md CHANGED
@@ -22,7 +22,8 @@ import { createSessionManager, sharedSandbox } from 'ai-sdk-harness-sessions';
22
22
  const sessions = createSessionManager({
23
23
  // The agent of a session, from what you keep with it.
24
24
  agent: ({ metadata }) => agents[metadata.agent],
25
- // One sandbox for every session, each with a port of its own for its bridge.
25
+ // One sandbox for every session, each with a port of its own for its bridge. `openSandbox` is
26
+ // yours: it opens a sandbox of ai-sdk-sandbox-sbx, ai-sdk-sandbox-cloud-run… (see "Sandboxes").
26
27
  sandboxes: sharedSandbox({
27
28
  open: openSandbox,
28
29
  ports: Array.from({ length: 4 }, (_, i) => 4001 + i),
@@ -41,11 +42,12 @@ return turn.toUIMessageStreamResponse();
41
42
  | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
42
43
  | A first message | Opens a sandbox, or a view of a shared one, and a harness session, in the background: a message sent meanwhile waits |
43
44
  | A message while a turn runs | Refuses it: one turn at a time per session (`SessionConflictError`, status `busy`) |
44
- | The turn asks approval for a tool call | Keeps the session `awaiting-input` until `continue()` brings the answer, even after a suspension or a restart |
45
+ | The turn asks approval for a tool call | Asks your `approve` option first, then keeps the session `awaiting-input` until `continue()` brings the answer |
45
46
  | Nobody writes for `idleTimeoutMs` | Suspends the session: its harness session stops with its state, its sandbox is freed. The next message resumes it |
46
47
  | Every sandbox slot is taken | Suspends the session idle the longest to make room |
47
48
  | Your server stops (`shutdown()`) | Cuts the turns under way short, and suspends every session: the next start resumes them on their next message |
48
49
  | Your server crashes | Marks the sessions it held `interrupted` at the next start: their conversation can still be read |
50
+ | Your server runs on several instances | With a store that gives leases, each instance runs the sessions it holds, and takes over those of one that crashed |
49
51
  | You want the conversation again | `get(id)` gives it as `useChat` shows it, `list()` every session, `subscribe()` every change, for a list to follow |
50
52
 
51
53
  ## Installation
@@ -134,6 +136,8 @@ export async function POST(request: Request) {
134
136
 
135
137
  The turn is read to its end on your server as well: a user who closes the tab does not leave the session hanging, and the session is written either way. `abortSignal: request.signal` lets the stop button of `useChat` cut the turn short; leave it out for turns that go on without the client. `interrupt(id)` cuts a turn short from anywhere.
136
138
 
139
+ A turn cut short — by `interrupt(id)`, `turnTimeoutMs` or the caller's `abortSignal` — starts its harness session again, in the same sandbox, before the session takes another message. A runtime goes on with a turn for a moment after it is told to stop, and its harness session would hand what it still says to the next turn: that turn would end at once, its answer lost ([vercel/ai#23507](https://github.com/vercel/ai/issues/23507)). The conversation goes on where it stopped; starting again takes a few seconds. A harness session that cannot start again leaves the session `suspended`, resumed by the next message.
140
+
137
141
  ### Showing the conversations again
138
142
 
139
143
  ```ts
@@ -183,16 +187,16 @@ stateDiagram-v2
183
187
  busy --> interrupted: the server crashed
184
188
  ```
185
189
 
186
- | Status | What it means |
187
- | ---------------- | ------------------------------------------------------------------------------------------------------------------ |
188
- | `preparing` | Its sandbox and harness session are being opened, or brought back from `suspended`. A message sent meanwhile waits |
189
- | `idle` | Ready for a message |
190
- | `busy` | A turn is under way. Another message is refused until it ends |
191
- | `awaiting-input` | The turn paused on a tool call that waits for you: an approval, or the result of a tool your application runs |
192
- | `suspended` | Stopped, its state kept in the store and its sandbox freed. The next message, or `continue()`, brings it back |
193
- | `closed` | Ended for good. Its conversation can still be read |
194
- | `failed` | A new session that could not start: a sandbox that did not open, say. The next message tries again |
195
- | `interrupted` | Cannot be brought back: the server stopped without suspending it. Its conversation can still be read |
190
+ | Status | What it means |
191
+ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
192
+ | `preparing` | Its sandbox and harness session are being opened, or brought back from `suspended`. A message sent meanwhile waits |
193
+ | `idle` | Ready for a message |
194
+ | `busy` | A turn is under way. Another message is refused until it ends |
195
+ | `awaiting-input` | The turn paused on a tool call that waits for you: an approval, or the result of a tool your application runs |
196
+ | `suspended` | Stopped, its state kept in the store and its sandbox freed. The next message, or `continue()`, brings it back |
197
+ | `closed` | Ended for good. Its conversation can still be read |
198
+ | `failed` | A new session that could not start: a sandbox that did not open, say. The next message tries again |
199
+ | `interrupted` | Cannot be brought back: the server stopped without suspending it. Its conversation can still be read; `close()` or `delete()` frees its sandbox |
196
200
 
197
201
  A session that fails to resume stays `suspended`, with the reason in its `error`: the next message tries again from the same state.
198
202
 
@@ -226,6 +230,31 @@ const turn =
226
230
 
227
231
  A session suspended while it waited keeps what it waits for: `continue()` resumes the session, without its paused turn, whose harness bridge the suspension stopped, and tells the agent the answers in a message. With Claude Code, see [Limitations](#limitations).
228
232
 
233
+ The agent's questions wait the same way: Claude Code's `AskUserQuestion` reaches you as a call of `askUserQuestions` in `pendingInput.toolCalls`, and its answers are the tool's output, `addToolOutput()` in `useChat`, `toolResultContinuations` otherwise. A session resumed without its turn tells the agent the answers in words, the labels of the options picked.
234
+
235
+ ### Answered by a policy
236
+
237
+ The `approve` option answers the approvals before anyone sees them. A verdict is sent at once and the turn goes on, in the same stream: the client sees the request, then its answer, then what the agent did. `undefined` leaves the approval to a person. [`ai-sdk-harness-approval`](https://ai-sdk-harness.pages.dev/docs/packages/harness-approval) gives one from a policy, with the "always allow" of each session kept in its metadata:
238
+
239
+ ```ts
240
+ import { type ApprovalGrant, defineApprovalPolicy } from 'ai-sdk-harness-approval';
241
+
242
+ const policy = defineApprovalPolicy({
243
+ commands: { allow: ['ls', 'git status', 'pnpm test'], deny: ['git push', 'rm -rf'] },
244
+ });
245
+
246
+ const sessions = createSessionManager<{ grants?: ApprovalGrant[] }>({
247
+ agent: () => new HarnessAgent({ harness, ...policy.agentSettings(harness) }),
248
+ sandboxes,
249
+ approve: policy.approver({ grants: ({ session }) => session.metadata.grants }),
250
+ });
251
+
252
+ // "Always allow": kept with the session, read by the next approvals.
253
+ await sessions.update(id, { metadata: (metadata) => ({ ...metadata, grants }) });
254
+ ```
255
+
256
+ When a pause holds several approvals and `approve` answers some, the session waits for the others: `pendingInput.approvals` carries the answers given in `decision`, and `continue()` sends them with the person's, which cannot overturn them. An approval asked again right after `approve` answered it, with the same input, is left to a person: a harness that does not take the answer would otherwise ask forever.
257
+
229
258
  ## Sandboxes
230
259
 
231
260
  Where each session runs is up to `sandboxes`:
@@ -236,7 +265,7 @@ Where each session runs is up to `sandboxes`:
236
265
  | `sandboxPerSession({ create, resume, maxSessions })` | A sandbox of its own, stopped when the session is suspended, destroyed when it is closed | Sessions that must not see each other: one per user or per repository |
237
266
  | Your own `SessionSandboxes` | What `acquire(sessionId, { resume })` returns | A pool, a sandbox per user shared by their sessions… |
238
267
 
239
- `sharedSandbox()` takes sandboxes that give views of themselves, `fork({ ports })`: those of `ai-sdk-sandbox-sbx` and `ai-sdk-sandbox-cloud-run`. A suspended session hands its port back and keeps its files in the sandbox. With `stopWhenUnused`, the sandbox itself is stopped once no session holds it, and opened again with the next one: on Cloud Run, a stopped sandbox costs its snapshot's storage alone.
268
+ `sharedSandbox()` takes sandboxes that give views of themselves, `fork({ ports })`: those of `ai-sdk-sandbox-sbx`, `ai-sdk-sandbox-cloud-run` and `ai-sdk-sandbox-runtime`. A suspended session hands its port back and keeps its files in the sandbox. With `stopWhenUnused`, the sandbox itself is stopped once no session holds it, and opened again with the next one: on Cloud Run, a stopped sandbox costs its snapshot's storage alone.
240
269
 
241
270
  ```ts
242
271
  import { createSbxNetworkSandboxSession, resumeSbxNetworkSandboxSession } from 'ai-sdk-sandbox-sbx';
@@ -251,6 +280,48 @@ const sandboxes = sandboxPerSession({
251
280
  });
252
281
  ```
253
282
 
283
+ ### Opening the sandbox
284
+
285
+ The package imports no sandbox package: the `open()` of `sharedSandbox()`, and the `create()` and `resume()` of `sandboxPerSession()`, are functions you write with the sandbox package of your choice. Throughout this documentation, `openSandbox` is the function of [The manager](#the-manager): it finds the sandbox again when it exists, and creates it otherwise. To run the sessions somewhere else, change that function and nothing else:
286
+
287
+ | Sandbox package | Its functions | `sharedSandbox()` | `sandboxPerSession()` |
288
+ | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------- | --------------------- |
289
+ | [`ai-sdk-sandbox-sbx`](https://ai-sdk-harness.pages.dev/docs/packages/sandbox-sbx) | `createSbxNetworkSandboxSession()`, `resumeSbxNetworkSandboxSession()` | Yes | Yes |
290
+ | [`ai-sdk-sandbox-cloud-run`](https://ai-sdk-harness.pages.dev/docs/packages/sandbox-cloud-run) | `createCloudRunNetworkSandboxSession()`, `resumeCloudRunNetworkSandboxSession()` | Yes | Yes |
291
+ | [`ai-sdk-sandbox-runtime`](https://ai-sdk-harness.pages.dev/docs/packages/sandbox-runtime) | `createSrtNetworkSandboxSession()`, `resumeSrtNetworkSandboxSession()` | Yes | Yes |
292
+ | [`ai-sdk-sandbox-microsandbox`](https://ai-sdk-harness.pages.dev/docs/packages/sandbox-microsandbox) | `createMicrosandboxNetworkSandboxSession()`, `resumeMicrosandboxNetworkSandboxSession()` | No: it has no `fork()` | Yes |
293
+
294
+ The same `openSandbox` on Cloud Run, through the sandbox service you deployed:
295
+
296
+ ```ts
297
+ import {
298
+ CloudRunSandboxNotFoundError,
299
+ createCloudRunNetworkSandboxSession,
300
+ resumeCloudRunNetworkSandboxSession,
301
+ } from 'ai-sdk-sandbox-cloud-run';
302
+
303
+ async function openSandbox() {
304
+ const settings = {
305
+ url: process.env.CLOUD_RUN_SANDBOX_URL!,
306
+ sandboxId: 'my-app',
307
+ ports: [4000],
308
+ // The bridges install their dependencies from npm.
309
+ allowedHosts: ['registry.npmjs.org'],
310
+ };
311
+ try {
312
+ const sandbox = await resumeCloudRunNetworkSandboxSession(settings);
313
+ await sandbox.killAllProcesses();
314
+ return sandbox;
315
+ } catch (error) {
316
+ if (!(error instanceof CloudRunSandboxNotFoundError)) throw error;
317
+ }
318
+ const template = await createHarnessSandboxTemplate({ harnesses: [harness] });
319
+ return createCloudRunNetworkSandboxSession({ ...settings, template });
320
+ }
321
+ ```
322
+
323
+ The [Next.js example](https://github.com/fpasquet/ai-sdk-harness/tree/main/examples/next-chat) picks one of the four with an environment variable, `EXAMPLE_SANDBOX`: see its `lib/sandbox.ts`.
324
+
254
325
  ### How many sessions run at once
255
326
 
256
327
  Claude Code and Codex run behind a bridge: a process the harness starts in the sandbox for each session, listening on a port of the sandbox that your server reaches over a WebSocket (published on `127.0.0.1` by `sbx`, tunnelled by the Cloud Run service). The harness takes the first port of the sandbox session it is given, so two sessions handed the same sandbox would both listen on it. `sharedSandbox()` gives each session a view of the sandbox with a port of its own: the same microVM, the same installed tools, a working directory of its own, and a port of `ports` for its bridge.
@@ -298,7 +369,51 @@ const store: SessionStore<Metadata> = {
298
369
 
299
370
  A resume state holds secrets: the token of the harness bridge, the placeholders of the credentials. Keep it apart from what you show, as you would a session token. The [Next.js example](https://github.com/fpasquet/ai-sdk-harness/tree/main/examples/next-chat) keeps its sessions in JSON files (`lib/session-store.ts`).
300
371
 
301
- Keep one manager per store: at its start, a manager takes the sessions the store holds as live for those of a process that crashed, and marks them `interrupted`.
372
+ Keep one manager per store, unless the store gives leases (below): at its start, a manager takes the sessions the store holds as live for those of a process that crashed, and marks them `interrupted`.
373
+
374
+ ### Several instances
375
+
376
+ An application that runs on several instances — behind a load balancer, scaled out, or a new version deployed beside the old one — shares one store between their managers. The store tells them apart with leases: three more methods, on the same database, and the managers share it.
377
+
378
+ - Each manager holds the sessions it runs, and renews its own lease three times within `leaseTtlMs` (30 seconds by default).
379
+ - A request for a session another manager runs fails with `SessionOwnedElsewhereError`, which names it (`owner`, its `managerId`): send the request there, or answer 409 and let the client try again. Reading a session, `get()` and `list()`, works from any instance.
380
+ - A suspended session is held by nobody: the next message resumes it on whichever instance receives it.
381
+ - A manager that crashed stops renewing its lease. Once it expired, the others mark its live sessions `interrupted`, within a lease or so, and so does any manager that starts.
382
+
383
+ ```ts
384
+ const store: SessionStore<Metadata> = {
385
+ // …the five methods above, and:
386
+ claimSession: async (id, owner) => {
387
+ // One statement, so that two managers never both win: take the session unless a manager
388
+ // whose lease has not expired holds it.
389
+ const row = await db.query(
390
+ `INSERT INTO session_owners (session_id, owner) VALUES ($1, $2)
391
+ ON CONFLICT (session_id) DO UPDATE SET owner = $2
392
+ WHERE session_owners.owner = $2
393
+ OR NOT EXISTS (SELECT 1 FROM manager_leases l
394
+ WHERE l.owner = session_owners.owner AND l.expires_at > now())
395
+ RETURNING (SELECT owner FROM session_owners WHERE session_id = $1) AS previous_owner`,
396
+ [id, owner],
397
+ );
398
+ if (row) return { claimed: true, previousOwner: row.previous_owner };
399
+ const { owner: holder } = await db.query(
400
+ 'SELECT owner FROM session_owners WHERE session_id = $1',
401
+ [id],
402
+ );
403
+ return { claimed: false, owner: holder };
404
+ },
405
+ renewLease: (owner, ttlMs) =>
406
+ db.query(
407
+ `INSERT INTO manager_leases (owner, expires_at) VALUES ($1, now() + $2 * interval '1 ms')
408
+ ON CONFLICT (owner) DO UPDATE SET expires_at = excluded.expires_at`,
409
+ [owner, ttlMs],
410
+ ),
411
+ releaseSession: (id, owner) =>
412
+ db.query('DELETE FROM session_owners WHERE session_id = $1 AND owner = $2', [id, owner]),
413
+ };
414
+ ```
415
+
416
+ The lease's expiry is read on the database's clock, the one clock all instances share. `delete()` forgets who holds the session too. Give each manager a `managerId` you can route by — the instance's address, a pod name — to send a request to the instance that runs the session; never two running managers the same.
302
417
 
303
418
  ## Reference
304
419
 
@@ -308,11 +423,14 @@ Keep one manager per store: at its start, a manager takes the sessions the store
308
423
  | ----------------------- | ------------------------ | --------------------------------------------------------------------------------------------------- |
309
424
  | `agent` | required | The agent of a session, from `{ id, metadata }`: called at each start and resume |
310
425
  | `sandboxes` | required | `sharedSandbox()`, `sandboxPerSession()` or your own `SessionSandboxes` |
311
- | `store` | in memory | Where sessions are kept: a `SessionStore` |
426
+ | `store` | in memory | Where sessions are kept: a `SessionStore`, shared by several managers when it gives leases |
427
+ | `managerId` | `crypto.randomUUID()` | This manager among those sharing the store, named by `SessionOwnedElsewhereError` |
428
+ | `leaseTtlMs` | 30 seconds | How long this manager holds its sessions without renewing its lease, with a store that gives leases |
312
429
  | `idleTimeoutMs` | never | Suspend a session idle, or awaiting input, for this long |
313
430
  | `closeSuspendedAfterMs` | never | Close a session suspended for this long |
314
431
  | `turnTimeoutMs` | never | Cut a turn short after this long |
315
432
  | `suspendIdleWhenFull` | `true` | When every sandbox slot is taken, suspend the session idle the longest rather than refuse a new one |
433
+ | `approve` | none | Answers an approval before anyone sees it: `{ approved, reason }`, or `undefined` to ask a person |
316
434
  | `generateId` | `crypto.randomUUID` | The ids of new sessions and of the messages of their turns |
317
435
  | `errorMessage` | `getHarnessErrorMessage` | What a client is told of an error in a turn; the session's `error` keeps the full message |
318
436
  | `onError` | `console.error` | Called with what fails in the background: a start, a suspension, a write to the store |
@@ -322,6 +440,7 @@ Keep one manager per store: at its start, a manager takes the sessions the store
322
440
  | `create({ id, metadata })` | Opens a session, and returns it `preparing` |
323
441
  | `send(id, options)` | Starts a turn in reply to a message (`message`, `prompt`, `abortSignal`): a `SessionTurn` |
324
442
  | `continue(id, options)` | Resumes a paused turn with its answers (`message`, `toolApprovalContinuations`, `toolResultContinuations`) |
443
+ | `update(id, { metadata })` | Changes the metadata of a session, whatever its status: a value, or a function of the current one |
325
444
  | `interrupt(id)` | Cuts the turn under way short |
326
445
  | `suspend(id)` | Suspends an idle session now |
327
446
  | `close(id)` | Ends a session for good |
@@ -338,11 +457,12 @@ Each has an `isInstance()` that recognizes it whichever copy of the package thre
338
457
 
339
458
  - `SessionNotFoundError` (404): no session has this id. Carries `sessionId`.
340
459
  - `SessionConflictError` (409): the session cannot do it in its status — a message while `busy` or `awaiting-input`, `continue()` with nothing to answer, anything once `closed` or `interrupted`. Carries `sessionId` and `status`.
460
+ - `SessionOwnedElsewhereError` (409, or a redirect): another manager sharing the store runs the session. A `SessionConflictError` as well; carries `owner`, that manager's `managerId`.
341
461
  - `SessionCapacityError` (503): every sandbox slot is taken by a session that is not idle. Carries `capacity`.
342
462
 
343
463
  ## Limitations
344
464
 
345
- - **One process per store.** The live sessions are held in the memory of one process: run the manager on one instance, or give each instance its own sessions.
465
+ - **One process per store, unless it gives leases.** Without `claimSession`, `renewLease` and `releaseSession`, the live sessions are held in the memory of one process: run the manager on one instance. With them, a turn under way is still read from the instance that runs it: a client whose request reaches another one gets `SessionOwnedElsewhereError`. And a manager stalled for longer than its lease — a process frozen, not crashed — may find its sessions taken over by another one when it wakes up.
346
466
  - **A turn does not survive a crash.** A session the process held when it crashed is `interrupted`; a clean `shutdown()` suspends it instead. Streams that reconnect after a page reload are not supported yet: a reload cuts the turn its request carried when it passed `abortSignal: request.signal`.
347
467
  - **Approvals after a resume, with Claude Code.** A resumed Claude Code session (`@ai-sdk/harness-claude-code` up to 1.0.152) sends "Continue." in place of the answers to the approvals it asks afterwards: the tool call is rejected, and asked again ([vercel/ai#22549](https://github.com/vercel/ai/issues/22549)). Approvals work in a session until it is suspended; a session that resumed asks again and again. A turn that was waiting for an approval when its session was suspended resumes without it: `continue()` tells the agent the answers in a message.
348
468
  - **No approvals of Codex's own tools.** `@ai-sdk/harness-codex` (up to 1.0.150) runs Codex with approvals turned off and refuses any `permissionMode` but `'allow-all'`, though Codex itself can ask before a command or a patch ([vercel/ai#22550](https://github.com/vercel/ai/issues/22550)). Your own tools can still ask, through `toolApproval`.
@@ -350,7 +470,7 @@ Each has an `isInstance()` that recognizes it whichever copy of the package thre
350
470
 
351
471
  ## Development
352
472
 
353
- `pnpm --filter ai-sdk-harness-sessions test:e2e` runs real Claude Code sessions in a Docker Sandbox: two side by side, suspended and resumed, across a restart, and waiting for an approval; it needs `sbx` signed in and a Claude credential. See [CONTRIBUTING.md](https://github.com/fpasquet/ai-sdk-harness/blob/main/CONTRIBUTING.md).
473
+ `pnpm --filter ai-sdk-harness-sessions test:e2e` runs real Claude Code sessions in a Docker Sandbox: two side by side, suspended and resumed, across a restart, and waiting for an approval; it needs `sbx` signed in and a Claude credential. `ai-sdk-harness-approval`'s end-to-end tests cover `approve`. See [CONTRIBUTING.md](https://github.com/fpasquet/ai-sdk-harness/blob/main/CONTRIBUTING.md).
354
474
 
355
475
  ## License
356
476
 
@@ -1,7 +1,7 @@
1
1
  import type { ToolApprovalResponse, ToolResultPart, UIMessage, UIMessageChunk } from 'ai';
2
2
  import type { SessionAgent } from './agent.js';
3
3
  import type { SessionSandboxes } from './sandboxes.js';
4
- import type { SessionRecord, SessionSummary } from './session.js';
4
+ import type { ApprovalVerdict, PendingApproval, SessionRecord, SessionSummary } from './session.js';
5
5
  import type { SessionStore } from './store.js';
6
6
  export interface SessionManagerOptions<METADATA> {
7
7
  /**
@@ -15,8 +15,25 @@ export interface SessionManagerOptions<METADATA> {
15
15
  }) => Promise<SessionAgent> | SessionAgent;
16
16
  /** How sessions get a sandbox: `sharedSandbox()`, `sandboxPerSession()`, or your own. */
17
17
  sandboxes: SessionSandboxes;
18
- /** Where sessions are kept. Default: in memory, `createMemorySessionStore()`. */
18
+ /**
19
+ * Where sessions are kept. Default: in memory, `createMemorySessionStore()`. A store that gives
20
+ * leases (`claimSession`, `renewLease`, `releaseSession`) is shared by several managers: the
21
+ * instances of an application.
22
+ */
19
23
  store?: SessionStore<METADATA>;
24
+ /**
25
+ * This manager, among those sharing the store: what `SessionOwnedElsewhereError.owner` names,
26
+ * to send a request to the instance that runs the session. Default: `crypto.randomUUID()`. Give
27
+ * an id of your own — the instance's address — to route by it; never two running managers the
28
+ * same.
29
+ */
30
+ managerId?: string;
31
+ /**
32
+ * How long this manager's hold on its sessions lasts without renewal, in milliseconds, with a
33
+ * store that gives leases: renewed three times within it, and what another manager waits, after
34
+ * a crash, before taking its sessions over. Default: 30 seconds.
35
+ */
36
+ leaseTtlMs?: number;
20
37
  /**
21
38
  * Suspend a session idle, or awaiting input, for this long, in milliseconds: its harness session
22
39
  * stops, its state is kept and its sandbox freed. The next message brings it back. Default:
@@ -32,6 +49,18 @@ export interface SessionManagerOptions<METADATA> {
32
49
  * one, or a resume, rather than refusing it with `SessionCapacityError`. Default: `true`.
33
50
  */
34
51
  suspendIdleWhenFull?: boolean;
52
+ /**
53
+ * Answers the approvals a turn asks for before anyone sees them: an approval policy, the grants
54
+ * of the session. A verdict is sent at once and the turn goes on, in the same stream, the client
55
+ * seeing the answer; `undefined` leaves the approval to a person, the session `awaiting-input`.
56
+ * Default: every approval waits for a person.
57
+ *
58
+ * An approval asked again right after it was answered, with the same input, is left to a
59
+ * person: a harness that does not take the answer (see the README) would ask forever.
60
+ */
61
+ approve?: (approval: PendingApproval, context: {
62
+ session: SessionSummary<METADATA>;
63
+ }) => ApprovalVerdict | PromiseLike<ApprovalVerdict | undefined> | undefined;
35
64
  /** The ids of new sessions and of the messages of their turns. Default: `crypto.randomUUID`. */
36
65
  generateId?: () => string;
37
66
  /**
@@ -93,6 +122,8 @@ export type SessionEvent<METADATA = unknown> = {
93
122
  * message to the last, across suspensions and restarts. See {@link createSessionManager}.
94
123
  */
95
124
  export interface SessionManager<METADATA = unknown> {
125
+ /** This manager, among those sharing the store. */
126
+ readonly managerId: string;
96
127
  /**
97
128
  * Opens a session and returns at once, `preparing`: its sandbox and its harness session are
98
129
  * opened in the background — minutes the very first time, when the harness is installed in the
@@ -118,6 +149,14 @@ export interface SessionManager<METADATA = unknown> {
118
149
  * resumed first, its turn with it.
119
150
  */
120
151
  continue(id: string, options: ContinueOptions): Promise<SessionTurn<METADATA>>;
152
+ /**
153
+ * Changes what your application keeps with a session, its metadata, whatever its status: a
154
+ * title, the grants of an approval policy… Given a function, it is called with the metadata as
155
+ * they stand.
156
+ */
157
+ update(id: string, changes: {
158
+ metadata: ((metadata: METADATA) => METADATA) | METADATA;
159
+ }): Promise<SessionSummary<METADATA>>;
121
160
  /** Cuts the turn under way short, and resolves once the session is written. */
122
161
  interrupt(id: string): Promise<void>;
123
162
  /** Suspends a session idle or awaiting input, as idleness would. */
@@ -29,6 +29,12 @@ export interface SessionUsage {
29
29
  outputTokens: number;
30
30
  totalTokens: number;
31
31
  }
32
+ /** An answer to an approval request, as `ToolApprovalResponse` carries it. */
33
+ export interface ApprovalVerdict {
34
+ approved: boolean;
35
+ /** Why: what the agent is told of a denial, what a person reads of an automatic approval. */
36
+ reason?: string;
37
+ }
32
38
  /** A tool call the agent asked approval for, waiting for your answer. */
33
39
  export interface PendingApproval {
34
40
  /** What the answer refers to: `ToolApprovalResponse.approvalId`. */
@@ -36,6 +42,11 @@ export interface PendingApproval {
36
42
  toolCallId: string;
37
43
  toolName: string;
38
44
  input: unknown;
45
+ /**
46
+ * The answer the manager's `approve` option gave already, when it gave one: the turn waits for
47
+ * the answers to the other approvals, and `continue()` sends this one with them.
48
+ */
49
+ decision?: ApprovalVerdict;
39
50
  }
40
51
  /** A tool call whose result your application gives, waiting for it. */
41
52
  export interface PendingToolCall {
@@ -21,6 +21,37 @@ export interface SessionStore<METADATA = unknown> {
21
21
  list(): Promise<SessionSummary<METADATA>[]>;
22
22
  /** What a suspended session needs to resume. */
23
23
  getResumeState(id: string): Promise<HarnessAgentResumeSessionState | undefined>;
24
- /** Forgets the session and its resume state. */
24
+ /** Forgets the session and its resume state, and who holds it. */
25
25
  delete(id: string): Promise<void>;
26
+ /**
27
+ * Optional, with `renewLease` and `releaseSession`: lets several managers share the store — the
28
+ * instances of an application behind a load balancer, or a new version deployed beside the old
29
+ * one. A manager holds each session it runs, and keeps its lease alive while it runs: another
30
+ * manager leaves its sessions alone, and takes them over once its lease expired.
31
+ *
32
+ * Makes `owner` hold the session `id`, in one atomic step (a compare-and-set on a database row),
33
+ * unless another manager whose lease has not expired holds it. The session need not be saved
34
+ * yet: `create()` takes it before writing it.
35
+ */
36
+ claimSession?(id: string, owner: string): Promise<SessionClaim>;
37
+ /**
38
+ * Keeps the lease of the manager `owner` alive for `ttlMs` milliseconds from now, by the store's
39
+ * clock: what decides whether the sessions it holds may be taken over.
40
+ */
41
+ renewLease?(owner: string, ttlMs: number): Promise<void>;
42
+ /** Lets go of the session `id`, when `owner` holds it. */
43
+ releaseSession?(id: string, owner: string): Promise<void>;
26
44
  }
45
+ /** What `claimSession` made of a claim. */
46
+ export type SessionClaim = {
47
+ claimed: false;
48
+ /** The manager that holds the session, its lease alive. */
49
+ owner: string;
50
+ } | {
51
+ claimed: true;
52
+ /**
53
+ * Who held the session before: `null` when nobody did, the claiming manager itself when it
54
+ * held it already, or a manager whose lease expired.
55
+ */
56
+ previousOwner: null | string;
57
+ };
@@ -0,0 +1,19 @@
1
+ import type { SessionStatus } from '../definitions/session.js';
2
+ import { SessionConflictError } from './session-conflict-error.js';
3
+ /** What `isInstance` looks for, shared by every copy of this package. */
4
+ declare const MARKER: unique symbol;
5
+ /**
6
+ * Another manager sharing the store runs the session: send the request to it — the instance
7
+ * `owner` names —, or answer 409 and let the client try again. A `SessionConflictError` as well.
8
+ */
9
+ export declare class SessionOwnedElsewhereError extends SessionConflictError {
10
+ /** The manager that runs the session: its `managerId`. */
11
+ readonly owner: string;
12
+ private readonly [MARKER];
13
+ constructor(sessionId: string, status: SessionStatus,
14
+ /** The manager that runs the session: its `managerId`. */
15
+ owner: string);
16
+ /** Whether `error` is one, whichever copy of the package threw it. */
17
+ static isInstance(error: unknown): error is SessionOwnedElsewhereError;
18
+ }
19
+ export {};
@@ -0,0 +1,22 @@
1
+ import { SessionConflictError } from './session-conflict-error.js';
2
+ /** What `isInstance` looks for, shared by every copy of this package. */
3
+ const MARKER = Symbol.for('ai-sdk-harness-sessions.SessionOwnedElsewhereError');
4
+ /**
5
+ * Another manager sharing the store runs the session: send the request to it — the instance
6
+ * `owner` names —, or answer 409 and let the client try again. A `SessionConflictError` as well.
7
+ */
8
+ export class SessionOwnedElsewhereError extends SessionConflictError {
9
+ owner;
10
+ [MARKER] = true;
11
+ constructor(sessionId, status,
12
+ /** The manager that runs the session: its `managerId`. */
13
+ owner) {
14
+ super(sessionId, status, `The session ${sessionId} is run by another manager, ${owner}.`);
15
+ this.owner = owner;
16
+ this.name = 'SessionOwnedElsewhereError';
17
+ }
18
+ /** Whether `error` is one, whichever copy of the package threw it. */
19
+ static isInstance(error) {
20
+ return typeof error === 'object' && error !== null && MARKER in error;
21
+ }
22
+ }
package/dist/index.d.ts CHANGED
@@ -3,10 +3,11 @@ export type { ContinueOptions, SendOptions, SessionEvent, SessionManager, Sessio
3
3
  export type { SessionSandboxes, SessionSandboxLease } from './definitions/sandboxes.js';
4
4
  export { LIVE_STATUSES, SESSION_STATUSES } from './definitions/session.js';
5
5
  export type { PendingApproval, PendingInput, PendingToolCall, SessionRecord, SessionStatus, SessionSummary, SessionUsage, } from './definitions/session.js';
6
- export type { SessionStore } from './definitions/store.js';
6
+ export type { SessionClaim, SessionStore } from './definitions/store.js';
7
7
  export { SessionCapacityError } from './errors/session-capacity-error.js';
8
8
  export { SessionConflictError } from './errors/session-conflict-error.js';
9
9
  export { SessionNotFoundError } from './errors/session-not-found-error.js';
10
+ export { SessionOwnedElsewhereError } from './errors/session-owned-elsewhere-error.js';
10
11
  export { createSessionManager } from './manager/session-manager.js';
11
12
  export { sandboxPerSession } from './sandboxes/sandbox-per-session.js';
12
13
  export type { SandboxPerSessionOptions } from './sandboxes/sandbox-per-session.js';
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export { LIVE_STATUSES, SESSION_STATUSES } from './definitions/session.js';
2
2
  export { SessionCapacityError } from './errors/session-capacity-error.js';
3
3
  export { SessionConflictError } from './errors/session-conflict-error.js';
4
4
  export { SessionNotFoundError } from './errors/session-not-found-error.js';
5
+ export { SessionOwnedElsewhereError } from './errors/session-owned-elsewhere-error.js';
5
6
  export { createSessionManager } from './manager/session-manager.js';
6
7
  export { sandboxPerSession } from './sandboxes/sandbox-per-session.js';
7
8
  export { sharedSandbox } from './sandboxes/shared-sandbox.js';
@@ -2,7 +2,7 @@ import type { HarnessAgentSession } from '@ai-sdk/harness/agent';
2
2
  import type { UIMessage } from 'ai';
3
3
  import type { SessionAgent } from '../definitions/agent.js';
4
4
  import type { SessionSandboxLease } from '../definitions/sandboxes.js';
5
- import type { SessionRecord, SessionStatus, SessionSummary } from '../definitions/session.js';
5
+ import type { PendingInput, SessionRecord, SessionStatus, SessionSummary } from '../definitions/session.js';
6
6
  /** A session held in memory, with its harness session and its sandbox once it started. */
7
7
  export interface LiveSession<METADATA> {
8
8
  record: SessionRecord<METADATA>;
@@ -18,10 +18,17 @@ export interface LiveSession<METADATA> {
18
18
  * agent the answers in a message.
19
19
  */
20
20
  lostTurn?: boolean;
21
+ /**
22
+ * What the turn paused on, when the manager's `approve` answered part of it: the approvals with
23
+ * the verdicts it gave, for the record.
24
+ */
25
+ paused?: PendingInput;
21
26
  turn?: {
22
27
  abort: AbortController;
23
28
  done: Promise<void>;
24
29
  };
30
+ /** Settles once a close, or a shutdown, that took the session over has written it. */
31
+ ending?: Promise<void>;
25
32
  idleTimer?: NodeJS.Timeout;
26
33
  }
27
34
  /** A live session that started: its agent and harness session are there. */
@@ -0,0 +1,38 @@
1
+ import type { SessionClaim, SessionStore } from '../definitions/store.js';
2
+ type Report = (action: string, sessionId?: string) => (error: unknown) => void;
3
+ /** The methods of a store that shares its sessions between managers. */
4
+ type LeasingStore = Pick<SessionStore<unknown>, 'claimSession' | 'releaseSession' | 'renewLease'>;
5
+ export interface SessionLeasesOptions {
6
+ store: LeasingStore;
7
+ /** This manager. */
8
+ owner: string;
9
+ ttlMs: number;
10
+ report: Report;
11
+ }
12
+ /**
13
+ * The sessions a manager holds in a store it shares with other managers, and its lease that
14
+ * keeps them: renewed in the background, checked by the store when another manager claims one. A
15
+ * store without leases has one manager: every claim succeeds, and nothing is renewed.
16
+ */
17
+ export declare class SessionLeases {
18
+ readonly owner: string;
19
+ /** Whether the store shares its sessions between managers. */
20
+ readonly shared: boolean;
21
+ private readonly store;
22
+ private readonly ttlMs;
23
+ private readonly report;
24
+ private renewal;
25
+ constructor({ store, owner, ttlMs, report }: SessionLeasesOptions);
26
+ /**
27
+ * Takes the lease, then keeps it alive until `stop()`; `onLeaseRenewed` runs once per lease,
28
+ * after a renewal.
29
+ */
30
+ start(onLeaseRenewed: () => void): Promise<void>;
31
+ stop(): void;
32
+ /** Makes this manager hold the session, unless another one does. */
33
+ claim(id: string): Promise<SessionClaim>;
34
+ /** Lets go of the session; a failure is reported, the lease expiring lets go of it anyway. */
35
+ release(id: string): Promise<void>;
36
+ private renew;
37
+ }
38
+ export {};
@@ -0,0 +1,70 @@
1
+ /** How often a manager renews its lease: three times within its life. */
2
+ const RENEWALS_PER_LEASE = 3;
3
+ /** Whether the store gives its lease methods: all three, or none. */
4
+ function sharesSessions(store) {
5
+ const given = [
6
+ typeof store.claimSession,
7
+ typeof store.renewLease,
8
+ typeof store.releaseSession,
9
+ ].filter((type) => type === 'function').length;
10
+ if (given !== 0 && given !== 3) {
11
+ throw new TypeError('A session store shares its sessions with claimSession, renewLease and releaseSession, all three.');
12
+ }
13
+ return given === 3;
14
+ }
15
+ /**
16
+ * The sessions a manager holds in a store it shares with other managers, and its lease that
17
+ * keeps them: renewed in the background, checked by the store when another manager claims one. A
18
+ * store without leases has one manager: every claim succeeds, and nothing is renewed.
19
+ */
20
+ export class SessionLeases {
21
+ owner;
22
+ /** Whether the store shares its sessions between managers. */
23
+ shared;
24
+ store;
25
+ ttlMs;
26
+ report;
27
+ renewal;
28
+ constructor({ store, owner, ttlMs, report }) {
29
+ this.shared = sharesSessions(store);
30
+ this.store = store;
31
+ this.owner = owner;
32
+ this.ttlMs = ttlMs;
33
+ this.report = report;
34
+ }
35
+ /**
36
+ * Takes the lease, then keeps it alive until `stop()`; `onLeaseRenewed` runs once per lease,
37
+ * after a renewal.
38
+ */
39
+ async start(onLeaseRenewed) {
40
+ if (!this.shared)
41
+ return;
42
+ await this.renew();
43
+ let renewals = 0;
44
+ this.renewal = setInterval(() => {
45
+ void this.renew().then(() => {
46
+ renewals += 1;
47
+ if (renewals % RENEWALS_PER_LEASE === 0)
48
+ onLeaseRenewed();
49
+ }, this.report('lease'));
50
+ }, this.ttlMs / RENEWALS_PER_LEASE);
51
+ this.renewal.unref();
52
+ }
53
+ stop() {
54
+ clearInterval(this.renewal);
55
+ }
56
+ /** Makes this manager hold the session, unless another one does. */
57
+ claim(id) {
58
+ const claimed = this.shared ? this.store.claimSession?.(id, this.owner) : undefined;
59
+ return claimed ?? Promise.resolve({ claimed: true, previousOwner: this.owner });
60
+ }
61
+ /** Lets go of the session; a failure is reported, the lease expiring lets go of it anyway. */
62
+ async release(id) {
63
+ if (!this.shared)
64
+ return;
65
+ await this.store.releaseSession?.(id, this.owner)?.catch(this.report('release', id));
66
+ }
67
+ async renew() {
68
+ await this.store.renewLease?.(this.owner, this.ttlMs);
69
+ }
70
+ }