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.
@@ -762,35 +762,111 @@ interaction that began elsewhere.
762
762
 
763
763
  ## Compact long conversations
764
764
 
765
- Compaction is optional application policy. A2 records both the decision and
766
- replacement messages, so later prompts remain explainable from history:
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
- The callback can call another model, create a deterministic summary, or retain
792
- selected messages. A2 owns only when the result enters the log and how later
793
- generations consume it.
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.v8`.
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` has `shouldCompact(context)` and `compact(context)` callbacks.
1247
- When selected, both the request and the replacement messages enter the log.
1248
- `progress` controls durable batching with `maxChunks` and `maxDelayMs`.
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
- {state.messages.length === 0 ? (
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
- state.messages.map((message) => (
267
- <AgentMessage
268
- key={message.id}
269
- message={message}
270
- thinking={
271
- generating &&
272
- message.role === 'assistant' &&
273
- !hasVisibleParts(message)
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 && state.messages.at(-1)?.role === 'user' ? (
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
+ }