@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.
- package/dist/chat/chat-core.d.ts +5 -1
- package/dist/chat/chat-core.js +16 -6
- package/dist/chat/chat-session.d.ts +13 -0
- package/dist/chat/chat-session.js +75 -5
- package/dist/chat/chat-store.d.ts +3 -2
- package/dist/chat/chat-store.js +42 -11
- package/dist/chat/readable-text.d.ts +2 -0
- package/dist/chat/readable-text.js +16 -0
- package/dist/chat/ui-queries.d.ts +12 -1
- package/dist/chat/ui-queries.js +107 -60
- package/dist/host/handlers.js +1 -1
- package/dist/tasks/task-coordinator.js +6 -2
- package/package.json +3 -3
- package/src/chat/chat-core.ts +26 -6
- package/src/chat/chat-session.ts +85 -5
- package/src/chat/chat-store.ts +53 -11
- package/src/chat/readable-text.ts +18 -0
- package/src/chat/ui-queries.ts +120 -64
- package/src/host/handlers.ts +1 -1
- package/src/tasks/task-coordinator.ts +6 -2
package/dist/chat/ui-queries.js
CHANGED
|
@@ -1,18 +1,40 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { copyUiValue, UiBudget } from '@adecore/intelligent-ui';
|
|
2
|
+
import { copyUiValue, UI_HOST_LIMITS, UiBudget, uiMayReferenceHost } from '@adecore/intelligent-ui';
|
|
3
3
|
import { UiLinkResolutionSchema, uiLinkTargets } from '@adecore/intelligent-ui/links';
|
|
4
4
|
import { uiQueryArguments, uiQueryFallback } from '@adecore/intelligent-ui/query';
|
|
5
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
|
+
}
|
|
6
26
|
const MAX_QUERY_BYTES = 64 * 1024;
|
|
7
27
|
const MAX_SNAPSHOT_BYTES = 128 * 1024;
|
|
8
|
-
const MAX_QUERIES = 8;
|
|
9
|
-
const REFRESH_MS = 10_000;
|
|
10
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;
|
|
11
31
|
export class ChatUiQueries {
|
|
12
32
|
schemas;
|
|
13
33
|
captures = new Map();
|
|
14
34
|
freezing = new Set();
|
|
15
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();
|
|
16
38
|
reading = new Map();
|
|
17
39
|
attempts = new Map();
|
|
18
40
|
active = new Map();
|
|
@@ -23,14 +45,16 @@ export class ChatUiQueries {
|
|
|
23
45
|
this.schemas = Object.fromEntries(Object.entries(host.sources).map(([name, source]) => [name, source.args]));
|
|
24
46
|
}
|
|
25
47
|
observe(session, item, final) {
|
|
26
|
-
|
|
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)) {
|
|
27
52
|
return;
|
|
28
53
|
}
|
|
29
54
|
const capture = this.access(session, item);
|
|
30
55
|
if (!final || !item.ui?.length) {
|
|
31
56
|
return;
|
|
32
57
|
}
|
|
33
|
-
const key = `${session.id}:${item.id}`;
|
|
34
58
|
if (this.freezing.has(key)) {
|
|
35
59
|
return;
|
|
36
60
|
}
|
|
@@ -47,7 +71,7 @@ export class ChatUiQueries {
|
|
|
47
71
|
}
|
|
48
72
|
const readings = Object.create(null);
|
|
49
73
|
const values = Object.create(null);
|
|
50
|
-
for (const name of Object.keys(block.queries).slice(0,
|
|
74
|
+
for (const name of Object.keys(block.queries).slice(0, UI_HOST_LIMITS.queries)) {
|
|
51
75
|
const reading = await this.query(session, {
|
|
52
76
|
chatId: session.id,
|
|
53
77
|
itemId: item.id,
|
|
@@ -64,12 +88,12 @@ export class ChatUiQueries {
|
|
|
64
88
|
if (this.host.link) {
|
|
65
89
|
const targets = uiLinkTargets(block, {}, values);
|
|
66
90
|
const access = await this.access(session, current);
|
|
67
|
-
for (const [id, target] of Object.entries(targets).slice(0,
|
|
91
|
+
for (const [id, target] of Object.entries(targets).slice(0, UI_HOST_LIMITS.links)) {
|
|
68
92
|
try {
|
|
69
|
-
links[id] = this.linkReading(await this.host.link(session.info, access
|
|
93
|
+
links[id] = this.linkReading(await this.host.link(session.info, access, target));
|
|
70
94
|
}
|
|
71
95
|
catch (error) {
|
|
72
|
-
links[id] = { state: 'plain',
|
|
96
|
+
links[id] = { state: 'plain', ...this.failure(error) };
|
|
73
97
|
}
|
|
74
98
|
}
|
|
75
99
|
}
|
|
@@ -94,9 +118,9 @@ export class ChatUiQueries {
|
|
|
94
118
|
if (Buffer.byteLength(JSON.stringify(blocks)) > MAX_SNAPSHOT_BYTES) {
|
|
95
119
|
frozen.readings = Object.fromEntries(Object.keys(readings).map((name) => [
|
|
96
120
|
name,
|
|
97
|
-
{ state: 'failed', readAt: Date.now(), reason: 'The frozen query values are too large.' }
|
|
121
|
+
{ state: 'failed', readAt: Date.now(), code: 'snapshot-too-large', reason: 'The frozen query values are too large.' }
|
|
98
122
|
]));
|
|
99
|
-
frozen.links = Object.fromEntries(Object.keys(links).map((id) => [id, { state: 'plain', reason: 'This link must be checked again.' }]));
|
|
123
|
+
frozen.links = Object.fromEntries(Object.keys(links).map((id) => [id, { state: 'plain', code: 'link-unchecked', reason: 'This link must be checked again.' }]));
|
|
100
124
|
frozen.fallback = `${block.fallback.slice(0, 2048)}\nSources: ${names}\nThe frozen query values are too large.`;
|
|
101
125
|
}
|
|
102
126
|
if (Buffer.byteLength(JSON.stringify(blocks)) > MAX_SNAPSHOT_BYTES) {
|
|
@@ -108,6 +132,7 @@ export class ChatUiQueries {
|
|
|
108
132
|
.catch(() => { })
|
|
109
133
|
.finally(() => this.freezing.delete(key));
|
|
110
134
|
}
|
|
135
|
+
/* The writer's access for a reply, captured once; null where it was denied or is unavailable. */
|
|
111
136
|
access(session, item) {
|
|
112
137
|
const key = `${session.id}:${item.id}`;
|
|
113
138
|
const existing = this.captures.get(key);
|
|
@@ -115,10 +140,11 @@ export class ChatUiQueries {
|
|
|
115
140
|
return existing;
|
|
116
141
|
}
|
|
117
142
|
if (item.uiQueries?.authorChatId === session.id) {
|
|
118
|
-
return Promise.resolve(item.
|
|
143
|
+
return Promise.resolve(session.uiAccess(item.id) ?? null);
|
|
119
144
|
}
|
|
120
145
|
// A pending capture is persisted as denied, so a restart cannot widen the writer's rights.
|
|
121
|
-
session.
|
|
146
|
+
session.setUiAccess(item.id, null);
|
|
147
|
+
session.updateUiQueries(item.id, { authorChatId: session.id, blocks: {} });
|
|
122
148
|
const captured = this.host
|
|
123
149
|
.capture(session.info)
|
|
124
150
|
.then((access) => {
|
|
@@ -126,11 +152,10 @@ export class ChatUiQueries {
|
|
|
126
152
|
if (Buffer.byteLength(JSON.stringify(captured)) > 32 * 1024) {
|
|
127
153
|
throw new Error('The UI access snapshot is too large.');
|
|
128
154
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return state;
|
|
155
|
+
session.setUiAccess(item.id, captured);
|
|
156
|
+
return captured;
|
|
132
157
|
})
|
|
133
|
-
.catch(() =>
|
|
158
|
+
.catch(() => null)
|
|
134
159
|
.finally(() => this.captures.delete(key));
|
|
135
160
|
this.captures.set(key, captured);
|
|
136
161
|
return captured;
|
|
@@ -139,18 +164,18 @@ export class ChatUiQueries {
|
|
|
139
164
|
const item = session.thread.get(payload.itemId);
|
|
140
165
|
const block = item?.kind === 'assistant' && !item.streaming && !item.parentToolUseId ? item.ui?.find((entry) => entry.id === payload.blockId) : undefined;
|
|
141
166
|
if (item?.kind !== 'assistant' || !block?.complete || block.revision !== payload.revision) {
|
|
142
|
-
throw new
|
|
167
|
+
throw new UiQueryError('stale-ui-block', 'block-stale', 'This completed UI block is no longer available.');
|
|
143
168
|
}
|
|
144
169
|
return { item, block };
|
|
145
170
|
}
|
|
146
171
|
stored(session, payload) {
|
|
147
172
|
const { item, block } = this.storedBlock(session, payload);
|
|
148
|
-
if (!Object.hasOwn(block.queries, payload.query) || Object.keys(block.queries).length >
|
|
149
|
-
throw new
|
|
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.');
|
|
150
175
|
}
|
|
151
176
|
const name = block.queries[payload.query].source;
|
|
152
177
|
if (!Object.hasOwn(this.host.sources, name)) {
|
|
153
|
-
throw new
|
|
178
|
+
throw new UiQueryError('refused-query', 'source-unregistered', 'This query source is not registered.');
|
|
154
179
|
}
|
|
155
180
|
return { item, block, source: this.host.sources[name] };
|
|
156
181
|
}
|
|
@@ -164,19 +189,18 @@ export class ChatUiQueries {
|
|
|
164
189
|
({ source, block, item } = this.stored(session, payload));
|
|
165
190
|
args = uiQueryArguments(block, payload.query, payload.values);
|
|
166
191
|
args = source.args.parse(args);
|
|
167
|
-
|
|
168
|
-
if (
|
|
169
|
-
throw new
|
|
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.');
|
|
170
195
|
}
|
|
171
|
-
authorAccess = access.access;
|
|
172
196
|
await source.authorize(session.info, authorAccess, args);
|
|
173
197
|
}
|
|
174
198
|
catch (error) {
|
|
175
|
-
return { state: 'refused', readAt: Date.now(),
|
|
199
|
+
return { state: 'refused', readAt: Date.now(), ...this.failure(error) };
|
|
176
200
|
}
|
|
177
201
|
const key = JSON.stringify([session.id, item.id, block.id, block.revision, payload.query, args]);
|
|
178
|
-
const cached =
|
|
179
|
-
if (cached && Date.now() - cached.reading.readAt <
|
|
202
|
+
const cached = this.cache.get(this.latest.get(key) ?? '');
|
|
203
|
+
if (cached && Date.now() - cached.reading.readAt < UI_HOST_LIMITS.refreshMilliseconds) {
|
|
180
204
|
return cached.reading;
|
|
181
205
|
}
|
|
182
206
|
const pending = this.reading.get(key);
|
|
@@ -184,14 +208,17 @@ export class ChatUiQueries {
|
|
|
184
208
|
return pending;
|
|
185
209
|
}
|
|
186
210
|
const identity = JSON.stringify([session.id, item.id, block.id, block.revision, payload.query]);
|
|
187
|
-
|
|
188
|
-
|
|
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.' };
|
|
189
215
|
}
|
|
190
216
|
if ((this.active.get(session.id) ?? 0) >= 2 || this.totalActive >= 16) {
|
|
191
|
-
return { state: 'failed', readAt:
|
|
217
|
+
return { state: 'failed', readAt: now, code: 'busy', reason: 'Too many UI queries are being read.' };
|
|
192
218
|
}
|
|
193
|
-
this.attempts.set(
|
|
194
|
-
|
|
219
|
+
this.attempts.set(key, now);
|
|
220
|
+
this.attempts.set(identity, now);
|
|
221
|
+
while (this.attempts.size > 256) {
|
|
195
222
|
this.attempts.delete(this.attempts.keys().next().value);
|
|
196
223
|
}
|
|
197
224
|
this.active.set(session.id, (this.active.get(session.id) ?? 0) + 1);
|
|
@@ -202,21 +229,24 @@ export class ChatUiQueries {
|
|
|
202
229
|
timeout.unref?.();
|
|
203
230
|
const read = Promise.race([
|
|
204
231
|
work,
|
|
205
|
-
new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(new
|
|
232
|
+
new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(new UiQueryError('refused-query', 'timed-out', 'The UI query timed out.')), {
|
|
233
|
+
once: true
|
|
234
|
+
}))
|
|
206
235
|
])
|
|
207
236
|
.then((result) => {
|
|
208
237
|
const value = copyUiValue(source.result.parse(result), new UiBudget());
|
|
209
238
|
if (Buffer.byteLength(JSON.stringify(value)) > MAX_QUERY_BYTES) {
|
|
210
|
-
throw new
|
|
239
|
+
throw new UiQueryError('refused-query', 'result-too-large', 'The UI query result is too large.');
|
|
211
240
|
}
|
|
212
241
|
const reading = { state: 'fresh', value, readId: randomUUID(), readAt: Date.now() };
|
|
213
242
|
this.cache.set(reading.readId, { key, chatId: session.id, reading });
|
|
243
|
+
this.latest.set(key, reading.readId);
|
|
214
244
|
while (this.cache.size > 128) {
|
|
215
|
-
this.
|
|
245
|
+
this.evict(this.cache.keys().next().value);
|
|
216
246
|
}
|
|
217
247
|
return reading;
|
|
218
248
|
})
|
|
219
|
-
.catch((error) => ({ state: 'failed', readAt: Date.now(),
|
|
249
|
+
.catch((error) => ({ state: 'failed', readAt: Date.now(), ...this.failure(error) }))
|
|
220
250
|
.finally(() => {
|
|
221
251
|
clearTimeout(timeout);
|
|
222
252
|
this.reading.delete(key);
|
|
@@ -237,31 +267,40 @@ export class ChatUiQueries {
|
|
|
237
267
|
this.reading.set(key, read);
|
|
238
268
|
return read;
|
|
239
269
|
}
|
|
270
|
+
/* The issued value of every query the block declares, each checked against the read id the person saw. */
|
|
240
271
|
async choiceValues(session, payload) {
|
|
241
272
|
const item = session.thread.get(payload.itemId);
|
|
242
273
|
const block = item?.kind === 'assistant' ? item.ui?.find((entry) => entry.id === payload.blockId) : undefined;
|
|
243
274
|
if (!block || item?.kind !== 'assistant' || ('choiceId' in payload && item.uiAnswers?.[block.id]?.revision === payload.revision)) {
|
|
244
275
|
return {};
|
|
245
276
|
}
|
|
277
|
+
const names = Object.keys(block.queries);
|
|
246
278
|
const values = Object.create(null);
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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 });
|
|
250
288
|
const args = source.args.parse(uiQueryArguments(block, name, payload.values));
|
|
251
|
-
|
|
252
|
-
|
|
289
|
+
await source.authorize(session.info, access, args);
|
|
290
|
+
const readId = payload.reads !== undefined && Object.hasOwn(payload.reads, name) ? payload.reads[name] : undefined;
|
|
253
291
|
const key = JSON.stringify([session.id, item.id, block.id, block.revision, name, args]);
|
|
254
|
-
const
|
|
255
|
-
const cached = this.cache.get(readId);
|
|
292
|
+
const cached = readId === undefined ? undefined : this.cache.get(readId);
|
|
256
293
|
const frozen = item.uiQueries?.blocks[block.id];
|
|
257
294
|
const snapshot = frozen?.revision === block.revision ? frozen?.readings[name] : undefined;
|
|
258
|
-
const reading =
|
|
259
|
-
?
|
|
260
|
-
:
|
|
261
|
-
?
|
|
262
|
-
:
|
|
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;
|
|
263
302
|
if (!reading || reading.state !== 'fresh') {
|
|
264
|
-
throw new
|
|
303
|
+
throw new UiQueryError('stale-ui-query', 'stale-read', 'Refresh this query before sending its choice.');
|
|
265
304
|
}
|
|
266
305
|
values[name] = reading.value;
|
|
267
306
|
}
|
|
@@ -270,34 +309,41 @@ export class ChatUiQueries {
|
|
|
270
309
|
async link(session, payload) {
|
|
271
310
|
try {
|
|
272
311
|
if (!this.host.link) {
|
|
273
|
-
throw new
|
|
312
|
+
throw new UiQueryError('refused-query', 'links-unsupported', 'This host does not provide UI links.');
|
|
274
313
|
}
|
|
275
314
|
const { item, block } = this.storedBlock(session, payload);
|
|
276
315
|
if (!item.uiQueries) {
|
|
277
|
-
throw new
|
|
316
|
+
throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
|
|
278
317
|
}
|
|
279
318
|
const access = await this.access(session, item);
|
|
280
|
-
if (access
|
|
281
|
-
throw new
|
|
319
|
+
if (access === null) {
|
|
320
|
+
throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
|
|
282
321
|
}
|
|
283
322
|
const queries = await this.choiceValues(session, payload);
|
|
284
323
|
const target = uiLinkTargets(block, payload.values, queries)[payload.nodeId];
|
|
285
324
|
if (!target) {
|
|
286
|
-
throw new
|
|
325
|
+
throw new UiQueryError('refused-query', 'link-unsupported', 'This visible node is not a supported link.');
|
|
287
326
|
}
|
|
288
|
-
return this.linkReading(await this.host.link(session.info, access
|
|
327
|
+
return this.linkReading(await this.host.link(session.info, access, target));
|
|
289
328
|
}
|
|
290
329
|
catch (error) {
|
|
291
|
-
return { state: 'plain',
|
|
330
|
+
return { state: 'plain', ...this.failure(error) };
|
|
292
331
|
}
|
|
293
332
|
}
|
|
294
333
|
forget(chatId) {
|
|
295
334
|
for (const [id, cached] of this.cache) {
|
|
296
335
|
if (cached.chatId === chatId) {
|
|
297
|
-
this.
|
|
336
|
+
this.evict(id);
|
|
298
337
|
}
|
|
299
338
|
}
|
|
300
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
|
+
}
|
|
301
347
|
linkReading(value) {
|
|
302
348
|
const reading = UiLinkResolutionSchema.parse(copyUiValue(value, new UiBudget()));
|
|
303
349
|
if (reading.state === 'chip' && !reading.target) {
|
|
@@ -305,7 +351,8 @@ export class ChatUiQueries {
|
|
|
305
351
|
}
|
|
306
352
|
return reading;
|
|
307
353
|
}
|
|
308
|
-
|
|
309
|
-
|
|
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 };
|
|
310
357
|
}
|
|
311
358
|
}
|
package/dist/host/handlers.js
CHANGED
|
@@ -32,7 +32,7 @@ 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) => chats.choose(payload),
|
|
35
|
+
'chat.uiChoice': (payload, clientId) => chats.choose(payload, clientId),
|
|
36
36
|
'ui.link': (payload, clientId) => chats.linkUi(payload, clientId),
|
|
37
37
|
'ui.query': (payload, clientId) => chats.queryUi(payload, clientId),
|
|
38
38
|
'chat.unqueue': (payload) => ({ message: chats.unqueue(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
|
|
9
|
-
|
|
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.21.
|
|
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,8 +20,8 @@
|
|
|
20
20
|
"test": "bun --conditions=source test"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@adecore/agent-contracts": "0.21.
|
|
24
|
-
"@adecore/intelligent-ui": "0.21.
|
|
23
|
+
"@adecore/agent-contracts": "0.21.1",
|
|
24
|
+
"@adecore/intelligent-ui": "0.21.1",
|
|
25
25
|
"zod": "^4.6.5"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
package/src/chat/chat-core.ts
CHANGED
|
@@ -57,6 +57,7 @@ import type { CodexProcessSpec } from './codex-thread.ts';
|
|
|
57
57
|
import type { CodexClientInfo } from './codex-transport.ts';
|
|
58
58
|
import { ComposerPreferences } from './composer-preferences.ts';
|
|
59
59
|
import { DeltaCoalescer } from './delta-coalescer.ts';
|
|
60
|
+
import { readableAssistantText } from './readable-text.ts';
|
|
60
61
|
import { ChatError } from './errors.ts';
|
|
61
62
|
import { commandLabel, isBackgroundWork, runsInBackground, type BackgroundWork } from './background-work.ts';
|
|
62
63
|
import { SubagentReader, type SubagentReaderOptions } from './subagent-reader.ts';
|
|
@@ -84,7 +85,8 @@ function isBookmarkable(item: ChatItem): item is BookmarkableItem {
|
|
|
84
85
|
/* The start of a message on one line; a message of only attachments is its file names. */
|
|
85
86
|
function excerptOf(item: BookmarkableItem): string {
|
|
86
87
|
const files = item.kind === 'user' ? (item.attachments ?? []).map((attachment) => attachment.name).join(', ') : '';
|
|
87
|
-
|
|
88
|
+
const text = item.kind === 'assistant' ? readableAssistantText(item) : item.text;
|
|
89
|
+
return (text.trim() === '' ? files : text).replace(/\s+/g, ' ').trim();
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
// One process worked on the turn and one more may take it up after a restart; a loop of resumes could redo a command forever.
|
|
@@ -102,6 +104,8 @@ const DEFAULT_RESUME_WORDS: ResumeWords = {
|
|
|
102
104
|
|
|
103
105
|
export interface ChatCoreOptions {
|
|
104
106
|
intelligentUi?: ChatUiHost;
|
|
107
|
+
// The info string that marks a UI fence in a reply, for a host that keeps a name of its own.
|
|
108
|
+
uiFenceLanguage?: string;
|
|
105
109
|
providers: ProviderRegistry;
|
|
106
110
|
// Where the threads are kept; without it a chat lives as long as the process.
|
|
107
111
|
store?: ChatStore;
|
|
@@ -175,6 +179,7 @@ export class ChatCore {
|
|
|
175
179
|
readonly claudeProjectsDir: string;
|
|
176
180
|
readonly composerPreferences = new ComposerPreferences();
|
|
177
181
|
private readonly uiQueries: ChatUiQueries | null;
|
|
182
|
+
private readonly uiFenceLanguage: string | undefined;
|
|
178
183
|
private readonly checkpoints: TurnCheckpoints | null;
|
|
179
184
|
private readonly instructions: string | null;
|
|
180
185
|
private readonly skillIndex: SkillIndex;
|
|
@@ -188,7 +193,7 @@ export class ChatCore {
|
|
|
188
193
|
// How big each record was the last time it went to disk, and the writes waiting for a big one.
|
|
189
194
|
private readonly sizes = new Map<string, number>();
|
|
190
195
|
private readonly waiting = new Map<string, ReturnType<typeof setTimeout>>();
|
|
191
|
-
// What the last record held that no event carries (preambles, the host's extras), as its JSON.
|
|
196
|
+
// What the last record held that no event carries (preambles, the host's extras, captured UI access), as its JSON.
|
|
192
197
|
private readonly unlogged = new Map<string, string>();
|
|
193
198
|
// The write in flight per chat, so the next one queues behind it instead of racing it.
|
|
194
199
|
private readonly writes = new Map<string, Promise<void>>();
|
|
@@ -202,6 +207,7 @@ export class ChatCore {
|
|
|
202
207
|
|
|
203
208
|
constructor(options: ChatCoreOptions) {
|
|
204
209
|
this.uiQueries = options.intelligentUi ? new ChatUiQueries(options.intelligentUi) : null;
|
|
210
|
+
this.uiFenceLanguage = options.uiFenceLanguage;
|
|
205
211
|
this.providers = options.providers;
|
|
206
212
|
this.store = options.store ?? null;
|
|
207
213
|
this.bookmarks = options.bookmarks ?? null;
|
|
@@ -398,9 +404,11 @@ export class ChatCore {
|
|
|
398
404
|
const references = this.referencesFor(payload.chatId);
|
|
399
405
|
const session = new ChatSession({
|
|
400
406
|
info,
|
|
407
|
+
...(this.uiFenceLanguage ? { uiFenceLanguage: this.uiFenceLanguage } : {}),
|
|
401
408
|
...(this.uiQueries ? { ui: { schemas: this.uiQueries.schemas, observe: (item, final) => this.uiQueries!.observe(session, item, final) } } : {}),
|
|
402
409
|
items: stored?.items ?? [],
|
|
403
410
|
preambles: stored?.preambles ?? [],
|
|
411
|
+
uiAccess: stored?.uiAccess ?? {},
|
|
404
412
|
provider,
|
|
405
413
|
command: this.commands[kind] ?? provider.command,
|
|
406
414
|
...(this.spawn ? { spawn: this.spawn } : {}),
|
|
@@ -735,7 +743,10 @@ export class ChatCore {
|
|
|
735
743
|
return this.uiQueries.query(this.require(payload.chatId), payload);
|
|
736
744
|
}
|
|
737
745
|
|
|
738
|
-
async choose(payload: ChatUiChoicePayload): Promise<{ queued: boolean; turnId: string }> {
|
|
746
|
+
async choose(payload: ChatUiChoicePayload, clientId: string): Promise<{ queued: boolean; turnId: string }> {
|
|
747
|
+
if (!this.attached.get(payload.chatId)?.has(clientId)) {
|
|
748
|
+
throw new ChatError('refused-query', 'Attach to the chat before answering its UI.');
|
|
749
|
+
}
|
|
739
750
|
const session = this.require(payload.chatId);
|
|
740
751
|
const queries = await this.uiQueries?.choiceValues(session, payload);
|
|
741
752
|
const result = session.choose(payload, queries);
|
|
@@ -1022,7 +1033,15 @@ export class ChatCore {
|
|
|
1022
1033
|
const log = this.logs.get(chatId);
|
|
1023
1034
|
// Not folded here: an older write still in flight may land after this one, and the log is what covers for it.
|
|
1024
1035
|
try {
|
|
1025
|
-
this.store.writeSync(
|
|
1036
|
+
this.store.writeSync(
|
|
1037
|
+
chatId,
|
|
1038
|
+
info,
|
|
1039
|
+
items,
|
|
1040
|
+
{ seq: log?.seq ?? 0, resetSeq: log?.resetSeq ?? 0 },
|
|
1041
|
+
session.preambles,
|
|
1042
|
+
this.recordExtras(chatId),
|
|
1043
|
+
session.uiAccessRecord
|
|
1044
|
+
);
|
|
1026
1045
|
} catch (e) {
|
|
1027
1046
|
console.error(`Chat record for ${chatId} failed:`, errorText(e));
|
|
1028
1047
|
}
|
|
@@ -1181,7 +1200,8 @@ export class ChatCore {
|
|
|
1181
1200
|
// A delta held back is already in the thread, so it gets its seq before the snapshot says where it ends.
|
|
1182
1201
|
this.coalescers.get(chatId)?.flush();
|
|
1183
1202
|
const extras = this.recordExtras(chatId);
|
|
1184
|
-
const
|
|
1203
|
+
const uiAccess = session.uiAccessRecord;
|
|
1204
|
+
const unlogged = JSON.stringify([session.preambles, extras, uiAccess]);
|
|
1185
1205
|
const folds = fold || log.size > COMPACT_ABOVE_BYTES || !log.onDisk;
|
|
1186
1206
|
// The log already holds every change to the thread, so the whole record is rewritten only to fold
|
|
1187
1207
|
// the log, on a chat's first write, or for what no event carries.
|
|
@@ -1190,7 +1210,7 @@ export class ChatCore {
|
|
|
1190
1210
|
}
|
|
1191
1211
|
const { info, items } = session.thread.snapshot();
|
|
1192
1212
|
const at = { seq: log.seq, resetSeq: log.resetSeq };
|
|
1193
|
-
this.sizes.set(chatId, await this.store.write(chatId, info, items, at, session.preambles, extras));
|
|
1213
|
+
this.sizes.set(chatId, await this.store.write(chatId, info, items, at, session.preambles, extras, uiAccess));
|
|
1194
1214
|
this.unlogged.set(chatId, unlogged);
|
|
1195
1215
|
// A chat killed while the write was out has no log left to fold.
|
|
1196
1216
|
if (this.logs.get(chatId) === log && folds) {
|