@datalayer/agent-runtimes 1.5.26 → 1.5.28

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 (49) hide show
  1. package/dist-embed/chunks/{ChatView-CUWE6I5M.js → ChatView-C0PWJ_wj.js} +4 -4
  2. package/dist-embed/datalayer-app-main.js +111 -101
  3. package/lib/apps/apps/AppKept.js +5 -1
  4. package/lib/apps/embed/element.js +10 -4
  5. package/lib/apps/embed/embedConfig.d.ts +10 -1
  6. package/lib/apps/embed/embedConfig.js +25 -1
  7. package/lib/apps/plugins/chat/ChatView.js +2 -2
  8. package/lib/chat/assistant/AssistantStage.js +17 -0
  9. package/lib/chat/assistant/clearance.d.ts +10 -0
  10. package/lib/chat/assistant/clearance.js +14 -0
  11. package/lib/chat/base/ChatBase.js +375 -437
  12. package/lib/chat/base/conversationItems.d.ts +55 -0
  13. package/lib/chat/base/conversationItems.js +126 -0
  14. package/lib/portable/adapterEvents.d.ts +107 -0
  15. package/lib/portable/adapterEvents.js +239 -0
  16. package/lib/portable/approvalCard.d.ts +56 -0
  17. package/lib/portable/approvalCard.js +56 -0
  18. package/lib/portable/conversation.d.ts +71 -18
  19. package/lib/portable/conversation.js +330 -74
  20. package/lib/portable/fixtures.js +12 -0
  21. package/lib/portable/index.d.ts +4 -1
  22. package/lib/portable/index.js +7 -1
  23. package/lib/portable/presence.js +4 -1
  24. package/lib/portable/runMeasures.d.ts +46 -0
  25. package/lib/portable/runMeasures.js +156 -0
  26. package/lib/protocols/ACPAdapter.js +13 -8
  27. package/lib/specs/voices.js +47 -0
  28. package/lib/utils/utils.d.ts +2 -8
  29. package/lib/utils/utils.js +3 -26
  30. package/lib/voice/ComposerVoice.js +25 -3
  31. package/lib/voice/VoiceInput.d.ts +2 -2
  32. package/lib/voice/VoiceInput.js +6 -4
  33. package/lib/voice/VoiceMenu.js +2 -1
  34. package/lib/voice/chatVoice.d.ts +4 -3
  35. package/lib/voice/chatVoice.js +6 -9
  36. package/lib/voice/consent.d.ts +8 -0
  37. package/lib/voice/consent.js +10 -0
  38. package/lib/voice/hearing.d.ts +23 -1
  39. package/lib/voice/hearing.js +18 -3
  40. package/lib/voice/index.d.ts +11 -6
  41. package/lib/voice/index.js +1 -0
  42. package/lib/voice/serverHearing.d.ts +58 -0
  43. package/lib/voice/serverHearing.js +136 -0
  44. package/lib/voice/settings.d.ts +5 -0
  45. package/lib/voice/settings.js +8 -0
  46. package/lib/voice/speaker.d.ts +4 -3
  47. package/lib/voice/speaker.js +1 -2
  48. package/lib/voice/useVoiceSpeaker.js +0 -3
  49. package/package.json +2 -2
@@ -3,24 +3,35 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
  /**
6
- * A conversation as a run stream builds it, for a client that draws it
7
- * natively (Datalayer Mobile): one pure reducer over the AG-UI events of an
8
- * application's session or an agent's run, and the person's own actions.
6
+ * A conversation as a run stream builds it, for every client that draws one:
7
+ * the web chat (`ChatBase`) and Datalayer Mobile. One pure reducer over the
8
+ * AG-UI events of an application's session or an agent's run, the events
9
+ * every other protocol adapter is normalised into (`portable/adapterEvents`),
10
+ * and the person's own actions.
9
11
  *
10
- * It reads the stream as the web chat reads it — `TEXT_MESSAGE_*` into one
12
+ * It reads the stream as the chat reads it — `TEXT_MESSAGE_*` into one
11
13
  * message streamed in place, `TOOL_CALL_*` into a collapsed row whose result
12
14
  * is read by `toolResultOf`, `STEP_*` into a step row, a `MESSAGES_SNAPSHOT`
13
- * through `messagesOfThread`, a `reactor.ui` op on the run refused — and adds
14
- * what a session says besides: `loop.session`, the question its application
15
- * asks (`RUN_FINISHED` with `result.asking`, LOOP `loop.ask`), and a turn
16
- * that ended without words said by how it ended (`answered`, `stopped`,
17
- * `gone`).
15
+ * (its tool calls and their results too) through `messagesOfThread`, a
16
+ * `reactor.ui` op on the run refused — and adds what a session says besides:
17
+ * `loop.session`, the question its application asks (`RUN_FINISHED` with
18
+ * `result.asking`, LOOP `loop.ask`), and a turn that ended without words said
19
+ * by how it ended (`answered`, `stopped`, `gone`).
20
+ *
21
+ * An adapter that tells a message or a tool call whole rather than by its
22
+ * deltas — A2A, Vercel AI, ACP, the in-page agent, and the web's AG-UI
23
+ * adapter, which accumulates — says it with three events of this reducer's
24
+ * own, never AG-UI's: `MESSAGE_SAID` (a message's words so far),
25
+ * `TOOL_CALL_SAID` (a call's arguments as an object) and `TOOL_CALL_DONE`
26
+ * (its result as a value). They are refused when malformed.
18
27
  *
19
28
  * Bounded: a long tool run cannot grow it without end. Each tool's arguments
20
- * and result keep their first `TOOL_TEXT_LIMIT` characters, an answer its
21
- * first `MESSAGE_TEXT_LIMIT`, the conversation its last `ITEM_LIMIT` rows
22
- * (`dropped` counts the rest), and an event it does not draw is kept only as
23
- * its type and name, the last `DIAGNOSTIC_LIMIT` of them — never its payload.
29
+ * and result keep their first `limits.toolText` characters (their values only
30
+ * when kept whole), an answer its first `limits.messageText`, the
31
+ * conversation its last `limits.items` rows (`dropped` counts the rest), and
32
+ * an event it does not draw is kept only as its type and name, the last
33
+ * `limits.diagnostics` of them — never its payload. A phone keeps
34
+ * `DEVICE_LIMITS`, a browser `BROWSER_LIMITS`.
24
35
  *
25
36
  * @module portable/conversation
26
37
  */
@@ -28,26 +39,48 @@ import { REACTOR_UI_NAME, toolResultOf, UI_ON_RUN, } from './agui';
28
39
  import { displayOfToolResult } from './displays';
29
40
  import { RUN_END_WORDS } from './records';
30
41
  import { messagesOfThread } from './thread';
31
- /** How many rows a conversation keeps: its newest. */
42
+ /** How many rows a conversation on a phone keeps: its newest. */
32
43
  export const ITEM_LIMIT = 400;
33
- /** How many characters of an answer are kept. */
44
+ /** How many characters of an answer a phone keeps. */
34
45
  export const MESSAGE_TEXT_LIMIT = 100_000;
35
- /** How many characters of a tool's arguments, and of its result, are kept. */
46
+ /** How many characters of a tool's arguments, and of its result, a phone keeps. */
36
47
  export const TOOL_TEXT_LIMIT = 4_000;
37
48
  /** How many events it does not draw are kept, by type and name. */
38
49
  export const DIAGNOSTIC_LIMIT = 50;
50
+ /** A phone's bounds (Datalayer Mobile). */
51
+ export const DEVICE_LIMITS = {
52
+ items: ITEM_LIMIT,
53
+ messageText: MESSAGE_TEXT_LIMIT,
54
+ toolText: TOOL_TEXT_LIMIT,
55
+ diagnostics: DIAGNOSTIC_LIMIT,
56
+ };
57
+ /** A browser's bounds (the web chat): a long working session, still bounded. */
58
+ export const BROWSER_LIMITS = {
59
+ items: 5_000,
60
+ messageText: 1_000_000,
61
+ toolText: 1_000_000,
62
+ diagnostics: DIAGNOSTIC_LIMIT,
63
+ };
39
64
  /** What is said after a text cut at its limit. */
40
65
  export const CUT_MARK = '… (cut: too long to keep on this device)';
41
- /** A conversation with nothing in it yet. */
42
- export const emptyConversation = () => ({
43
- items: [],
44
- running: false,
45
- session: null,
46
- asking: null,
47
- dropped: 0,
48
- diagnostics: [],
49
- turnRows: 0,
50
- });
66
+ /** A conversation with nothing in it yet, kept within `limits` (a phone's by default). */
67
+ export const emptyConversation = (limits = DEVICE_LIMITS) => {
68
+ for (const [name, bound] of Object.entries(limits)) {
69
+ if (!Number.isInteger(bound) || bound < 1) {
70
+ throw new Error(`A conversation's ${name} bound is ${String(bound)}: give a whole number of at least 1.`);
71
+ }
72
+ }
73
+ return {
74
+ items: [],
75
+ running: false,
76
+ session: null,
77
+ asking: null,
78
+ dropped: 0,
79
+ diagnostics: [],
80
+ turnRows: 0,
81
+ limits,
82
+ };
83
+ };
51
84
  const text = (value) => typeof value === 'string' ? value : '';
52
85
  const cut = (value, limit) => value.length > limit ? `${value.slice(0, limit)}${CUT_MARK}` : value;
53
86
  /** Text appended to what is kept, never past its limit. */
@@ -57,12 +90,10 @@ const appended = (kept, delta, limit) => {
57
90
  }
58
91
  return cut(kept + delta, limit);
59
92
  };
93
+ const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
60
94
  /** A result as the model read it: the page's `_meta` (its display) is the card's, not words. */
61
95
  function withoutMeta(result) {
62
- if (result &&
63
- typeof result === 'object' &&
64
- !Array.isArray(result) &&
65
- '_meta' in result) {
96
+ if (isRecord(result) && '_meta' in result) {
66
97
  const { _meta: _page, ...view } = result;
67
98
  return view;
68
99
  }
@@ -96,14 +127,12 @@ export function questionOf(raw) {
96
127
  kind,
97
128
  prompt: text(asked.prompt),
98
129
  options: strings(asked.options),
99
- schema: schema && typeof schema === 'object' && !Array.isArray(schema)
100
- ? schema
101
- : null,
130
+ schema: isRecord(schema) ? schema : null,
102
131
  accept: strings(asked.accept),
103
132
  };
104
133
  }
105
134
  function withItems(state, items, added = 0) {
106
- const over = items.length - ITEM_LIMIT;
135
+ const over = items.length - state.limits.items;
107
136
  return {
108
137
  ...state,
109
138
  items: over > 0 ? items.slice(over) : items,
@@ -127,11 +156,151 @@ function noted(state, event) {
127
156
  };
128
157
  return {
129
158
  ...state,
130
- diagnostics: [...state.diagnostics, entry].slice(-DIAGNOSTIC_LIMIT),
159
+ diagnostics: [...state.diagnostics, entry].slice(-state.limits.diagnostics),
131
160
  };
132
161
  }
133
162
  let rowCount = 0;
134
163
  const rowId = (prefix) => `${prefix}-${Date.now().toString(36)}-${(rowCount += 1)}`;
164
+ /** A tool call just begun: running, nothing said of it yet. */
165
+ function toolRow(id, name) {
166
+ return {
167
+ kind: 'tool',
168
+ id,
169
+ name,
170
+ args: '',
171
+ input: null,
172
+ status: 'running',
173
+ awaiting: null,
174
+ result: '',
175
+ value: undefined,
176
+ error: '',
177
+ executionError: '',
178
+ codeError: null,
179
+ exitCode: null,
180
+ display: null,
181
+ };
182
+ }
183
+ /** Its arguments kept: their words cut at the limit, the object only when kept whole. */
184
+ function withInput(item, input, limit) {
185
+ const words = JSON.stringify(input);
186
+ const whole = words.length <= limit;
187
+ return { ...item, args: cut(words, limit), input: whole ? input : null };
188
+ }
189
+ /** A human-in-the-loop call: its arguments are steps the person confirms. */
190
+ const asksThePerson = (input) => !!input && Array.isArray(input.steps);
191
+ /**
192
+ * A tool call with its result: failed when the protocol, its sandbox, its
193
+ * code or its exit code says so; still running when it waits on an approval
194
+ * (`pending_approval`) or on the person (its steps). A result read from a
195
+ * conversation's past is its outcome only: nothing waits there any more.
196
+ */
197
+ function withResult(item, result, limit, past = false) {
198
+ const given = result.result;
199
+ const words = said(withoutMeta(given));
200
+ const record = isRecord(given) ? given : null;
201
+ const executionError = typeof record?.execution_error === 'string' ? record.execution_error : '';
202
+ const raised = isRecord(record?.code_error) ? record.code_error : null;
203
+ const codeError = raised
204
+ ? {
205
+ name: text(raised.name) || 'Error',
206
+ value: text(raised.value) || 'Unknown error',
207
+ traceback: cut(text(raised.traceback), limit),
208
+ }
209
+ : null;
210
+ const exitCode = record && typeof record.exit_code === 'number' ? record.exit_code : null;
211
+ const failed = !result.success ||
212
+ executionError !== '' ||
213
+ codeError !== null ||
214
+ (exitCode !== null && exitCode !== 0) ||
215
+ record?.execution_ok === false;
216
+ const awaiting = failed || past
217
+ ? null
218
+ : record?.pending_approval === true
219
+ ? 'approval'
220
+ : asksThePerson(item.input)
221
+ ? 'person'
222
+ : null;
223
+ return {
224
+ ...item,
225
+ status: failed ? 'error' : awaiting ? 'running' : 'complete',
226
+ awaiting,
227
+ result: cut(words, limit),
228
+ value: words.length <= limit ? given : undefined,
229
+ error: result.error ?? '',
230
+ executionError: cut(executionError, limit),
231
+ codeError,
232
+ exitCode,
233
+ display: displayOfToolResult(item.name, given, item.id),
234
+ };
235
+ }
236
+ /** The rows a conversation said again holds: its messages, and its tool calls with their results. */
237
+ function rowsOfSnapshot(messages, limits, unread) {
238
+ if (!Array.isArray(messages)) {
239
+ throw new Error('A messages snapshot carries no list of messages.');
240
+ }
241
+ const rows = [];
242
+ const tools = new Map();
243
+ for (const message of messages) {
244
+ for (const drawn of messagesOfThread([message])) {
245
+ rows.push({
246
+ kind: 'message',
247
+ id: drawn.id,
248
+ role: drawn.role === 'user' ? 'user' : 'assistant',
249
+ text: cut(typeof drawn.content === 'string' ? drawn.content : '', limits.messageText),
250
+ streaming: false,
251
+ });
252
+ }
253
+ if (!isRecord(message)) {
254
+ continue;
255
+ }
256
+ if (message.role === 'assistant' && message.toolCalls !== undefined) {
257
+ if (!Array.isArray(message.toolCalls)) {
258
+ throw new Error(`Message ${String(message.id)}'s tool calls are not a list.`);
259
+ }
260
+ for (const call of message.toolCalls) {
261
+ const fn = isRecord(call) ? call.function : undefined;
262
+ if (!isRecord(call) || typeof call.id !== 'string' || !isRecord(fn)) {
263
+ throw new Error(`Message ${String(message.id)} holds a tool call without its id and function.`);
264
+ }
265
+ const argsWords = text(fn.arguments);
266
+ let input = null;
267
+ if (argsWords && argsWords.length <= limits.toolText) {
268
+ let parsed;
269
+ try {
270
+ parsed = JSON.parse(argsWords);
271
+ }
272
+ catch {
273
+ parsed = undefined;
274
+ }
275
+ if (!isRecord(parsed)) {
276
+ throw new Error(`Tool call ${call.id}'s arguments are not a JSON object.`);
277
+ }
278
+ input = parsed;
279
+ }
280
+ tools.set(call.id, rows.length);
281
+ rows.push({
282
+ ...toolRow(call.id, text(fn.name)),
283
+ args: cut(argsWords, limits.toolText),
284
+ input: argsWords ? input : {},
285
+ // Said again from the past: it ended, as its result says.
286
+ status: 'complete',
287
+ });
288
+ }
289
+ }
290
+ if (message.role === 'tool') {
291
+ const id = text(message.toolCallId);
292
+ const at = tools.get(id);
293
+ if (at === undefined) {
294
+ // A result whose call the snapshot does not carry (a client that
295
+ // reads words only): kept as the unread events are, by name.
296
+ unread.push(id || 'no call');
297
+ continue;
298
+ }
299
+ rows[at] = withResult(rows[at], toolResultOf(message.content), limits.toolText, true);
300
+ }
301
+ }
302
+ return rows;
303
+ }
135
304
  /** The person's prompt, added before it is sent: its own row. */
136
305
  export function withPrompt(state, id, words) {
137
306
  return withItems({ ...state, asking: null }, [
@@ -139,6 +308,14 @@ export function withPrompt(state, id, words) {
139
308
  { kind: 'message', id, role: 'user', text: words, streaming: false },
140
309
  ]);
141
310
  }
311
+ /**
312
+ * The conversation without some of its rows: those the person removed (a
313
+ * turn taken away), or a message its application took back.
314
+ */
315
+ export function withoutItems(state, ids) {
316
+ const drop = new Set(ids);
317
+ return { ...state, items: state.items.filter(item => !drop.has(item.id)) };
318
+ }
142
319
  /**
143
320
  * The turn ended without the stream saying so: the person stopped it
144
321
  * (`stopped`), or its client went before the answer (`gone`). What streamed
@@ -148,7 +325,12 @@ export function withTurnEnded(state, end) {
148
325
  const items = state.items.map(item => item.kind === 'message' && item.streaming
149
326
  ? { ...item, streaming: false }
150
327
  : item.kind === 'tool' && item.status === 'running'
151
- ? { ...item, status: 'error', error: RUN_END_WORDS[end] }
328
+ ? {
329
+ ...item,
330
+ status: 'error',
331
+ awaiting: null,
332
+ error: RUN_END_WORDS[end],
333
+ }
152
334
  : item.kind === 'step' && item.status === 'running'
153
335
  ? { ...item, status: 'complete' }
154
336
  : item);
@@ -163,7 +345,7 @@ export function withError(state, words) {
163
345
  const items = state.items.map(item => item.kind === 'message' && item.streaming
164
346
  ? { ...item, streaming: false }
165
347
  : item.kind === 'tool' && item.status === 'running'
166
- ? { ...item, status: 'error' }
348
+ ? { ...item, status: 'error', awaiting: null }
167
349
  : item.kind === 'step' && item.status === 'running'
168
350
  ? { ...item, status: 'complete' }
169
351
  : item);
@@ -172,8 +354,17 @@ export function withError(state, words) {
172
354
  { kind: 'error', id: rowId('error'), text: words },
173
355
  ]);
174
356
  }
357
+ /** The string field an event must carry, or a refusal that names it. */
358
+ function required(event, field) {
359
+ const value = event[field];
360
+ if (typeof value !== 'string' || !value) {
361
+ throw new Error(`${event.type} carries no ${field}.`);
362
+ }
363
+ return value;
364
+ }
175
365
  /** A conversation with one more event of its stream. */
176
366
  export function reduceConversation(state, event) {
367
+ const { limits } = state;
177
368
  switch (event.type) {
178
369
  case 'RUN_STARTED':
179
370
  return { ...state, running: true, asking: null, turnRows: 0 };
@@ -193,7 +384,7 @@ export function reduceConversation(state, event) {
193
384
  const delta = text(event.delta);
194
385
  const id = text(event.messageId);
195
386
  const grown = changed(state, id, item => item.kind === 'message'
196
- ? { ...item, text: appended(item.text, delta, MESSAGE_TEXT_LIMIT) }
387
+ ? { ...item, text: appended(item.text, delta, limits.messageText) }
197
388
  : item);
198
389
  if (grown) {
199
390
  return grown;
@@ -204,13 +395,37 @@ export function reduceConversation(state, event) {
204
395
  kind: 'message',
205
396
  id: id || rowId('message'),
206
397
  role: event.role === 'user' ? 'user' : 'assistant',
207
- text: cut(delta, MESSAGE_TEXT_LIMIT),
398
+ text: cut(delta, limits.messageText),
208
399
  streaming: event.type === 'TEXT_MESSAGE_CONTENT',
209
400
  },
210
401
  ], 1);
211
402
  }
212
403
  case 'TEXT_MESSAGE_END':
213
404
  return (changed(state, text(event.messageId), item => item.kind === 'message' ? { ...item, streaming: false } : item) ?? state);
405
+ case 'MESSAGE_SAID': {
406
+ // A message told whole so far, by an adapter that accumulates it.
407
+ const id = required(event, 'messageId');
408
+ if (typeof event.text !== 'string') {
409
+ throw new Error(`MESSAGE_SAID of ${id} carries no text.`);
410
+ }
411
+ if (event.role !== 'user' && event.role !== 'assistant') {
412
+ throw new Error(`MESSAGE_SAID of ${id} is said by ${String(event.role)}: only the person or the assistant speaks here.`);
413
+ }
414
+ const role = event.role;
415
+ const words = cut(event.text, limits.messageText);
416
+ const streaming = role === 'assistant' && event.streaming !== false;
417
+ const at = state.items.findIndex(item => item.id === id);
418
+ if (at >= 0) {
419
+ const kept = state.items[at];
420
+ if (kept.kind !== 'message') {
421
+ throw new Error(`MESSAGE_SAID of ${id} names a ${kept.kind} row.`);
422
+ }
423
+ const items = state.items.slice();
424
+ items[at] = { ...kept, text: words, streaming };
425
+ return { ...state, items };
426
+ }
427
+ return withItems(state, [...state.items, { kind: 'message', id, role, text: words, streaming }], 1);
428
+ }
214
429
  case 'TOOL_CALL_START': {
215
430
  const id = text(event.toolCallId) || rowId('tool');
216
431
  if (state.items.some(item => item.id === id)) {
@@ -218,37 +433,88 @@ export function reduceConversation(state, event) {
218
433
  }
219
434
  return withItems(state, [
220
435
  ...state.items,
221
- {
222
- kind: 'tool',
223
- id,
224
- name: text(event.toolCallName) || text(event.name),
225
- args: '',
226
- status: 'running',
227
- result: '',
228
- error: '',
229
- display: null,
230
- },
436
+ toolRow(id, text(event.toolCallName) || text(event.name)),
231
437
  ], 1);
232
438
  }
233
439
  case 'TOOL_CALL_ARGS':
234
440
  return (changed(state, text(event.toolCallId), item => item.kind === 'tool'
235
441
  ? {
236
442
  ...item,
237
- args: appended(item.args, text(event.delta), TOOL_TEXT_LIMIT),
443
+ args: appended(item.args, text(event.delta), limits.toolText),
238
444
  }
239
445
  : item) ?? state);
240
446
  case 'TOOL_CALL_END':
241
- return state;
447
+ // Its arguments are whole: read them, unless they were cut.
448
+ return (changed(state, text(event.toolCallId), item => {
449
+ if (item.kind !== 'tool' || item.args.endsWith(CUT_MARK)) {
450
+ return item;
451
+ }
452
+ if (!item.args) {
453
+ return { ...item, input: {} };
454
+ }
455
+ let parsed;
456
+ try {
457
+ parsed = JSON.parse(item.args);
458
+ }
459
+ catch {
460
+ parsed = undefined;
461
+ }
462
+ if (!isRecord(parsed)) {
463
+ return {
464
+ ...item,
465
+ status: 'error',
466
+ awaiting: null,
467
+ error: 'Its arguments are not a JSON object.',
468
+ };
469
+ }
470
+ return { ...item, input: parsed };
471
+ }) ?? state);
472
+ case 'TOOL_CALL_SAID': {
473
+ // A call's arguments told whole, by an adapter that parsed them.
474
+ const id = required(event, 'toolCallId');
475
+ const name = required(event, 'toolCallName');
476
+ if (!isRecord(event.args)) {
477
+ throw new Error(`TOOL_CALL_SAID of ${id} carries arguments that are not an object.`);
478
+ }
479
+ const args = event.args;
480
+ const at = state.items.findIndex(item => item.id === id);
481
+ if (at < 0) {
482
+ return withItems(state, [...state.items, withInput(toolRow(id, name), args, limits.toolText)], 1);
483
+ }
484
+ const kept = state.items[at];
485
+ if (kept.kind !== 'tool') {
486
+ throw new Error(`TOOL_CALL_SAID of ${id} names a ${kept.kind} row.`);
487
+ }
488
+ const items = state.items.slice();
489
+ items[at] = withInput(kept, { ...(kept.input ?? {}), ...args }, limits.toolText);
490
+ return { ...state, items };
491
+ }
242
492
  case 'TOOL_CALL_RESULT': {
243
493
  const result = toolResultOf(event.content);
244
494
  return (changed(state, text(event.toolCallId), item => item.kind === 'tool'
245
- ? {
246
- ...item,
247
- status: result.success ? 'complete' : 'error',
248
- result: cut(said(withoutMeta(result.result)), TOOL_TEXT_LIMIT),
249
- error: result.error ?? '',
250
- display: displayOfToolResult(item.name, result.result, item.id),
251
- }
495
+ ? withResult(item, result, limits.toolText)
496
+ : item) ?? noted(state, event));
497
+ }
498
+ case 'TOOL_CALL_DONE': {
499
+ // A call's result as a value, by an adapter (or the page) that has it;
500
+ // `answered` when it is the person's answer.
501
+ const id = required(event, 'toolCallId');
502
+ if (typeof event.success !== 'boolean') {
503
+ throw new Error(`TOOL_CALL_DONE of ${id} does not say whether it succeeded.`);
504
+ }
505
+ if (event.error !== undefined && typeof event.error !== 'string') {
506
+ throw new Error(`TOOL_CALL_DONE of ${id} says its error in no words.`);
507
+ }
508
+ const result = {
509
+ result: event.result,
510
+ success: event.success,
511
+ ...(event.error !== undefined ? { error: event.error } : {}),
512
+ };
513
+ return (changed(state, id, item => item.kind === 'tool'
514
+ ? withResult(item, result, limits.toolText,
515
+ // The person's own answer (an approval decided, steps
516
+ // confirmed): nothing waits after it.
517
+ event.answered === true)
252
518
  : item) ?? noted(state, event));
253
519
  }
254
520
  case 'STEP_STARTED': {
@@ -277,27 +543,19 @@ export function reduceConversation(state, event) {
277
543
  return { ...state, items };
278
544
  }
279
545
  case 'MESSAGES_SNAPSHOT': {
280
- const messages = messagesOfThread(event.messages);
281
- return withItems({ ...state, dropped: 0 }, messages.map(message => ({
282
- kind: 'message',
283
- id: message.id,
284
- role: message.role === 'user'
285
- ? 'user'
286
- : 'assistant',
287
- text: cut(typeof message.content === 'string' ? message.content : '', MESSAGE_TEXT_LIMIT),
288
- streaming: false,
289
- })),
546
+ const unread = [];
547
+ const rows = rowsOfSnapshot(event.messages, limits, unread);
548
+ const restored = withItems({ ...state, dropped: 0 }, rows,
290
549
  // The conversation said again is what this turn says (a resume):
291
550
  // never also said as a wordless answer.
292
551
  1);
552
+ return unread.reduce((kept, id) => noted(kept, { type: 'TOOL_CALL_RESULT', name: `${id} (no call)` }), restored);
293
553
  }
294
554
  case 'CUSTOM': {
295
555
  if (event.name === REACTOR_UI_NAME) {
296
556
  return withError(state, UI_ON_RUN);
297
557
  }
298
- if (event.name === 'loop.session' &&
299
- event.value &&
300
- typeof event.value === 'object') {
558
+ if (event.name === 'loop.session' && isRecord(event.value)) {
301
559
  const session = event.value;
302
560
  return {
303
561
  ...state,
@@ -314,9 +572,7 @@ export function reduceConversation(state, event) {
314
572
  }
315
573
  return noted(state, event);
316
574
  case 'RUN_FINISHED': {
317
- const result = event.result && typeof event.result === 'object'
318
- ? event.result
319
- : {};
575
+ const result = isRecord(event.result) ? event.result : {};
320
576
  const asking = questionOf(result.asking);
321
577
  const items = state.items.map(item => item.kind === 'message' && item.streaming
322
578
  ? { ...item, streaming: false }
@@ -29,9 +29,15 @@ export function conversationFixture() {
29
29
  id: 't-1',
30
30
  name: 'lookup_order',
31
31
  args: '{"order":7}',
32
+ input: { order: 7 },
32
33
  status: 'complete',
34
+ awaiting: null,
33
35
  result: 'Shipped on Monday.',
36
+ value: 'Shipped on Monday.',
34
37
  error: '',
38
+ executionError: '',
39
+ codeError: null,
40
+ exitCode: null,
35
41
  display: null,
36
42
  },
37
43
  {
@@ -39,9 +45,15 @@ export function conversationFixture() {
39
45
  id: 't-2',
40
46
  name: 'send_mail',
41
47
  args: '{}',
48
+ input: {},
42
49
  status: 'error',
50
+ awaiting: null,
43
51
  result: '',
52
+ value: undefined,
44
53
  error: 'Refused by its rule.',
54
+ executionError: '',
55
+ codeError: null,
56
+ exitCode: null,
45
57
  display: null,
46
58
  },
47
59
  {
@@ -7,11 +7,13 @@
7
7
  * @module portable
8
8
  */
9
9
  export { messageOfApproval, normalizeApproval, whyAsked, type ApprovalArgs, type ApprovalRecord, type MessageAsked, } from './approvals';
10
+ export { actionOf, APPROVAL_CARD_WORDS, approvalCardOf, argumentsShown, type ApprovalCard, type ApprovalCardProps, type ApprovalCardSource, } from './approvalCard';
10
11
  export { AgUiFrames, endsRun, eventsOfStream, NOT_STREAMING, REACTOR_UI_NAME, readRunStream, RUN_CUT_SHORT, toolResultOf, UI_ON_RUN, type AgUiEvent, type ToolResultSaid, } from './agui';
11
12
  export { AG_UI_NOT_ALLOWED, AG_UI_NOT_ON_RUNTIME, agUiRefusal, refusalBody, refusalDetail, } from './agUiRefusal';
12
13
  export { ASKED_FOR_TAG, askedOf, RUN_END_WORDS } from './records';
13
14
  export { messagesOfThread, openThread, snapshotOfStream, THREAD_WORDS, } from './thread';
14
- export { CUT_MARK, DIAGNOSTIC_LIMIT, emptyConversation, formFieldsOf, ITEM_LIMIT, MESSAGE_TEXT_LIMIT, questionOf, questionWords, reduceConversation, TOOL_TEXT_LIMIT, withError, withPrompt, withTurnEnded, type ConversationItem, type ConversationQuestion, type ConversationState, type TurnEnd, } from './conversation';
15
+ export { BROWSER_LIMITS, CUT_MARK, DEVICE_LIMITS, DIAGNOSTIC_LIMIT, emptyConversation, formFieldsOf, ITEM_LIMIT, MESSAGE_TEXT_LIMIT, questionOf, questionWords, reduceConversation, TOOL_TEXT_LIMIT, withError, withoutItems, withPrompt, withTurnEnded, type ConversationItem, type ConversationLimits, type ConversationQuestion, type ConversationState, type ToolAwaiting, type ToolCodeError, type TurnEnd, } from './conversation';
16
+ export { conversationEventsOf, sanitizeAssistantContent, snapshotOfMessages, type AdapterEvent, type SaidMessage, } from './adapterEvents';
15
17
  export { PRESENCE_LINES, presenceOfConversation, presenceState, presenceToolOf, type PresenceState, type PresenceTool, } from './presence';
16
18
  export { ANSWER_BELOW, ANSWER_COMPLETE, composerOf, droppedWords, ENDED_WORDS, messageLabel, OPENING_WORDS, presenceLine, showsQuestion, stepLine, TOOL_STATUS_WORDS, toolLabel, type ComposerProps, type ComposerState, type ConversationViewProps, type ErrorItem, type ErrorRowProps, type MessageItem, type MessageRowProps, type PresenceLineProps, type ProtocolDisplayProps, type QuestionCardProps, type StepItem, type StepRowProps, type ToolItem, type ToolRowProps, type TurnEndItem, type TurnEndRowProps, } from './views';
17
19
  export { conversationFixture } from './fixtures';
@@ -20,5 +22,6 @@ export { isPapSignInTool, PAP_SIGN_IN_WORDS, readPapSignIn, safeSignInUrl, type
20
22
  export { DIRECT_MAX, DIRECT_WORDS, directAnswerOf, directAuthor, directClient, openDirect, withDirectAnswer, type DirectAnswer, type DirectClient, type DirectClientOptions, type DirectLine, type DirectOpen, type DirectReply, type DirectTarget, } from './direct';
21
23
  export { SIGN_IN_POLL_MS, SIGN_IN_STATUS_PATH, signInStandsOf, signInStatusClient, type SignInStands, } from './signInStatus';
22
24
  export { emptyFields, fieldProblems, MAX_VALUE_LENGTH, PROTECTED_INPUT_WORDS, protectedInputClient, protectedRequestOf, ProtectedRequestGone, sentWords, submitBody, submitWords, ticketComplete, ticketOfPage, type ProtectedField, type ProtectedRequest, } from './protectedInput';
25
+ export { failureOf, otlpMetricsOf, RunMeter, type RunMeasure, type StreamFailure, } from './runMeasures';
23
26
  export { agentRunClient, agentRuntimesUrlOf, appSessionsClient, RuntimeRefusal, type AgentRunClient, type AgentTurn, type AppSessionsClient, type RuntimeClientOptions, type SessionStart, type StreamOptions, } from './sessions';
24
27
  export type { ChatMessage } from '../types/messages';
@@ -11,6 +11,8 @@
11
11
  * @module portable
12
12
  */
13
13
  export { messageOfApproval, normalizeApproval, whyAsked, } from './approvals';
14
+ // An approval as every card says it (MOBILE M-016).
15
+ export { actionOf, APPROVAL_CARD_WORDS, approvalCardOf, argumentsShown, } from './approvalCard';
14
16
  // A run's stream, read as every client reads it.
15
17
  export { AgUiFrames, endsRun, eventsOfStream, NOT_STREAMING, REACTOR_UI_NAME, readRunStream, RUN_CUT_SHORT, toolResultOf, UI_ON_RUN, } from './agui';
16
18
  export { AG_UI_NOT_ALLOWED, AG_UI_NOT_ON_RUNTIME, agUiRefusal, refusalBody, refusalDetail, } from './agUiRefusal';
@@ -18,7 +20,9 @@ export { AG_UI_NOT_ALLOWED, AG_UI_NOT_ON_RUNTIME, agUiRefusal, refusalBody, refu
18
20
  export { ASKED_FOR_TAG, askedOf, RUN_END_WORDS } from './records';
19
21
  export { messagesOfThread, openThread, snapshotOfStream, THREAD_WORDS, } from './thread';
20
22
  // A conversation as its stream builds it, for a native client.
21
- export { CUT_MARK, DIAGNOSTIC_LIMIT, emptyConversation, formFieldsOf, ITEM_LIMIT, MESSAGE_TEXT_LIMIT, questionOf, questionWords, reduceConversation, TOOL_TEXT_LIMIT, withError, withPrompt, withTurnEnded, } from './conversation';
23
+ export { BROWSER_LIMITS, CUT_MARK, DEVICE_LIMITS, DIAGNOSTIC_LIMIT, emptyConversation, formFieldsOf, ITEM_LIMIT, MESSAGE_TEXT_LIMIT, questionOf, questionWords, reduceConversation, TOOL_TEXT_LIMIT, withError, withoutItems, withPrompt, withTurnEnded, } from './conversation';
24
+ // What every protocol adapter says, as the conversation reads it (M-030).
25
+ export { conversationEventsOf, sanitizeAssistantContent, snapshotOfMessages, } from './adapterEvents';
22
26
  // What an application is doing, in the words beside its face (LOOP T-08).
23
27
  export { PRESENCE_LINES, presenceOfConversation, presenceState, presenceToolOf, } from './presence';
24
28
  // One contract for every renderer of a conversation's components.
@@ -33,5 +37,7 @@ export { DIRECT_MAX, DIRECT_WORDS, directAnswerOf, directAuthor, directClient, o
33
37
  export { SIGN_IN_POLL_MS, SIGN_IN_STATUS_PATH, signInStandsOf, signInStatusClient, } from './signInStatus';
34
38
  // What a company asks the person to type in a mediated sign-in, sent with its one-use ticket (MOBILE M-039).
35
39
  export { emptyFields, fieldProblems, MAX_VALUE_LENGTH, PROTECTED_INPUT_WORDS, protectedInputClient, protectedRequestOf, ProtectedRequestGone, sentWords, submitBody, submitWords, ticketComplete, ticketOfPage, } from './protectedInput';
40
+ // How a streamed turn went, measured without its words (MOBILE M-036).
41
+ export { failureOf, otlpMetricsOf, RunMeter, } from './runMeasures';
36
42
  // The runtime's routes a native client drives.
37
43
  export { agentRunClient, agentRuntimesUrlOf, appSessionsClient, RuntimeRefusal, } from './sessions';
@@ -62,7 +62,10 @@ export function presenceOfConversation(state, paused = false) {
62
62
  for (let index = state.items.length - 1; index >= 0; index -= 1) {
63
63
  const item = state.items[index];
64
64
  if (item.kind === 'tool') {
65
- tool = { open: item.status === 'running', pendingApproval: false };
65
+ tool = {
66
+ open: item.status === 'running',
67
+ pendingApproval: item.awaiting === 'approval',
68
+ };
66
69
  break;
67
70
  }
68
71
  }