experimental-a2 0.11.0 → 0.13.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 (46) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/{actor-D_54lz_1.d.ts → actor-DJi3RsNu.d.ts} +2 -2
  3. package/dist/{actor-D_54lz_1.d.ts.map → actor-DJi3RsNu.d.ts.map} +1 -1
  4. package/dist/actor-client.d.ts +1 -1
  5. package/dist/actor-react.d.ts +1 -1
  6. package/dist/actor.d.ts +1 -1
  7. package/dist/actor.js +1 -1
  8. package/dist/ai-server.d.ts +12 -4
  9. package/dist/ai-server.d.ts.map +1 -1
  10. package/dist/ai-server.js +477 -143
  11. package/dist/ai-server.js.map +1 -1
  12. package/dist/ai.d.ts +33 -2
  13. package/dist/ai.d.ts.map +1 -1
  14. package/dist/ai.js +39 -7
  15. package/dist/ai.js.map +1 -1
  16. package/dist/scheduler-qstash.d.ts +1 -1
  17. package/dist/scheduler-qstash.js +1 -1
  18. package/dist/scheduler-vercel.d.ts +1 -1
  19. package/dist/scheduler-vercel.js +1 -1
  20. package/dist/{server-Dkz2a84E.js → server-BeNADlCI.js} +8 -5
  21. package/dist/server-BeNADlCI.js.map +1 -0
  22. package/dist/{server-DwPrMqHB.d.ts → server-DjZZa1wr.d.ts} +8 -2
  23. package/dist/{server-DwPrMqHB.d.ts.map → server-DjZZa1wr.d.ts.map} +1 -1
  24. package/dist/server.d.ts +2 -2
  25. package/dist/server.js +2 -2
  26. package/docs/guides/06-ai-agents.mdx +100 -18
  27. package/docs/reference/01-api.mdx +105 -5
  28. package/examples/playground/app/agent/[agentId]/agent-client.tsx +31 -16
  29. package/examples/playground/app/agent/[agentId]/compaction/route.ts +14 -0
  30. package/examples/playground/app/agent/[agentId]/compaction-event.tsx +38 -0
  31. package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +294 -0
  32. package/examples/playground/app/agent/compaction-settings.test.ts +128 -0
  33. package/examples/playground/app/agent/compaction-settings.ts +49 -0
  34. package/examples/playground/app/agent/compaction-timeline.test.ts +337 -0
  35. package/examples/playground/app/agent/compaction-timeline.ts +198 -0
  36. package/examples/playground/app/agent/model.ts +47 -1
  37. package/examples/playground/app/agent/server.ts +3 -2
  38. package/examples/playground/app/globals.css +154 -0
  39. package/examples/playground/package.json +1 -1
  40. package/package.json +1 -1
  41. package/src/ai-model-metadata.ts +108 -0
  42. package/src/ai-sdk-step.ts +5 -1
  43. package/src/ai-server.ts +712 -203
  44. package/src/ai.ts +99 -4
  45. package/src/server.ts +14 -3
  46. package/dist/server-Dkz2a84E.js.map +0 -1
@@ -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
+ }
@@ -0,0 +1,128 @@
1
+ import { expect, it } from 'vitest'
2
+ import { createAgentServer } from 'experimental-a2/ai/server'
3
+ import { memory } from 'experimental-a2/store-memory'
4
+ import { compactionOptions, demoAgent, latestCompactionSettings } from './model'
5
+ import { updateCompactionSettings } from './compaction-settings'
6
+
7
+ const request = (
8
+ settings: unknown,
9
+ options: { origin?: string; contentType?: string; id?: string } = {},
10
+ ): Request =>
11
+ new Request('https://example.test/agent/session/compaction', {
12
+ method: 'POST',
13
+ headers: {
14
+ origin: options.origin ?? 'https://example.test',
15
+ 'content-type': options.contentType ?? 'application/json',
16
+ },
17
+ body: JSON.stringify({ id: options.id ?? crypto.randomUUID(), settings }),
18
+ })
19
+
20
+ it('uses the request Host when Next normalizes the internal URL', async () => {
21
+ let writes = 0
22
+ const response = await updateCompactionSettings({
23
+ request: new Request('http://localhost:3107/agent/s/compaction', {
24
+ method: 'POST',
25
+ headers: {
26
+ host: '127.0.0.1:3107',
27
+ origin: 'http://127.0.0.1:3107',
28
+ 'content-type': 'application/json',
29
+ },
30
+ body: JSON.stringify({
31
+ id: crypto.randomUUID(),
32
+ settings: { mode: 'manual', thresholdTokens: 1 },
33
+ }),
34
+ }),
35
+ sessionId: 's',
36
+ append: async () => {
37
+ writes += 1
38
+ },
39
+ })
40
+ expect(response.status).toBe(200)
41
+ expect(writes).toBe(1)
42
+ })
43
+
44
+ it('saves session settings durably and idempotently without starting a turn', async () => {
45
+ let generations = 0
46
+ const server = createAgentServer({
47
+ agent: demoAgent,
48
+ store: memory(),
49
+ model: 'test/model',
50
+ compaction: ({ history }) => compactionOptions(history),
51
+ generate: () => {
52
+ generations += 1
53
+ return new ReadableStream({
54
+ start(controller) {
55
+ controller.close()
56
+ },
57
+ })
58
+ },
59
+ })
60
+ const session = server.session('settings-test')
61
+ const id = crypto.randomUUID()
62
+ const save = () =>
63
+ updateCompactionSettings({
64
+ request: request({ mode: 'manual', thresholdTokens: 1 }, { id }),
65
+ sessionId: session.id,
66
+ append: (event) => session.append(event),
67
+ })
68
+ expect((await save()).status).toBe(200)
69
+ expect((await save()).status).toBe(200)
70
+ await server.drain(session.id)
71
+ const history = await session.history()
72
+ expect(history.map((event) => event.type)).toEqual([
73
+ 'compaction.settings.updated',
74
+ ])
75
+ expect(latestCompactionSettings(history).settings).toEqual({
76
+ mode: 'manual',
77
+ thresholdTokens: 1,
78
+ })
79
+ expect(compactionOptions(history)).toEqual({ thresholdTokens: 1 })
80
+ expect(compactionOptions(await server.session('other').history())).toEqual({})
81
+ expect(generations).toBe(0)
82
+
83
+ await updateCompactionSettings({
84
+ request: request({ mode: 'disabled' }),
85
+ sessionId: session.id,
86
+ append: (event) => session.append(event),
87
+ })
88
+ expect(compactionOptions(await session.history())).toBe(false)
89
+ await updateCompactionSettings({
90
+ request: request({ mode: 'automatic' }),
91
+ sessionId: session.id,
92
+ append: (event) => session.append(event),
93
+ })
94
+ expect(compactionOptions(await session.history())).toEqual({})
95
+ })
96
+
97
+ it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, '1', null])(
98
+ 'rejects invalid threshold %s',
99
+ async (thresholdTokens) => {
100
+ let writes = 0
101
+ const response = await updateCompactionSettings({
102
+ request: request({ mode: 'manual', thresholdTokens }),
103
+ sessionId: 's',
104
+ append: async () => {
105
+ writes += 1
106
+ },
107
+ })
108
+ expect(response.status).toBe(400)
109
+ expect(writes).toBe(0)
110
+ },
111
+ )
112
+
113
+ it.each([
114
+ { origin: 'https://other.test', status: 403 },
115
+ { contentType: 'text/plain', status: 415 },
116
+ { id: 'not-an-id', status: 400 },
117
+ ])('rejects invalid request boundaries %j', async ({ status, ...options }) => {
118
+ let writes = 0
119
+ const response = await updateCompactionSettings({
120
+ request: request({ mode: 'automatic' }, options),
121
+ sessionId: 's',
122
+ append: async () => {
123
+ writes += 1
124
+ },
125
+ })
126
+ expect(response.status).toBe(status)
127
+ expect(writes).toBe(0)
128
+ })
@@ -0,0 +1,49 @@
1
+ import { z } from 'zod'
2
+ import { compactionSettingsSchema, type CompactionSettings } from './model'
3
+
4
+ const updateSchema = z.strictObject({
5
+ id: z.uuid(),
6
+ settings: compactionSettingsSchema,
7
+ })
8
+
9
+ export async function updateCompactionSettings(options: {
10
+ request: Request
11
+ sessionId: string
12
+ append: (event: {
13
+ id: string
14
+ type: 'compaction.settings.updated'
15
+ payload: CompactionSettings
16
+ }) => Promise<unknown>
17
+ }): Promise<Response> {
18
+ const { request, sessionId } = options
19
+ const origin = request.headers.get('origin')
20
+ const requestUrl = new URL(request.url)
21
+ requestUrl.host = request.headers.get('host') ?? requestUrl.host
22
+ if (origin !== null && origin !== requestUrl.origin) {
23
+ return Response.json(
24
+ { error: 'Use this page to update compaction settings.' },
25
+ { status: 403 },
26
+ )
27
+ }
28
+ if (
29
+ request.headers.get('content-type')?.split(';')[0]?.trim() !==
30
+ 'application/json'
31
+ ) {
32
+ return Response.json({ error: 'Expected JSON settings.' }, { status: 415 })
33
+ }
34
+ if (sessionId.length === 0)
35
+ return Response.json({ error: 'Session is required.' }, { status: 400 })
36
+ const parsed = updateSchema.safeParse(await request.json().catch(() => null))
37
+ if (!parsed.success) {
38
+ return Response.json(
39
+ { error: 'Choose a mode and a positive whole-number threshold.' },
40
+ { status: 400 },
41
+ )
42
+ }
43
+ await options.append({
44
+ id: `agent.compaction.settings:${encodeURIComponent(sessionId)}:${parsed.data.id}`,
45
+ type: 'compaction.settings.updated',
46
+ payload: parsed.data.settings,
47
+ })
48
+ return Response.json({ settings: parsed.data.settings })
49
+ }