@adecore/agents 0.20.1 → 0.21.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.
@@ -187,7 +187,7 @@ export class ThreadProjector {
187
187
  break;
188
188
  case 'text.delta':
189
189
  this.closeThinking(events);
190
- this.appendText(this.itemId(generation, event.ref), event.text, events);
190
+ this.appendText(this.itemId(generation, event.ref), event.text, events, event.phase);
191
191
  break;
192
192
  case 'text.done': {
193
193
  if (event.parentRef) {
@@ -206,6 +206,12 @@ export class ThreadProjector {
206
206
  createdAt: existing?.createdAt ?? this.now(),
207
207
  turnId: existing?.turnId ?? info.activeTurnId,
208
208
  text: event.text,
209
+ ...(existing?.kind === 'assistant' ? { uiAnswers: existing.uiAnswers, uiQueries: existing.uiQueries } : {}),
210
+ ...(event.phase === undefined
211
+ ? existing?.kind === 'assistant' && existing.phase !== undefined
212
+ ? { phase: existing.phase }
213
+ : {}
214
+ : { phase: event.phase }),
209
215
  streaming: false
210
216
  })
211
217
  );
@@ -507,7 +513,7 @@ export class ThreadProjector {
507
513
  }
508
514
  }
509
515
 
510
- private appendText(id: string, text: string, events: ChatEvent[]): void {
516
+ private appendText(id: string, text: string, events: ChatEvent[], phase?: string): void {
511
517
  const existing = this.thread.get(id);
512
518
  if (!existing) {
513
519
  events.push(
@@ -517,6 +523,7 @@ export class ThreadProjector {
517
523
  createdAt: this.now(),
518
524
  turnId: this.thread.info.activeTurnId,
519
525
  text,
526
+ ...(phase === undefined ? {} : { phase }),
520
527
  streaming: true
521
528
  })
522
529
  );
@@ -93,6 +93,12 @@ export class ChatThread {
93
93
  break;
94
94
  case 'delta':
95
95
  this.appendText(event.itemId, event.text);
96
+ if (event.uiQueries) {
97
+ const item = this.items.get(event.itemId);
98
+ if (item?.kind === 'assistant') {
99
+ this.items.set(item.id, { ...item, uiQueries: event.uiQueries });
100
+ }
101
+ }
96
102
  break;
97
103
  case 'info':
98
104
  this.info = event.info;
@@ -0,0 +1,360 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { z } from 'zod';
3
+ import type {
4
+ ChatUiLinkPayload,
5
+ ChatUiLinkReading,
6
+ ChatAssistantItem,
7
+ ChatInfo,
8
+ ChatUiChoicePayload,
9
+ ChatUiQueryPayload,
10
+ ChatUiQueryReading,
11
+ ChatUiQueryState
12
+ } from '@adecore/agent-contracts';
13
+ import { copyUiValue, UiBudget, type UiValue } from '@adecore/intelligent-ui';
14
+ import { UiLinkResolutionSchema, uiLinkTargets, type UiLinkTarget } from '@adecore/intelligent-ui/links';
15
+ import { uiQueryArguments, uiQueryFallback } from '@adecore/intelligent-ui/query';
16
+ import type { UiBlock } from '@adecore/intelligent-ui/compiler';
17
+ import type { ChatSession } from './chat-session.ts';
18
+ import { ChatError } from './errors.ts';
19
+
20
+ export interface ChatUiSource {
21
+ args: z.ZodType;
22
+ result: z.ZodType;
23
+ authorize(info: ChatInfo, access: unknown, args: Record<string, UiValue>): Promise<void>;
24
+ read(info: ChatInfo, args: Record<string, UiValue>, signal: AbortSignal, access: unknown): Promise<unknown>;
25
+ }
26
+
27
+ export interface ChatUiHost {
28
+ capture(info: ChatInfo): Promise<unknown>;
29
+ sources: Readonly<Record<string, ChatUiSource>>;
30
+ link?(info: ChatInfo, access: unknown, target: UiLinkTarget): Promise<ChatUiLinkReading>;
31
+ }
32
+
33
+ interface CachedReading {
34
+ key: string;
35
+ chatId: string;
36
+ reading: ChatUiQueryReading;
37
+ }
38
+
39
+ const MAX_QUERY_BYTES = 64 * 1024;
40
+ const MAX_SNAPSHOT_BYTES = 128 * 1024;
41
+ const MAX_QUERIES = 8;
42
+ const REFRESH_MS = 10_000;
43
+ const READ_TIMEOUT_MS = 8_000;
44
+
45
+ export class ChatUiQueries {
46
+ readonly schemas: Record<string, z.ZodType>;
47
+ private readonly captures = new Map<string, Promise<ChatUiQueryState>>();
48
+ private readonly freezing = new Set<string>();
49
+ private readonly cache = new Map<string, CachedReading>();
50
+ private readonly reading = new Map<string, Promise<ChatUiQueryReading>>();
51
+ private readonly attempts = new Map<string, number>();
52
+ private readonly active = new Map<string, number>();
53
+ private totalActive = 0;
54
+
55
+ private readonly host: ChatUiHost;
56
+
57
+ constructor(host: ChatUiHost) {
58
+ this.host = host;
59
+ this.schemas = Object.fromEntries(Object.entries(host.sources).map(([name, source]) => [name, source.args]));
60
+ }
61
+
62
+ observe(session: ChatSession, item: ChatAssistantItem, final: boolean): void {
63
+ if (!item.text.includes('@Query') && !/<(?:File|Diff|Commit|Node)\b/.test(item.text) && !item.ui?.some((block) => Object.keys(block.queries).length)) {
64
+ return;
65
+ }
66
+ const capture = this.access(session, item);
67
+ if (!final || !item.ui?.length) {
68
+ return;
69
+ }
70
+ const key = `${session.id}:${item.id}`;
71
+ if (this.freezing.has(key)) {
72
+ return;
73
+ }
74
+ this.freezing.add(key);
75
+ void capture
76
+ .then(async () => {
77
+ for (const block of (item.ui ?? []).slice(0, 16)) {
78
+ const current = session.thread.get(item.id);
79
+ if (current?.kind !== 'assistant' || current.ui?.find((entry) => entry.id === block.id)?.revision !== block.revision) {
80
+ return;
81
+ }
82
+ if (current.uiQueries?.blocks[block.id]?.revision === block.revision) {
83
+ continue;
84
+ }
85
+ const readings: Record<string, ChatUiQueryReading> = Object.create(null);
86
+ const values: Record<string, unknown> = Object.create(null);
87
+ for (const name of Object.keys(block.queries).slice(0, MAX_QUERIES)) {
88
+ const reading = await this.query(session, {
89
+ chatId: session.id,
90
+ itemId: item.id,
91
+ blockId: block.id,
92
+ revision: block.revision!,
93
+ query: name
94
+ });
95
+ readings[name] = reading;
96
+ if (reading.state === 'fresh') {
97
+ values[name] = reading.value;
98
+ }
99
+ }
100
+ const links: Record<string, ChatUiLinkReading> = Object.create(null);
101
+ if (this.host.link) {
102
+ const targets = uiLinkTargets(block, {}, values);
103
+ const access = await this.access(session, current);
104
+ for (const [id, target] of Object.entries(targets).slice(0, 64)) {
105
+ try {
106
+ links[id] = this.linkReading(await this.host.link(session.info, access.access, target));
107
+ } catch (error) {
108
+ links[id] = { state: 'plain', reason: this.reason(error) };
109
+ }
110
+ }
111
+ }
112
+ const latest = session.thread.get(item.id);
113
+ if (latest?.kind !== 'assistant' || !latest.uiQueries || latest.ui?.find((entry) => entry.id === block.id)?.revision !== block.revision) {
114
+ return;
115
+ }
116
+ const names = Object.entries(block.queries)
117
+ .map(([name, query]) => `${name}: ${query.source}`)
118
+ .join(', ');
119
+ const failures = Object.entries(readings)
120
+ .filter(([, reading]) => reading.state !== 'fresh')
121
+ .map(([name, reading]) => `${name}: ${reading.reason}`)
122
+ .join('\n');
123
+ const frozen = {
124
+ revision: block.revision!,
125
+ readings,
126
+ links,
127
+ fallback: `${uiQueryFallback(block, values).slice(0, 8192)}${names ? `\nSources: ${names}` : ''}${failures ? `\n${failures}` : ''}`
128
+ };
129
+ const blocks = { ...latest.uiQueries.blocks, [block.id]: frozen };
130
+ if (Buffer.byteLength(JSON.stringify(blocks)) > MAX_SNAPSHOT_BYTES) {
131
+ frozen.readings = Object.fromEntries(
132
+ Object.keys(readings).map((name) => [
133
+ name,
134
+ { state: 'failed', readAt: Date.now(), reason: 'The frozen query values are too large.' }
135
+ ])
136
+ );
137
+ frozen.links = Object.fromEntries(Object.keys(links).map((id) => [id, { state: 'plain', reason: 'This link must be checked again.' }]));
138
+ frozen.fallback = `${block.fallback.slice(0, 2048)}\nSources: ${names}\nThe frozen query values are too large.`;
139
+ }
140
+ if (Buffer.byteLength(JSON.stringify(blocks)) > MAX_SNAPSHOT_BYTES) {
141
+ delete blocks[block.id];
142
+ }
143
+ session.updateUiQueries(item.id, { ...latest.uiQueries, blocks });
144
+ }
145
+ })
146
+ .catch(() => {})
147
+ .finally(() => this.freezing.delete(key));
148
+ }
149
+
150
+ private access(session: ChatSession, item: ChatAssistantItem): Promise<ChatUiQueryState> {
151
+ const key = `${session.id}:${item.id}`;
152
+ const existing = this.captures.get(key);
153
+ if (existing) {
154
+ return existing;
155
+ }
156
+ if (item.uiQueries?.authorChatId === session.id) {
157
+ return Promise.resolve(item.uiQueries);
158
+ }
159
+ // A pending capture is persisted as denied, so a restart cannot widen the writer's rights.
160
+ session.updateUiQueries(item.id, { authorChatId: session.id, access: null, blocks: {} });
161
+ const captured = this.host
162
+ .capture(session.info)
163
+ .then((access) => {
164
+ const captured = copyUiValue(access, new UiBudget());
165
+ if (Buffer.byteLength(JSON.stringify(captured)) > 32 * 1024) {
166
+ throw new Error('The UI access snapshot is too large.');
167
+ }
168
+ const state: ChatUiQueryState = { authorChatId: session.id, access: captured, blocks: {} };
169
+ session.updateUiQueries(item.id, state);
170
+ return state;
171
+ })
172
+ .catch(() => ({ authorChatId: session.id, access: null, blocks: {} }))
173
+ .finally(() => this.captures.delete(key));
174
+ this.captures.set(key, captured);
175
+ return captured;
176
+ }
177
+
178
+ private storedBlock(
179
+ session: ChatSession,
180
+ payload: Pick<ChatUiQueryPayload, 'itemId' | 'blockId' | 'revision'>
181
+ ): { item: ChatAssistantItem; block: UiBlock } {
182
+ const item = session.thread.get(payload.itemId);
183
+ const block =
184
+ item?.kind === 'assistant' && !item.streaming && !item.parentToolUseId ? item.ui?.find((entry) => entry.id === payload.blockId) : undefined;
185
+ if (item?.kind !== 'assistant' || !block?.complete || block.revision !== payload.revision) {
186
+ throw new ChatError('stale-ui-block', 'This completed UI block is no longer available.');
187
+ }
188
+ return { item, block };
189
+ }
190
+
191
+ private stored(session: ChatSession, payload: ChatUiQueryPayload): { item: ChatAssistantItem; block: UiBlock; source: ChatUiSource } {
192
+ const { item, block } = this.storedBlock(session, payload);
193
+ if (!Object.hasOwn(block.queries, payload.query) || Object.keys(block.queries).length > MAX_QUERIES) {
194
+ throw new ChatError('refused-query', 'This block does not declare an allowed query.');
195
+ }
196
+ const name = block.queries[payload.query].source;
197
+ if (!Object.hasOwn(this.host.sources, name)) {
198
+ throw new ChatError('refused-query', 'This query source is not registered.');
199
+ }
200
+ return { item, block, source: this.host.sources[name] };
201
+ }
202
+
203
+ async query(session: ChatSession, payload: ChatUiQueryPayload): Promise<ChatUiQueryReading> {
204
+ let args: Record<string, UiValue>;
205
+ let source: ChatUiSource;
206
+ let block: UiBlock;
207
+ let item: ChatAssistantItem;
208
+ let authorAccess: unknown;
209
+ try {
210
+ ({ source, block, item } = this.stored(session, payload));
211
+ args = uiQueryArguments(block, payload.query, payload.values);
212
+ args = source.args.parse(args) as Record<string, UiValue>;
213
+ const access = await this.access(session, item);
214
+ if (access.access === null) {
215
+ throw new ChatError('refused-query', 'The writer’s original access is unavailable.');
216
+ }
217
+ authorAccess = access.access;
218
+ await source.authorize(session.info, authorAccess, args);
219
+ } catch (error) {
220
+ return { state: 'refused', readAt: Date.now(), reason: this.reason(error) };
221
+ }
222
+ const key = JSON.stringify([session.id, item.id, block.id, block.revision, payload.query, args]);
223
+ const cached = [...this.cache.values()].find((entry) => entry.key === key);
224
+ if (cached && Date.now() - cached.reading.readAt < REFRESH_MS) {
225
+ return cached.reading;
226
+ }
227
+ const pending = this.reading.get(key);
228
+ if (pending) {
229
+ return pending;
230
+ }
231
+ const identity = JSON.stringify([session.id, item.id, block.id, block.revision, payload.query]);
232
+ if (Date.now() - (this.attempts.get(identity) ?? 0) < REFRESH_MS) {
233
+ return { state: 'failed', readAt: Date.now(), reason: 'This query may refresh once every ten seconds.' };
234
+ }
235
+ if ((this.active.get(session.id) ?? 0) >= 2 || this.totalActive >= 16) {
236
+ return { state: 'failed', readAt: Date.now(), reason: 'Too many UI queries are being read.' };
237
+ }
238
+ this.attempts.set(identity, Date.now());
239
+ while (this.attempts.size > 128) {
240
+ this.attempts.delete(this.attempts.keys().next().value!);
241
+ }
242
+ this.active.set(session.id, (this.active.get(session.id) ?? 0) + 1);
243
+ this.totalActive++;
244
+ const controller = new AbortController();
245
+ const work = Promise.resolve().then(() => source.read(session.info, args, controller.signal, authorAccess));
246
+ const timeout = setTimeout(() => controller.abort(), READ_TIMEOUT_MS);
247
+ timeout.unref?.();
248
+ const read = Promise.race([
249
+ work,
250
+ new Promise<never>((_, reject) => controller.signal.addEventListener('abort', () => reject(new Error('The UI query timed out.')), { once: true }))
251
+ ])
252
+ .then((result): ChatUiQueryReading => {
253
+ const value = copyUiValue(source.result.parse(result), new UiBudget());
254
+ if (Buffer.byteLength(JSON.stringify(value)) > MAX_QUERY_BYTES) {
255
+ throw new Error('The UI query result is too large.');
256
+ }
257
+ const reading: ChatUiQueryReading = { state: 'fresh', value, readId: randomUUID(), readAt: Date.now() };
258
+ this.cache.set(reading.readId!, { key, chatId: session.id, reading });
259
+ while (this.cache.size > 128) {
260
+ this.cache.delete(this.cache.keys().next().value!);
261
+ }
262
+ return reading;
263
+ })
264
+ .catch((error): ChatUiQueryReading => ({ state: 'failed', readAt: Date.now(), reason: this.reason(error) }))
265
+ .finally(() => {
266
+ clearTimeout(timeout);
267
+ this.reading.delete(key);
268
+ });
269
+ // Timed-out sources still occupy their slot until their actual work ends.
270
+ void work
271
+ .catch(() => {})
272
+ .finally(() => {
273
+ const count = (this.active.get(session.id) ?? 1) - 1;
274
+ if (count) {
275
+ this.active.set(session.id, count);
276
+ } else {
277
+ this.active.delete(session.id);
278
+ }
279
+ this.totalActive--;
280
+ });
281
+ this.reading.set(key, read);
282
+ return read;
283
+ }
284
+
285
+ async choiceValues(session: ChatSession, payload: ChatUiChoicePayload | ChatUiLinkPayload): Promise<Record<string, unknown>> {
286
+ const item = session.thread.get(payload.itemId);
287
+ const block = item?.kind === 'assistant' ? item.ui?.find((entry) => entry.id === payload.blockId) : undefined;
288
+ if (!block || item?.kind !== 'assistant' || ('choiceId' in payload && item.uiAnswers?.[block.id]?.revision === payload.revision)) {
289
+ return {};
290
+ }
291
+ const values: Record<string, unknown> = Object.create(null);
292
+ for (const name of Object.keys(payload.reads ?? {})) {
293
+ const identity = { ...payload, query: name };
294
+ const { source } = this.stored(session, identity);
295
+ const args = source.args.parse(uiQueryArguments(block, name, payload.values)) as Record<string, UiValue>;
296
+ const access = await this.access(session, item);
297
+ await source.authorize(session.info, access.access, args);
298
+ const key = JSON.stringify([session.id, item.id, block.id, block.revision, name, args]);
299
+ const readId = payload.reads![name];
300
+ const cached = this.cache.get(readId);
301
+ const frozen = item.uiQueries?.blocks[block.id];
302
+ const snapshot = frozen?.revision === block.revision ? frozen?.readings[name] : undefined;
303
+ const reading =
304
+ cached?.key === key
305
+ ? cached.reading
306
+ : snapshot?.readId === readId && JSON.stringify(args) === JSON.stringify(source.args.parse(uiQueryArguments(block, name)))
307
+ ? snapshot
308
+ : undefined;
309
+ if (!reading || reading.state !== 'fresh') {
310
+ throw new ChatError('stale-ui-query', 'Refresh this query before sending its choice.');
311
+ }
312
+ values[name] = reading.value;
313
+ }
314
+ return values;
315
+ }
316
+
317
+ async link(session: ChatSession, payload: ChatUiLinkPayload): Promise<ChatUiLinkReading> {
318
+ try {
319
+ if (!this.host.link) {
320
+ throw new ChatError('refused-query', 'This host does not provide UI links.');
321
+ }
322
+ const { item, block } = this.storedBlock(session, payload);
323
+ if (!item.uiQueries) {
324
+ throw new ChatError('refused-query', 'The writer’s original access is unavailable.');
325
+ }
326
+ const access = await this.access(session, item);
327
+ if (access.access === null) {
328
+ throw new ChatError('refused-query', 'The writer’s original access is unavailable.');
329
+ }
330
+ const queries = await this.choiceValues(session, payload);
331
+ const target = uiLinkTargets(block, payload.values, queries)[payload.nodeId];
332
+ if (!target) {
333
+ throw new ChatError('refused-query', 'This visible node is not a supported link.');
334
+ }
335
+ return this.linkReading(await this.host.link(session.info, access.access, target));
336
+ } catch (error) {
337
+ return { state: 'plain', reason: this.reason(error) };
338
+ }
339
+ }
340
+
341
+ forget(chatId: string): void {
342
+ for (const [id, cached] of this.cache) {
343
+ if (cached.chatId === chatId) {
344
+ this.cache.delete(id);
345
+ }
346
+ }
347
+ }
348
+
349
+ private linkReading(value: unknown): ChatUiLinkReading {
350
+ const reading = UiLinkResolutionSchema.parse(copyUiValue(value, new UiBudget()));
351
+ if (reading.state === 'chip' && !reading.target) {
352
+ throw new Error('The host did not resolve a link target.');
353
+ }
354
+ return reading;
355
+ }
356
+
357
+ private reason(error: unknown): string {
358
+ return (error instanceof Error ? error.message : 'This query could not be read.').slice(0, 240);
359
+ }
360
+ }
@@ -66,6 +66,9 @@ export function chatHandlers(chats: ChatCore, providers: ProviderRegistry): { [T
66
66
  { delivery: payload.delivery, mentions: payload.mentions, skills: payload.skills, chats: payload.chats },
67
67
  payload.attachments
68
68
  ),
69
+ 'chat.uiChoice': (payload) => chats.choose(payload),
70
+ 'ui.link': (payload, clientId) => chats.linkUi(payload, clientId),
71
+ 'ui.query': (payload, clientId) => chats.queryUi(payload, clientId),
69
72
  'chat.unqueue': (payload) => ({ message: chats.unqueue(payload.chatId, payload.messageId) }),
70
73
  'chat.sendNow': (payload) => {
71
74
  chats.sendNow(payload.chatId, payload.messageId);