experimental-a2 0.18.1 → 0.20.0

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.
Files changed (143) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/actor.js +1 -1
  3. package/dist/{ai-BMLlI2Ff.d.ts → ai-Cz3hqVhk.d.ts} +33 -16
  4. package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
  5. package/dist/ai-client.d.ts +1 -1
  6. package/dist/ai-client.js +1 -1
  7. package/dist/ai-control-CvLbW10B.js +46 -0
  8. package/dist/ai-control-CvLbW10B.js.map +1 -0
  9. package/dist/ai-fx.d.ts +1 -1
  10. package/dist/ai-fx.d.ts.map +1 -1
  11. package/dist/ai-fx.js +63 -13
  12. package/dist/ai-fx.js.map +1 -1
  13. package/dist/{ai-harness-B2suP2Qh.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
  14. package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
  15. package/dist/{ai-harness-BuLh_EYi.js → ai-harness-Clbg5ei7.js} +88 -15
  16. package/dist/ai-harness-Clbg5ei7.js.map +1 -0
  17. package/dist/{ai-D6mGlO8j.js → ai-mMiWul1T.js} +116 -77
  18. package/dist/ai-mMiWul1T.js.map +1 -0
  19. package/dist/ai-pi.d.ts +1 -1
  20. package/dist/ai-pi.d.ts.map +1 -1
  21. package/dist/ai-pi.js +56 -11
  22. package/dist/ai-pi.js.map +1 -1
  23. package/dist/ai-react.d.ts +1 -1
  24. package/dist/ai-react.js +1 -1
  25. package/dist/ai-server.d.ts +2 -2
  26. package/dist/ai-server.d.ts.map +1 -1
  27. package/dist/ai-server.js +123 -53
  28. package/dist/ai-server.js.map +1 -1
  29. package/dist/ai.d.ts +2 -2
  30. package/dist/ai.js +1 -1
  31. package/dist/internal-CscSY7K_.js.map +1 -1
  32. package/dist/scheduler-qstash.d.ts.map +1 -1
  33. package/dist/scheduler-qstash.js +5 -5
  34. package/dist/scheduler-qstash.js.map +1 -1
  35. package/dist/scheduler-vercel.d.ts.map +1 -1
  36. package/dist/scheduler-vercel.js +5 -7
  37. package/dist/scheduler-vercel.js.map +1 -1
  38. package/dist/server-DYAATFuT.d.ts.map +1 -1
  39. package/dist/{server-D-Y4pqwu.js → server-qyb5aJJG.js} +39 -25
  40. package/dist/server-qyb5aJJG.js.map +1 -0
  41. package/dist/server.js +1 -1
  42. package/dist/session-clear-DGI4jTQ5.js.map +1 -1
  43. package/dist/store-postgres.d.ts.map +1 -1
  44. package/dist/store-postgres.js +38 -136
  45. package/dist/store-postgres.js.map +1 -1
  46. package/docs/01-quickstart.mdx +7 -4
  47. package/docs/actors/01-introduction.mdx +1 -1
  48. package/docs/actors/meta.ts +1 -1
  49. package/docs/agents/02-tools.mdx +1 -1
  50. package/docs/agents/03-conversations.mdx +26 -14
  51. package/docs/agents/04-harnesses.mdx +51 -5
  52. package/docs/agents/meta.ts +1 -1
  53. package/docs/client/meta.ts +1 -1
  54. package/docs/contract/03-sessions.mdx +2 -2
  55. package/docs/index.mdx +2 -2
  56. package/docs/reference/01-core.mdx +8 -8
  57. package/docs/reference/02-server.mdx +3 -3
  58. package/docs/reference/04-ai.mdx +36 -19
  59. package/docs/reference/05-telemetry.mdx +132 -0
  60. package/docs/reference/06-errors.mdx +1 -1
  61. package/docs/reference/meta.ts +1 -1
  62. package/docs/schedulers/01-overview.mdx +112 -0
  63. package/docs/schedulers/02-vercel-queues.mdx +87 -0
  64. package/docs/schedulers/03-qstash.mdx +123 -0
  65. package/docs/schedulers/meta.ts +1 -0
  66. package/docs/server/01-handlers.mdx +29 -2
  67. package/docs/server/02-timers.mdx +3 -3
  68. package/docs/server/06-guarantees.mdx +6 -3
  69. package/docs/stores/01-overview.mdx +37 -0
  70. package/docs/stores/02-redis.mdx +49 -0
  71. package/docs/stores/03-redis-http.mdx +56 -0
  72. package/docs/stores/04-postgres.mdx +121 -0
  73. package/docs/stores/05-sqlite.mdx +34 -0
  74. package/docs/stores/06-memory.mdx +22 -0
  75. package/docs/stores/meta.ts +1 -0
  76. package/examples/playground/DEPLOY.md +19 -5
  77. package/examples/playground/app/a0/a0.css +244 -5
  78. package/examples/playground/app/a0/app-view.tsx +2 -1
  79. package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
  80. package/examples/playground/app/a0/attachments.ts +133 -0
  81. package/examples/playground/app/a0/chat.test.tsx +595 -0
  82. package/examples/playground/app/a0/chat.tsx +623 -63
  83. package/examples/playground/app/a0/context-menu.tsx +38 -11
  84. package/examples/playground/app/a0/model.test.ts +28 -0
  85. package/examples/playground/app/a0/model.ts +30 -0
  86. package/examples/playground/app/a0/server.ts +5 -0
  87. package/examples/playground/app/a0/shell.tsx +1 -1
  88. package/examples/playground/app/a0/uploads/route.test.ts +59 -0
  89. package/examples/playground/app/a0/uploads/route.ts +29 -0
  90. package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
  91. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -89
  92. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
  93. package/examples/playground/app/agent/ingress.test.ts +41 -17
  94. package/examples/playground/app/agent/model-input.test.tsx +92 -43
  95. package/examples/playground/app/agent/model.test.ts +12 -3
  96. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
  97. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
  98. package/examples/playground/app/chat/server.test.ts +38 -65
  99. package/examples/playground/app/components/activity-history.test.tsx +60 -18
  100. package/examples/playground/app/components/fast-link.test.tsx +3 -5
  101. package/examples/playground/app/initial-state.test.tsx +86 -45
  102. package/examples/playground/next.config.ts +7 -4
  103. package/examples/playground/package.json +4 -1
  104. package/examples/playground/test/agent.ts +35 -0
  105. package/examples/playground/test/model.ts +149 -0
  106. package/examples/playground/test/serve.ts +184 -0
  107. package/examples/playground/test/server-only.ts +1 -0
  108. package/examples/playground/vitest.config.ts +27 -0
  109. package/package.json +3 -1
  110. package/sql/postgres/notifications.sql +24 -0
  111. package/sql/postgres/schema.sql +137 -0
  112. package/src/ai-agent.ts +9 -4
  113. package/src/ai-client-state.ts +21 -9
  114. package/src/ai-context-schema.ts +28 -4
  115. package/src/ai-context.ts +83 -3
  116. package/src/ai-control-server.ts +70 -43
  117. package/src/ai-control-state.ts +3 -4
  118. package/src/ai-control.ts +59 -10
  119. package/src/ai-coordinator.ts +7 -6
  120. package/src/ai-fx.ts +95 -23
  121. package/src/ai-harness.ts +171 -29
  122. package/src/ai-message-shape.ts +41 -0
  123. package/src/ai-pi.ts +88 -32
  124. package/src/ai-stored-state.ts +1 -3
  125. package/src/ai.ts +116 -77
  126. package/src/errors.ts +1 -1
  127. package/src/internal.ts +2 -1
  128. package/src/libfx.d.ts +8 -1
  129. package/src/postgres-notifications.ts +0 -22
  130. package/src/postgres-resources.ts +6 -9
  131. package/src/postgres-schema.ts +80 -0
  132. package/src/scheduler-qstash.ts +12 -5
  133. package/src/scheduler-vercel.ts +11 -16
  134. package/src/server.ts +66 -32
  135. package/src/store-postgres.ts +5 -123
  136. package/dist/ai-BMLlI2Ff.d.ts.map +0 -1
  137. package/dist/ai-D6mGlO8j.js.map +0 -1
  138. package/dist/ai-harness-B2suP2Qh.d.ts.map +0 -1
  139. package/dist/ai-harness-BuLh_EYi.js.map +0 -1
  140. package/dist/server-D-Y4pqwu.js.map +0 -1
  141. package/docs/reference/05-adapters.mdx +0 -351
  142. package/docs/server/05-production.mdx +0 -323
  143. package/examples/playground/app/a0/no-photon.cjs +0 -4
@@ -0,0 +1,121 @@
1
+ ---
2
+ title: Postgres
3
+ description: Any Postgres database. You create A2's tables; A2 never changes your schema.
4
+ ---
5
+
6
+ ```ts server/orders.ts
7
+ import { createServer } from 'experimental-a2/server'
8
+ import { postgres } from 'experimental-a2/store-postgres'
9
+ import { orders } from '@/contracts'
10
+
11
+ export const ordersServer = createServer({
12
+ contract: orders,
13
+ store: postgres({ connectionString: process.env.DATABASE_URL }),
14
+ handlers: {
15
+ /* ... */
16
+ },
17
+ })
18
+ ```
19
+
20
+ ```package-install
21
+ npm i pg
22
+ ```
23
+
24
+ Works with Neon, Supabase, RDS, or your own server, including transaction-mode
25
+ poolers.
26
+
27
+ ## Create the tables
28
+
29
+ A2 doesn't create tables in your database. This is the SQL it needs:
30
+ [`schema.sql`](https://github.com/vercel-labs/a2/blob/main/packages/a2/sql/postgres/schema.sql),
31
+ also in the package at `node_modules/experimental-a2/sql/postgres/schema.sql`.
32
+ Apply it once before your first deploy, however you run SQL against your
33
+ database, as the role your app connects with.
34
+
35
+ ## Upgrade
36
+
37
+ When a release changes the schema, its changelog says so. Apply that
38
+ release's `schema.sql` before you deploy it. The version you're running keeps
39
+ working against the new schema.
40
+
41
+ If the tables are missing or out of date, the store's first query throws
42
+ `STORE_NOT_CONFIGURED` naming the file to apply. Once it's applied, the next
43
+ request works, with no redeploy. The store checks with one read before its
44
+ first query, which never waits on or blocks writes.
45
+
46
+ ## Live updates (polling)
47
+
48
+ By default, live streams poll every 25ms while a session is busy, backing off
49
+ to every two seconds when it's quiet, so an update from another client can take
50
+ up to two seconds, plus query latency, to reach a quiet stream. A client's own
51
+ `push()` refreshes its stream immediately, even when the write and the stream
52
+ run in different invocations.
53
+
54
+ ## Live updates (notifications)
55
+
56
+ For instant updates, pass `listenConnectionString`, a direct connection to the
57
+ same database (not through a pooler), and apply
58
+ [`notifications.sql`](https://github.com/vercel-labs/a2/blob/main/packages/a2/sql/postgres/notifications.sql)
59
+ once, the same way as `schema.sql`.
60
+
61
+ Its triggers notify listeners of every write, including writes from stores
62
+ without the option. Notifications arrive after commit, a reconnect catches up
63
+ from the cursor, and subscriptions also reconcile every ten seconds. Every
64
+ serverless instance opens its own direct connection, so leave connection
65
+ headroom.
66
+
67
+ ## Use postgres.js
68
+
69
+ `connectionString` creates a `pg` pool. To use
70
+ [postgres.js](https://github.com/porsager/postgres) instead, wrap it as
71
+ `client`:
72
+
73
+ ```ts server/store.ts
74
+ import postgresJs from 'postgres'
75
+ import { postgres } from 'experimental-a2/store-postgres'
76
+
77
+ const url = process.env.DATABASE_URL
78
+ if (!url) throw new Error('set DATABASE_URL')
79
+
80
+ const sql = postgresJs(url, {
81
+ // A2 sends JSON as text; this keeps postgres.js from encoding it again.
82
+ types: {
83
+ json: {
84
+ to: 114,
85
+ from: [114, 3802],
86
+ serialize: (x: unknown) => (typeof x === 'string' ? x : JSON.stringify(x)),
87
+ parse: (x: string) => JSON.parse(x),
88
+ },
89
+ },
90
+ })
91
+
92
+ export const store = postgres({
93
+ client: {
94
+ query: async (text, values = []) => ({
95
+ rows: [...(await sql.unsafe(text, values))],
96
+ }),
97
+ connect: async () => {
98
+ const connection = await sql.reserve()
99
+ return {
100
+ query: async (text, values = []) => ({
101
+ rows: [...(await connection.unsafe(text, values))],
102
+ }),
103
+ release: () => connection.release(),
104
+ }
105
+ },
106
+ end: () => sql.end(),
107
+ },
108
+ })
109
+ ```
110
+
111
+ `listenConnectionString` still opens its listener with `pg`, so live
112
+ notifications need `pg` installed too.
113
+
114
+ ## Options
115
+
116
+ | Option | |
117
+ | --- | --- |
118
+ | `connectionString` | creates a `pg` pool, shared by stores with the same settings |
119
+ | `client` | your own pool instead: a `pg` `Pool`, PGlite, or a pooler wrapper. Its lifecycle is yours |
120
+ | `listenConnectionString` | a direct connection for notifications |
121
+ | `clock`, `ids` | test seams for timestamps and generated event ids |
@@ -0,0 +1,34 @@
1
+ ---
2
+ title: SQLite
3
+ description: The development default, in a file at `.a2/dev.db`.
4
+ ---
5
+
6
+ ```ts server/orders.ts
7
+ import { createServer } from 'experimental-a2/server'
8
+ import { sqlite } from 'experimental-a2/store-sqlite'
9
+ import { orders } from '@/contracts'
10
+
11
+ export const ordersServer = createServer({
12
+ contract: orders,
13
+ store: sqlite({ path: '.a2/orders.db' }),
14
+ handlers: {
15
+ /* ... */
16
+ },
17
+ })
18
+ ```
19
+
20
+ In development, a server without a `store` uses SQLite at `.a2/dev.db`, so
21
+ your sessions survive `next dev` restarts. Pass it yourself to pick another
22
+ file.
23
+
24
+ It runs on `node:sqlite`, with no dependencies, and creates its tables when it
25
+ opens the file. It's for development: a serverless filesystem doesn't last or
26
+ share one file across instances, so production needs another
27
+ [store](/stores/overview).
28
+
29
+ ## Options
30
+
31
+ | Option | |
32
+ | --- | --- |
33
+ | `path` | the database file. Default `.a2/dev.db`; `:memory:` works |
34
+ | `clock`, `ids` | test seams for timestamps and generated event ids |
@@ -0,0 +1,22 @@
1
+ ---
2
+ title: Memory
3
+ description: An in-process store for tests, the default when `NODE_ENV=test`.
4
+ ---
5
+
6
+ ```ts
7
+ // in a test:
8
+ import { createServer } from 'experimental-a2/server'
9
+ import { memory } from 'experimental-a2/store-memory'
10
+ import { orders } from '@/contracts'
11
+
12
+ const ordersServer = createServer({ contract: orders, store: memory() })
13
+ ```
14
+
15
+ Sessions live in the process and vanish when it exits. Under `NODE_ENV=test`, a
16
+ server without a `store` already uses memory.
17
+
18
+ ## Options
19
+
20
+ | Option | |
21
+ | --- | --- |
22
+ | `clock`, `ids` | test seams for timestamps and generated event ids |
@@ -0,0 +1 @@
1
+ export default { order: 4 }
@@ -2,8 +2,9 @@
2
2
 
3
3
  The event log runs with zero setup in development (SQLite at `.a2/dev.db`,
4
4
  no scheduler). The Agent demo and A0 also need AI Gateway and Vercel Sandbox
5
- credentials. Production needs the deliberate pieces from
6
- `node_modules/experimental-a2/docs/server/05-production.mdx`.
5
+ credentials. Production needs a store
6
+ (`node_modules/experimental-a2/docs/stores/01-overview.mdx`) and a scheduler
7
+ (`node_modules/experimental-a2/docs/schedulers/01-overview.mdx`).
7
8
 
8
9
  ## 1. A store
9
10
 
@@ -23,7 +24,12 @@ Redis, poll cadence elsewhere. Upstash is durable by default; use its TCP
23
24
  `rediss://` URL, not the REST one. Each feature creates its own server with
24
25
  the shared store; storage remains namespaced per contract. Without either variable, a
25
26
  production boot fails fast with `STORE_NOT_CONFIGURED` (by design).
26
- Schemas/keys are created lazily on first use; no migration step.
27
+ Redis needs no setup. With Postgres, create A2's tables once before the
28
+ first deploy (A2 never creates them at runtime):
29
+
30
+ ```sh
31
+ psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f node_modules/experimental-a2/sql/postgres/schema.sql
32
+ ```
27
33
 
28
34
  To verify a provider before leaning on it, run the full conformance
29
35
  kit against it (throwaway database; the suite isolates by key prefix
@@ -105,8 +111,8 @@ A sandbox stays up while it's used: each tool call keeps it running at least
105
111
  10 more minutes, and so does the preview while it's on screen. Otherwise it
106
112
  stops, and resumes with its files the next time it's needed. A new or resumed
107
113
  sandbox starts `next dev` in the background, so commands don't wait for it;
108
- the preview does. Deleting an app stops its turn and deletes its sandbox; its
109
- conversation stays in the log. An app nobody uses for 14 days loses its files.
114
+ the preview does. Deleting an app clears its conversation from the log and
115
+ deletes its sandbox. An app nobody uses for 14 days loses its files.
110
116
 
111
117
  App ids are the only access control: anyone with an A0 URL can open that app,
112
118
  and anyone who can reach the deployment can start new apps, each with its own
@@ -116,6 +122,14 @@ of a public deployment. A0's sandboxes are named `a2-a0-*`, which is where to
116
122
  look when cleaning up. The sidebar lists the apps this browser opened, from a
117
123
  cookie.
118
124
 
125
+ Files attached in A0's chat upload from the browser straight to a public
126
+ Vercel Blob store, through a token from `/a0/uploads`. Connect a Blob store to
127
+ the project, which sets `BLOB_READ_WRITE_TOKEN`; `vercel env pull` brings it
128
+ to local runs. Images go to the model with the message; other files go into
129
+ its text as links, which the agent downloads in its sandbox. Each lives at an
130
+ unguessable public URL under `a0/<app id>/`, which anyone with the URL can
131
+ read. Nothing deletes them, not even deleting their app.
132
+
119
133
  A0's dev tools have a Crash button, which ends the function instance running
120
134
  an app's agent, mid-turn, so the scheduler resumes the turn in a fresh one.
121
135
  Crashing ends everything else that instance is serving, and under `next dev`
@@ -345,10 +345,140 @@ body:has(.a0-splitter[data-dragging]) iframe {
345
345
  .a0-text[data-role='user'] {
346
346
  align-self: flex-end;
347
347
  padding: 0.5rem 0.75rem;
348
+ overflow-wrap: anywhere;
348
349
  background: var(--shade);
349
350
  color: var(--ink);
350
351
  }
351
352
 
353
+ /* ── attachments ─────────────────────────────────────────────────── */
354
+
355
+ .a0-chat-drop {
356
+ display: contents;
357
+ }
358
+
359
+ /* Over the whole chat column, which positions it. */
360
+ .a0-drop {
361
+ position: absolute;
362
+ z-index: 2;
363
+ inset: 0;
364
+ display: flex;
365
+ align-items: center;
366
+ justify-content: center;
367
+ gap: 0.5rem;
368
+ background: color-mix(in oklab, var(--bg) 85%, transparent);
369
+ outline: 2px dashed var(--muted);
370
+ outline-offset: -0.75rem;
371
+ color: var(--bright);
372
+ pointer-events: none;
373
+ }
374
+
375
+ .a0-files {
376
+ display: flex;
377
+ flex-wrap: wrap;
378
+ gap: 0.375rem;
379
+ max-width: 85%;
380
+ }
381
+
382
+ .a0-files[data-role='user'] {
383
+ align-self: flex-end;
384
+ justify-content: flex-end;
385
+ }
386
+
387
+ .a0-files[data-role='assistant'] {
388
+ align-self: flex-start;
389
+ }
390
+
391
+ .a0-files img {
392
+ display: block;
393
+ max-width: 12rem;
394
+ max-height: 9rem;
395
+ border: 1px solid var(--line);
396
+ object-fit: cover;
397
+ }
398
+
399
+ .a0-file {
400
+ display: inline-flex;
401
+ align-items: center;
402
+ gap: 0.25rem;
403
+ color: inherit;
404
+ text-decoration: underline dotted var(--faint);
405
+ text-underline-offset: 3px;
406
+ vertical-align: bottom;
407
+ }
408
+
409
+ .a0-files .a0-file {
410
+ padding: 0.25rem 0.5rem;
411
+ border: 1px dashed var(--line);
412
+ text-decoration: none;
413
+ }
414
+
415
+ .a0-attachments {
416
+ display: flex;
417
+ flex-wrap: wrap;
418
+ gap: 0.375rem;
419
+ margin: 0;
420
+ padding: 0.625rem 0.75rem 0;
421
+ list-style: none;
422
+ font-size: 0.75rem;
423
+ }
424
+
425
+ .a0-attachments li {
426
+ position: relative;
427
+ display: inline-flex;
428
+ align-items: center;
429
+ gap: 0.25rem;
430
+ min-width: 0;
431
+ max-width: 100%;
432
+ color: var(--muted);
433
+ }
434
+
435
+ .a0-attachments li[data-kind='link'] {
436
+ height: 1.75rem;
437
+ padding-left: 0.5rem;
438
+ border: 1px dashed var(--line);
439
+ }
440
+
441
+ .a0-attachments li[data-kind='link'] span {
442
+ overflow: hidden;
443
+ text-overflow: ellipsis;
444
+ white-space: nowrap;
445
+ }
446
+
447
+ .a0-attachments img {
448
+ display: block;
449
+ width: 3rem;
450
+ height: 3rem;
451
+ border: 1px solid var(--line);
452
+ object-fit: cover;
453
+ }
454
+
455
+ .a0-attachments li[data-kind='image'][data-uploading] img {
456
+ opacity: 0.5;
457
+ }
458
+
459
+ /* On the thumbnail's corners: the spinner, and the button that removes it. */
460
+ .a0-attachments li[data-kind='image'] .a0-spin {
461
+ position: absolute;
462
+ top: calc(50% - 6px);
463
+ left: calc(1.5rem - 6px);
464
+ }
465
+
466
+ .a0-attachments li[data-kind='image'] .a0-icon-button {
467
+ position: absolute;
468
+ top: -0.375rem;
469
+ right: -0.375rem;
470
+ width: 1.125rem;
471
+ height: 1.125rem;
472
+ border: 1px solid var(--line);
473
+ border-radius: 50%;
474
+ background: var(--bg);
475
+ }
476
+
477
+ .a0-attachments li[data-kind='link'] .a0-icon-button {
478
+ width: 1.5rem;
479
+ height: 1.5rem;
480
+ }
481
+
352
482
  .a0-text[data-role='assistant'] {
353
483
  align-self: flex-start;
354
484
  color: var(--muted);
@@ -428,6 +558,86 @@ body:has(.a0-splitter[data-dragging]) iframe {
428
558
  white-space: nowrap;
429
559
  }
430
560
 
561
+ /* A tool step that shows what the call writes, folded once it's done. */
562
+ .a0-tool {
563
+ align-self: stretch;
564
+ min-width: 0;
565
+ }
566
+
567
+ .a0-tool summary {
568
+ width: fit-content;
569
+ cursor: pointer;
570
+ list-style: none;
571
+ }
572
+
573
+ .a0-tool summary::-webkit-details-marker {
574
+ display: none;
575
+ }
576
+
577
+ .a0-tool summary:hover .a0-step-label {
578
+ color: var(--ink);
579
+ }
580
+
581
+ .a0-tool[data-failed] summary,
582
+ .a0-tool[data-failed] .a0-step-label {
583
+ color: var(--danger);
584
+ }
585
+
586
+ .a0-tool-text {
587
+ max-height: 14rem;
588
+ margin: 0.375rem 0 0 1.125rem;
589
+ padding: 0.5rem 0.625rem;
590
+ overflow: auto;
591
+ background: color-mix(in oklab, var(--shade) 60%, transparent);
592
+ color: var(--muted);
593
+ font-size: 0.6875rem;
594
+ line-height: 1.5;
595
+ overflow-wrap: anywhere;
596
+ scrollbar-color: var(--faint) transparent;
597
+ scrollbar-width: thin;
598
+ white-space: pre-wrap;
599
+ }
600
+
601
+ /* Messages waiting for the turn to end, just above the composer. */
602
+ .a0-queue {
603
+ display: flex;
604
+ flex-shrink: 0;
605
+ flex-direction: column;
606
+ gap: 0.25rem;
607
+ margin: 0 0.75rem;
608
+ padding: 0;
609
+ list-style: none;
610
+ font-size: 0.75rem;
611
+ }
612
+
613
+ .a0-queue li {
614
+ display: flex;
615
+ align-items: center;
616
+ gap: 0.375rem;
617
+ padding: 0.25rem 0.25rem 0.25rem 0.625rem;
618
+ border: 1px dashed var(--line);
619
+ color: var(--muted);
620
+ }
621
+
622
+ .a0-queue span {
623
+ flex: 1;
624
+ min-width: 0;
625
+ overflow: hidden;
626
+ text-overflow: ellipsis;
627
+ white-space: nowrap;
628
+ }
629
+
630
+ .a0-queue button {
631
+ height: 1.375rem;
632
+ padding: 0 0.375rem;
633
+ }
634
+
635
+ .a0-queue .a0-icon-button {
636
+ width: 1.375rem;
637
+ padding: 0;
638
+ color: var(--faint);
639
+ }
640
+
431
641
  .a0-send-error {
432
642
  margin: 0;
433
643
  padding: 0 1.25rem 0.5rem;
@@ -533,16 +743,45 @@ body:has(.a0-splitter[data-dragging]) iframe {
533
743
  gap: 0.5rem;
534
744
  }
535
745
 
536
- .a0-queued {
537
- color: var(--faint);
538
- font-size: 0.75rem;
539
- }
540
-
541
746
  .a0-composer-actions button {
542
747
  height: 1.75rem;
543
748
  padding: 0 0.625rem;
544
749
  }
545
750
 
751
+ .a0-composer-actions > .a0-icon-button {
752
+ padding: 0;
753
+ }
754
+
755
+ /* Pause or resume, then stop, as a player's buttons. */
756
+ .a0-player {
757
+ display: flex;
758
+ align-items: center;
759
+ gap: 0.125rem;
760
+ }
761
+
762
+ .a0-composer-actions .a0-player button {
763
+ width: 1.75rem;
764
+ padding: 0;
765
+ color: var(--muted);
766
+ }
767
+
768
+ /* Send, or during a turn Steer or Queue with its mode on the chevron. */
769
+ .a0-send {
770
+ display: flex;
771
+ align-items: center;
772
+ gap: 1px;
773
+ }
774
+
775
+ .a0-composer-actions .a0-send-mode {
776
+ width: 1.5rem;
777
+ padding: 0;
778
+ }
779
+
780
+ .a0-menu-check {
781
+ margin-right: 0.375rem;
782
+ vertical-align: -0.125rem;
783
+ }
784
+
546
785
  .a0-ghost {
547
786
  color: var(--muted);
548
787
  }
@@ -44,11 +44,12 @@ export function AppView({
44
44
  <section aria-label="Chat" className="a0-chat">
45
45
  <h1 className="a0-title">{name}</h1>
46
46
  <Chat
47
+ appId={appId}
47
48
  harness={harness}
48
49
  onPrompt={(text) =>
49
50
  prompted({
50
51
  id: appId,
51
- name: titleFromPrompt(prompt ?? text),
52
+ name: titleFromPrompt(prompt || text),
52
53
  harness,
53
54
  })
54
55
  }
@@ -1,11 +1,11 @@
1
- import { inputs } from 'experimental-a2/ai'
2
1
  import { isAppId } from '../../apps'
3
2
  import { deleteAppSandbox } from '../../sandbox'
4
3
  import { agentServers } from '../../server'
5
4
 
6
5
  /**
7
- * Stops the agent's current turn, so no later tool call brings the sandbox
8
- * back, then deletes it. The conversation stays in the A2 log.
6
+ * Deletes the app: its conversation, then its sandbox. A clear reads no
7
+ * state, so it also deletes a log this A2 can no longer fold, and no pending
8
+ * step of the old conversation runs to bring the sandbox back.
9
9
  */
10
10
  export async function DELETE(
11
11
  _request: Request,
@@ -14,14 +14,10 @@ export async function DELETE(
14
14
  const { appId } = await context.params
15
15
  if (!isAppId(appId))
16
16
  return Response.json({ error: 'Unknown app.' }, { status: 404 })
17
- // The app's agent is whichever harness's agent has its log.
17
+ // Only one harness's agent has the app's log; clearing the others writes
18
+ // nothing.
18
19
  await Promise.all(
19
- Object.values(agentServers).map(async (server) => {
20
- const session = server.session(appId)
21
- const { state } = await session.state()
22
- if (state.active)
23
- await session.append(...inputs.stop({ turnId: state.active.turnId }))
24
- }),
20
+ Object.values(agentServers).map((server) => server.session(appId).clear()),
25
21
  )
26
22
  await deleteAppSandbox({ appId })
27
23
  return Response.json({ ok: true })
@@ -0,0 +1,133 @@
1
+ 'use client'
2
+ import { upload as uploadToBlob } from '@vercel/blob/client'
3
+ import type { FileUIPart } from 'ai'
4
+ import { useEffect, useRef, useState } from 'react'
5
+ import { isAttachmentUrl } from './model'
6
+
7
+ /**
8
+ * A file the user attached. An image goes out with the message, for the
9
+ * model to see; any other file goes into the message's text as a link, which
10
+ * the agent downloads in its sandbox.
11
+ */
12
+ export type Attachment = {
13
+ id: string
14
+ file: File
15
+ kind: 'image' | 'link'
16
+ /** Once Blob has it. */
17
+ part?: FileUIPart
18
+ }
19
+
20
+ export type Attachments = {
21
+ list: Attachment[]
22
+ add: (files: File[]) => void
23
+ /** Stops its upload, if it's still running. */
24
+ remove: (id: string) => void
25
+ clear: () => void
26
+ /** Puts back what a failed send took, before anything attached since. */
27
+ restore: (sent: Attachment[]) => void
28
+ }
29
+
30
+ const isImage = (file: File): boolean => file.type.startsWith('image/')
31
+
32
+ /** Uploads straight to Blob, with a token from the app's uploads route. */
33
+ async function upload({
34
+ appId,
35
+ file,
36
+ signal,
37
+ }: {
38
+ appId: string
39
+ file: File
40
+ signal: AbortSignal
41
+ }): Promise<FileUIPart> {
42
+ const blob = await uploadToBlob(`a0/${appId}/${file.name}`, file, {
43
+ access: 'public',
44
+ handleUploadUrl: '/a0/uploads',
45
+ clientPayload: appId,
46
+ ...(file.type ? { contentType: file.type } : {}),
47
+ abortSignal: signal,
48
+ }).catch((cause: unknown) => {
49
+ throw new Error(
50
+ `Couldn't upload ${file.name}: ${cause instanceof Error ? cause.message : String(cause)}`,
51
+ { cause },
52
+ )
53
+ })
54
+ return {
55
+ type: 'file',
56
+ mediaType: blob.contentType,
57
+ filename: file.name,
58
+ url: blob.url,
59
+ }
60
+ }
61
+
62
+ /** The line a file that isn't an image adds to the message. */
63
+ const linkLine = (part: FileUIPart): string =>
64
+ `${part.filename ?? 'file'}: ${part.url}`
65
+
66
+ /** One of the composer's link lines, as the file it names. */
67
+ export function attachedLink(
68
+ line: string,
69
+ ): { name: string; url: string } | null {
70
+ const match = /^(.+): (https:\/\/\S+)$/.exec(line)
71
+ return match?.[1] && match[2] && isAttachmentUrl(match[2])
72
+ ? { name: match[1], url: match[2] }
73
+ : null
74
+ }
75
+
76
+ /** The files attached to the next message, each uploading as it's added. */
77
+ export function useAttachments({
78
+ appId,
79
+ onLink,
80
+ onError,
81
+ }: {
82
+ appId: string
83
+ /** A file that isn't an image finished uploading: its line for the text. */
84
+ onLink: (line: string) => void
85
+ onError: (cause: unknown) => void
86
+ }): Attachments {
87
+ const [list, setList] = useState<Attachment[]>([])
88
+ // The uploads still running, which removing their file or leaving the app
89
+ // stops.
90
+ const uploads = useRef(new Map<string, AbortController>())
91
+ useEffect(() => {
92
+ const running = uploads.current
93
+ return () => {
94
+ for (const controller of running.values()) controller.abort()
95
+ }
96
+ }, [])
97
+ const drop = (id: string): void =>
98
+ setList((current) => current.filter((item) => item.id !== id))
99
+ const attach = async (file: File): Promise<void> => {
100
+ const id = crypto.randomUUID()
101
+ const kind = isImage(file) ? 'image' : 'link'
102
+ const controller = new AbortController()
103
+ uploads.current.set(id, controller)
104
+ setList((current) => [...current, { id, file, kind }])
105
+ try {
106
+ const part = await upload({ appId, file, signal: controller.signal })
107
+ if (kind === 'link') {
108
+ drop(id)
109
+ onLink(linkLine(part))
110
+ } else
111
+ setList((current) =>
112
+ current.map((item) => (item.id === id ? { ...item, part } : item)),
113
+ )
114
+ } catch (cause) {
115
+ drop(id)
116
+ if (!controller.signal.aborted) onError(cause)
117
+ } finally {
118
+ uploads.current.delete(id)
119
+ }
120
+ }
121
+ return {
122
+ list,
123
+ add: (files) => {
124
+ for (const file of files) void attach(file)
125
+ },
126
+ remove: (id) => {
127
+ uploads.current.get(id)?.abort()
128
+ drop(id)
129
+ },
130
+ clear: () => setList([]),
131
+ restore: (sent) => setList((current) => [...sent, ...current]),
132
+ }
133
+ }