@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.
Files changed (105) hide show
  1. package/dist/async.js +3 -8
  2. package/dist/chat/chat-core.d.ts +7 -3
  3. package/dist/chat/chat-core.js +19 -9
  4. package/dist/chat/chat-log.js +2 -2
  5. package/dist/chat/chat-session.d.ts +13 -0
  6. package/dist/chat/chat-session.js +75 -5
  7. package/dist/chat/chat-store.d.ts +3 -2
  8. package/dist/chat/chat-store.js +44 -16
  9. package/dist/chat/chat-title.js +4 -8
  10. package/dist/chat/claude-backend.d.ts +1 -0
  11. package/dist/chat/claude-backend.js +8 -6
  12. package/dist/chat/claude-title.js +1 -1
  13. package/dist/chat/codex-backend.d.ts +2 -2
  14. package/dist/chat/codex-backend.js +5 -8
  15. package/dist/chat/codex-protocol.js +1 -1
  16. package/dist/chat/codex-thread.js +4 -5
  17. package/dist/chat/composer-preferences.js +2 -3
  18. package/dist/chat/limit-resume.js +2 -7
  19. package/dist/chat/readable-text.d.ts +2 -0
  20. package/dist/chat/readable-text.js +16 -0
  21. package/dist/chat/ui-queries.d.ts +12 -1
  22. package/dist/chat/ui-queries.js +107 -60
  23. package/dist/chat/visual-store.js +8 -6
  24. package/dist/context/refusal.js +3 -7
  25. package/dist/context/verb.js +1 -5
  26. package/dist/host/agent-host.js +2 -2
  27. package/dist/host/handlers.js +1 -2
  28. package/dist/host/wiring.js +1 -1
  29. package/dist/lineage.js +2 -3
  30. package/dist/messages/deliver-message.js +6 -11
  31. package/dist/messages/deliver-notice.js +8 -12
  32. package/dist/messages/notice-store.js +3 -5
  33. package/dist/outbox/outbox-worker.js +5 -2
  34. package/dist/providers/accounts/service.d.ts +2 -1
  35. package/dist/providers/accounts/service.js +11 -13
  36. package/dist/providers/claude.js +0 -1
  37. package/dist/providers/detect.js +2 -10
  38. package/dist/record-directory.js +0 -1
  39. package/dist/tasks/task-coordinator.d.ts +1 -0
  40. package/dist/tasks/task-coordinator.js +40 -36
  41. package/dist/tasks/task-store.js +1 -1
  42. package/dist/tasks/task-work.js +2 -2
  43. package/dist/tasks/waiting-child.js +5 -3
  44. package/dist/tasks/wake-parent.js +15 -10
  45. package/dist/tasks/wiring.js +32 -37
  46. package/dist/usage/aggregate.js +62 -46
  47. package/dist/usage/exchange.js +1 -1
  48. package/dist/usage/limits/monitor.d.ts +2 -2
  49. package/dist/usage/limits/monitor.js +2 -2
  50. package/dist/usage/limits/probe.js +1 -1
  51. package/dist/usage/pricing.js +1 -1
  52. package/dist/usage/roots.js +1 -2
  53. package/dist/usage/scanner.js +7 -9
  54. package/dist/usage/usage-service.d.ts +1 -2
  55. package/dist/usage/usage-service.js +3 -6
  56. package/package.json +3 -3
  57. package/src/async.ts +3 -9
  58. package/src/chat/chat-core.ts +29 -9
  59. package/src/chat/chat-log.ts +2 -2
  60. package/src/chat/chat-session.ts +85 -5
  61. package/src/chat/chat-store.ts +54 -16
  62. package/src/chat/chat-title.ts +4 -8
  63. package/src/chat/claude-backend.ts +9 -6
  64. package/src/chat/claude-title.ts +1 -1
  65. package/src/chat/claude-transcript.ts +1 -1
  66. package/src/chat/codex-backend.ts +5 -8
  67. package/src/chat/codex-protocol.ts +1 -1
  68. package/src/chat/codex-thread.ts +6 -5
  69. package/src/chat/composer-preferences.ts +2 -3
  70. package/src/chat/limit-resume.ts +2 -7
  71. package/src/chat/readable-text.ts +18 -0
  72. package/src/chat/thread.ts +1 -1
  73. package/src/chat/ui-queries.ts +120 -64
  74. package/src/chat/visual-store.ts +9 -6
  75. package/src/chat/wake-chat.ts +4 -1
  76. package/src/context/refusal.ts +3 -7
  77. package/src/context/verb.ts +1 -5
  78. package/src/host/agent-host.ts +2 -2
  79. package/src/host/handlers.ts +1 -2
  80. package/src/host/wiring.ts +1 -1
  81. package/src/lineage.ts +2 -3
  82. package/src/messages/deliver-message.ts +6 -11
  83. package/src/messages/deliver-notice.ts +8 -12
  84. package/src/messages/notice-store.ts +3 -5
  85. package/src/outbox/outbox-worker.ts +5 -2
  86. package/src/providers/accounts/service.ts +12 -13
  87. package/src/providers/accounts/variables.ts +1 -0
  88. package/src/providers/claude.ts +0 -1
  89. package/src/providers/detect.ts +2 -11
  90. package/src/record-directory.ts +0 -1
  91. package/src/skills.ts +0 -1
  92. package/src/tasks/task-coordinator.ts +41 -35
  93. package/src/tasks/task-store.ts +1 -1
  94. package/src/tasks/task-work.ts +2 -2
  95. package/src/tasks/waiting-child.ts +6 -3
  96. package/src/tasks/wake-parent.ts +21 -16
  97. package/src/tasks/wiring.ts +33 -41
  98. package/src/usage/aggregate.ts +80 -50
  99. package/src/usage/exchange.ts +1 -1
  100. package/src/usage/limits/monitor.ts +4 -4
  101. package/src/usage/limits/probe.ts +2 -2
  102. package/src/usage/pricing.ts +1 -1
  103. package/src/usage/roots.ts +1 -2
  104. package/src/usage/scanner.ts +8 -10
  105. package/src/usage/usage-service.ts +5 -9
@@ -7,12 +7,12 @@ export interface CodexBackendOptions {
7
7
  }
8
8
  export declare class CodexBackend implements ChatBackend {
9
9
  readonly acknowledgesTurns = true;
10
- private promptId;
11
10
  private readonly launch;
12
- private imageWrites;
13
11
  private readonly host;
14
12
  private readonly protocol;
15
13
  private readonly client;
14
+ private promptId;
15
+ private imageWrites;
16
16
  private transport;
17
17
  private threadId;
18
18
  private contextPending;
@@ -1,10 +1,10 @@
1
1
  import { accessSync, constants } from 'node:fs';
2
2
  import { attachmentImageMime } from '@adecore/agent-contracts';
3
+ import { errorText } from '../error-text.js';
3
4
  import { codexServiceTier, codexThreadOptions } from '../providers/codex.js';
4
5
  import { CodexProtocol } from './codex-protocol.js';
5
- import { attachmentNote } from './input.js';
6
6
  import { CodexTransport, DEFAULT_CODEX_CLIENT } from './codex-transport.js';
7
- import { errorText } from '../error-text.js';
7
+ import { attachmentNote } from './input.js';
8
8
  function textInput(text) {
9
9
  return [{ type: 'text', text, text_elements: [] }];
10
10
  }
@@ -37,18 +37,15 @@ export function parseSkillsList(result) {
37
37
  */
38
38
  export class CodexBackend {
39
39
  acknowledgesTurns = true;
40
- promptId = null;
41
40
  launch;
42
- imageWrites = null;
43
41
  host;
44
42
  protocol;
45
43
  client;
44
+ promptId = null;
45
+ imageWrites = null;
46
46
  transport = null;
47
47
  threadId = '';
48
- /*
49
- * Codex 0.154 ignores developer instructions on `thread/resume` (measured), so a thread started
50
- * before anything was linked would never hear of its links; the first prompt of a resumed process says so instead.
51
- */
48
+ // Codex 0.154 ignores developer instructions on `thread/resume` (measured), so the first prompt of a resumed process carries them.
52
49
  contextPending = null;
53
50
  imageInputSupported = null;
54
51
  /* A turn asked for that Codex has not named yet; `turn/interrupt` needs that name. */
@@ -130,7 +130,7 @@ function elicitationInput(meta) {
130
130
  }
131
131
  return input;
132
132
  }
133
- // Every spent window must reset; one unknown reset keeps resuming a person's choice.
133
+ // The latest reset of every spent window; one without a known reset leaves resuming to a person.
134
134
  function spentUntil(windows) {
135
135
  const spent = windows.filter((window) => window.used >= 1);
136
136
  if (spent.length === 0 || spent.some((window) => window.resetsAt === null)) {
@@ -1,8 +1,8 @@
1
+ import { errorText } from '../error-text.js';
1
2
  import { CodexProtocol } from './codex-protocol.js';
2
3
  import { CodexTransport, DEFAULT_CODEX_CLIENT } from './codex-transport.js';
3
4
  import { ChatError } from './errors.js';
4
5
  import { readingThread, settledReading } from './subagent-projection.js';
5
- import { errorText } from '../error-text.js';
6
6
  function isRecord(value) {
7
7
  return typeof value === 'object' && value !== null && !Array.isArray(value);
8
8
  }
@@ -85,14 +85,13 @@ async function nthTurnId(transport, threadId, turns) {
85
85
  let seen = 0;
86
86
  let cursor = null;
87
87
  do {
88
- const page = await transport.request('thread/turns/list', { threadId, limit: 100, sortDirection: 'asc', ...(cursor === null ? {} : { cursor }) });
89
- const data = isRecord(page) && Array.isArray(page.data) ? page.data.filter(isRecord) : [];
90
- for (const turn of data) {
88
+ const page = parseThreadItemsPage(await transport.request('thread/turns/list', { threadId, limit: 100, sortDirection: 'asc', ...(cursor === null ? {} : { cursor }) }));
89
+ for (const turn of page.entries) {
91
90
  if (++seen === turns) {
92
91
  return typeof turn.id === 'string' ? turn.id : null;
93
92
  }
94
93
  }
95
- cursor = isRecord(page) && typeof page.nextCursor === 'string' && page.nextCursor !== '' ? page.nextCursor : null;
94
+ cursor = page.nextCursor;
96
95
  } while (cursor !== null);
97
96
  return null;
98
97
  }
@@ -1,8 +1,7 @@
1
1
  /*
2
2
  * What each connected client says a new chat starts with, for the chats the host starts with no
3
- * client mounting them. Held per socket like the approval switch: a machine never remembers a
4
- * person's pick after that person's client is gone, so with nobody connected a chat gets the
5
- * host's own defaults.
3
+ * client mounting them. Held per client only, so with nobody connected a chat gets the host's own
4
+ * defaults rather than the pick of a person who left.
6
5
  */
7
6
  export class ComposerPreferences {
8
7
  byClient = new Map();
@@ -2,13 +2,8 @@
2
2
  export const LIMIT_RETRY_DELAYS_MS = [60_000, 5 * 60_000, 15 * 60_000];
3
3
  /* The last turn of a thread, when it stopped on a limit. */
4
4
  export function limitedTurn(items) {
5
- for (let i = items.length - 1; i >= 0; i--) {
6
- const item = items[i];
7
- if (item.kind === 'turn') {
8
- return item.state === 'error' && item.limit !== undefined ? item : null;
9
- }
10
- }
11
- return null;
5
+ const last = items.findLast((item) => item.kind === 'turn');
6
+ return last?.kind === 'turn' && last.state === 'error' && last.limit !== undefined ? last : null;
12
7
  }
13
8
  /* How many turns in a row, up to and including this one, stopped on a limit: the tries a person did not step in between. */
14
9
  function limitedInARow(items, turn) {
@@ -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
+ }
@@ -3,6 +3,7 @@ import type { ChatUiLinkPayload, ChatUiLinkReading, ChatAssistantItem, ChatInfo,
3
3
  import { type UiValue } from '@adecore/intelligent-ui';
4
4
  import { type UiLinkTarget } from '@adecore/intelligent-ui/links';
5
5
  import type { ChatSession } from './chat-session.ts';
6
+ import { ChatError } from './errors.ts';
6
7
  export interface ChatUiSource {
7
8
  args: z.ZodType;
8
9
  result: z.ZodType;
@@ -14,11 +15,19 @@ export interface ChatUiHost {
14
15
  sources: Readonly<Record<string, ChatUiSource>>;
15
16
  link?(info: ChatInfo, access: unknown, target: UiLinkTarget): Promise<ChatUiLinkReading>;
16
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
+ }
17
25
  export declare class ChatUiQueries {
18
26
  readonly schemas: Record<string, z.ZodType>;
19
27
  private readonly captures;
20
28
  private readonly freezing;
21
29
  private readonly cache;
30
+ private readonly latest;
22
31
  private readonly reading;
23
32
  private readonly attempts;
24
33
  private readonly active;
@@ -33,6 +42,8 @@ export declare class ChatUiQueries {
33
42
  choiceValues(session: ChatSession, payload: ChatUiChoicePayload | ChatUiLinkPayload): Promise<Record<string, unknown>>;
34
43
  link(session: ChatSession, payload: ChatUiLinkPayload): Promise<ChatUiLinkReading>;
35
44
  forget(chatId: string): void;
45
+ private evict;
36
46
  private linkReading;
37
- private reason;
47
+ private failure;
38
48
  }
49
+ export {};
@@ -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
- if (!item.text.includes('@Query') && !/<(?:File|Diff|Commit|Node)\b/.test(item.text) && !item.ui?.some((block) => Object.keys(block.queries).length)) {
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, MAX_QUERIES)) {
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, 64)) {
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.access, target));
93
+ links[id] = this.linkReading(await this.host.link(session.info, access, target));
70
94
  }
71
95
  catch (error) {
72
- links[id] = { state: 'plain', reason: this.reason(error) };
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.uiQueries);
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.updateUiQueries(item.id, { authorChatId: session.id, access: null, blocks: {} });
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
- const state = { authorChatId: session.id, access: captured, blocks: {} };
130
- session.updateUiQueries(item.id, state);
131
- return state;
155
+ session.setUiAccess(item.id, captured);
156
+ return captured;
132
157
  })
133
- .catch(() => ({ authorChatId: session.id, access: null, blocks: {} }))
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 ChatError('stale-ui-block', 'This completed UI block is no longer available.');
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 > MAX_QUERIES) {
149
- throw new ChatError('refused-query', 'This block does not declare an allowed query.');
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 ChatError('refused-query', 'This query source is not registered.');
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
- const access = await this.access(session, item);
168
- if (access.access === null) {
169
- throw new ChatError('refused-query', 'The writer’s original access is unavailable.');
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(), reason: this.reason(error) };
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 = [...this.cache.values()].find((entry) => entry.key === key);
179
- if (cached && Date.now() - cached.reading.readAt < REFRESH_MS) {
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
- if (Date.now() - (this.attempts.get(identity) ?? 0) < REFRESH_MS) {
188
- return { state: 'failed', readAt: Date.now(), reason: 'This query may refresh once every ten seconds.' };
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: Date.now(), reason: 'Too many UI queries are being read.' };
217
+ return { state: 'failed', readAt: now, code: 'busy', reason: 'Too many UI queries are being read.' };
192
218
  }
193
- this.attempts.set(identity, Date.now());
194
- while (this.attempts.size > 128) {
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 Error('The UI query timed out.')), { once: true }))
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 Error('The UI query result is too large.');
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.cache.delete(this.cache.keys().next().value);
245
+ this.evict(this.cache.keys().next().value);
216
246
  }
217
247
  return reading;
218
248
  })
219
- .catch((error) => ({ state: 'failed', readAt: Date.now(), reason: this.reason(error) }))
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
- for (const name of Object.keys(payload.reads ?? {})) {
248
- const identity = { ...payload, query: name };
249
- const { source } = this.stored(session, identity);
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
- const access = await this.access(session, item);
252
- await source.authorize(session.info, access.access, args);
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 readId = payload.reads[name];
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 = cached?.key === key
259
- ? cached.reading
260
- : snapshot?.readId === readId && JSON.stringify(args) === JSON.stringify(source.args.parse(uiQueryArguments(block, name)))
261
- ? snapshot
262
- : 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;
263
302
  if (!reading || reading.state !== 'fresh') {
264
- throw new ChatError('stale-ui-query', 'Refresh this query before sending its choice.');
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 ChatError('refused-query', 'This host does not provide UI links.');
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 ChatError('refused-query', 'The writer’s original access is unavailable.');
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.access === null) {
281
- throw new ChatError('refused-query', 'The writer’s original access is unavailable.');
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 ChatError('refused-query', 'This visible node is not a supported link.');
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.access, target));
327
+ return this.linkReading(await this.host.link(session.info, access, target));
289
328
  }
290
329
  catch (error) {
291
- return { state: 'plain', reason: this.reason(error) };
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.cache.delete(id);
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
- reason(error) {
309
- return (error instanceof Error ? error.message : 'This query could not be read.').slice(0, 240);
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
  }
@@ -59,6 +59,12 @@ function tooLarge(bytes) {
59
59
  return refuse('visual-too-large', `The page is ${mebibytes(bytes)} and a visual may be at most ${mebibytes(VISUAL_LIMITS.bytes)}; ` +
60
60
  'load libraries from a public CDN URL and refer to large images by their http(s) URL instead of inlining them');
61
61
  }
62
+ function refuseOversized(html) {
63
+ const bytes = Buffer.byteLength(html, 'utf8');
64
+ if (bytes > VISUAL_LIMITS.bytes) {
65
+ tooLarge(bytes);
66
+ }
67
+ }
62
68
  /*
63
69
  * The visuals of every chat. The list of a chat is one file beside its record under `<home>/chats`;
64
70
  * each page is a file in the chat's attachment folder, `<id>.html`, so whatever serves an attachment
@@ -104,9 +110,7 @@ export class VisualStore {
104
110
  if (html.trim() === '') {
105
111
  refuse('visual-invalid', 'The page is empty; pass one self-contained HTML document');
106
112
  }
107
- if (Buffer.byteLength(html, 'utf8') > VISUAL_LIMITS.bytes) {
108
- tooLarge(Buffer.byteLength(html, 'utf8'));
109
- }
113
+ refuseOversized(html);
110
114
  return this.writeWorkspaceFile(chatId, name, html);
111
115
  }
112
116
  writePreview(chatId, png) {
@@ -116,9 +120,7 @@ export class VisualStore {
116
120
  async publish(chatId, input) {
117
121
  const { title, html, maxHeight, layout, heights } = checked(input);
118
122
  // Before the bootstrap is added, so a page far too large is never copied first.
119
- if (Buffer.byteLength(html, 'utf8') > VISUAL_LIMITS.bytes) {
120
- tooLarge(Buffer.byteLength(html, 'utf8'));
121
- }
123
+ refuseOversized(html);
122
124
  const page = Buffer.from(injectVisualBootstrap(html), 'utf8');
123
125
  if (page.byteLength > VISUAL_LIMITS.bytes) {
124
126
  tooLarge(page.byteLength);
@@ -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. One row per line, and a line is a row of
4
- * tab-separated fields of its own, so only a newline is taken out of it: that would split the row in
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
  /* A field that goes into a tab-separated line; a tab or a newline in a title would split the row. */
8
7
  export function field(value) {
@@ -19,10 +18,7 @@ export function refusalBody(code, message, lines = []) {
19
18
  const [first, ...rest] = rows(code, message, lines);
20
19
  return [`refused\t${first}`, ...rest].join('\n');
21
20
  }
22
- /*
23
- * The same without the word that names it, for a host that adds the prefix in its CLI:
24
- * The context CLI puts the prefix on, so a CLI of an older build still prints one refusal.
25
- */
21
+ /* Without the `refused` prefix, for a host whose CLI adds it, so a CLI of an older build still prints one refusal. */
26
22
  export function refusalRows(code, message, lines = []) {
27
23
  return rows(code, message, lines).join('\n');
28
24
  }
@@ -223,11 +223,7 @@ export function lengthOf(input) {
223
223
  export function requiredField(needs) {
224
224
  return z.string({ error: needs }).min(1, needs);
225
225
  }
226
- /*
227
- * What a refusal lists, or one line saying the set is empty. A refusal that promises the groups of a
228
- * canvas and then prints nothing reads as a daemon that lost them, where the truth is that there are
229
- * none, which is a different thing to do something about.
230
- */
226
+ /* A refusal that promises a list and prints nothing reads as a host that lost it, where there is none. */
231
227
  export function orNote(lines, note) {
232
228
  return lines.length === 0 ? [`note\t${note}`] : lines;
233
229
  }
@@ -10,8 +10,8 @@ function isAgentRequest(type) {
10
10
  }
11
11
  /*
12
12
  * The chats of an app that has no daemon: it answers the agent requests over any `FramePort` and
13
- * sends the agent events back on it, frames checked on arrival. One port is one client. Closing the host writes every thread and ends every CLI; a chat
14
- * goes on at the next start through its CLI's own session.
13
+ * sends the agent events back on it, frames checked on arrival. One port is one client. A chat
14
+ * closed with the host goes on at the next start through its CLI's own session.
15
15
  */
16
16
  export class AgentHost {
17
17
  chats;
@@ -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) }),
@@ -81,7 +81,6 @@ export function chatHandlers(chats, providers) {
81
81
  'chat.list': () => ({ chats: chats.list() })
82
82
  };
83
83
  }
84
- /* The accounts are the person's settings of this host. */
85
84
  export function accountHandlers(accounts) {
86
85
  return {
87
86
  'accounts.list': () => accounts.snapshot(),