@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.
@@ -18,7 +18,7 @@ import type {
18
18
  RuntimeMode
19
19
  } from '@adecore/agent-contracts';
20
20
  import { GeneratedImageInputSchema, notResumedNote } from '@adecore/agent-contracts';
21
- import { resolveUiChoice, UiFailure } from '@adecore/intelligent-ui';
21
+ import { resolveUiChoice, UI_FENCE_LANGUAGE, UiFailure, uiHasFence, uiMayReferenceHost } from '@adecore/intelligent-ui';
22
22
  import type { UiCompileOptions } from '@adecore/intelligent-ui/compiler';
23
23
  import { UiStream, type UiStreamPreview } from '@adecore/intelligent-ui/stream';
24
24
  import type { ChatProvider } from '../providers/provider.ts';
@@ -30,6 +30,7 @@ import type { ChatTitleInput } from './chat-title.ts';
30
30
  import { ChatError } from './errors.ts';
31
31
  import { limitedTurn, limitResumeAt, limitResumeWake } from './limit-resume.ts';
32
32
  import { isMainAgentOutput, ThreadProjector } from './projector.ts';
33
+ import { readableAssistantText } from './readable-text.ts';
33
34
  import type { SubagentSettlement } from './subagent-settlement.ts';
34
35
  import { ChatThread } from './thread.ts';
35
36
  import { errorText } from '../error-text.ts';
@@ -60,6 +61,8 @@ export interface ChatSessionOptions {
60
61
  items?: ChatItem[];
61
62
  // Said once, in front of the next real prompt, and kept in the record until then (a fork's note for its agent).
62
63
  preambles?: string[];
64
+ // The access a UI host captured per reply, from the record; never on the wire.
65
+ uiAccess?: Readonly<Record<string, unknown>>;
63
66
  provider: ChatProvider;
64
67
  // The executable and leading arguments; a test points this at a fake CLI.
65
68
  command: string[];
@@ -76,6 +79,8 @@ export interface ChatSessionOptions {
76
79
  references?: ChatReferences;
77
80
  // Trees per turn, so a settled turn can show what the working tree holds against its start.
78
81
  checkpoints?: TurnCheckpoints;
82
+ // The info string of the host's UI fences; `UI_FENCE_LANGUAGE` without one.
83
+ uiFenceLanguage?: string;
79
84
  ui?: {
80
85
  schemas: UiCompileOptions['querySchemas'];
81
86
  observe(item: ChatAssistantItem, final: boolean): void;
@@ -109,6 +114,8 @@ export interface LimitResumeHooks {
109
114
 
110
115
  // Claude Code names a session about six seconds after its first prompt, and a first turn can run for minutes.
111
116
  const TITLE_RECHECK_MS = 10_000;
117
+ // Longer than any fence line or the name of a tag a scan looks for, so a match never falls between two deltas.
118
+ const UI_SCAN_OVERLAP = 64;
112
119
 
113
120
  export interface ChatSendExtras {
114
121
  uiChoice?: ChatUiChoiceOrigin;
@@ -193,10 +200,18 @@ export class ChatSession {
193
200
  private readonly steering = new Set<string>();
194
201
  private readonly uiStreams = new Map<string, UiStream>();
195
202
  private readonly uiCompiled = new Map<string, UiStreamPreview>();
203
+ private readonly capturedUiAccess: Map<string, unknown>;
204
+ // How much of a streaming reply's text the fence and host scans already covered, so a delta scans only its tail.
205
+ private readonly uiScanned = new Map<string, number>();
206
+ private readonly uiReferencing = new Set<string>();
207
+ private readonly uiAttachments = new Map<string | null, string | undefined>();
208
+ private readonly uiFenceLanguage: string;
196
209
 
197
210
  constructor(options: ChatSessionOptions) {
198
211
  this.options = options;
212
+ this.uiFenceLanguage = options.uiFenceLanguage ?? UI_FENCE_LANGUAGE;
199
213
  this.pendingPreambles = options.preambles ?? [];
214
+ this.capturedUiAccess = new Map(Object.entries(options.uiAccess ?? {}));
200
215
  this.thread = new ChatThread(options.info, options.items);
201
216
  this.projector = new ThreadProjector(this.thread, { providerName: options.provider.name });
202
217
  }
@@ -314,6 +329,24 @@ export class ChatSession {
314
329
  return { queued: false, turnId };
315
330
  }
316
331
 
332
+ /* What a UI host captured for a reply; null where the capture was denied or is still out, undefined where none started. */
333
+ uiAccess(itemId: string): unknown {
334
+ return this.capturedUiAccess.get(itemId);
335
+ }
336
+
337
+ setUiAccess(itemId: string, access: unknown): void {
338
+ if (this.frozen) {
339
+ return;
340
+ }
341
+ this.capturedUiAccess.set(itemId, access);
342
+ this.options.persistSoon();
343
+ }
344
+
345
+ /* The captured access of the replies still in the thread, for the record. */
346
+ get uiAccessRecord(): Record<string, unknown> {
347
+ return Object.fromEntries([...this.capturedUiAccess].filter(([itemId]) => this.thread.get(itemId) !== undefined));
348
+ }
349
+
317
350
  updateUiQueries(itemId: string, uiQueries: ChatUiQueryState): void {
318
351
  const item = this.thread.get(itemId);
319
352
  if (this.frozen || item?.kind !== 'assistant' || item.parentToolUseId) {
@@ -1828,7 +1861,7 @@ export class ChatSession {
1828
1861
  this.naming = true;
1829
1862
  const turnId = done[0]!.id;
1830
1863
  const answer = items
1831
- .map((item) => (item.kind === 'assistant' && item.turnId === turnId ? item.text : ''))
1864
+ .map((item) => (item.kind === 'assistant' && item.turnId === turnId ? readableAssistantText(item) : ''))
1832
1865
  .filter((text) => text !== '')
1833
1866
  .join('\n\n');
1834
1867
  const agentSessionId = this.thread.info.agentSessionId;
@@ -1886,19 +1919,28 @@ export class ChatSession {
1886
1919
  if (event.type === 'reset') {
1887
1920
  this.clearUi();
1888
1921
  }
1922
+ if (event.type === 'item' && event.item.kind === 'tool') {
1923
+ this.uiAttachments.delete(event.item.turnId);
1924
+ }
1889
1925
  const item = event.type === 'item' ? event.item : event.type === 'delta' ? this.thread.get(event.itemId) : null;
1890
1926
  if (item?.kind !== 'assistant' || item.parentToolUseId) {
1891
1927
  this.options.emit(event);
1892
1928
  continue;
1893
1929
  }
1930
+ const tail = this.unscannedUiText(item, event.type === 'item');
1931
+ if (uiMayReferenceHost(tail)) {
1932
+ this.uiReferencing.add(item.id);
1933
+ }
1894
1934
  let stream = this.uiStreams.get(item.id);
1895
- if (!stream && !item.text.includes('ruimte-ui')) {
1935
+ if (!stream && !uiHasFence(tail, this.uiFenceLanguage)) {
1936
+ this.forgetUiScan(item);
1896
1937
  this.options.emit(event);
1897
1938
  continue;
1898
1939
  }
1899
1940
  if (!stream) {
1900
1941
  stream = new UiStream({
1901
1942
  querySchemas: this.options.ui?.schemas,
1943
+ fenceLanguage: this.uiFenceLanguage,
1902
1944
  id: item.id,
1903
1945
  clock: this.options.clock ?? systemClock,
1904
1946
  emit: (preview) => {
@@ -1908,7 +1950,10 @@ export class ChatSession {
1908
1950
  });
1909
1951
  this.uiStreams.set(item.id, stream);
1910
1952
  }
1911
- this.options.ui?.observe(item, false);
1953
+ if (this.uiReferencing.has(item.id)) {
1954
+ this.options.ui?.observe(item, false);
1955
+ }
1956
+ this.forgetUiScan(item);
1912
1957
  const latestAttachment = this.latestUiAttachment(item.turnId);
1913
1958
  if (event.type === 'item' && !item.streaming) {
1914
1959
  const ui = stream
@@ -1919,7 +1964,7 @@ export class ChatSession {
1919
1964
  const diagnostics = [...new Set(ui.flatMap((block) => block.diagnostics.map((diagnostic) => diagnostic.message)))].slice(0, 6);
1920
1965
  if (diagnostics.length > 0) {
1921
1966
  const repair =
1922
- `Your last ruimte-ui block had these diagnostics: ${diagnostics.join(' ')} Use the catalog's supported props and components.`.slice(
1967
+ `Your last ${this.uiFenceLanguage} block had these diagnostics: ${diagnostics.join(' ')} Use the catalog's supported props and components.`.slice(
1923
1968
  0,
1924
1969
  1200
1925
1970
  );
@@ -1939,7 +1984,39 @@ export class ChatSession {
1939
1984
  }
1940
1985
  }
1941
1986
 
1987
+ /*
1988
+ * The text a scan has not covered yet, from the start of the line the last scan ended in, since a fence
1989
+ * is one line; a long line is cut short, behind a character no fence or tag can follow.
1990
+ */
1991
+ private unscannedUiText(item: ChatAssistantItem, whole: boolean): string {
1992
+ const scanned = whole ? 0 : (this.uiScanned.get(item.id) ?? 0);
1993
+ this.uiScanned.set(item.id, item.text.length);
1994
+ if (scanned === 0 || scanned > item.text.length) {
1995
+ return item.text;
1996
+ }
1997
+ const line = item.text.lastIndexOf('\n', scanned - 1) + 1;
1998
+ const from = Math.max(line, scanned - UI_SCAN_OVERLAP);
1999
+ return from === line ? item.text.slice(from) : `\0${item.text.slice(from)}`;
2000
+ }
2001
+
2002
+ private forgetUiScan(item: ChatAssistantItem): void {
2003
+ if (!item.streaming) {
2004
+ this.uiScanned.delete(item.id);
2005
+ this.uiReferencing.delete(item.id);
2006
+ }
2007
+ }
2008
+
2009
+ /* Looked up once per turn, and again only after one of its tool calls changed. */
1942
2010
  private latestUiAttachment(turnId: string | null): string | undefined {
2011
+ if (this.uiAttachments.has(turnId)) {
2012
+ return this.uiAttachments.get(turnId);
2013
+ }
2014
+ const attachment = this.findUiAttachment(turnId);
2015
+ this.uiAttachments.set(turnId, attachment);
2016
+ return attachment;
2017
+ }
2018
+
2019
+ private findUiAttachment(turnId: string | null): string | undefined {
1943
2020
  for (const item of this.thread.list().toReversed()) {
1944
2021
  if (item.kind !== 'tool' || item.turnId !== turnId || item.name !== 'ImageGeneration' || item.state !== 'done') {
1945
2022
  continue;
@@ -1958,6 +2035,9 @@ export class ChatSession {
1958
2035
  }
1959
2036
  this.uiStreams.clear();
1960
2037
  this.uiCompiled.clear();
2038
+ this.uiScanned.clear();
2039
+ this.uiReferencing.clear();
2040
+ this.uiAttachments.clear();
1961
2041
  }
1962
2042
 
1963
2043
  /* Said on the info as well as by the rows, since a client with the thread closed sees only the info. */
@@ -20,10 +20,12 @@ const RecordSchema = z.looseObject({
20
20
  seq: z.number().int().nonnegative().optional(),
21
21
  resetSeq: z.number().int().nonnegative().optional(),
22
22
  // Never on the wire: what goes in front of the next real prompt, once (a fork's note for its agent).
23
- preambles: z.array(z.string()).optional()
23
+ preambles: z.array(z.string()).optional(),
24
+ // Never on the wire: the access a UI host captured for each reply that reads it, by item id.
25
+ uiAccess: z.record(z.string(), z.unknown()).optional()
24
26
  });
25
27
 
26
- const OWN_KEYS = new Set(['info', 'items', 'seq', 'resetSeq', 'preambles']);
28
+ const OWN_KEYS = new Set(['info', 'items', 'seq', 'resetSeq', 'preambles', 'uiAccess']);
27
29
 
28
30
  /* What a host keeps in a chat's record beside the thread; it never reaches the wire. */
29
31
  export type ChatRecordExtras = Record<string, unknown>;
@@ -39,6 +41,7 @@ export interface ChatRecord {
39
41
  seq: number;
40
42
  resetSeq: number;
41
43
  preambles: string[];
44
+ uiAccess: Record<string, unknown>;
42
45
  extras: ChatRecordExtras;
43
46
  // The log as it is on disk, for the lines an attach with `since` may still be answered from.
44
47
  lines: ChatLogLine[];
@@ -54,8 +57,37 @@ function logName(chatId: string): string {
54
57
  return `${encodeURIComponent(chatId)}.log`;
55
58
  }
56
59
 
57
- function recordBody(info: ChatInfo, items: ChatItem[], at: ChatSeq, preambles: readonly string[], extras: ChatRecordExtras): string {
58
- return JSON.stringify({ info, items, ...at, ...(preambles.length === 0 ? {} : { preambles }), ...withoutOwnKeys(extras) });
60
+ function recordBody(
61
+ info: ChatInfo,
62
+ items: ChatItem[],
63
+ at: ChatSeq,
64
+ preambles: readonly string[],
65
+ extras: ChatRecordExtras,
66
+ uiAccess: Readonly<Record<string, unknown>>
67
+ ): string {
68
+ return JSON.stringify({
69
+ info,
70
+ items,
71
+ ...at,
72
+ ...(preambles.length === 0 ? {} : { preambles }),
73
+ ...(Object.keys(uiAccess).length === 0 ? {} : { uiAccess }),
74
+ ...withoutOwnKeys(extras)
75
+ });
76
+ }
77
+
78
+ /* A record from before `uiAccess` kept the access inside each reply's `uiQueries`, which the item schema now drops. */
79
+ function liftUiAccess(record: unknown): unknown {
80
+ if (typeof record !== 'object' || record === null || 'uiAccess' in record || !Array.isArray((record as { items?: unknown }).items)) {
81
+ return record;
82
+ }
83
+ const uiAccess: Record<string, unknown> = {};
84
+ for (const item of (record as { items: unknown[] }).items) {
85
+ const queries = (item as { uiQueries?: unknown } | null)?.uiQueries;
86
+ if (typeof queries === 'object' && queries !== null && 'access' in queries) {
87
+ uiAccess[String((item as { id: unknown }).id)] = queries.access;
88
+ }
89
+ }
90
+ return Object.keys(uiAccess).length === 0 ? record : { ...record, uiAccess };
59
91
  }
60
92
 
61
93
  function withoutOwnKeys(extras: ChatRecordExtras): ChatRecordExtras {
@@ -127,9 +159,10 @@ export class ChatStore {
127
159
  items: ChatItem[],
128
160
  at: ChatSeq = { seq: 0, resetSeq: 0 },
129
161
  preambles: readonly string[] = [],
130
- extras: ChatRecordExtras = {}
162
+ extras: ChatRecordExtras = {},
163
+ uiAccess: Readonly<Record<string, unknown>> = {}
131
164
  ): Promise<number> {
132
- const body = recordBody(info, items, at, preambles, extras);
165
+ const body = recordBody(info, items, at, preambles, extras, uiAccess);
133
166
  await mkdir(this.dir, { recursive: true, mode: 0o700 });
134
167
  await writeAtomic(join(this.dir, recordFileName(chatId)), body);
135
168
  return body.length;
@@ -145,10 +178,11 @@ export class ChatStore {
145
178
  items: ChatItem[],
146
179
  at: ChatSeq = { seq: 0, resetSeq: 0 },
147
180
  preambles: readonly string[] = [],
148
- extras: ChatRecordExtras = {}
181
+ extras: ChatRecordExtras = {},
182
+ uiAccess: Readonly<Record<string, unknown>> = {}
149
183
  ): void {
150
184
  mkdirSync(this.dir, { recursive: true, mode: 0o700 });
151
- writeAtomicSync(join(this.dir, recordFileName(chatId)), recordBody(info, items, at, preambles, extras));
185
+ writeAtomicSync(join(this.dir, recordFileName(chatId)), recordBody(info, items, at, preambles, extras, uiAccess));
152
186
  }
153
187
 
154
188
  /* Null for a chat with nothing on disk to make it of, and for one whose record does not parse (see `unreadable`). */
@@ -174,7 +208,15 @@ export class ChatStore {
174
208
  resetSeq = line.seq;
175
209
  }
176
210
  }
177
- return { ...thread.snapshot(), seq, resetSeq, preambles: snapshot.preambles ?? [], extras: extrasOf(snapshot), lines };
211
+ return {
212
+ ...thread.snapshot(),
213
+ seq,
214
+ resetSeq,
215
+ preambles: snapshot.preambles ?? [],
216
+ uiAccess: snapshot.uiAccess ?? {},
217
+ extras: extrasOf(snapshot),
218
+ lines
219
+ };
178
220
  }
179
221
 
180
222
  /*
@@ -204,7 +246,7 @@ export class ChatStore {
204
246
  await writeAtomic(join(this.dir, recordFileName(chatId)), JSON.stringify(migrated));
205
247
  }
206
248
  }
207
- const parsed = RecordSchema.safeParse(record);
249
+ const parsed = RecordSchema.safeParse(liftUiAccess(record));
208
250
  return parsed.success ? parsed.data : UNREADABLE;
209
251
  }
210
252
 
@@ -260,5 +302,5 @@ function fromLogAlone(lines: ChatLogLine[]): ChatRecord | null {
260
302
  resetSeq = line.seq;
261
303
  }
262
304
  }
263
- return { ...thread.snapshot(), seq: 0, resetSeq, preambles: [], extras: {}, lines };
305
+ return { ...thread.snapshot(), seq: 0, resetSeq, preambles: [], uiAccess: {}, extras: {}, lines };
264
306
  }
@@ -0,0 +1,18 @@
1
+ import type { ChatAssistantItem } from '@adecore/agent-contracts';
2
+ import { uiFallbackText } from '@adecore/intelligent-ui/text';
3
+
4
+ /*
5
+ * An assistant's words as another agent or a person outside the thread reads them: each UI block becomes
6
+ * its text fallback, with the first query values when they were frozen for the block as stored. It works
7
+ * from the stored block ranges, so it holds whatever fence language the host compiled.
8
+ */
9
+ export function readableAssistantText(item: Pick<ChatAssistantItem, 'text' | 'ui' | 'uiQueries'>): string {
10
+ if (!item.ui?.length) {
11
+ return item.text;
12
+ }
13
+ const blocks = item.ui.map((block) => {
14
+ const frozen = item.uiQueries?.blocks[block.id];
15
+ return frozen && frozen.revision === block.revision ? { ...block, fallback: frozen.fallback } : block;
16
+ });
17
+ return uiFallbackText(item.text, blocks);
18
+ }