experimental-a2 0.12.0 → 0.14.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 (106) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/{actor-DJi3RsNu.d.ts → actor-BfQSE0KC.d.ts} +4 -4
  3. package/dist/{actor-DJi3RsNu.d.ts.map → actor-BfQSE0KC.d.ts.map} +1 -1
  4. package/dist/actor-client.d.ts +1 -1
  5. package/dist/actor-client.js +1 -1
  6. package/dist/actor-react.d.ts +3 -3
  7. package/dist/actor-react.js +2 -2
  8. package/dist/{actor-shared-DI7J5upy.js → actor-shared-B5tJfzt-.js} +2 -2
  9. package/dist/{actor-shared-DI7J5upy.js.map → actor-shared-B5tJfzt-.js.map} +1 -1
  10. package/dist/actor.d.ts +1 -1
  11. package/dist/actor.js +3 -3
  12. package/dist/ai-Cai-lCbj.d.ts +580 -0
  13. package/dist/ai-Cai-lCbj.d.ts.map +1 -0
  14. package/dist/ai-control-CcD4hh3y.js +119 -0
  15. package/dist/ai-control-CcD4hh3y.js.map +1 -0
  16. package/dist/ai-server.d.ts +16 -8
  17. package/dist/ai-server.d.ts.map +1 -1
  18. package/dist/ai-server.js +1322 -475
  19. package/dist/ai-server.js.map +1 -1
  20. package/dist/ai.d.ts +2 -334
  21. package/dist/ai.js +838 -85
  22. package/dist/ai.js.map +1 -1
  23. package/dist/{client-P_NNNRM-.d.ts → client-BAEABRZB.d.ts} +2 -2
  24. package/dist/{client-P_NNNRM-.d.ts.map → client-BAEABRZB.d.ts.map} +1 -1
  25. package/dist/{client-Bf6uSEAk.js → client-BYzHjkwU.js} +21 -6
  26. package/dist/client-BYzHjkwU.js.map +1 -0
  27. package/dist/client.d.ts +1 -1
  28. package/dist/client.js +1 -1
  29. package/dist/{contract-48bUMgcL.js → contract-CKRg_E4q.js} +3 -26
  30. package/dist/contract-CKRg_E4q.js.map +1 -0
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.js +1 -1
  33. package/dist/react.d.ts +2 -2
  34. package/dist/react.js +1 -1
  35. package/dist/{reducer-DJKWm3cp.d.ts → reducer-BcS9VDKC.d.ts} +4 -1
  36. package/dist/{reducer-DJKWm3cp.d.ts.map → reducer-BcS9VDKC.d.ts.map} +1 -1
  37. package/dist/reducer-DEMjEY_O.js +29 -0
  38. package/dist/reducer-DEMjEY_O.js.map +1 -0
  39. package/dist/scheduler-qstash.d.ts +2 -2
  40. package/dist/scheduler-qstash.js +1 -1
  41. package/dist/scheduler-vercel.d.ts +2 -2
  42. package/dist/scheduler-vercel.js +1 -1
  43. package/dist/{server-DjZZa1wr.d.ts → server-Bp5Nd1pF.d.ts} +3 -3
  44. package/dist/{server-DjZZa1wr.d.ts.map → server-Bp5Nd1pF.d.ts.map} +1 -1
  45. package/dist/{server-BeNADlCI.js → server-CjJSGcF7.js} +4 -3
  46. package/dist/server-CjJSGcF7.js.map +1 -0
  47. package/dist/server.d.ts +3 -3
  48. package/dist/server.js +1 -1
  49. package/dist/{store-DtDOWLSn.d.ts → store-D_yhNdPz.d.ts} +7 -2
  50. package/dist/{store-DtDOWLSn.d.ts.map → store-D_yhNdPz.d.ts.map} +1 -1
  51. package/dist/store-N8PXxDAS.js.map +1 -1
  52. package/dist/store-memory.d.ts +1 -1
  53. package/dist/store-postgres.d.ts +1 -1
  54. package/dist/store-postgres.js +19 -0
  55. package/dist/store-postgres.js.map +1 -1
  56. package/dist/store-redis-http.d.ts +1 -1
  57. package/dist/store-redis-http.js +1 -1
  58. package/dist/{store-redis-notify-BUCyXOn0.js → store-redis-notify-D2EI6gwX.js} +27 -2
  59. package/dist/store-redis-notify-D2EI6gwX.js.map +1 -0
  60. package/dist/store-redis.d.ts +1 -1
  61. package/dist/store-redis.js +1 -1
  62. package/dist/store-sqlite.d.ts +1 -1
  63. package/docs/guides/06-ai-agents.mdx +361 -76
  64. package/docs/reference/01-api.mdx +159 -27
  65. package/examples/playground/app/agent/[agentId]/agent-client.tsx +145 -33
  66. package/examples/playground/app/agent/[agentId]/agent-queue.tsx +289 -0
  67. package/examples/playground/app/agent/[agentId]/compaction/route.ts +14 -0
  68. package/examples/playground/app/agent/[agentId]/compaction-event.tsx +38 -0
  69. package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +294 -0
  70. package/examples/playground/app/agent/compaction-settings.test.ts +144 -0
  71. package/examples/playground/app/agent/compaction-settings.ts +49 -0
  72. package/examples/playground/app/agent/compaction-timeline.test.ts +337 -0
  73. package/examples/playground/app/agent/compaction-timeline.ts +198 -0
  74. package/examples/playground/app/agent/model.ts +56 -1
  75. package/examples/playground/app/agent/server.ts +9 -2
  76. package/examples/playground/app/chat/[chatId]/chat-client.tsx +3 -13
  77. package/examples/playground/app/chat/model.ts +2 -2
  78. package/examples/playground/app/chat/server.ts +24 -17
  79. package/examples/playground/app/globals.css +333 -0
  80. package/examples/playground/package.json +1 -1
  81. package/package.json +1 -1
  82. package/src/ai-client-state.ts +185 -0
  83. package/src/ai-control-server.ts +829 -0
  84. package/src/ai-control-state.ts +152 -0
  85. package/src/ai-control.ts +139 -0
  86. package/src/ai-coordinator.ts +99 -32
  87. package/src/ai-model-metadata.ts +108 -0
  88. package/src/ai-progress-batches.ts +68 -0
  89. package/src/ai-projector.ts +76 -15
  90. package/src/ai-sdk-step.ts +5 -2
  91. package/src/ai-server.ts +920 -638
  92. package/src/ai.ts +650 -110
  93. package/src/client.ts +31 -9
  94. package/src/licenses/Apache-2.0.txt +55 -0
  95. package/src/parse-partial-json.ts +441 -0
  96. package/src/reducer.ts +6 -0
  97. package/src/server.ts +8 -4
  98. package/src/store-postgres.ts +27 -0
  99. package/src/store-redis-core.ts +53 -1
  100. package/src/store-redis-notify.ts +1 -0
  101. package/src/store.ts +6 -0
  102. package/dist/ai.d.ts.map +0 -1
  103. package/dist/client-Bf6uSEAk.js.map +0 -1
  104. package/dist/contract-48bUMgcL.js.map +0 -1
  105. package/dist/server-BeNADlCI.js.map +0 -1
  106. package/dist/store-redis-notify-BUCyXOn0.js.map +0 -1
@@ -0,0 +1,289 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
4
+ import type { UIMessage } from 'ai'
5
+ import type { InboxItem } from 'experimental-a2/ai'
6
+
7
+ type QueueProps = {
8
+ items: InboxItem[]
9
+ paused: boolean
10
+ pendingEdits: readonly string[]
11
+ edit(message: UIMessage, revision: number): Promise<void>
12
+ move(inputId: string, beforeId: string | null): void
13
+ remove(inputId: string): Promise<void>
14
+ sendNow(inputId: string): Promise<void>
15
+ }
16
+
17
+ const queueIconPaths = {
18
+ edit: 'm15 5 4 4M4 20l4-1L20 7a2.8 2.8 0 0 0-4-4L4 15z',
19
+ up: 'm6 14 6-6 6 6',
20
+ down: 'm6 10 6 6 6-6',
21
+ remove: 'm6 6 12 12M6 18 18 6',
22
+ send: 'M4 12h15m-6-6 6 6-6 6',
23
+ }
24
+
25
+ function QueueIcon({ name }: { name: keyof typeof queueIconPaths }): ReactNode {
26
+ return (
27
+ <svg
28
+ width="14"
29
+ height="14"
30
+ viewBox="0 0 24 24"
31
+ fill="none"
32
+ stroke="currentColor"
33
+ strokeWidth="1.7"
34
+ strokeLinecap="round"
35
+ strokeLinejoin="round"
36
+ aria-hidden="true"
37
+ >
38
+ <path d={queueIconPaths[name]} />
39
+ </svg>
40
+ )
41
+ }
42
+
43
+ function QueuedInput({
44
+ item,
45
+ position,
46
+ count,
47
+ edit,
48
+ moveUp,
49
+ moveDown,
50
+ remove,
51
+ sendNow,
52
+ paused,
53
+ pendingEdit,
54
+ }: {
55
+ item: InboxItem
56
+ position: number
57
+ count: number
58
+ edit: QueueProps['edit']
59
+ moveUp(): void
60
+ moveDown(): void
61
+ remove: QueueProps['remove']
62
+ sendNow: QueueProps['sendNow']
63
+ paused: boolean
64
+ pendingEdit: boolean
65
+ }): ReactNode {
66
+ const [draft, setDraft] = useState<{ text: string; revision: number } | null>(
67
+ null,
68
+ )
69
+ const [pending, setPending] = useState(false)
70
+ const [error, setError] = useState<string | null>(null)
71
+ const editButton = useRef<HTMLButtonElement>(null)
72
+ const restoreFocus = useRef(false)
73
+ useEffect(() => {
74
+ if (draft === null && !pending && !pendingEdit && restoreFocus.current) {
75
+ if (document.activeElement === document.body) editButton.current?.focus()
76
+ restoreFocus.current = false
77
+ }
78
+ }, [draft, pending, pendingEdit])
79
+ const text = item.message.parts
80
+ .filter((part) => part.type === 'text')
81
+ .map((part) => part.text)
82
+ .join('\n')
83
+ const cancel = (): void => {
84
+ restoreFocus.current = true
85
+ setDraft(null)
86
+ setError(null)
87
+ }
88
+ const perform = async (action: () => Promise<void>): Promise<void> => {
89
+ if (pending) return
90
+ setPending(true)
91
+ setError(null)
92
+ try {
93
+ await action()
94
+ } catch (failure) {
95
+ setError(failure instanceof Error ? failure.message : String(failure))
96
+ } finally {
97
+ setPending(false)
98
+ }
99
+ }
100
+ const save = async (): Promise<void> => {
101
+ if (!draft || pendingEdit || !draft.text.trim()) return
102
+ await perform(async () => {
103
+ await edit(
104
+ { ...item.message, parts: [{ type: 'text', text: draft.text }] },
105
+ draft.revision,
106
+ )
107
+ cancel()
108
+ })
109
+ }
110
+ return (
111
+ <li className={`agent-queue-item${draft ? ' editing' : ''}`}>
112
+ <div className="agent-queue-row">
113
+ <span
114
+ className="agent-queue-number"
115
+ aria-label={`Queue position ${position + 1}`}
116
+ >
117
+ {position + 1}
118
+ </span>
119
+ <p className="agent-queue-text" title={text}>
120
+ {text || 'Message'}
121
+ </p>
122
+ <div className="agent-queue-actions">
123
+ <button
124
+ type="button"
125
+ className="agent-queue-send"
126
+ title={
127
+ paused
128
+ ? 'Send next · waits for Resume while paused'
129
+ : 'Send after current step'
130
+ }
131
+ aria-label={`Send queued message ${position + 1} after current step`}
132
+ disabled={
133
+ pending ||
134
+ pendingEdit ||
135
+ draft !== null ||
136
+ item.message.role !== 'user'
137
+ }
138
+ onClick={() => void perform(() => sendNow(item.id))}
139
+ >
140
+ <QueueIcon name="send" />
141
+ </button>
142
+ <button
143
+ ref={editButton}
144
+ type="button"
145
+ title={
146
+ pendingEdit ? 'Waiting for edit confirmation' : 'Edit message'
147
+ }
148
+ aria-label={`Edit queued message ${position + 1}`}
149
+ aria-expanded={draft !== null}
150
+ disabled={pending || pendingEdit}
151
+ onClick={() =>
152
+ draft ? cancel() : setDraft({ text, revision: item.revision })
153
+ }
154
+ >
155
+ <QueueIcon name="edit" />
156
+ </button>
157
+ <button
158
+ type="button"
159
+ title="Move up"
160
+ aria-label={`Move queued message ${position + 1} up`}
161
+ disabled={position === 0 || pending}
162
+ onClick={moveUp}
163
+ >
164
+ <QueueIcon name="up" />
165
+ </button>
166
+ <button
167
+ type="button"
168
+ title="Move down"
169
+ aria-label={`Move queued message ${position + 1} down`}
170
+ disabled={position === count - 1 || pending}
171
+ onClick={moveDown}
172
+ >
173
+ <QueueIcon name="down" />
174
+ </button>
175
+ <button
176
+ type="button"
177
+ className="agent-queue-remove"
178
+ title="Remove from queue"
179
+ aria-label={`Remove queued message ${position + 1}`}
180
+ disabled={pending || draft !== null}
181
+ onClick={() => void perform(() => remove(item.id))}
182
+ >
183
+ <QueueIcon name="remove" />
184
+ </button>
185
+ </div>
186
+ </div>
187
+ {error ? (
188
+ <p className="error agent-queue-error" role="alert">
189
+ {error}
190
+ </p>
191
+ ) : null}
192
+ {draft ? (
193
+ <form
194
+ className="agent-queue-editor"
195
+ onSubmit={(event) => {
196
+ event.preventDefault()
197
+ void save()
198
+ }}
199
+ >
200
+ <textarea
201
+ autoFocus
202
+ aria-label={`Edit text for queued message ${position + 1}`}
203
+ rows={2}
204
+ value={draft.text}
205
+ disabled={pending}
206
+ onChange={(event) =>
207
+ setDraft({ ...draft, text: event.target.value })
208
+ }
209
+ onKeyDown={(event) => {
210
+ if (event.nativeEvent.isComposing) return
211
+ if (event.key === 'Escape' && !pending) {
212
+ event.preventDefault()
213
+ cancel()
214
+ }
215
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
216
+ event.preventDefault()
217
+ void save()
218
+ }
219
+ }}
220
+ />
221
+ <div className="agent-queue-editor-actions">
222
+ <span>⌘ / Ctrl + Enter to save</span>
223
+ <button
224
+ type="button"
225
+ className="secondary"
226
+ disabled={pending}
227
+ onClick={cancel}
228
+ >
229
+ Cancel
230
+ </button>
231
+ <button
232
+ type="submit"
233
+ disabled={pending || pendingEdit || !draft.text.trim()}
234
+ >
235
+ {pending ? 'Saving…' : 'Save'}
236
+ </button>
237
+ </div>
238
+ </form>
239
+ ) : null}
240
+ </li>
241
+ )
242
+ }
243
+
244
+ export function AgentQueue({
245
+ items,
246
+ paused,
247
+ pendingEdits,
248
+ edit,
249
+ move,
250
+ remove,
251
+ sendNow,
252
+ }: QueueProps): ReactNode {
253
+ if (items.length === 0) return null
254
+ return (
255
+ <section className="agent-queue" aria-label="Queued messages">
256
+ <div className="agent-queue-header">
257
+ <h3>
258
+ Queued <span>{items.length}</span>
259
+ </h3>
260
+ <span className={`agent-queue-status${paused ? ' paused' : ''}`}>
261
+ {paused ? 'Paused' : 'Pending'}
262
+ </span>
263
+ </div>
264
+ <ol className="agent-queue-list">
265
+ {items.map((item, position) => (
266
+ <QueuedInput
267
+ key={item.id}
268
+ item={item}
269
+ position={position}
270
+ count={items.length}
271
+ edit={edit}
272
+ remove={remove}
273
+ sendNow={sendNow}
274
+ paused={paused}
275
+ pendingEdit={pendingEdits.includes(item.id)}
276
+ moveUp={() => {
277
+ const previous = items[position - 1]
278
+ if (previous) move(item.id, previous.id)
279
+ }}
280
+ moveDown={() => {
281
+ if (position < items.length - 1)
282
+ move(item.id, items[position + 2]?.id ?? null)
283
+ }}
284
+ />
285
+ ))}
286
+ </ol>
287
+ </section>
288
+ )
289
+ }
@@ -0,0 +1,14 @@
1
+ import { updateCompactionSettings } from '../../compaction-settings'
2
+ import { agentServer } from '../../server'
3
+
4
+ export async function POST(
5
+ request: Request,
6
+ context: { params: Promise<{ agentId: string }> },
7
+ ): Promise<Response> {
8
+ const { agentId } = await context.params
9
+ return updateCompactionSettings({
10
+ request,
11
+ sessionId: agentId,
12
+ append: (event) => agentServer.session(agentId).append(event),
13
+ })
14
+ }
@@ -0,0 +1,38 @@
1
+ import type { ReactNode } from 'react'
2
+ import { formatActivityTime } from '@/app/components/activity-feed'
3
+ import type { CompactionEntry } from '../compaction-timeline'
4
+
5
+ export function CompactionEvent({
6
+ entry,
7
+ }: {
8
+ entry: CompactionEntry
9
+ }): ReactNode {
10
+ const { event, running, outcome } = entry
11
+ const completed = event.type === 'ai.compaction.completed'
12
+ const label = completed
13
+ ? 'Context compacted'
14
+ : running
15
+ ? 'Compacting…'
16
+ : 'Compaction did not complete'
17
+
18
+ return (
19
+ <details className="agent-compaction-event">
20
+ <summary>
21
+ <span role={running ? 'status' : undefined}>{label}</span>
22
+ <time dateTime={event.createdAt.toISOString()}>
23
+ {formatActivityTime(event.createdAt)}
24
+ </time>
25
+ </summary>
26
+ <p className="hint">
27
+ Event #{event.index} ·{' '}
28
+ {event.payload.throughIndex === undefined
29
+ ? `Through message ${event.payload.throughMessageId}`
30
+ : `Summarized through event #${event.payload.throughIndex}`}
31
+ {outcome === undefined ? null : ` · Generation ${outcome}`}
32
+ </p>
33
+ {completed && event.payload.summary ? (
34
+ <pre>{event.payload.summary}</pre>
35
+ ) : null}
36
+ </details>
37
+ )
38
+ }
@@ -0,0 +1,294 @@
1
+ 'use client'
2
+
3
+ import type { FormEvent, ReactNode } from 'react'
4
+ import { useEffect, useId, useRef, useState } from 'react'
5
+ import type { AIState } from 'experimental-a2/ai'
6
+ import { formatActivityTime } from '@/app/components/activity-feed'
7
+ import {
8
+ agentModel,
9
+ compactionSettingsSchema,
10
+ latestCompactionSettings,
11
+ type AgentEvent,
12
+ type CompactionSettings,
13
+ } from '../model'
14
+
15
+ const number = new Intl.NumberFormat('en-US')
16
+ const tokens = (value: number | undefined): string =>
17
+ value === undefined
18
+ ? 'Not available'
19
+ : `${number.format(value)} ${value === 1 ? 'token' : 'tokens'}`
20
+
21
+ function SettingsForm({
22
+ agentId,
23
+ settings,
24
+ closed,
25
+ }: {
26
+ agentId: string
27
+ settings: CompactionSettings
28
+ closed: boolean
29
+ }): ReactNode {
30
+ const modeId = useId()
31
+ const thresholdId = useId()
32
+ const [mode, setMode] = useState(settings.mode)
33
+ const [threshold, setThreshold] = useState(
34
+ settings.mode === 'manual' ? String(settings.thresholdTokens) : '',
35
+ )
36
+ const [saving, setSaving] = useState(false)
37
+ const [error, setError] = useState<string | null>(null)
38
+ const [notice, setNotice] = useState<string | null>(null)
39
+ const pending = useRef<AbortController | null>(null)
40
+
41
+ useEffect(() => () => pending.current?.abort(), [])
42
+
43
+ const save = async (next: CompactionSettings): Promise<void> => {
44
+ pending.current?.abort()
45
+ const controller = new AbortController()
46
+ pending.current = controller
47
+ setSaving(true)
48
+ setError(null)
49
+ setNotice(null)
50
+ try {
51
+ const response = await fetch(
52
+ `/agent/${encodeURIComponent(agentId)}/compaction`,
53
+ {
54
+ method: 'POST',
55
+ headers: { 'content-type': 'application/json' },
56
+ body: JSON.stringify({ id: crypto.randomUUID(), settings: next }),
57
+ signal: controller.signal,
58
+ },
59
+ )
60
+ if (!response.ok)
61
+ throw new Error('Could not save compaction settings. Try again.')
62
+ setNotice('Saved. The live feed will confirm the latest settings.')
63
+ } catch (cause) {
64
+ if (!controller.signal.aborted)
65
+ setError(cause instanceof Error ? cause.message : String(cause))
66
+ } finally {
67
+ if (pending.current === controller) {
68
+ pending.current = null
69
+ setSaving(false)
70
+ }
71
+ }
72
+ }
73
+
74
+ const submit = (event: FormEvent): void => {
75
+ event.preventDefault()
76
+ const parsed = compactionSettingsSchema.safeParse(
77
+ mode === 'manual'
78
+ ? { mode, thresholdTokens: Number(threshold) }
79
+ : { mode },
80
+ )
81
+ if (!parsed.success) {
82
+ setError('Enter a positive whole-number threshold.')
83
+ return
84
+ }
85
+ void save(parsed.data)
86
+ }
87
+
88
+ return (
89
+ <form className="compaction-form" onSubmit={submit}>
90
+ <fieldset disabled={saving || closed}>
91
+ <div className="compaction-fields">
92
+ <label htmlFor={modeId}>
93
+ Mode
94
+ <select
95
+ id={modeId}
96
+ value={mode}
97
+ onChange={(event) =>
98
+ setMode(event.target.value as CompactionSettings['mode'])
99
+ }
100
+ >
101
+ <option value="automatic">Automatic</option>
102
+ <option value="manual">Custom threshold</option>
103
+ <option value="disabled">Disable further compaction</option>
104
+ </select>
105
+ </label>
106
+ <label htmlFor={thresholdId}>
107
+ Threshold in tokens
108
+ <input
109
+ id={thresholdId}
110
+ type="number"
111
+ min={1}
112
+ max={Number.MAX_SAFE_INTEGER}
113
+ step={1}
114
+ required={mode === 'manual'}
115
+ disabled={mode !== 'manual'}
116
+ value={threshold}
117
+ onChange={(event) => setThreshold(event.target.value)}
118
+ placeholder="Automatic"
119
+ />
120
+ </label>
121
+ </div>
122
+ <div className="compaction-actions">
123
+ <button type="submit">{saving ? 'Saving…' : 'Apply settings'}</button>
124
+ <button
125
+ type="button"
126
+ className="secondary"
127
+ onClick={() => void save({ mode: 'manual', thresholdTokens: 1 })}
128
+ >
129
+ Set to 1 token
130
+ </button>
131
+ <button
132
+ type="button"
133
+ className="secondary"
134
+ onClick={() => void save({ mode: 'automatic' })}
135
+ >
136
+ Reset to automatic
137
+ </button>
138
+ </div>
139
+ </fieldset>
140
+ <p className="hint">
141
+ Changes apply to the next step that has not started and do not start a
142
+ new turn. Send a message to test. At 1 token, every eligible step
143
+ compacts until you reset it.
144
+ </p>
145
+ {mode === 'disabled' ? (
146
+ <p className="hint">
147
+ Existing summaries remain in use. This stops future compaction.
148
+ </p>
149
+ ) : null}
150
+ {notice ? (
151
+ <p role="status" className="hint">
152
+ {notice}
153
+ </p>
154
+ ) : null}
155
+ {error ? (
156
+ <p role="alert" className="error">
157
+ {error}
158
+ </p>
159
+ ) : null}
160
+ </form>
161
+ )
162
+ }
163
+
164
+ export function CompactionPanel({
165
+ agentId,
166
+ state,
167
+ events,
168
+ }: {
169
+ agentId: string
170
+ state: AIState
171
+ events: readonly AgentEvent[]
172
+ }): ReactNode {
173
+ const { settings, id } = latestCompactionSettings(events)
174
+ const metadata = state.modelMetadata[agentModel]
175
+ const limits = metadata?.status === 'resolved' ? metadata.limits : undefined
176
+ const automaticThreshold =
177
+ limits === undefined ? undefined : Math.floor(limits.contextWindow * 0.75)
178
+ const effectiveThreshold =
179
+ settings.mode === 'manual' ? settings.thresholdTokens : automaticThreshold
180
+ const lastStep = events.findLast(
181
+ (event) => event.type === 'ai.generation.completed',
182
+ )
183
+ const completed = events
184
+ .filter((event) => event.type === 'ai.compaction.completed')
185
+ .toReversed()
186
+ const compacting = state.compaction?.status === 'running'
187
+
188
+ return (
189
+ <details className="agent-compaction">
190
+ <summary>
191
+ <span className="compaction-chevron" aria-hidden="true">
192
+ ›
193
+ </span>
194
+ <strong>Compaction</strong>
195
+ <span className={`badge${compacting ? ' live' : ''}`}>
196
+ {compacting
197
+ ? 'Compacting…'
198
+ : settings.mode === 'disabled'
199
+ ? 'Disabled'
200
+ : settings.mode === 'manual'
201
+ ? `Custom · ${tokens(settings.thresholdTokens)}`
202
+ : 'Automatic'}
203
+ </span>
204
+ </summary>
205
+ <div className="compaction-controls">
206
+ <p className="hint">
207
+ Model: <code>{agentModel}</code>. Limits are saved for this session.
208
+ </p>
209
+ <dl className="compaction-metrics">
210
+ <div>
211
+ <dt>Context window</dt>
212
+ <dd>{tokens(limits?.contextWindow)}</dd>
213
+ </div>
214
+ <div>
215
+ <dt>Model output ceiling</dt>
216
+ <dd>{tokens(limits?.maxOutputTokens)}</dd>
217
+ </div>
218
+ <div>
219
+ <dt>Automatic trigger · 75%</dt>
220
+ <dd>{tokens(automaticThreshold)}</dd>
221
+ </div>
222
+ <div>
223
+ <dt>Threshold for next step</dt>
224
+ <dd>
225
+ {settings.mode === 'disabled'
226
+ ? 'Disabled'
227
+ : tokens(effectiveThreshold)}
228
+ </dd>
229
+ </div>
230
+ <div>
231
+ <dt>Last step input · reported</dt>
232
+ <dd>{tokens(lastStep?.payload.usage?.inputTokens)}</dd>
233
+ </div>
234
+ <div>
235
+ <dt>Last step input · estimated</dt>
236
+ <dd>{tokens(lastStep?.payload.inputTokenEstimate)}</dd>
237
+ </div>
238
+ </dl>
239
+ {limits === undefined ? (
240
+ <p className="hint">
241
+ {metadata?.status === 'unavailable'
242
+ ? 'Gateway did not report limits for this model. You can set a custom threshold.'
243
+ : metadata?.status === 'pending'
244
+ ? 'Model limits have been requested but are not resolved yet. The agent can continue.'
245
+ : 'Send a message to discover the model limits, or set a custom threshold first.'}
246
+ </p>
247
+ ) : null}
248
+ <SettingsForm
249
+ key={id}
250
+ agentId={agentId}
251
+ settings={settings}
252
+ closed={state.status === 'closed'}
253
+ />
254
+ <details className="compaction-history">
255
+ <summary>
256
+ Completed compactions <span>{completed.length}</span>
257
+ </summary>
258
+ {completed.length === 0 ? (
259
+ <p className="hint">
260
+ No completed compactions yet. Each completion will stay here after
261
+ reload.
262
+ </p>
263
+ ) : (
264
+ <ol>
265
+ {completed.map((event) => (
266
+ <li key={event.id}>
267
+ <div className="compaction-marker">
268
+ <strong>Context compacted · #{event.index}</strong>
269
+ <time dateTime={event.createdAt.toISOString()}>
270
+ {formatActivityTime(event.createdAt)}
271
+ </time>
272
+ </div>
273
+ <p className="hint">
274
+ {event.payload.throughIndex === undefined
275
+ ? `Through message ${event.payload.throughMessageId}`
276
+ : `Summarized through event #${event.payload.throughIndex}`}{' '}
277
+ · {tokens(event.payload.usage?.inputTokens)} in ·{' '}
278
+ {tokens(event.payload.usage?.outputTokens)} out
279
+ </p>
280
+ {event.payload.summary ? (
281
+ <details>
282
+ <summary>View summary</summary>
283
+ <pre>{event.payload.summary}</pre>
284
+ </details>
285
+ ) : null}
286
+ </li>
287
+ ))}
288
+ </ol>
289
+ )}
290
+ </details>
291
+ </div>
292
+ </details>
293
+ )
294
+ }