@adecore/agents 0.21.0 → 0.21.2
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/async.js +3 -8
- package/dist/chat/chat-core.d.ts +7 -3
- package/dist/chat/chat-core.js +19 -9
- package/dist/chat/chat-log.js +2 -2
- 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 +44 -16
- package/dist/chat/chat-title.js +4 -8
- package/dist/chat/claude-backend.d.ts +1 -0
- package/dist/chat/claude-backend.js +8 -6
- package/dist/chat/claude-title.js +1 -1
- package/dist/chat/codex-backend.d.ts +2 -2
- package/dist/chat/codex-backend.js +5 -8
- package/dist/chat/codex-protocol.js +1 -1
- package/dist/chat/codex-thread.js +4 -5
- package/dist/chat/composer-preferences.js +2 -3
- package/dist/chat/limit-resume.js +2 -7
- 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/chat/visual-store.js +8 -6
- package/dist/context/refusal.js +3 -7
- package/dist/context/verb.js +1 -5
- package/dist/host/agent-host.js +2 -2
- package/dist/host/handlers.js +1 -2
- package/dist/host/wiring.js +1 -1
- package/dist/lineage.js +2 -3
- package/dist/messages/deliver-message.js +6 -11
- package/dist/messages/deliver-notice.js +8 -12
- package/dist/messages/notice-store.js +3 -5
- package/dist/outbox/outbox-worker.js +5 -2
- package/dist/providers/accounts/service.d.ts +2 -1
- package/dist/providers/accounts/service.js +11 -13
- package/dist/providers/claude.js +0 -1
- package/dist/providers/detect.js +2 -10
- package/dist/record-directory.js +0 -1
- package/dist/tasks/task-coordinator.d.ts +1 -0
- package/dist/tasks/task-coordinator.js +40 -36
- package/dist/tasks/task-store.js +1 -1
- package/dist/tasks/task-work.js +2 -2
- package/dist/tasks/waiting-child.js +5 -3
- package/dist/tasks/wake-parent.js +15 -10
- package/dist/tasks/wiring.js +32 -37
- package/dist/usage/aggregate.js +62 -46
- package/dist/usage/exchange.js +1 -1
- package/dist/usage/limits/monitor.d.ts +2 -2
- package/dist/usage/limits/monitor.js +2 -2
- package/dist/usage/limits/probe.js +1 -1
- package/dist/usage/pricing.js +1 -1
- package/dist/usage/roots.js +1 -2
- package/dist/usage/scanner.js +7 -9
- package/dist/usage/usage-service.d.ts +1 -2
- package/dist/usage/usage-service.js +3 -6
- package/package.json +3 -3
- package/src/async.ts +3 -9
- package/src/chat/chat-core.ts +29 -9
- package/src/chat/chat-log.ts +2 -2
- package/src/chat/chat-session.ts +85 -5
- package/src/chat/chat-store.ts +54 -16
- package/src/chat/chat-title.ts +4 -8
- package/src/chat/claude-backend.ts +9 -6
- package/src/chat/claude-title.ts +1 -1
- package/src/chat/claude-transcript.ts +1 -1
- package/src/chat/codex-backend.ts +5 -8
- package/src/chat/codex-protocol.ts +1 -1
- package/src/chat/codex-thread.ts +6 -5
- package/src/chat/composer-preferences.ts +2 -3
- package/src/chat/limit-resume.ts +2 -7
- package/src/chat/readable-text.ts +18 -0
- package/src/chat/thread.ts +1 -1
- package/src/chat/ui-queries.ts +120 -64
- package/src/chat/visual-store.ts +9 -6
- package/src/chat/wake-chat.ts +4 -1
- package/src/context/refusal.ts +3 -7
- package/src/context/verb.ts +1 -5
- package/src/host/agent-host.ts +2 -2
- package/src/host/handlers.ts +1 -2
- package/src/host/wiring.ts +1 -1
- package/src/lineage.ts +2 -3
- package/src/messages/deliver-message.ts +6 -11
- package/src/messages/deliver-notice.ts +8 -12
- package/src/messages/notice-store.ts +3 -5
- package/src/outbox/outbox-worker.ts +5 -2
- package/src/providers/accounts/service.ts +12 -13
- package/src/providers/accounts/variables.ts +1 -0
- package/src/providers/claude.ts +0 -1
- package/src/providers/detect.ts +2 -11
- package/src/record-directory.ts +0 -1
- package/src/skills.ts +0 -1
- package/src/tasks/task-coordinator.ts +41 -35
- package/src/tasks/task-store.ts +1 -1
- package/src/tasks/task-work.ts +2 -2
- package/src/tasks/waiting-child.ts +6 -3
- package/src/tasks/wake-parent.ts +21 -16
- package/src/tasks/wiring.ts +33 -41
- package/src/usage/aggregate.ts +80 -50
- package/src/usage/exchange.ts +1 -1
- package/src/usage/limits/monitor.ts +4 -4
- package/src/usage/limits/probe.ts +2 -2
- package/src/usage/pricing.ts +1 -1
- package/src/usage/roots.ts +1 -2
- package/src/usage/scanner.ts +8 -10
- package/src/usage/usage-service.ts +5 -9
package/src/chat/ui-queries.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
132
|
+
links[id] = this.linkReading(await this.host.link(session.info, access, target));
|
|
107
133
|
} catch (error) {
|
|
108
|
-
links[id] = { state: 'plain',
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
return state;
|
|
198
|
+
session.setUiAccess(item.id, captured);
|
|
199
|
+
return captured as unknown;
|
|
171
200
|
})
|
|
172
|
-
.catch(() =>
|
|
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
|
|
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 >
|
|
194
|
-
throw new
|
|
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
|
|
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
|
-
|
|
214
|
-
if (
|
|
215
|
-
throw new
|
|
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(),
|
|
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 =
|
|
224
|
-
if (cached && Date.now() - cached.reading.readAt <
|
|
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
|
-
|
|
233
|
-
|
|
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:
|
|
268
|
+
return { state: 'failed', readAt: now, code: 'busy', reason: 'Too many UI queries are being read.' };
|
|
237
269
|
}
|
|
238
|
-
this.attempts.set(
|
|
239
|
-
|
|
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) =>
|
|
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
|
|
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.
|
|
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(),
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
|
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
|
-
|
|
305
|
-
?
|
|
306
|
-
:
|
|
307
|
-
?
|
|
308
|
-
:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
328
|
-
throw new
|
|
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
|
|
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
|
|
382
|
+
return this.linkReading(await this.host.link(session.info, access, target));
|
|
336
383
|
} catch (error) {
|
|
337
|
-
return { state: 'plain',
|
|
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.
|
|
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
|
|
358
|
-
|
|
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
|
}
|
package/src/chat/visual-store.ts
CHANGED
|
@@ -113,6 +113,13 @@ function tooLarge(bytes: number): never {
|
|
|
113
113
|
);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
function refuseOversized(html: string): void {
|
|
117
|
+
const bytes = Buffer.byteLength(html, 'utf8');
|
|
118
|
+
if (bytes > VISUAL_LIMITS.bytes) {
|
|
119
|
+
tooLarge(bytes);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
116
123
|
/*
|
|
117
124
|
* The visuals of every chat. The list of a chat is one file beside its record under `<home>/chats`;
|
|
118
125
|
* each page is a file in the chat's attachment folder, `<id>.html`, so whatever serves an attachment
|
|
@@ -164,9 +171,7 @@ export class VisualStore {
|
|
|
164
171
|
if (html.trim() === '') {
|
|
165
172
|
refuse('visual-invalid', 'The page is empty; pass one self-contained HTML document');
|
|
166
173
|
}
|
|
167
|
-
|
|
168
|
-
tooLarge(Buffer.byteLength(html, 'utf8'));
|
|
169
|
-
}
|
|
174
|
+
refuseOversized(html);
|
|
170
175
|
return this.writeWorkspaceFile(chatId, name, html);
|
|
171
176
|
}
|
|
172
177
|
|
|
@@ -178,9 +183,7 @@ export class VisualStore {
|
|
|
178
183
|
async publish(chatId: string, input: VisualInput): Promise<ChatVisual> {
|
|
179
184
|
const { title, html, maxHeight, layout, heights } = checked(input);
|
|
180
185
|
// Before the bootstrap is added, so a page far too large is never copied first.
|
|
181
|
-
|
|
182
|
-
tooLarge(Buffer.byteLength(html, 'utf8'));
|
|
183
|
-
}
|
|
186
|
+
refuseOversized(html);
|
|
184
187
|
const page = Buffer.from(injectVisualBootstrap(html), 'utf8');
|
|
185
188
|
if (page.byteLength > VISUAL_LIMITS.bytes) {
|
|
186
189
|
tooLarge(page.byteLength);
|
package/src/chat/wake-chat.ts
CHANGED
|
@@ -2,7 +2,10 @@ import type { ChatItem } from '@adecore/agent-contracts';
|
|
|
2
2
|
import type { ChatCore } from './chat-core.ts';
|
|
3
3
|
import type { ChatSession } from './chat-session.ts';
|
|
4
4
|
|
|
5
|
-
/*
|
|
5
|
+
/*
|
|
6
|
+
* A chat the host may open a turn in, as the outbox handlers see it. `wake` answers false while a
|
|
7
|
+
* turn or an owed resume is in the way.
|
|
8
|
+
*/
|
|
6
9
|
export interface WakeChat {
|
|
7
10
|
items(): ChatItem[];
|
|
8
11
|
persist?(): Promise<void>;
|
package/src/context/refusal.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* How every no reads: `refused`, the code a script branches on and the sentence a model reads, and
|
|
3
|
-
* under it a line per thing the actor can do instead.
|
|
4
|
-
*
|
|
5
|
-
* two, and everything under the first row is read as advice.
|
|
3
|
+
* under it a line per thing the actor can do instead. A line keeps its tabs but loses its newlines,
|
|
4
|
+
* since everything under the first row is read as advice.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
/* A field that goes into a tab-separated line; a tab or a newline in a title would split the row. */
|
|
@@ -24,10 +23,7 @@ export function refusalBody(code: string, message: string, lines: readonly strin
|
|
|
24
23
|
return [`refused\t${first}`, ...rest].join('\n');
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
/*
|
|
28
|
-
* The same without the word that names it, for a host that adds the prefix in its CLI:
|
|
29
|
-
* The context CLI puts the prefix on, so a CLI of an older build still prints one refusal.
|
|
30
|
-
*/
|
|
26
|
+
/* Without the `refused` prefix, for a host whose CLI adds it, so a CLI of an older build still prints one refusal. */
|
|
31
27
|
export function refusalRows(code: string, message: string, lines: readonly string[] = []): string {
|
|
32
28
|
return rows(code, message, lines).join('\n');
|
|
33
29
|
}
|
package/src/context/verb.ts
CHANGED
|
@@ -382,11 +382,7 @@ export function requiredField(needs: string): z.ZodString {
|
|
|
382
382
|
return z.string({ error: needs }).min(1, needs);
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
-
/*
|
|
386
|
-
* What a refusal lists, or one line saying the set is empty. A refusal that promises the groups of a
|
|
387
|
-
* canvas and then prints nothing reads as a daemon that lost them, where the truth is that there are
|
|
388
|
-
* none, which is a different thing to do something about.
|
|
389
|
-
*/
|
|
385
|
+
/* A refusal that promises a list and prints nothing reads as a host that lost it, where there is none. */
|
|
390
386
|
export function orNote(lines: string[], note: string): string[] {
|
|
391
387
|
return lines.length === 0 ? [`note\t${note}`] : lines;
|
|
392
388
|
}
|
package/src/host/agent-host.ts
CHANGED
|
@@ -24,8 +24,8 @@ function isAgentRequest(type: string): type is AgentRequestType {
|
|
|
24
24
|
|
|
25
25
|
/*
|
|
26
26
|
* The chats of an app that has no daemon: it answers the agent requests over any `FramePort` and
|
|
27
|
-
* sends the agent events back on it, frames checked on arrival. One port is one client.
|
|
28
|
-
* goes on at the next start through its CLI's own session.
|
|
27
|
+
* sends the agent events back on it, frames checked on arrival. One port is one client. A chat
|
|
28
|
+
* closed with the host goes on at the next start through its CLI's own session.
|
|
29
29
|
*/
|
|
30
30
|
export class AgentHost<Core extends ChatCore = ChatCore> {
|
|
31
31
|
readonly chats: Core;
|
package/src/host/handlers.ts
CHANGED
|
@@ -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) }),
|
|
@@ -116,7 +116,6 @@ export function chatHandlers(chats: ChatCore, providers: ProviderRegistry): { [T
|
|
|
116
116
|
};
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
/* The accounts are the person's settings of this host. */
|
|
120
119
|
export function accountHandlers(accounts: ProviderAccountsService): { [T in AccountRequestType]: AgentHandler<T> } {
|
|
121
120
|
return {
|
|
122
121
|
'accounts.list': () => accounts.snapshot(),
|
package/src/host/wiring.ts
CHANGED
|
@@ -9,12 +9,12 @@ import { VisualStore } from '../chat/visual-store.ts';
|
|
|
9
9
|
import type { ClaudeBackendOptions } from '../chat/claude-backend.ts';
|
|
10
10
|
import { ClaudeTitleReader } from '../chat/claude-title.ts';
|
|
11
11
|
import { DEFAULT_CODEX_CLIENT, type CodexClientInfo } from '../chat/codex-transport.ts';
|
|
12
|
+
import { errorText } from '../error-text.ts';
|
|
12
13
|
import type { AgentEvent } from '../events.ts';
|
|
13
14
|
import { definedEnv } from '../providers/accounts/launch.ts';
|
|
14
15
|
import { ProviderAccountsService } from '../providers/accounts/service.ts';
|
|
15
16
|
import type { AccountsHost } from '../providers/accounts/variables.ts';
|
|
16
17
|
import { createClaudeProvider } from '../providers/claude-provider.ts';
|
|
17
|
-
import { errorText } from '../error-text.ts';
|
|
18
18
|
import { createCodexProvider } from '../providers/codex-provider.ts';
|
|
19
19
|
import { codexRulesPathIn, defaultCodexHome, installCodexRules, type CodexRules } from '../providers/codex-rules.ts';
|
|
20
20
|
import { ProviderRegistry, type ProviderRegistryOptions } from '../providers/registry.ts';
|
package/src/lineage.ts
CHANGED
|
@@ -30,8 +30,8 @@ function openedByAgent(entry: Lineage): boolean {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
33
|
+
* Lives under the host's data folder, outside the agent-writable project, so an agent cannot reset its
|
|
34
|
+
* own depth; on disk, so a restart of the host does not reset it either.
|
|
35
35
|
*/
|
|
36
36
|
export class AgentLineageStore {
|
|
37
37
|
readonly dir: string;
|
|
@@ -140,7 +140,6 @@ export class AgentLineageStore {
|
|
|
140
140
|
return entry?.relation === 'fork' ? entry.openedBy : null;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
/* The forks made from a chat. */
|
|
144
143
|
forksOf(nodeId: string): string[] {
|
|
145
144
|
return [...this.opened.all()].filter((entry) => entry.relation === 'fork' && entry.openedBy === nodeId).map((entry) => entry.nodeId);
|
|
146
145
|
}
|
|
@@ -24,13 +24,11 @@ export interface DeliverMessageDeps {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/*
|
|
27
|
-
* Opens the turn a message earns a chat. The messages
|
|
28
|
-
*
|
|
29
|
-
* own prompt reads them through, so one message is heard once whichever of the two opens the turn.
|
|
27
|
+
* Opens the turn a message earns a chat. The messages wait in the notice store and the turn's preamble
|
|
28
|
+
* takes them (`NoticeNotes`), as a person's own prompt does, so a message is heard once either way.
|
|
30
29
|
*
|
|
31
|
-
* One attempt, never `wait
|
|
32
|
-
*
|
|
33
|
-
* make the host start turns nobody asked for long after the news was worth anything.
|
|
30
|
+
* One attempt, never `wait`: a chat already in a turn reads the message in front of its next one, and
|
|
31
|
+
* holding the entry would start turns nobody asked for long after the news mattered.
|
|
34
32
|
*/
|
|
35
33
|
export function deliverMessageHandler(deps: DeliverMessageDeps) {
|
|
36
34
|
return async (entry: DeliverMessageEntry): Promise<OutboxOutcome> => {
|
|
@@ -46,11 +44,8 @@ export function deliverMessageHandler(deps: DeliverMessageDeps) {
|
|
|
46
44
|
if (chat === null) {
|
|
47
45
|
return;
|
|
48
46
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
* landed (`showNotices`), so anything the turn adds about it only says the same thing again;
|
|
52
|
-
* the label on the turn already names who it came from.
|
|
53
|
-
*/
|
|
47
|
+
// No note and no preamble on the thread: a person read the message as it landed (`showNotices`),
|
|
48
|
+
// and the label on the turn names who it came from.
|
|
54
49
|
chat.wake({
|
|
55
50
|
text: deps.words.prompt(waiting.length),
|
|
56
51
|
label: deps.words.label(waiting),
|
|
@@ -33,11 +33,10 @@ export interface ChatDelivery {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/*
|
|
36
|
-
* Whether the turn a chat is running was itself opened by a message
|
|
37
|
-
*
|
|
38
|
-
* and nothing about that started with a person. It rides on the turn, so it survives a restart.
|
|
36
|
+
* Whether the turn a chat is running was itself opened by a message, which stops two agents that
|
|
37
|
+
* read each other from waking each other forever. It rides on the turn, so it survives a restart.
|
|
39
38
|
* Between turns the last one counts, since work it left running (a background subagent) may still
|
|
40
|
-
* write, and a turn the CLI opened by itself
|
|
39
|
+
* write, and a turn the CLI opened by itself takes the step of the turn before it.
|
|
41
40
|
*/
|
|
42
41
|
export function turnFromMessage(items: readonly ChatItem[], activeTurnId: string | null): boolean {
|
|
43
42
|
const from = activeTurnId === null ? items.findLastIndex((item) => item.kind === 'turn') : items.findIndex((item) => item.id === activeTurnId);
|
|
@@ -76,10 +75,9 @@ export function chatNoticeTargets(chats: Pick<ChatCore, 'get' | 'hasStored'>): C
|
|
|
76
75
|
|
|
77
76
|
/*
|
|
78
77
|
* One message on its way to a chat, and whether the chat owes a turn on it. A chat between turns gets
|
|
79
|
-
* one,
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* host runs something else in, such as a terminal, is the host's to deliver to before this.
|
|
78
|
+
* one, as for a settled task, or the message sits there until someone happens to prompt that chat.
|
|
79
|
+
* One step deep: the turn a message opened wakes nobody with a message of its own. A node that runs
|
|
80
|
+
* something else, such as a terminal, is the host's to deliver to before this.
|
|
83
81
|
*/
|
|
84
82
|
export async function deliverToChat(store: Pick<NoticeStore, 'put'>, targets: ChatNoticeTargets, notice: Omit<Notice, 'createdAt'>): Promise<ChatDelivery> {
|
|
85
83
|
const waiting = await store.put(notice);
|
|
@@ -105,10 +103,8 @@ export interface NoticeChat {
|
|
|
105
103
|
}
|
|
106
104
|
|
|
107
105
|
/*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* An id no chat holds shows nothing and marks nothing, so the chat that opens on that id later still
|
|
111
|
-
* has all of it.
|
|
106
|
+
* Shows a person the message in the chat's thread the moment it lands, not when the model gets round
|
|
107
|
+
* to it. An id no chat holds shows and marks nothing, so a chat that opens on it later still has all of it.
|
|
112
108
|
*/
|
|
113
109
|
export async function showNotices(store: Pick<NoticeStore, 'show'>, chat: NoticeChat, words: Pick<MessageWords, 'shown'>, targetId: string): Promise<void> {
|
|
114
110
|
if (!(await chat.has(targetId))) {
|
|
@@ -7,10 +7,8 @@ import { RecordDirectory } from '../record-directory.ts';
|
|
|
7
7
|
export const MAX_NOTICES = 10;
|
|
8
8
|
|
|
9
9
|
/*
|
|
10
|
-
* A message
|
|
11
|
-
*
|
|
12
|
-
* something that has moved on. The cap alone would not do it: one message in a queue of one waits
|
|
13
|
-
* for ever.
|
|
10
|
+
* A message is news about work happening now ("the build is green"), and a model reading it six hours
|
|
11
|
+
* late would act on something that moved on. The cap alone would leave a queue of one waiting forever.
|
|
14
12
|
*/
|
|
15
13
|
export const NOTICE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
16
14
|
|
|
@@ -131,7 +129,7 @@ export class NoticeStore {
|
|
|
131
129
|
return this.now() - notice.createdAt < NOTICE_MAX_AGE_MS;
|
|
132
130
|
}
|
|
133
131
|
|
|
134
|
-
/*
|
|
132
|
+
/* In memory and on disk both; an empty queue leaves no file behind. */
|
|
135
133
|
private async persist(targetId: string, queue: Notice[]): Promise<void> {
|
|
136
134
|
if (queue.length === 0) {
|
|
137
135
|
await this.queues.remove(targetId);
|