@adecore/agents 0.21.0 → 0.21.1

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.
@@ -7,10 +7,9 @@ import type {
7
7
  ChatInfo,
8
8
  ChatUiChoicePayload,
9
9
  ChatUiQueryPayload,
10
- ChatUiQueryReading,
11
- ChatUiQueryState
10
+ ChatUiQueryReading
12
11
  } from '@adecore/agent-contracts';
13
- import { copyUiValue, UiBudget, type UiValue } from '@adecore/intelligent-ui';
12
+ import { copyUiValue, UI_HOST_LIMITS, UiBudget, uiMayReferenceHost, type UiValue } from '@adecore/intelligent-ui';
14
13
  import { UiLinkResolutionSchema, uiLinkTargets, type UiLinkTarget } from '@adecore/intelligent-ui/links';
15
14
  import { uiQueryArguments, uiQueryFallback } from '@adecore/intelligent-ui/query';
16
15
  import type { UiBlock } from '@adecore/intelligent-ui/compiler';
@@ -36,17 +35,42 @@ interface CachedReading {
36
35
  reading: ChatUiQueryReading;
37
36
  }
38
37
 
38
+ /*
39
+ * A failure a client can say in its own words: `reason` is a code that stays the same across versions,
40
+ * the message is the fallback for a client that does not know the code.
41
+ */
42
+ class UiQueryError extends ChatError<string> {
43
+ readonly reason: string;
44
+
45
+ constructor(code: string, reason: string, message: string) {
46
+ super(code, message);
47
+ this.reason = reason;
48
+ }
49
+ }
50
+
51
+ /*
52
+ * What a host's `authorize`, `read` or `link` throws to say why with a code: `code` reaches the reading's
53
+ * `code` and should stay the same across versions, `reason` is the plain sentence a client shows without it.
54
+ */
55
+ export class ChatUiRefusal extends UiQueryError {
56
+ constructor(code: string, reason: string) {
57
+ super('refused-query', code, reason);
58
+ }
59
+ }
60
+
39
61
  const MAX_QUERY_BYTES = 64 * 1024;
40
62
  const MAX_SNAPSHOT_BYTES = 128 * 1024;
41
- const MAX_QUERIES = 8;
42
- const REFRESH_MS = 10_000;
43
63
  const READ_TIMEOUT_MS = 8_000;
64
+ // A changed input reads at once, but a query never reads more often than this across its inputs.
65
+ const INPUT_READ_MS = 250;
44
66
 
45
67
  export class ChatUiQueries {
46
68
  readonly schemas: Record<string, z.ZodType>;
47
- private readonly captures = new Map<string, Promise<ChatUiQueryState>>();
69
+ private readonly captures = new Map<string, Promise<unknown>>();
48
70
  private readonly freezing = new Set<string>();
49
71
  private readonly cache = new Map<string, CachedReading>();
72
+ // The read id of the newest reading per cache key, which a remount or a second client is answered with.
73
+ private readonly latest = new Map<string, string>();
50
74
  private readonly reading = new Map<string, Promise<ChatUiQueryReading>>();
51
75
  private readonly attempts = new Map<string, number>();
52
76
  private readonly active = new Map<string, number>();
@@ -60,14 +84,16 @@ export class ChatUiQueries {
60
84
  }
61
85
 
62
86
  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)) {
87
+ const key = `${session.id}:${item.id}`;
88
+ // Once a capture started, every delta of a streaming reply would scan its whole text again for nothing.
89
+ const captured = this.captures.has(key) || item.uiQueries?.authorChatId === session.id;
90
+ if (!captured && !uiMayReferenceHost(item.text) && !item.ui?.some((block) => Object.keys(block.queries).length)) {
64
91
  return;
65
92
  }
66
93
  const capture = this.access(session, item);
67
94
  if (!final || !item.ui?.length) {
68
95
  return;
69
96
  }
70
- const key = `${session.id}:${item.id}`;
71
97
  if (this.freezing.has(key)) {
72
98
  return;
73
99
  }
@@ -84,7 +110,7 @@ export class ChatUiQueries {
84
110
  }
85
111
  const readings: Record<string, ChatUiQueryReading> = Object.create(null);
86
112
  const values: Record<string, unknown> = Object.create(null);
87
- for (const name of Object.keys(block.queries).slice(0, MAX_QUERIES)) {
113
+ for (const name of Object.keys(block.queries).slice(0, UI_HOST_LIMITS.queries)) {
88
114
  const reading = await this.query(session, {
89
115
  chatId: session.id,
90
116
  itemId: item.id,
@@ -101,11 +127,11 @@ export class ChatUiQueries {
101
127
  if (this.host.link) {
102
128
  const targets = uiLinkTargets(block, {}, values);
103
129
  const access = await this.access(session, current);
104
- for (const [id, target] of Object.entries(targets).slice(0, 64)) {
130
+ for (const [id, target] of Object.entries(targets).slice(0, UI_HOST_LIMITS.links)) {
105
131
  try {
106
- links[id] = this.linkReading(await this.host.link(session.info, access.access, target));
132
+ links[id] = this.linkReading(await this.host.link(session.info, access, target));
107
133
  } catch (error) {
108
- links[id] = { state: 'plain', reason: this.reason(error) };
134
+ links[id] = { state: 'plain', ...this.failure(error) };
109
135
  }
110
136
  }
111
137
  }
@@ -131,10 +157,12 @@ export class ChatUiQueries {
131
157
  frozen.readings = Object.fromEntries(
132
158
  Object.keys(readings).map((name) => [
133
159
  name,
134
- { state: 'failed', readAt: Date.now(), reason: 'The frozen query values are too large.' }
160
+ { state: 'failed', readAt: Date.now(), code: 'snapshot-too-large', reason: 'The frozen query values are too large.' }
135
161
  ])
136
162
  );
137
- frozen.links = Object.fromEntries(Object.keys(links).map((id) => [id, { state: 'plain', reason: 'This link must be checked again.' }]));
163
+ frozen.links = Object.fromEntries(
164
+ Object.keys(links).map((id) => [id, { state: 'plain', code: 'link-unchecked', reason: 'This link must be checked again.' }])
165
+ );
138
166
  frozen.fallback = `${block.fallback.slice(0, 2048)}\nSources: ${names}\nThe frozen query values are too large.`;
139
167
  }
140
168
  if (Buffer.byteLength(JSON.stringify(blocks)) > MAX_SNAPSHOT_BYTES) {
@@ -147,17 +175,19 @@ export class ChatUiQueries {
147
175
  .finally(() => this.freezing.delete(key));
148
176
  }
149
177
 
150
- private access(session: ChatSession, item: ChatAssistantItem): Promise<ChatUiQueryState> {
178
+ /* The writer's access for a reply, captured once; null where it was denied or is unavailable. */
179
+ private access(session: ChatSession, item: ChatAssistantItem): Promise<unknown> {
151
180
  const key = `${session.id}:${item.id}`;
152
181
  const existing = this.captures.get(key);
153
182
  if (existing) {
154
183
  return existing;
155
184
  }
156
185
  if (item.uiQueries?.authorChatId === session.id) {
157
- return Promise.resolve(item.uiQueries);
186
+ return Promise.resolve(session.uiAccess(item.id) ?? null);
158
187
  }
159
188
  // 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: {} });
189
+ session.setUiAccess(item.id, null);
190
+ session.updateUiQueries(item.id, { authorChatId: session.id, blocks: {} });
161
191
  const captured = this.host
162
192
  .capture(session.info)
163
193
  .then((access) => {
@@ -165,11 +195,10 @@ export class ChatUiQueries {
165
195
  if (Buffer.byteLength(JSON.stringify(captured)) > 32 * 1024) {
166
196
  throw new Error('The UI access snapshot is too large.');
167
197
  }
168
- const state: ChatUiQueryState = { authorChatId: session.id, access: captured, blocks: {} };
169
- session.updateUiQueries(item.id, state);
170
- return state;
198
+ session.setUiAccess(item.id, captured);
199
+ return captured as unknown;
171
200
  })
172
- .catch(() => ({ authorChatId: session.id, access: null, blocks: {} }))
201
+ .catch(() => null)
173
202
  .finally(() => this.captures.delete(key));
174
203
  this.captures.set(key, captured);
175
204
  return captured;
@@ -183,19 +212,19 @@ export class ChatUiQueries {
183
212
  const block =
184
213
  item?.kind === 'assistant' && !item.streaming && !item.parentToolUseId ? item.ui?.find((entry) => entry.id === payload.blockId) : undefined;
185
214
  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.');
215
+ throw new UiQueryError('stale-ui-block', 'block-stale', 'This completed UI block is no longer available.');
187
216
  }
188
217
  return { item, block };
189
218
  }
190
219
 
191
220
  private stored(session: ChatSession, payload: ChatUiQueryPayload): { item: ChatAssistantItem; block: UiBlock; source: ChatUiSource } {
192
221
  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.');
222
+ if (!Object.hasOwn(block.queries, payload.query) || Object.keys(block.queries).length > UI_HOST_LIMITS.queries) {
223
+ throw new UiQueryError('refused-query', 'query-undeclared', 'This block does not declare an allowed query.');
195
224
  }
196
225
  const name = block.queries[payload.query].source;
197
226
  if (!Object.hasOwn(this.host.sources, name)) {
198
- throw new ChatError('refused-query', 'This query source is not registered.');
227
+ throw new UiQueryError('refused-query', 'source-unregistered', 'This query source is not registered.');
199
228
  }
200
229
  return { item, block, source: this.host.sources[name] };
201
230
  }
@@ -210,18 +239,17 @@ export class ChatUiQueries {
210
239
  ({ source, block, item } = this.stored(session, payload));
211
240
  args = uiQueryArguments(block, payload.query, payload.values);
212
241
  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.');
242
+ authorAccess = await this.access(session, item);
243
+ if (authorAccess === null) {
244
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
216
245
  }
217
- authorAccess = access.access;
218
246
  await source.authorize(session.info, authorAccess, args);
219
247
  } catch (error) {
220
- return { state: 'refused', readAt: Date.now(), reason: this.reason(error) };
248
+ return { state: 'refused', readAt: Date.now(), ...this.failure(error) };
221
249
  }
222
250
  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) {
251
+ const cached = this.cache.get(this.latest.get(key) ?? '');
252
+ if (cached && Date.now() - cached.reading.readAt < UI_HOST_LIMITS.refreshMilliseconds) {
225
253
  return cached.reading;
226
254
  }
227
255
  const pending = this.reading.get(key);
@@ -229,14 +257,19 @@ export class ChatUiQueries {
229
257
  return pending;
230
258
  }
231
259
  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.' };
260
+ const now = Date.now();
261
+ if (
262
+ now - (this.attempts.get(key) ?? -Infinity) < UI_HOST_LIMITS.refreshMilliseconds ||
263
+ now - (this.attempts.get(identity) ?? -Infinity) < INPUT_READ_MS
264
+ ) {
265
+ return { state: 'failed', readAt: now, code: 'refresh-limit', reason: 'This query may refresh once every ten seconds.' };
234
266
  }
235
267
  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.' };
268
+ return { state: 'failed', readAt: now, code: 'busy', reason: 'Too many UI queries are being read.' };
237
269
  }
238
- this.attempts.set(identity, Date.now());
239
- while (this.attempts.size > 128) {
270
+ this.attempts.set(key, now);
271
+ this.attempts.set(identity, now);
272
+ while (this.attempts.size > 256) {
240
273
  this.attempts.delete(this.attempts.keys().next().value!);
241
274
  }
242
275
  this.active.set(session.id, (this.active.get(session.id) ?? 0) + 1);
@@ -247,21 +280,26 @@ export class ChatUiQueries {
247
280
  timeout.unref?.();
248
281
  const read = Promise.race([
249
282
  work,
250
- new Promise<never>((_, reject) => controller.signal.addEventListener('abort', () => reject(new Error('The UI query timed out.')), { once: true }))
283
+ new Promise<never>((_, reject) =>
284
+ controller.signal.addEventListener('abort', () => reject(new UiQueryError('refused-query', 'timed-out', 'The UI query timed out.')), {
285
+ once: true
286
+ })
287
+ )
251
288
  ])
252
289
  .then((result): ChatUiQueryReading => {
253
290
  const value = copyUiValue(source.result.parse(result), new UiBudget());
254
291
  if (Buffer.byteLength(JSON.stringify(value)) > MAX_QUERY_BYTES) {
255
- throw new Error('The UI query result is too large.');
292
+ throw new UiQueryError('refused-query', 'result-too-large', 'The UI query result is too large.');
256
293
  }
257
294
  const reading: ChatUiQueryReading = { state: 'fresh', value, readId: randomUUID(), readAt: Date.now() };
258
295
  this.cache.set(reading.readId!, { key, chatId: session.id, reading });
296
+ this.latest.set(key, reading.readId!);
259
297
  while (this.cache.size > 128) {
260
- this.cache.delete(this.cache.keys().next().value!);
298
+ this.evict(this.cache.keys().next().value!);
261
299
  }
262
300
  return reading;
263
301
  })
264
- .catch((error): ChatUiQueryReading => ({ state: 'failed', readAt: Date.now(), reason: this.reason(error) }))
302
+ .catch((error): ChatUiQueryReading => ({ state: 'failed', readAt: Date.now(), ...this.failure(error) }))
265
303
  .finally(() => {
266
304
  clearTimeout(timeout);
267
305
  this.reading.delete(key);
@@ -282,32 +320,41 @@ export class ChatUiQueries {
282
320
  return read;
283
321
  }
284
322
 
323
+ /* The issued value of every query the block declares, each checked against the read id the person saw. */
285
324
  async choiceValues(session: ChatSession, payload: ChatUiChoicePayload | ChatUiLinkPayload): Promise<Record<string, unknown>> {
286
325
  const item = session.thread.get(payload.itemId);
287
326
  const block = item?.kind === 'assistant' ? item.ui?.find((entry) => entry.id === payload.blockId) : undefined;
288
327
  if (!block || item?.kind !== 'assistant' || ('choiceId' in payload && item.uiAnswers?.[block.id]?.revision === payload.revision)) {
289
328
  return {};
290
329
  }
330
+ const names = Object.keys(block.queries);
291
331
  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);
332
+ if (names.length === 0) {
333
+ return values;
334
+ }
335
+ const access = await this.access(session, item);
336
+ if (access === null) {
337
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
338
+ }
339
+ for (const name of names) {
340
+ const { source } = this.stored(session, { ...payload, query: name });
295
341
  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);
342
+ await source.authorize(session.info, access, args);
343
+ const readId = payload.reads !== undefined && Object.hasOwn(payload.reads, name) ? payload.reads[name] : undefined;
298
344
  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);
345
+ const cached = readId === undefined ? undefined : this.cache.get(readId);
301
346
  const frozen = item.uiQueries?.blocks[block.id];
302
347
  const snapshot = frozen?.revision === block.revision ? frozen?.readings[name] : undefined;
303
348
  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;
349
+ readId === undefined
350
+ ? undefined
351
+ : cached?.key === key
352
+ ? cached.reading
353
+ : snapshot?.readId === readId && JSON.stringify(args) === JSON.stringify(source.args.parse(uiQueryArguments(block, name)))
354
+ ? snapshot
355
+ : undefined;
309
356
  if (!reading || reading.state !== 'fresh') {
310
- throw new ChatError('stale-ui-query', 'Refresh this query before sending its choice.');
357
+ throw new UiQueryError('stale-ui-query', 'stale-read', 'Refresh this query before sending its choice.');
311
358
  }
312
359
  values[name] = reading.value;
313
360
  }
@@ -317,35 +364,43 @@ export class ChatUiQueries {
317
364
  async link(session: ChatSession, payload: ChatUiLinkPayload): Promise<ChatUiLinkReading> {
318
365
  try {
319
366
  if (!this.host.link) {
320
- throw new ChatError('refused-query', 'This host does not provide UI links.');
367
+ throw new UiQueryError('refused-query', 'links-unsupported', 'This host does not provide UI links.');
321
368
  }
322
369
  const { item, block } = this.storedBlock(session, payload);
323
370
  if (!item.uiQueries) {
324
- throw new ChatError('refused-query', 'The writer’s original access is unavailable.');
371
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
325
372
  }
326
373
  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.');
374
+ if (access === null) {
375
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
329
376
  }
330
377
  const queries = await this.choiceValues(session, payload);
331
378
  const target = uiLinkTargets(block, payload.values, queries)[payload.nodeId];
332
379
  if (!target) {
333
- throw new ChatError('refused-query', 'This visible node is not a supported link.');
380
+ throw new UiQueryError('refused-query', 'link-unsupported', 'This visible node is not a supported link.');
334
381
  }
335
- return this.linkReading(await this.host.link(session.info, access.access, target));
382
+ return this.linkReading(await this.host.link(session.info, access, target));
336
383
  } catch (error) {
337
- return { state: 'plain', reason: this.reason(error) };
384
+ return { state: 'plain', ...this.failure(error) };
338
385
  }
339
386
  }
340
387
 
341
388
  forget(chatId: string): void {
342
389
  for (const [id, cached] of this.cache) {
343
390
  if (cached.chatId === chatId) {
344
- this.cache.delete(id);
391
+ this.evict(id);
345
392
  }
346
393
  }
347
394
  }
348
395
 
396
+ private evict(readId: string): void {
397
+ const cached = this.cache.get(readId);
398
+ this.cache.delete(readId);
399
+ if (cached && this.latest.get(cached.key) === readId) {
400
+ this.latest.delete(cached.key);
401
+ }
402
+ }
403
+
349
404
  private linkReading(value: unknown): ChatUiLinkReading {
350
405
  const reading = UiLinkResolutionSchema.parse(copyUiValue(value, new UiBudget()));
351
406
  if (reading.state === 'chip' && !reading.target) {
@@ -354,7 +409,8 @@ export class ChatUiQueries {
354
409
  return reading;
355
410
  }
356
411
 
357
- private reason(error: unknown): string {
358
- return (error instanceof Error ? error.message : 'This query could not be read.').slice(0, 240);
412
+ private failure(error: unknown): { reason: string; code?: string } {
413
+ const reason = (error instanceof Error ? error.message : 'This query could not be read.').slice(0, 240);
414
+ return error instanceof UiQueryError ? { reason, code: error.reason } : { reason };
359
415
  }
360
416
  }
@@ -66,7 +66,7 @@ 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),
69
+ 'chat.uiChoice': (payload, clientId) => chats.choose(payload, clientId),
70
70
  'ui.link': (payload, clientId) => chats.linkUi(payload, clientId),
71
71
  'ui.query': (payload, clientId) => chats.queryUi(payload, clientId),
72
72
  'chat.unqueue': (payload) => ({ message: chats.unqueue(payload.chatId, payload.messageId) }),
@@ -1,6 +1,7 @@
1
1
  import type { ChatBackgroundTask, ChatItem, ChatTurnItem, Task, TaskResult } from '@adecore/agent-contracts';
2
2
  import { abortedByMachine, clipText } from '@adecore/agent-contracts';
3
3
  import { BACKGROUND_COMMAND_LIMIT_MS, commandLabel, isBackgroundWork, runningInBackground, runsInBackground } from '../chat/background-work.ts';
4
+ import { readableAssistantText } from '../chat/readable-text.ts';
4
5
  import { errorText } from '../error-text.ts';
5
6
  import type { AgentEvent } from '../events.ts';
6
7
  import type { TaskStore } from './task-store.ts';
@@ -40,8 +41,11 @@ export interface TaskCoordinatorDeps {
40
41
  function answerOf(items: readonly ChatItem[], turnId: string): string | null {
41
42
  for (let i = items.length - 1; i >= 0; i--) {
42
43
  const item = items[i]!;
43
- if (item.kind === 'assistant' && item.turnId === turnId && !item.parentToolUseId && item.text.trim() !== '') {
44
- return item.text.trim();
44
+ if (item.kind === 'assistant' && item.turnId === turnId && !item.parentToolUseId) {
45
+ const text = readableAssistantText(item).trim();
46
+ if (text !== '') {
47
+ return text;
48
+ }
45
49
  }
46
50
  }
47
51
  return null;