experimental-a2 0.12.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.
- package/CHANGELOG.md +13 -0
- package/dist/ai-server.d.ts +11 -3
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +476 -142
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +32 -1
- package/dist/ai.d.ts.map +1 -1
- package/dist/ai.js +39 -7
- package/dist/ai.js.map +1 -1
- package/docs/guides/06-ai-agents.mdx +100 -18
- package/docs/reference/01-api.mdx +41 -4
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +31 -16
- 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 +128 -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 +47 -1
- package/examples/playground/app/agent/server.ts +3 -2
- package/examples/playground/app/globals.css +154 -0
- package/examples/playground/package.json +1 -1
- package/package.json +1 -1
- package/src/ai-model-metadata.ts +108 -0
- package/src/ai-sdk-step.ts +5 -1
- package/src/ai-server.ts +712 -203
- package/src/ai.ts +99 -4
|
@@ -762,35 +762,111 @@ interaction that began elsewhere.
|
|
|
762
762
|
|
|
763
763
|
## Compact long conversations
|
|
764
764
|
|
|
765
|
-
Compaction is
|
|
766
|
-
|
|
765
|
+
Compaction is automatic for supported Gateway models. A2 uses the active model
|
|
766
|
+
with the same instructions, tool definitions, and provider settings, and appends
|
|
767
|
+
an internal user message asking for a summary. This preserves the request prefix
|
|
768
|
+
for prompt caching when the provider has a matching cache entry.
|
|
767
769
|
|
|
768
770
|
```ts server/compacted.ts
|
|
769
|
-
import type { UIMessage } from 'ai'
|
|
770
771
|
import { createAgentServer } from 'experimental-a2/ai/server'
|
|
771
772
|
import { assistant } from '../assistant'
|
|
772
773
|
|
|
773
774
|
export const compactedAssistantServer = createAgentServer({
|
|
774
775
|
agent: assistant,
|
|
775
776
|
model: 'openai/gpt-5.6-terra',
|
|
776
|
-
compaction: {
|
|
777
|
-
shouldCompact: ({ messages }) => messages.length > 40,
|
|
778
|
-
compact: async ({ messages }) => {
|
|
779
|
-
const summary = {
|
|
780
|
-
id: crypto.randomUUID(),
|
|
781
|
-
role: 'user',
|
|
782
|
-
parts: [{ type: 'text', text: 'Summary of the earlier conversation.' }],
|
|
783
|
-
} satisfies UIMessage
|
|
784
|
-
|
|
785
|
-
return [summary, ...messages.slice(-10)]
|
|
786
|
-
},
|
|
787
|
-
},
|
|
788
777
|
})
|
|
789
778
|
```
|
|
790
779
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
780
|
+
On first use of each Gateway model in a session, A2 includes
|
|
781
|
+
`ai.model.metadata.requested` in the existing generation-start append. A separate
|
|
782
|
+
handler fetches the public Gateway model catalog and records the selected limits
|
|
783
|
+
in `ai.model.metadata.resolved`. It runs outside the AI turn lane. The first model
|
|
784
|
+
request proceeds immediately; metadata that arrives afterward is available to
|
|
785
|
+
later steps. Returning to a previously used model reuses its durable metadata.
|
|
786
|
+
The catalog is also cached in memory for one hour across agent servers, with one
|
|
787
|
+
shared in-flight request and a five-second timeout. A failed lookup follows A2's
|
|
788
|
+
handler retry policy without failing the model generation. A model absent from
|
|
789
|
+
the catalog is recorded as unavailable. A pending entry means no result has been
|
|
790
|
+
recorded, including when the metadata handler exhausts its retry budget. A2 does
|
|
791
|
+
not start a fresh lookup on every turn after exhaustion. Inspect the handler
|
|
792
|
+
failure or use an explicit threshold in that case.
|
|
793
|
+
|
|
794
|
+
The default threshold is 75% of the context window. A larger configured
|
|
795
|
+
`generation.maxOutputTokens` lowers the threshold further to reserve that output
|
|
796
|
+
allowance. This is an input-context policy, not a new output limit. Limits and
|
|
797
|
+
metadata status are available in `state.modelMetadata`.
|
|
798
|
+
|
|
799
|
+
`compaction: { thresholdTokens: 80_000 }` overrides discovery with an explicit
|
|
800
|
+
positive safe integer. `compaction: { instructions: 'Preserve exact IDs.' }`
|
|
801
|
+
adds guidance to the built-in summary prompt while keeping the derived threshold.
|
|
802
|
+
`compaction: false` disables both compaction and metadata lookup. The `compaction`
|
|
803
|
+
option can also be a synchronous resolver receiving the same
|
|
804
|
+
`{ event, state, history, signal }` context as `model` and `instructions`. It
|
|
805
|
+
returns `false`, automatic options, or a custom policy once per generation.
|
|
806
|
+
Use this to select a session-specific threshold from already-recorded settings.
|
|
807
|
+
Changes after resolution apply to a later model step. Disabling compaction
|
|
808
|
+
preserves summaries that were already recorded. Custom provider
|
|
809
|
+
models and custom global SDK providers need an explicit threshold. A custom
|
|
810
|
+
`generate` callback keeps compaction disabled unless explicitly configured. Gateway
|
|
811
|
+
fallback model lists also need an explicit threshold appropriate for every model
|
|
812
|
+
they may use.
|
|
813
|
+
|
|
814
|
+
The input estimate starts from serialized UTF-8 bytes divided by four, including
|
|
815
|
+
model messages, instructions, and tool schemas. When a preceding model call has
|
|
816
|
+
reported token usage for the same model and context, A2 uses that measurement as
|
|
817
|
+
an anchor and estimates subsequent growth. It is not a provider tokenizer and
|
|
818
|
+
does not precisely measure new image or audio tokens. A2 performs no remote token
|
|
819
|
+
counting.
|
|
820
|
+
|
|
821
|
+
On a cold session without recorded metadata or an explicit threshold, automatic
|
|
822
|
+
compaction waits for discovery. An oversized first prompt is sent normally and
|
|
823
|
+
its provider error becomes a generation failure. A2 does not silently truncate
|
|
824
|
+
it or attempt automatic recovery from a context-limit error. Use an explicit threshold for
|
|
825
|
+
applications that import a large initial conversation. The estimate is a
|
|
826
|
+
compaction trigger, not a definitive overflow check. Provider context-limit
|
|
827
|
+
errors during generation or summarization remain generation failures.
|
|
828
|
+
|
|
829
|
+
Crossing the threshold adds one summary model request. Application generation
|
|
830
|
+
callbacks, stream transforms, and tool hooks do not run for the summary, and it
|
|
831
|
+
never executes local tools. Provider-executed tools, structured output, and forced
|
|
832
|
+
tool choices disable implicit automatic compaction; explicitly configuring
|
|
833
|
+
automatic compaction for those requests throws. Use a custom policy for them.
|
|
834
|
+
An empty summary or a summary tool call fails the generation instead of replacing
|
|
835
|
+
its context.
|
|
836
|
+
|
|
837
|
+
A2 records `ai.compaction.requested` before the call and
|
|
838
|
+
`ai.compaction.completed` after a successful summary. The completed event stores
|
|
839
|
+
the summary, its usage, and the exact event frontier it covers. Later model
|
|
840
|
+
requests receive the summary plus everything produced after that frontier,
|
|
841
|
+
including later tool steps in the same assistant message. Queued user messages
|
|
842
|
+
remain queued. Compaction waits until earlier tool calls have terminal results.
|
|
843
|
+
The raw log and `state.messages` remain available in full.
|
|
844
|
+
|
|
845
|
+
Custom `shouldCompact(context)` and `compact(context)` callbacks remain
|
|
846
|
+
available for deterministic replacement messages or application-specific
|
|
847
|
+
retention. Both receive the typed UI messages and `modelMessages`, the model-ready
|
|
848
|
+
context including any existing summary. Custom generation callbacks also receive
|
|
849
|
+
`modelMessages`; use it when forwarding context to a provider. A2 stores built-in
|
|
850
|
+
summaries separately from application-typed UI messages, so a summary does not
|
|
851
|
+
need your message metadata or schema.
|
|
852
|
+
|
|
853
|
+
Render compaction as activity alongside the conversation:
|
|
854
|
+
|
|
855
|
+
```tsx app/agent/compaction-status.tsx
|
|
856
|
+
'use client'
|
|
857
|
+
import { useSession } from './session'
|
|
858
|
+
|
|
859
|
+
export function CompactionStatus() {
|
|
860
|
+
const { state } = useSession()
|
|
861
|
+
return state.compaction?.status === 'running' ? (
|
|
862
|
+
<p role="status">Compacting…</p>
|
|
863
|
+
) : null
|
|
864
|
+
}
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
A failed or interrupted generation restores the prior compaction state. The
|
|
868
|
+
synthetic summary request is not a user turn or an assistant reply in
|
|
869
|
+
`state.messages`.
|
|
794
870
|
|
|
795
871
|
## Extend the assembly
|
|
796
872
|
|
|
@@ -900,6 +976,12 @@ one atomic durable operation. Deterministic ids make scheduling and lifecycle
|
|
|
900
976
|
appends effectively once, and A2 marks an incomplete model attempt
|
|
901
977
|
`superseded` before starting its replacement.
|
|
902
978
|
|
|
979
|
+
An expired claim or superseded attempt preserves its abort reason through model,
|
|
980
|
+
resolver, compaction-policy, and tool calls. A2 retries that attempt without
|
|
981
|
+
spending its failure budget. Explicit user interruption remains terminal.
|
|
982
|
+
Streaming tool progress belongs to an execution attempt, so a retry can produce
|
|
983
|
+
different preliminary output while preserving the tool-call id for idempotency.
|
|
984
|
+
|
|
903
985
|
Provider calls and external tool side effects remain outside that transaction.
|
|
904
986
|
A process can die after an external effect succeeds and before its handler
|
|
905
987
|
completion commits, so recovery may run the call again. Give side-effecting
|
|
@@ -1149,7 +1149,7 @@ superseded owner cannot alter the projection. `terminalRequestIds` and
|
|
|
1149
1149
|
and supersession fences across snapshots and recovery. They are optional
|
|
1150
1150
|
snapshot-compatible fields with the shapes `Record<string, true>` and
|
|
1151
1151
|
`Record<string, 'completed' | 'failed' | 'interrupted' | 'superseded'>`.
|
|
1152
|
-
Extension events are ignored. The default reducer name is `a2.ai.state.
|
|
1152
|
+
Extension events are ignored. The default reducer name is `a2.ai.state.v10`.
|
|
1153
1153
|
|
|
1154
1154
|
### `deriveUIMessages(history)` and `reduceAIState(state, event)`
|
|
1155
1155
|
|
|
@@ -1243,9 +1243,46 @@ support wait for their provider result instead of a local executor. An
|
|
|
1243
1243
|
authenticated provider callback appends the terminal `ai.tool.result` through
|
|
1244
1244
|
the trusted server session API; the browser push allowlist rejects it.
|
|
1245
1245
|
|
|
1246
|
-
`compaction`
|
|
1247
|
-
|
|
1248
|
-
|
|
1246
|
+
`compaction` defaults to automatic for supported Gateway requests. It accepts
|
|
1247
|
+
`false` to disable compaction and discovery, `{ thresholdTokens?, instructions? }`
|
|
1248
|
+
to override automatic behavior, or the existing custom
|
|
1249
|
+
`{ shouldCompact(context), compact(context) }` policy. It also accepts a
|
|
1250
|
+
resolver `(context: AgentResolverContext) => CompactionOptions`.
|
|
1251
|
+
The resolver runs synchronously once per generation and must return a valid policy
|
|
1252
|
+
or `false`. Custom `shouldCompact` and `compact` callbacks can still be asynchronous.
|
|
1253
|
+
Static options are validated at construction; resolved options are validated
|
|
1254
|
+
before that generation starts. Each session uses its own resolved value. `thresholdTokens` is an
|
|
1255
|
+
optional positive safe integer. `instructions` adds to A2's internal summary
|
|
1256
|
+
prompt without replacing the agent's instructions.
|
|
1257
|
+
|
|
1258
|
+
Without an explicit threshold, the first generation batches a metadata request
|
|
1259
|
+
into its existing start write. An independent handler reads the public Gateway
|
|
1260
|
+
catalog and records that session/model's limits. It does not delay model dispatch.
|
|
1261
|
+
The default threshold is 75% of the context window, lowered for a larger configured
|
|
1262
|
+
output allowance. A pending or unavailable lookup leaves the threshold unknown;
|
|
1263
|
+
the first request proceeds, and provider context-limit errors fail normally.
|
|
1264
|
+
`state.modelMetadata` retains each model's pending, resolved, or unavailable state.
|
|
1265
|
+
Pending also covers exhausted handler retries; no new request is added per turn.
|
|
1266
|
+
The catalog has a shared one-hour process cache and a five-second fetch timeout.
|
|
1267
|
+
|
|
1268
|
+
Direct/custom providers, fallback model lists, and custom generators need explicit
|
|
1269
|
+
compaction configuration. Provider-executed tools, structured output, and forced
|
|
1270
|
+
tool choices skip implicit compaction and reject explicit automatic policies.
|
|
1271
|
+
Custom policies remain available for these configurations.
|
|
1272
|
+
|
|
1273
|
+
The input estimate includes messages, tools, and instructions, and uses prior
|
|
1274
|
+
reported input usage to anchor later growth where applicable. It is not a provider
|
|
1275
|
+
tokenizer. Completed generations record `inputTokenEstimate` beside usage. The
|
|
1276
|
+
built-in summary adds one model request and preserves the request prefix for
|
|
1277
|
+
caching. It does not invoke application generation callbacks, transforms, or tool
|
|
1278
|
+
hooks. Both custom policy callbacks and `generate` receive `modelMessages`, the
|
|
1279
|
+
model-ready prompt including summaries, alongside typed UI `messages`.
|
|
1280
|
+
|
|
1281
|
+
Built-in summaries remain separate from application-typed messages. Render
|
|
1282
|
+
`state.compaction?.status === 'running'` as current activity, and the durable
|
|
1283
|
+
`ai.compaction.*` events as historical timeline markers. The conversation in
|
|
1284
|
+
`state.messages` remains intact. `progress` controls durable batching with
|
|
1285
|
+
`maxChunks` and `maxDelayMs`.
|
|
1249
1286
|
|
|
1250
1287
|
This entry point is server-only and resolves to a throwing browser stub.
|
|
1251
1288
|
|
|
@@ -10,6 +10,9 @@ import { inputs } from 'experimental-a2/ai'
|
|
|
10
10
|
import { ActivityFeed } from '@/app/components/activity-feed'
|
|
11
11
|
import { ConnectionPill } from '@/app/components/connection-pill'
|
|
12
12
|
import { useSession } from '../session'
|
|
13
|
+
import { compactionTimeline } from '../compaction-timeline'
|
|
14
|
+
import { CompactionEvent } from './compaction-event'
|
|
15
|
+
import { CompactionPanel } from './compaction-panel'
|
|
13
16
|
|
|
14
17
|
type MessagePart = UIMessage['parts'][number]
|
|
15
18
|
type ToolPartView = {
|
|
@@ -169,10 +172,12 @@ export function AgentClient({ agentId }: { agentId: string }): ReactNode {
|
|
|
169
172
|
const [responding, setResponding] = useState(false)
|
|
170
173
|
|
|
171
174
|
const generating = state.status === 'generating'
|
|
175
|
+
const compacting = state.compaction?.status === 'running'
|
|
172
176
|
const canSend = state.status === 'idle' || state.status === 'failed'
|
|
177
|
+
const timeline = compactionTimeline({ state, events })
|
|
173
178
|
|
|
174
179
|
useEffect(() => {
|
|
175
|
-
if (canSend) promptRef.current?.focus()
|
|
180
|
+
if (canSend) promptRef.current?.focus({ preventScroll: true })
|
|
176
181
|
}, [canSend])
|
|
177
182
|
|
|
178
183
|
const send = async (event: FormEvent): Promise<void> => {
|
|
@@ -250,32 +255,43 @@ export function AgentClient({ agentId }: { agentId: string }): ReactNode {
|
|
|
250
255
|
state.status === 'waiting' ? ' waiting' : ''
|
|
251
256
|
}${state.status === 'failed' ? ' cut' : ''}`}
|
|
252
257
|
>
|
|
253
|
-
{STATUS_LABEL[state.status]}
|
|
258
|
+
{compacting ? 'compacting…' : STATUS_LABEL[state.status]}
|
|
254
259
|
</span>
|
|
255
260
|
</header>
|
|
256
261
|
|
|
262
|
+
<CompactionPanel agentId={agentId} state={state} events={events} />
|
|
263
|
+
|
|
257
264
|
<section className="agent-conversation" aria-label="Conversation">
|
|
258
265
|
<div className="agent-transcript" aria-live="polite">
|
|
259
|
-
{
|
|
266
|
+
{timeline.length === 0 ? (
|
|
260
267
|
<p className="agent-empty">
|
|
261
268
|
Ask the agent to investigate an incident, run a Bash command, or
|
|
262
269
|
set a reminder for itself. Its tools and decisions will appear
|
|
263
270
|
here as they enter the log.
|
|
264
271
|
</p>
|
|
265
272
|
) : (
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
key={
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
message.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
273
|
+
timeline.map((entry) =>
|
|
274
|
+
entry.type === 'compaction' ? (
|
|
275
|
+
<CompactionEvent key={entry.id} entry={entry} />
|
|
276
|
+
) : (
|
|
277
|
+
<AgentMessage
|
|
278
|
+
key={entry.id}
|
|
279
|
+
message={entry.message}
|
|
280
|
+
thinking={
|
|
281
|
+
generating &&
|
|
282
|
+
!compacting &&
|
|
283
|
+
entry.lastSegment &&
|
|
284
|
+
entry.message.id ===
|
|
285
|
+
state.activeGeneration?.responseMessageId &&
|
|
286
|
+
!hasVisibleParts(entry.message)
|
|
287
|
+
}
|
|
288
|
+
/>
|
|
289
|
+
),
|
|
290
|
+
)
|
|
277
291
|
)}
|
|
278
|
-
{generating &&
|
|
292
|
+
{generating &&
|
|
293
|
+
!compacting &&
|
|
294
|
+
state.messages.at(-1)?.role === 'user' ? (
|
|
279
295
|
<div className="agent-message assistant">
|
|
280
296
|
<span className="agent-role">Agent</span>
|
|
281
297
|
<div>
|
|
@@ -340,7 +356,6 @@ export function AgentClient({ agentId }: { agentId: string }): ReactNode {
|
|
|
340
356
|
Message
|
|
341
357
|
<textarea
|
|
342
358
|
ref={promptRef}
|
|
343
|
-
autoFocus
|
|
344
359
|
rows={3}
|
|
345
360
|
value={prompt}
|
|
346
361
|
onChange={(event) => setPrompt(event.target.value)}
|
|
@@ -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
|
+
}
|