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.
- package/CHANGELOG.md +47 -0
- package/dist/{actor-DJi3RsNu.d.ts → actor-BfQSE0KC.d.ts} +4 -4
- package/dist/{actor-DJi3RsNu.d.ts.map → actor-BfQSE0KC.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-client.js +1 -1
- package/dist/actor-react.d.ts +3 -3
- package/dist/actor-react.js +2 -2
- package/dist/{actor-shared-DI7J5upy.js → actor-shared-B5tJfzt-.js} +2 -2
- package/dist/{actor-shared-DI7J5upy.js.map → actor-shared-B5tJfzt-.js.map} +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +3 -3
- package/dist/ai-Cai-lCbj.d.ts +580 -0
- package/dist/ai-Cai-lCbj.d.ts.map +1 -0
- package/dist/ai-control-CcD4hh3y.js +119 -0
- package/dist/ai-control-CcD4hh3y.js.map +1 -0
- package/dist/ai-server.d.ts +16 -8
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +1322 -475
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -334
- package/dist/ai.js +838 -85
- package/dist/ai.js.map +1 -1
- package/dist/{client-P_NNNRM-.d.ts → client-BAEABRZB.d.ts} +2 -2
- package/dist/{client-P_NNNRM-.d.ts.map → client-BAEABRZB.d.ts.map} +1 -1
- package/dist/{client-Bf6uSEAk.js → client-BYzHjkwU.js} +21 -6
- package/dist/client-BYzHjkwU.js.map +1 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/{contract-48bUMgcL.js → contract-CKRg_E4q.js} +3 -26
- package/dist/contract-CKRg_E4q.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/{reducer-DJKWm3cp.d.ts → reducer-BcS9VDKC.d.ts} +4 -1
- package/dist/{reducer-DJKWm3cp.d.ts.map → reducer-BcS9VDKC.d.ts.map} +1 -1
- package/dist/reducer-DEMjEY_O.js +29 -0
- package/dist/reducer-DEMjEY_O.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +1 -1
- package/dist/{server-DjZZa1wr.d.ts → server-Bp5Nd1pF.d.ts} +3 -3
- package/dist/{server-DjZZa1wr.d.ts.map → server-Bp5Nd1pF.d.ts.map} +1 -1
- package/dist/{server-BeNADlCI.js → server-CjJSGcF7.js} +4 -3
- package/dist/server-CjJSGcF7.js.map +1 -0
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/{store-DtDOWLSn.d.ts → store-D_yhNdPz.d.ts} +7 -2
- package/dist/{store-DtDOWLSn.d.ts.map → store-D_yhNdPz.d.ts.map} +1 -1
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.js +19 -0
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +1 -1
- package/dist/{store-redis-notify-BUCyXOn0.js → store-redis-notify-D2EI6gwX.js} +27 -2
- package/dist/store-redis-notify-D2EI6gwX.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/docs/guides/06-ai-agents.mdx +361 -76
- package/docs/reference/01-api.mdx +159 -27
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +145 -33
- package/examples/playground/app/agent/[agentId]/agent-queue.tsx +289 -0
- package/examples/playground/app/agent/[agentId]/compaction/route.ts +14 -0
- package/examples/playground/app/agent/[agentId]/compaction-event.tsx +38 -0
- package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +294 -0
- package/examples/playground/app/agent/compaction-settings.test.ts +144 -0
- package/examples/playground/app/agent/compaction-settings.ts +49 -0
- package/examples/playground/app/agent/compaction-timeline.test.ts +337 -0
- package/examples/playground/app/agent/compaction-timeline.ts +198 -0
- package/examples/playground/app/agent/model.ts +56 -1
- package/examples/playground/app/agent/server.ts +9 -2
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +3 -13
- package/examples/playground/app/chat/model.ts +2 -2
- package/examples/playground/app/chat/server.ts +24 -17
- package/examples/playground/app/globals.css +333 -0
- package/examples/playground/package.json +1 -1
- package/package.json +1 -1
- package/src/ai-client-state.ts +185 -0
- package/src/ai-control-server.ts +829 -0
- package/src/ai-control-state.ts +152 -0
- package/src/ai-control.ts +139 -0
- package/src/ai-coordinator.ts +99 -32
- package/src/ai-model-metadata.ts +108 -0
- package/src/ai-progress-batches.ts +68 -0
- package/src/ai-projector.ts +76 -15
- package/src/ai-sdk-step.ts +5 -2
- package/src/ai-server.ts +920 -638
- package/src/ai.ts +650 -110
- package/src/client.ts +31 -9
- package/src/licenses/Apache-2.0.txt +55 -0
- package/src/parse-partial-json.ts +441 -0
- package/src/reducer.ts +6 -0
- package/src/server.ts +8 -4
- package/src/store-postgres.ts +27 -0
- package/src/store-redis-core.ts +53 -1
- package/src/store-redis-notify.ts +1 -0
- package/src/store.ts +6 -0
- package/dist/ai.d.ts.map +0 -1
- package/dist/client-Bf6uSEAk.js.map +0 -1
- package/dist/contract-48bUMgcL.js.map +0 -1
- package/dist/server-BeNADlCI.js.map +0 -1
- 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
|
+
}
|