@adecore/agents 0.20.1 → 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.
Files changed (43) hide show
  1. package/dist/chat/attachment-store.d.ts +2 -0
  2. package/dist/chat/attachment-store.js +38 -2
  3. package/dist/chat/backend.d.ts +12 -1
  4. package/dist/chat/chat-core.d.ts +57 -1
  5. package/dist/chat/chat-core.js +67 -14
  6. package/dist/chat/chat-session.d.ts +35 -2
  7. package/dist/chat/chat-session.js +261 -11
  8. package/dist/chat/chat-store.d.ts +3 -2
  9. package/dist/chat/chat-store.js +42 -11
  10. package/dist/chat/codex-backend.d.ts +2 -0
  11. package/dist/chat/codex-backend.js +51 -1
  12. package/dist/chat/codex-protocol.js +30 -3
  13. package/dist/chat/delta-coalescer.js +1 -1
  14. package/dist/chat/errors.d.ts +1 -1
  15. package/dist/chat/fake-codex.js +33 -0
  16. package/dist/chat/generated-image.d.ts +6 -0
  17. package/dist/chat/generated-image.js +74 -0
  18. package/dist/chat/projector.js +9 -2
  19. package/dist/chat/readable-text.d.ts +2 -0
  20. package/dist/chat/readable-text.js +16 -0
  21. package/dist/chat/thread.js +6 -0
  22. package/dist/chat/ui-queries.d.ts +49 -0
  23. package/dist/chat/ui-queries.js +358 -0
  24. package/dist/host/handlers.js +3 -0
  25. package/dist/tasks/task-coordinator.js +6 -2
  26. package/package.json +5 -4
  27. package/src/chat/attachment-store.ts +38 -2
  28. package/src/chat/backend.ts +14 -2
  29. package/src/chat/chat-core.ts +84 -13
  30. package/src/chat/chat-session.ts +304 -12
  31. package/src/chat/chat-store.ts +53 -11
  32. package/src/chat/codex-backend.ts +50 -1
  33. package/src/chat/codex-protocol.ts +31 -3
  34. package/src/chat/delta-coalescer.ts +1 -1
  35. package/src/chat/errors.ts +2 -0
  36. package/src/chat/fake-codex.ts +34 -0
  37. package/src/chat/generated-image.ts +88 -0
  38. package/src/chat/projector.ts +9 -2
  39. package/src/chat/readable-text.ts +18 -0
  40. package/src/chat/thread.ts +6 -0
  41. package/src/chat/ui-queries.ts +416 -0
  42. package/src/host/handlers.ts +3 -0
  43. package/src/tasks/task-coordinator.ts +6 -2
@@ -169,6 +169,39 @@ export function fakeCodexWith(options) {
169
169
  turnCompleted('failed', "You've hit your usage limit.", 'usageLimitExceeded');
170
170
  return;
171
171
  }
172
+ if (text.startsWith('image:')) {
173
+ const scenario = text.slice(6);
174
+ const entry = item('imageGeneration', {
175
+ status: 'inProgress',
176
+ result: '',
177
+ savedPath: '/never-read-generated-image.png',
178
+ revisedPrompt: 'A rabbit',
179
+ transparentBackground: true
180
+ });
181
+ started(entry);
182
+ const result = scenario === 'empty'
183
+ ? ''
184
+ : scenario === 'invalid'
185
+ ? 'not base64'
186
+ : scenario === 'not-image'
187
+ ? Buffer.from('text').toString('base64')
188
+ : scenario === 'too-large'
189
+ ? Buffer.alloc(10 * 1024 * 1024 + 1).toString('base64')
190
+ : 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=';
191
+ const done = {
192
+ ...entry,
193
+ status: scenario === 'failed' ? 'failed' : 'completed',
194
+ result,
195
+ ...(scenario === 'failed' ? { failure: { message: 'Image generation failed' } } : {})
196
+ };
197
+ completed(done);
198
+ if (scenario === 'replay') {
199
+ completed(done);
200
+ }
201
+ agentMessage('Here is the generated image.');
202
+ turnCompleted('completed');
203
+ return;
204
+ }
172
205
  if (text === 'slow') {
173
206
  slow = true;
174
207
  started(item('agentMessage', { text: '', phase: 'final_answer', memoryCitation: null, delivery: null, questions: null }));
@@ -0,0 +1,6 @@
1
+ import { type ChatAttachmentUpload } from '@adecore/agent-contracts';
2
+ export declare function generatedImage(data: string, prompt?: string): {
3
+ upload: ChatAttachmentUpload;
4
+ width?: number;
5
+ height?: number;
6
+ };
@@ -0,0 +1,74 @@
1
+ import { ChatAttachmentUploadSchema } from '@adecore/agent-contracts';
2
+ function imageInfo(bytes) {
3
+ if (bytes.length >= 33 &&
4
+ bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) &&
5
+ bytes.readUInt32BE(8) === 13 &&
6
+ bytes.toString('ascii', 12, 16) === 'IHDR') {
7
+ return { mime: 'image/png', extension: 'png', width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
8
+ }
9
+ if (bytes.length >= 13 && /GIF8[79]a/.test(bytes.toString('ascii', 0, 6))) {
10
+ return { mime: 'image/gif', extension: 'gif', width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) };
11
+ }
12
+ if (bytes.length >= 20 &&
13
+ bytes.toString('ascii', 0, 4) === 'RIFF' &&
14
+ bytes.toString('ascii', 8, 12) === 'WEBP' &&
15
+ bytes.readUInt32LE(4) + 8 === bytes.length) {
16
+ const kind = bytes.toString('ascii', 12, 16);
17
+ if (kind === 'VP8X' && bytes.length >= 30) {
18
+ return { mime: 'image/webp', extension: 'webp', width: bytes.readUIntLE(24, 3) + 1, height: bytes.readUIntLE(27, 3) + 1 };
19
+ }
20
+ if (kind === 'VP8 ' && bytes.length >= 30 && bytes.subarray(23, 26).equals(Buffer.from([157, 1, 42]))) {
21
+ return { mime: 'image/webp', extension: 'webp', width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff };
22
+ }
23
+ if (kind === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
24
+ const size = bytes.readUInt32LE(21);
25
+ return { mime: 'image/webp', extension: 'webp', width: (size & 0x3fff) + 1, height: ((size >> 14) & 0x3fff) + 1 };
26
+ }
27
+ return null;
28
+ }
29
+ if (bytes.length >= 4 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) {
30
+ let offset = 2;
31
+ while (offset + 4 <= bytes.length && bytes[offset] === 255) {
32
+ const marker = bytes[offset + 1];
33
+ if (marker === 218 || marker === 217) {
34
+ break;
35
+ }
36
+ if (marker === 255) {
37
+ offset++;
38
+ continue;
39
+ }
40
+ const size = bytes.readUInt16BE(offset + 2);
41
+ if (size < 2 || offset + 2 + size > bytes.length) {
42
+ return null;
43
+ }
44
+ if ([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207].includes(marker) && size >= 8) {
45
+ return { mime: 'image/jpeg', extension: 'jpg', width: bytes.readUInt16BE(offset + 7), height: bytes.readUInt16BE(offset + 5) };
46
+ }
47
+ offset += size + 2;
48
+ }
49
+ }
50
+ return null;
51
+ }
52
+ export function generatedImage(data, prompt) {
53
+ const parsed = ChatAttachmentUploadSchema.safeParse({ name: 'image', mime: 'application/octet-stream', data });
54
+ if (!parsed.success) {
55
+ throw new Error('The generated image is empty, invalid base64 or exceeds 10 MiB');
56
+ }
57
+ const bytes = Buffer.from(data, 'base64');
58
+ // Buffer's decoder accepts malformed padding and trailing bits; the protocol requires canonical base64.
59
+ if (bytes.toString('base64') !== data) {
60
+ throw new Error('The generated image contains invalid base64');
61
+ }
62
+ const info = imageInfo(bytes);
63
+ if (info === null || (info.width !== undefined && info.width === 0) || (info.height !== undefined && info.height === 0)) {
64
+ throw new Error('The generated bytes are not a supported image');
65
+ }
66
+ const stem = (prompt ?? '')
67
+ .normalize('NFKD')
68
+ .replace(/[^a-zA-Z0-9]+/g, '-')
69
+ .replace(/^-|-$/g, '')
70
+ .slice(0, 80)
71
+ .toLowerCase() || 'generated-image';
72
+ const upload = { name: `${stem}.${info.extension}`, mime: info.mime, data };
73
+ return { upload, ...(info.width === undefined ? {} : { width: info.width }), ...(info.height === undefined ? {} : { height: info.height }) };
74
+ }
@@ -150,7 +150,7 @@ export class ThreadProjector {
150
150
  break;
151
151
  case 'text.delta':
152
152
  this.closeThinking(events);
153
- this.appendText(this.itemId(generation, event.ref), event.text, events);
153
+ this.appendText(this.itemId(generation, event.ref), event.text, events, event.phase);
154
154
  break;
155
155
  case 'text.done': {
156
156
  if (event.parentRef) {
@@ -168,6 +168,12 @@ export class ThreadProjector {
168
168
  createdAt: existing?.createdAt ?? this.now(),
169
169
  turnId: existing?.turnId ?? info.activeTurnId,
170
170
  text: event.text,
171
+ ...(existing?.kind === 'assistant' ? { uiAnswers: existing.uiAnswers, uiQueries: existing.uiQueries } : {}),
172
+ ...(event.phase === undefined
173
+ ? existing?.kind === 'assistant' && existing.phase !== undefined
174
+ ? { phase: existing.phase }
175
+ : {}
176
+ : { phase: event.phase }),
171
177
  streaming: false
172
178
  }));
173
179
  break;
@@ -452,7 +458,7 @@ export class ThreadProjector {
452
458
  events.push(this.thread.upsert({ ...item, streaming: false, endedAt: this.now() }));
453
459
  }
454
460
  }
455
- appendText(id, text, events) {
461
+ appendText(id, text, events, phase) {
456
462
  const existing = this.thread.get(id);
457
463
  if (!existing) {
458
464
  events.push(this.thread.upsert({
@@ -461,6 +467,7 @@ export class ThreadProjector {
461
467
  createdAt: this.now(),
462
468
  turnId: this.thread.info.activeTurnId,
463
469
  text,
470
+ ...(phase === undefined ? {} : { phase }),
464
471
  streaming: true
465
472
  }));
466
473
  return;
@@ -0,0 +1,2 @@
1
+ import type { ChatAssistantItem } from '@adecore/agent-contracts';
2
+ export declare function readableAssistantText(item: Pick<ChatAssistantItem, 'text' | 'ui' | 'uiQueries'>): string;
@@ -0,0 +1,16 @@
1
+ import { uiFallbackText } from '@adecore/intelligent-ui/text';
2
+ /*
3
+ * An assistant's words as another agent or a person outside the thread reads them: each UI block becomes
4
+ * its text fallback, with the first query values when they were frozen for the block as stored. It works
5
+ * from the stored block ranges, so it holds whatever fence language the host compiled.
6
+ */
7
+ export function readableAssistantText(item) {
8
+ if (!item.ui?.length) {
9
+ return item.text;
10
+ }
11
+ const blocks = item.ui.map((block) => {
12
+ const frozen = item.uiQueries?.blocks[block.id];
13
+ return frozen && frozen.revision === block.revision ? { ...block, fallback: frozen.fallback } : block;
14
+ });
15
+ return uiFallbackText(item.text, blocks);
16
+ }
@@ -82,6 +82,12 @@ export class ChatThread {
82
82
  break;
83
83
  case 'delta':
84
84
  this.appendText(event.itemId, event.text);
85
+ if (event.uiQueries) {
86
+ const item = this.items.get(event.itemId);
87
+ if (item?.kind === 'assistant') {
88
+ this.items.set(item.id, { ...item, uiQueries: event.uiQueries });
89
+ }
90
+ }
85
91
  break;
86
92
  case 'info':
87
93
  this.info = event.info;
@@ -0,0 +1,49 @@
1
+ import type { z } from 'zod';
2
+ import type { ChatUiLinkPayload, ChatUiLinkReading, ChatAssistantItem, ChatInfo, ChatUiChoicePayload, ChatUiQueryPayload, ChatUiQueryReading } from '@adecore/agent-contracts';
3
+ import { type UiValue } from '@adecore/intelligent-ui';
4
+ import { type UiLinkTarget } from '@adecore/intelligent-ui/links';
5
+ import type { ChatSession } from './chat-session.ts';
6
+ import { ChatError } from './errors.ts';
7
+ export interface ChatUiSource {
8
+ args: z.ZodType;
9
+ result: z.ZodType;
10
+ authorize(info: ChatInfo, access: unknown, args: Record<string, UiValue>): Promise<void>;
11
+ read(info: ChatInfo, args: Record<string, UiValue>, signal: AbortSignal, access: unknown): Promise<unknown>;
12
+ }
13
+ export interface ChatUiHost {
14
+ capture(info: ChatInfo): Promise<unknown>;
15
+ sources: Readonly<Record<string, ChatUiSource>>;
16
+ link?(info: ChatInfo, access: unknown, target: UiLinkTarget): Promise<ChatUiLinkReading>;
17
+ }
18
+ declare class UiQueryError extends ChatError<string> {
19
+ readonly reason: string;
20
+ constructor(code: string, reason: string, message: string);
21
+ }
22
+ export declare class ChatUiRefusal extends UiQueryError {
23
+ constructor(code: string, reason: string);
24
+ }
25
+ export declare class ChatUiQueries {
26
+ readonly schemas: Record<string, z.ZodType>;
27
+ private readonly captures;
28
+ private readonly freezing;
29
+ private readonly cache;
30
+ private readonly latest;
31
+ private readonly reading;
32
+ private readonly attempts;
33
+ private readonly active;
34
+ private totalActive;
35
+ private readonly host;
36
+ constructor(host: ChatUiHost);
37
+ observe(session: ChatSession, item: ChatAssistantItem, final: boolean): void;
38
+ private access;
39
+ private storedBlock;
40
+ private stored;
41
+ query(session: ChatSession, payload: ChatUiQueryPayload): Promise<ChatUiQueryReading>;
42
+ choiceValues(session: ChatSession, payload: ChatUiChoicePayload | ChatUiLinkPayload): Promise<Record<string, unknown>>;
43
+ link(session: ChatSession, payload: ChatUiLinkPayload): Promise<ChatUiLinkReading>;
44
+ forget(chatId: string): void;
45
+ private evict;
46
+ private linkReading;
47
+ private failure;
48
+ }
49
+ export {};
@@ -0,0 +1,358 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { copyUiValue, UI_HOST_LIMITS, UiBudget, uiMayReferenceHost } from '@adecore/intelligent-ui';
3
+ import { UiLinkResolutionSchema, uiLinkTargets } from '@adecore/intelligent-ui/links';
4
+ import { uiQueryArguments, uiQueryFallback } from '@adecore/intelligent-ui/query';
5
+ import { ChatError } from './errors.js';
6
+ /*
7
+ * A failure a client can say in its own words: `reason` is a code that stays the same across versions,
8
+ * the message is the fallback for a client that does not know the code.
9
+ */
10
+ class UiQueryError extends ChatError {
11
+ reason;
12
+ constructor(code, reason, message) {
13
+ super(code, message);
14
+ this.reason = reason;
15
+ }
16
+ }
17
+ /*
18
+ * What a host's `authorize`, `read` or `link` throws to say why with a code: `code` reaches the reading's
19
+ * `code` and should stay the same across versions, `reason` is the plain sentence a client shows without it.
20
+ */
21
+ export class ChatUiRefusal extends UiQueryError {
22
+ constructor(code, reason) {
23
+ super('refused-query', code, reason);
24
+ }
25
+ }
26
+ const MAX_QUERY_BYTES = 64 * 1024;
27
+ const MAX_SNAPSHOT_BYTES = 128 * 1024;
28
+ const READ_TIMEOUT_MS = 8_000;
29
+ // A changed input reads at once, but a query never reads more often than this across its inputs.
30
+ const INPUT_READ_MS = 250;
31
+ export class ChatUiQueries {
32
+ schemas;
33
+ captures = new Map();
34
+ freezing = new Set();
35
+ cache = new Map();
36
+ // The read id of the newest reading per cache key, which a remount or a second client is answered with.
37
+ latest = new Map();
38
+ reading = new Map();
39
+ attempts = new Map();
40
+ active = new Map();
41
+ totalActive = 0;
42
+ host;
43
+ constructor(host) {
44
+ this.host = host;
45
+ this.schemas = Object.fromEntries(Object.entries(host.sources).map(([name, source]) => [name, source.args]));
46
+ }
47
+ observe(session, item, final) {
48
+ const key = `${session.id}:${item.id}`;
49
+ // Once a capture started, every delta of a streaming reply would scan its whole text again for nothing.
50
+ const captured = this.captures.has(key) || item.uiQueries?.authorChatId === session.id;
51
+ if (!captured && !uiMayReferenceHost(item.text) && !item.ui?.some((block) => Object.keys(block.queries).length)) {
52
+ return;
53
+ }
54
+ const capture = this.access(session, item);
55
+ if (!final || !item.ui?.length) {
56
+ return;
57
+ }
58
+ if (this.freezing.has(key)) {
59
+ return;
60
+ }
61
+ this.freezing.add(key);
62
+ void capture
63
+ .then(async () => {
64
+ for (const block of (item.ui ?? []).slice(0, 16)) {
65
+ const current = session.thread.get(item.id);
66
+ if (current?.kind !== 'assistant' || current.ui?.find((entry) => entry.id === block.id)?.revision !== block.revision) {
67
+ return;
68
+ }
69
+ if (current.uiQueries?.blocks[block.id]?.revision === block.revision) {
70
+ continue;
71
+ }
72
+ const readings = Object.create(null);
73
+ const values = Object.create(null);
74
+ for (const name of Object.keys(block.queries).slice(0, UI_HOST_LIMITS.queries)) {
75
+ const reading = await this.query(session, {
76
+ chatId: session.id,
77
+ itemId: item.id,
78
+ blockId: block.id,
79
+ revision: block.revision,
80
+ query: name
81
+ });
82
+ readings[name] = reading;
83
+ if (reading.state === 'fresh') {
84
+ values[name] = reading.value;
85
+ }
86
+ }
87
+ const links = Object.create(null);
88
+ if (this.host.link) {
89
+ const targets = uiLinkTargets(block, {}, values);
90
+ const access = await this.access(session, current);
91
+ for (const [id, target] of Object.entries(targets).slice(0, UI_HOST_LIMITS.links)) {
92
+ try {
93
+ links[id] = this.linkReading(await this.host.link(session.info, access, target));
94
+ }
95
+ catch (error) {
96
+ links[id] = { state: 'plain', ...this.failure(error) };
97
+ }
98
+ }
99
+ }
100
+ const latest = session.thread.get(item.id);
101
+ if (latest?.kind !== 'assistant' || !latest.uiQueries || latest.ui?.find((entry) => entry.id === block.id)?.revision !== block.revision) {
102
+ return;
103
+ }
104
+ const names = Object.entries(block.queries)
105
+ .map(([name, query]) => `${name}: ${query.source}`)
106
+ .join(', ');
107
+ const failures = Object.entries(readings)
108
+ .filter(([, reading]) => reading.state !== 'fresh')
109
+ .map(([name, reading]) => `${name}: ${reading.reason}`)
110
+ .join('\n');
111
+ const frozen = {
112
+ revision: block.revision,
113
+ readings,
114
+ links,
115
+ fallback: `${uiQueryFallback(block, values).slice(0, 8192)}${names ? `\nSources: ${names}` : ''}${failures ? `\n${failures}` : ''}`
116
+ };
117
+ const blocks = { ...latest.uiQueries.blocks, [block.id]: frozen };
118
+ if (Buffer.byteLength(JSON.stringify(blocks)) > MAX_SNAPSHOT_BYTES) {
119
+ frozen.readings = Object.fromEntries(Object.keys(readings).map((name) => [
120
+ name,
121
+ { state: 'failed', readAt: Date.now(), code: 'snapshot-too-large', reason: 'The frozen query values are too large.' }
122
+ ]));
123
+ frozen.links = Object.fromEntries(Object.keys(links).map((id) => [id, { state: 'plain', code: 'link-unchecked', reason: 'This link must be checked again.' }]));
124
+ frozen.fallback = `${block.fallback.slice(0, 2048)}\nSources: ${names}\nThe frozen query values are too large.`;
125
+ }
126
+ if (Buffer.byteLength(JSON.stringify(blocks)) > MAX_SNAPSHOT_BYTES) {
127
+ delete blocks[block.id];
128
+ }
129
+ session.updateUiQueries(item.id, { ...latest.uiQueries, blocks });
130
+ }
131
+ })
132
+ .catch(() => { })
133
+ .finally(() => this.freezing.delete(key));
134
+ }
135
+ /* The writer's access for a reply, captured once; null where it was denied or is unavailable. */
136
+ access(session, item) {
137
+ const key = `${session.id}:${item.id}`;
138
+ const existing = this.captures.get(key);
139
+ if (existing) {
140
+ return existing;
141
+ }
142
+ if (item.uiQueries?.authorChatId === session.id) {
143
+ return Promise.resolve(session.uiAccess(item.id) ?? null);
144
+ }
145
+ // A pending capture is persisted as denied, so a restart cannot widen the writer's rights.
146
+ session.setUiAccess(item.id, null);
147
+ session.updateUiQueries(item.id, { authorChatId: session.id, blocks: {} });
148
+ const captured = this.host
149
+ .capture(session.info)
150
+ .then((access) => {
151
+ const captured = copyUiValue(access, new UiBudget());
152
+ if (Buffer.byteLength(JSON.stringify(captured)) > 32 * 1024) {
153
+ throw new Error('The UI access snapshot is too large.');
154
+ }
155
+ session.setUiAccess(item.id, captured);
156
+ return captured;
157
+ })
158
+ .catch(() => null)
159
+ .finally(() => this.captures.delete(key));
160
+ this.captures.set(key, captured);
161
+ return captured;
162
+ }
163
+ storedBlock(session, payload) {
164
+ const item = session.thread.get(payload.itemId);
165
+ const block = item?.kind === 'assistant' && !item.streaming && !item.parentToolUseId ? item.ui?.find((entry) => entry.id === payload.blockId) : undefined;
166
+ if (item?.kind !== 'assistant' || !block?.complete || block.revision !== payload.revision) {
167
+ throw new UiQueryError('stale-ui-block', 'block-stale', 'This completed UI block is no longer available.');
168
+ }
169
+ return { item, block };
170
+ }
171
+ stored(session, payload) {
172
+ const { item, block } = this.storedBlock(session, payload);
173
+ if (!Object.hasOwn(block.queries, payload.query) || Object.keys(block.queries).length > UI_HOST_LIMITS.queries) {
174
+ throw new UiQueryError('refused-query', 'query-undeclared', 'This block does not declare an allowed query.');
175
+ }
176
+ const name = block.queries[payload.query].source;
177
+ if (!Object.hasOwn(this.host.sources, name)) {
178
+ throw new UiQueryError('refused-query', 'source-unregistered', 'This query source is not registered.');
179
+ }
180
+ return { item, block, source: this.host.sources[name] };
181
+ }
182
+ async query(session, payload) {
183
+ let args;
184
+ let source;
185
+ let block;
186
+ let item;
187
+ let authorAccess;
188
+ try {
189
+ ({ source, block, item } = this.stored(session, payload));
190
+ args = uiQueryArguments(block, payload.query, payload.values);
191
+ args = source.args.parse(args);
192
+ authorAccess = await this.access(session, item);
193
+ if (authorAccess === null) {
194
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
195
+ }
196
+ await source.authorize(session.info, authorAccess, args);
197
+ }
198
+ catch (error) {
199
+ return { state: 'refused', readAt: Date.now(), ...this.failure(error) };
200
+ }
201
+ const key = JSON.stringify([session.id, item.id, block.id, block.revision, payload.query, args]);
202
+ const cached = this.cache.get(this.latest.get(key) ?? '');
203
+ if (cached && Date.now() - cached.reading.readAt < UI_HOST_LIMITS.refreshMilliseconds) {
204
+ return cached.reading;
205
+ }
206
+ const pending = this.reading.get(key);
207
+ if (pending) {
208
+ return pending;
209
+ }
210
+ const identity = JSON.stringify([session.id, item.id, block.id, block.revision, payload.query]);
211
+ const now = Date.now();
212
+ if (now - (this.attempts.get(key) ?? -Infinity) < UI_HOST_LIMITS.refreshMilliseconds ||
213
+ now - (this.attempts.get(identity) ?? -Infinity) < INPUT_READ_MS) {
214
+ return { state: 'failed', readAt: now, code: 'refresh-limit', reason: 'This query may refresh once every ten seconds.' };
215
+ }
216
+ if ((this.active.get(session.id) ?? 0) >= 2 || this.totalActive >= 16) {
217
+ return { state: 'failed', readAt: now, code: 'busy', reason: 'Too many UI queries are being read.' };
218
+ }
219
+ this.attempts.set(key, now);
220
+ this.attempts.set(identity, now);
221
+ while (this.attempts.size > 256) {
222
+ this.attempts.delete(this.attempts.keys().next().value);
223
+ }
224
+ this.active.set(session.id, (this.active.get(session.id) ?? 0) + 1);
225
+ this.totalActive++;
226
+ const controller = new AbortController();
227
+ const work = Promise.resolve().then(() => source.read(session.info, args, controller.signal, authorAccess));
228
+ const timeout = setTimeout(() => controller.abort(), READ_TIMEOUT_MS);
229
+ timeout.unref?.();
230
+ const read = Promise.race([
231
+ work,
232
+ new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(new UiQueryError('refused-query', 'timed-out', 'The UI query timed out.')), {
233
+ once: true
234
+ }))
235
+ ])
236
+ .then((result) => {
237
+ const value = copyUiValue(source.result.parse(result), new UiBudget());
238
+ if (Buffer.byteLength(JSON.stringify(value)) > MAX_QUERY_BYTES) {
239
+ throw new UiQueryError('refused-query', 'result-too-large', 'The UI query result is too large.');
240
+ }
241
+ const reading = { state: 'fresh', value, readId: randomUUID(), readAt: Date.now() };
242
+ this.cache.set(reading.readId, { key, chatId: session.id, reading });
243
+ this.latest.set(key, reading.readId);
244
+ while (this.cache.size > 128) {
245
+ this.evict(this.cache.keys().next().value);
246
+ }
247
+ return reading;
248
+ })
249
+ .catch((error) => ({ state: 'failed', readAt: Date.now(), ...this.failure(error) }))
250
+ .finally(() => {
251
+ clearTimeout(timeout);
252
+ this.reading.delete(key);
253
+ });
254
+ // Timed-out sources still occupy their slot until their actual work ends.
255
+ void work
256
+ .catch(() => { })
257
+ .finally(() => {
258
+ const count = (this.active.get(session.id) ?? 1) - 1;
259
+ if (count) {
260
+ this.active.set(session.id, count);
261
+ }
262
+ else {
263
+ this.active.delete(session.id);
264
+ }
265
+ this.totalActive--;
266
+ });
267
+ this.reading.set(key, read);
268
+ return read;
269
+ }
270
+ /* The issued value of every query the block declares, each checked against the read id the person saw. */
271
+ async choiceValues(session, payload) {
272
+ const item = session.thread.get(payload.itemId);
273
+ const block = item?.kind === 'assistant' ? item.ui?.find((entry) => entry.id === payload.blockId) : undefined;
274
+ if (!block || item?.kind !== 'assistant' || ('choiceId' in payload && item.uiAnswers?.[block.id]?.revision === payload.revision)) {
275
+ return {};
276
+ }
277
+ const names = Object.keys(block.queries);
278
+ const values = Object.create(null);
279
+ if (names.length === 0) {
280
+ return values;
281
+ }
282
+ const access = await this.access(session, item);
283
+ if (access === null) {
284
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
285
+ }
286
+ for (const name of names) {
287
+ const { source } = this.stored(session, { ...payload, query: name });
288
+ const args = source.args.parse(uiQueryArguments(block, name, payload.values));
289
+ await source.authorize(session.info, access, args);
290
+ const readId = payload.reads !== undefined && Object.hasOwn(payload.reads, name) ? payload.reads[name] : undefined;
291
+ const key = JSON.stringify([session.id, item.id, block.id, block.revision, name, args]);
292
+ const cached = readId === undefined ? undefined : this.cache.get(readId);
293
+ const frozen = item.uiQueries?.blocks[block.id];
294
+ const snapshot = frozen?.revision === block.revision ? frozen?.readings[name] : undefined;
295
+ const reading = readId === undefined
296
+ ? undefined
297
+ : cached?.key === key
298
+ ? cached.reading
299
+ : snapshot?.readId === readId && JSON.stringify(args) === JSON.stringify(source.args.parse(uiQueryArguments(block, name)))
300
+ ? snapshot
301
+ : undefined;
302
+ if (!reading || reading.state !== 'fresh') {
303
+ throw new UiQueryError('stale-ui-query', 'stale-read', 'Refresh this query before sending its choice.');
304
+ }
305
+ values[name] = reading.value;
306
+ }
307
+ return values;
308
+ }
309
+ async link(session, payload) {
310
+ try {
311
+ if (!this.host.link) {
312
+ throw new UiQueryError('refused-query', 'links-unsupported', 'This host does not provide UI links.');
313
+ }
314
+ const { item, block } = this.storedBlock(session, payload);
315
+ if (!item.uiQueries) {
316
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
317
+ }
318
+ const access = await this.access(session, item);
319
+ if (access === null) {
320
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
321
+ }
322
+ const queries = await this.choiceValues(session, payload);
323
+ const target = uiLinkTargets(block, payload.values, queries)[payload.nodeId];
324
+ if (!target) {
325
+ throw new UiQueryError('refused-query', 'link-unsupported', 'This visible node is not a supported link.');
326
+ }
327
+ return this.linkReading(await this.host.link(session.info, access, target));
328
+ }
329
+ catch (error) {
330
+ return { state: 'plain', ...this.failure(error) };
331
+ }
332
+ }
333
+ forget(chatId) {
334
+ for (const [id, cached] of this.cache) {
335
+ if (cached.chatId === chatId) {
336
+ this.evict(id);
337
+ }
338
+ }
339
+ }
340
+ evict(readId) {
341
+ const cached = this.cache.get(readId);
342
+ this.cache.delete(readId);
343
+ if (cached && this.latest.get(cached.key) === readId) {
344
+ this.latest.delete(cached.key);
345
+ }
346
+ }
347
+ linkReading(value) {
348
+ const reading = UiLinkResolutionSchema.parse(copyUiValue(value, new UiBudget()));
349
+ if (reading.state === 'chip' && !reading.target) {
350
+ throw new Error('The host did not resolve a link target.');
351
+ }
352
+ return reading;
353
+ }
354
+ failure(error) {
355
+ const reason = (error instanceof Error ? error.message : 'This query could not be read.').slice(0, 240);
356
+ return error instanceof UiQueryError ? { reason, code: error.reason } : { reason };
357
+ }
358
+ }
@@ -32,6 +32,9 @@ export function chatHandlers(chats, providers) {
32
32
  return {};
33
33
  },
34
34
  'chat.send': (payload) => chats.send(payload.chatId, payload.text, { delivery: payload.delivery, mentions: payload.mentions, skills: payload.skills, chats: payload.chats }, payload.attachments),
35
+ 'chat.uiChoice': (payload, clientId) => chats.choose(payload, clientId),
36
+ 'ui.link': (payload, clientId) => chats.linkUi(payload, clientId),
37
+ 'ui.query': (payload, clientId) => chats.queryUi(payload, clientId),
35
38
  'chat.unqueue': (payload) => ({ message: chats.unqueue(payload.chatId, payload.messageId) }),
36
39
  'chat.sendNow': (payload) => {
37
40
  chats.sendNow(payload.chatId, payload.messageId);
@@ -1,12 +1,16 @@
1
1
  import { abortedByMachine, clipText } from '@adecore/agent-contracts';
2
2
  import { BACKGROUND_COMMAND_LIMIT_MS, commandLabel, isBackgroundWork, runningInBackground, runsInBackground } from '../chat/background-work.js';
3
+ import { readableAssistantText } from '../chat/readable-text.js';
3
4
  import { errorText } from '../error-text.js';
4
5
  // A child's answer is its last word of the turn; the thinking and the tools before it are not the result.
5
6
  function answerOf(items, turnId) {
6
7
  for (let i = items.length - 1; i >= 0; i--) {
7
8
  const item = items[i];
8
- if (item.kind === 'assistant' && item.turnId === turnId && !item.parentToolUseId && item.text.trim() !== '') {
9
- return item.text.trim();
9
+ if (item.kind === 'assistant' && item.turnId === turnId && !item.parentToolUseId) {
10
+ const text = readableAssistantText(item).trim();
11
+ if (text !== '') {
12
+ return text;
13
+ }
10
14
  }
11
15
  }
12
16
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.20.1",
3
+ "version": "0.21.1",
4
4
  "description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,13 +20,14 @@
20
20
  "test": "bun --conditions=source test"
21
21
  },
22
22
  "dependencies": {
23
- "@adecore/agent-contracts": "0.20.1",
23
+ "@adecore/agent-contracts": "0.21.1",
24
+ "@adecore/intelligent-ui": "0.21.1",
24
25
  "zod": "^4.6.5"
25
26
  },
26
27
  "devDependencies": {
27
28
  "@types/bun": "^1.4.2",
28
- "typescript": "^7.0.2",
29
- "@types/node": "^26.6.3"
29
+ "@types/node": "^26.6.3",
30
+ "typescript": "^7.0.2"
30
31
  },
31
32
  "license": "FSL-1.1-MIT",
32
33
  "author": "Bas Milius",