@ouispec/agent-worker 0.1.1 → 0.2.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 (68) hide show
  1. package/dist/approvals/continuation.d.ts +14 -1
  2. package/dist/approvals/continuation.d.ts.map +1 -1
  3. package/dist/approvals/continuation.js +12 -2
  4. package/dist/approvals/continuation.js.map +1 -1
  5. package/dist/index.d.ts +6 -3
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +5 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/orchestrator.d.ts.map +1 -1
  10. package/dist/orchestrator.js +406 -129
  11. package/dist/orchestrator.js.map +1 -1
  12. package/dist/prompt/clock.d.ts +34 -0
  13. package/dist/prompt/clock.d.ts.map +1 -0
  14. package/dist/prompt/clock.js +63 -0
  15. package/dist/prompt/clock.js.map +1 -0
  16. package/dist/prompt/rules/ui-control.d.ts +3 -3
  17. package/dist/prompt/rules/ui-control.d.ts.map +1 -1
  18. package/dist/prompt/rules/ui-control.js +45 -22
  19. package/dist/prompt/rules/ui-control.js.map +1 -1
  20. package/dist/runtime/turn-runner.d.ts.map +1 -1
  21. package/dist/runtime/turn-runner.js +3 -1
  22. package/dist/runtime/turn-runner.js.map +1 -1
  23. package/dist/runtime/types.d.ts +5 -3
  24. package/dist/runtime/types.d.ts.map +1 -1
  25. package/dist/tools/builtin-tools.js +11 -4
  26. package/dist/tools/builtin-tools.js.map +1 -1
  27. package/dist/tools/input-validation.d.ts +6 -0
  28. package/dist/tools/input-validation.d.ts.map +1 -1
  29. package/dist/tools/input-validation.js +50 -1
  30. package/dist/tools/input-validation.js.map +1 -1
  31. package/dist/tools/types.d.ts +17 -0
  32. package/dist/tools/types.d.ts.map +1 -1
  33. package/dist/tools/types.js.map +1 -1
  34. package/dist/turn-ledger.d.ts +30 -0
  35. package/dist/turn-ledger.d.ts.map +1 -0
  36. package/dist/turn-ledger.js +45 -0
  37. package/dist/turn-ledger.js.map +1 -0
  38. package/dist/types.d.ts +9 -2
  39. package/dist/types.d.ts.map +1 -1
  40. package/dist/ui/answer-image.d.ts +32 -0
  41. package/dist/ui/answer-image.d.ts.map +1 -0
  42. package/dist/ui/answer-image.js +50 -0
  43. package/dist/ui/answer-image.js.map +1 -0
  44. package/dist/ui/observations.d.ts +29 -5
  45. package/dist/ui/observations.d.ts.map +1 -1
  46. package/dist/ui/observations.js +122 -46
  47. package/dist/ui/observations.js.map +1 -1
  48. package/dist/ui/outline.d.ts +63 -0
  49. package/dist/ui/outline.d.ts.map +1 -0
  50. package/dist/ui/outline.js +258 -0
  51. package/dist/ui/outline.js.map +1 -0
  52. package/dist/ui/page-index.d.ts +74 -0
  53. package/dist/ui/page-index.d.ts.map +1 -0
  54. package/dist/ui/page-index.js +137 -0
  55. package/dist/ui/page-index.js.map +1 -0
  56. package/dist/ui/snapshot.d.ts +24 -12
  57. package/dist/ui/snapshot.d.ts.map +1 -1
  58. package/dist/ui/snapshot.js +34 -39
  59. package/dist/ui/snapshot.js.map +1 -1
  60. package/dist/ui/ui-sequence.d.ts +9 -9
  61. package/dist/ui/ui-sequence.d.ts.map +1 -1
  62. package/dist/ui/ui-sequence.js +2 -2
  63. package/dist/ui/ui-sequence.js.map +1 -1
  64. package/dist/ui/ui-tools.d.ts +64 -14
  65. package/dist/ui/ui-tools.d.ts.map +1 -1
  66. package/dist/ui/ui-tools.js +473 -92
  67. package/dist/ui/ui-tools.js.map +1 -1
  68. package/package.json +6 -6
@@ -1,6 +1,72 @@
1
- import { boundObservations } from './observations.js';
1
+ /**
2
+ * UI tools: the page's actions as the worker runs them (ADR-0209, ADR-0245).
3
+ *
4
+ * The page sends an index of what it offers. Each action of the index is a
5
+ * tool here, run by sending an OUI action request to the client that sent the
6
+ * turn and waiting for its answer (ADR-0209 D2): the model gets what really
7
+ * happened — the action's result or error — and what the page offers and shows
8
+ * afterwards. An action's definition is fetched from the page when it is
9
+ * needed (`oui.describe`), and what did not fit an answer is read from it
10
+ * (`oui.read`).
11
+ *
12
+ * The model is not given one tool per action: it is given the index, and three
13
+ * tools to work it with (`ui_act`, `ui_describe`, `ui_read`). The per-action
14
+ * tools built here are what `ui_act` runs, so every check a tool call goes
15
+ * through — input, quota, policy, approval — sees the action itself.
16
+ */
17
+ import { randomUUID } from 'node:crypto';
18
+ import { AGENT_UI_TOOLS } from '@ouispec/agent-core';
19
+ import { jsonBytes, OUI_DESCRIBE_ACTION, OUI_READ_ACTION, OUI_RUNTIME_SURFACE, } from 'oui-spec/spec';
20
+ import { boundObservations, DEFAULT_PAGE_STATE_CHARS, observationSchemas } from './observations.js';
21
+ import { describeSchema } from './outline.js';
22
+ import { definitionKey, indexDiff, indexText, isFullSurface, isSurfaceIndex, pageActions, pageFromIndex, pageFromSurfaces, } from './page-index.js';
23
+ import { liftAnswerImage } from './answer-image.js';
2
24
  import { createUISequence } from './ui-sequence.js';
3
- const DEFAULT_MAX_OBSERVATION_CHARS = 6_000;
25
+ /** The tool that runs one action of the page. Built by the orchestrator, which unwraps it to the action's own tool. */
26
+ export const UI_ACT_TOOL = AGENT_UI_TOOLS.act;
27
+ /** The tool that says what actions take. */
28
+ export const UI_DESCRIBE_TOOL = AGENT_UI_TOOLS.describe;
29
+ /** The tool that reads part of the page's state. */
30
+ export const UI_READ_TOOL = AGENT_UI_TOOLS.read;
31
+ /** After this many changes refused in a row, the turn changes nothing more in the page. */
32
+ export const BLIND_REFUSALS_BEFORE_STOP = 2;
33
+ export function createPageSight() {
34
+ let why = null;
35
+ let inARow = 0;
36
+ let total = 0;
37
+ let stopped = false;
38
+ const STOPPED = 'Not run: the page still cannot be seen, so nothing more is changed in it this turn. ' +
39
+ 'Tell the user plainly what you did, what you could not confirm, and what they can check on their screen.';
40
+ return {
41
+ blind: () => why,
42
+ lost: (reason) => {
43
+ why = reason;
44
+ },
45
+ seen: () => {
46
+ why = null;
47
+ inARow = 0;
48
+ },
49
+ refuseChange() {
50
+ if (stopped) {
51
+ total++;
52
+ return { error: STOPPED, stopped: true };
53
+ }
54
+ if (!why)
55
+ return null;
56
+ inARow++;
57
+ total++;
58
+ if (inARow >= BLIND_REFUSALS_BEFORE_STOP)
59
+ stopped = true;
60
+ return {
61
+ error: `Not run: you cannot see the page (${why}). Read it first, with ${AGENT_UI_TOOLS.read} or an action that only reads; ` +
62
+ 'then run this again if it is still needed. Never change the page to find out what it shows.',
63
+ stopped: false,
64
+ };
65
+ },
66
+ refusals: () => total,
67
+ stopped: () => stopped,
68
+ };
69
+ }
4
70
  /**
5
71
  * When to send a request again while no tab has received it (dispatchUntilAnswered):
6
72
  * after 1 s, then 2, 4, and every 8 s.
@@ -15,60 +81,299 @@ export const MAX_RESEND_AFTER_MS = 8_000;
15
81
  export const RECEIVED_RESEND_AFTER_MS = 10_000;
16
82
  /** How long to wait for an async action's outcome when it declares no limit of its own. */
17
83
  export const DEFAULT_JOB_WAIT_MS = 5 * 60_000;
18
- /** Build the UI tools for a set of surfaces. The first surface to declare an action id keeps it. */
19
- export function buildUITools(surfaces, deps) {
20
- const tools = [];
21
- const owner = new Map();
22
- const collisions = [];
23
- const sequence = deps.sequence ?? createUISequence();
24
- for (const surface of surfaces) {
25
- for (const action of surface.actions) {
26
- const kept = owner.get(action.id);
27
- if (kept !== undefined) {
28
- collisions.push({ actionId: action.id, keptSurface: kept, droppedSurface: surface.id });
29
- continue;
30
- }
31
- owner.set(action.id, surface.id);
32
- tools.push(uiTool(surface, action, deps, sequence));
33
- }
34
- }
35
- return { tools, collisions };
36
- }
37
- function uiTool(surface, action, deps, sequence) {
84
+ /** The most actions one `ui_describe` call describes. */
85
+ export const MAX_DESCRIBED_ACTIONS = 8;
86
+ /**
87
+ * Build the UI tools for a page: one per action, which `ui_act` runs, and the
88
+ * two the model calls itself to learn what actions take and to read the page.
89
+ * The first surface to offer an action id keeps it.
90
+ */
91
+ export function buildUITools(page, deps) {
92
+ const shared = {
93
+ deps,
94
+ sequence: deps.sequence ?? createUISequence(),
95
+ held: deps.held ?? new Map(),
96
+ sight: deps.sight ?? createPageSight(),
97
+ };
98
+ const { actions, collisions } = pageActions(page);
38
99
  return {
39
- name: action.id,
100
+ tools: actions.map((action) => actionTool(action, shared)),
101
+ collisions,
102
+ describe: describeTool(shared),
103
+ read: readTool(shared),
104
+ };
105
+ }
106
+ /** The tool for one action of the page: what `ui_act` runs. */
107
+ function actionTool(action, shared) {
108
+ const { surface, entry } = action;
109
+ const tool = {
110
+ name: entry.id,
40
111
  kind: 'ui',
41
- description: describe(surface, action),
42
- inputSchema: (action.input ?? { type: 'object', properties: {} }),
112
+ description: `[UI · ${surface.name}] ${entry.description}`,
113
+ // Stands in until the definition is fetched: `resolveInputSchema` puts the
114
+ // real one here, with the action's whole description, so what reads the
115
+ // tool after (the approval card's labels and its words) reads the
116
+ // declaration itself and not its line in the index.
117
+ inputSchema: { type: 'object' },
118
+ resolveInputSchema: async (ctx) => {
119
+ const definition = await definitionOf(action, shared, ctx);
120
+ tool.inputSchema = (definition.input ?? { type: 'object', properties: {} });
121
+ tool.title = definition.title ?? definition.description;
122
+ tool.consequence = definition.description;
123
+ return tool.inputSchema;
124
+ },
43
125
  // The approval card's words and whether a call needs approval come from
44
126
  // the declaration (ADR-0228 §2.1–2.2). `confirm` marks a destructive
45
127
  // action, or one that changes what the person works in: either asks.
46
- ...(action.title ? { title: action.title } : { title: action.description }),
47
- consequence: action.description,
48
- ...(action.effect !== undefined ? { effect: action.effect } : {}),
49
- ...(action.confirm ? { destructive: true } : {}),
128
+ title: entry.title ?? entry.description,
129
+ consequence: entry.description,
130
+ ...(entry.effect !== undefined ? { effect: entry.effect } : {}),
131
+ ...(entry.confirm ? { destructive: true } : {}),
50
132
  execute(input, ctx) {
51
133
  // In the place the orchestrator took when the model's call arrived, or,
52
134
  // called directly, in a place taken now (ui-sequence.ts).
53
- const slot = ctx.uiSlot ?? sequence.reserve();
54
- return slot.run(() => runAction(surface, action, deps, sequence, input, ctx));
135
+ const slot = ctx.uiSlot ?? shared.sequence.reserve();
136
+ return slot.run(async () => {
137
+ // Checked here, in the action's place in the turn's order, and not when
138
+ // the model's call arrived: of two changes called in one response, the
139
+ // first's answer decides whether the second may run (ADR-0245 §2.5).
140
+ const refusal = entry.effect === 'view' ? null : shared.sight.refuseChange();
141
+ if (refusal) {
142
+ shared.deps.log?.('warn', 'UI action refused: the page cannot be seen', { action: entry.id, stopped: refusal.stopped });
143
+ return {
144
+ success: false,
145
+ error: refusal.error,
146
+ data: { notRun: true, ...(refusal.stopped ? { uiStopped: true } : { blind: true }) },
147
+ };
148
+ }
149
+ return runAction(action, shared, input, ctx);
150
+ });
151
+ },
152
+ };
153
+ return tool;
154
+ }
155
+ // ─── Definitions ─────────────────────────────────────────────────────────────
156
+ /** Thrown when an action's definition cannot be had: the model is told why. */
157
+ export class DefinitionUnavailable extends Error {
158
+ }
159
+ /**
160
+ * The definitions of `wanted`, from what the turn holds and, for the rest,
161
+ * from the page (`oui.describe`). An action the page no longer offers is left
162
+ * out of the result.
163
+ */
164
+ async function definitionsOf(wanted, shared, ctx) {
165
+ const found = new Map();
166
+ let missing = wanted.filter((a) => {
167
+ const held = shared.held.get(definitionKey(a.surface.id, a.entry));
168
+ if (held)
169
+ found.set(a, held);
170
+ return !held;
171
+ });
172
+ // A frame carries so many definitions; the page defers the rest, which are asked for again.
173
+ for (let round = 0; missing.length > 0 && round < wanted.length; round++) {
174
+ const room = ctx.socketRoom;
175
+ if (!room)
176
+ throw new Error('[agent-sdk] Describing a UI action needs the turn’s room');
177
+ const request = {
178
+ requestId: `describe-${randomUUID()}`,
179
+ surfaceId: OUI_RUNTIME_SURFACE,
180
+ actionId: OUI_DESCRIBE_ACTION,
181
+ params: { actions: missing.map((a) => ({ surface: a.surface.id, action: a.entry.id })) },
182
+ timestamp: Date.now(),
183
+ };
184
+ const answered = await dispatchUntilAnswered(shared.deps, room, request, ctx);
185
+ if (!answered.result) {
186
+ // A page that does not answer cannot be seen either: nothing is changed in it until it is read.
187
+ shared.sight.lost('the page is not answering');
188
+ throw new DefinitionUnavailable('The page did not answer when asked what its actions take. It may be closed or offline: tell the user the page is not responding.');
189
+ }
190
+ if (!answered.result.success) {
191
+ throw new DefinitionUnavailable(`The page could not say what its actions take: ${answered.result.error?.message ?? 'it gave no reason'}`);
192
+ }
193
+ const data = (answered.result.data ?? {});
194
+ const before = missing.length;
195
+ for (const { surface, action: definition } of data.definitions ?? []) {
196
+ const asked = missing.find((a) => a.surface.id === surface && a.entry.id === definition.id);
197
+ if (!asked)
198
+ continue;
199
+ shared.held.set(definitionKey(surface, asked.entry), definition);
200
+ found.set(asked, definition);
201
+ }
202
+ const deferred = new Set((answered.result.data?.deferred ?? []).map((d) => `${d.surface}\u0000${d.action}`));
203
+ missing = missing.filter((a) => !found.has(a) && deferred.has(`${a.surface.id}\u0000${a.entry.id}`));
204
+ if (missing.length === before)
205
+ break;
206
+ }
207
+ return found;
208
+ }
209
+ async function definitionOf(action, shared, ctx) {
210
+ const definition = (await definitionsOf([action], shared, ctx)).get(action);
211
+ if (!definition) {
212
+ throw new DefinitionUnavailable(`"${action.entry.id}" is no longer on the page. Read the page's index for what it offers now.`);
213
+ }
214
+ return definition;
215
+ }
216
+ // ─── ui_describe ─────────────────────────────────────────────────────────────
217
+ function describeTool(shared) {
218
+ return {
219
+ name: UI_DESCRIBE_TOOL,
220
+ kind: 'ui',
221
+ effect: 'view',
222
+ description: 'Says what actions of the page take: each action’s full description and its input, as a JSON Schema when it is small, ' +
223
+ 'or in outline when it is large. An outline names how to open each part: call again with that one action and `path`. ' +
224
+ 'A part opened by its path is given whole; a list too long for one answer says how many rows it has and where the next begin (`from`). ' +
225
+ `Describe an action before the first time you use it, unless its line in the index already says everything it takes. Up to ${MAX_DESCRIBED_ACTIONS} actions a call.`,
226
+ inputSchema: {
227
+ type: 'object',
228
+ properties: {
229
+ actions: {
230
+ type: 'array',
231
+ items: { type: 'string' },
232
+ minItems: 1,
233
+ maxItems: MAX_DESCRIBED_ACTIONS,
234
+ description: 'The ids of the actions, as the page’s index gives them.',
235
+ },
236
+ path: {
237
+ type: 'string',
238
+ description: 'With one action: the part of its input to open, as an outline named it. A property ("style.fill"), ' +
239
+ 'a union member ("effect=glow"), a list’s items ("stops.[]").',
240
+ },
241
+ from: {
242
+ type: 'integer',
243
+ minimum: 1,
244
+ description: 'With one action: the row to start at, when an answer said its rows continue ("call again with from: 81").',
245
+ },
246
+ },
247
+ required: ['actions'],
248
+ additionalProperties: false,
249
+ sideEffects: false,
250
+ },
251
+ async execute(input, ctx) {
252
+ const ids = input.actions;
253
+ const path = typeof input.path === 'string' ? input.path : '';
254
+ const from = typeof input.from === 'number' ? input.from : undefined;
255
+ if ((path || from) && ids.length !== 1) {
256
+ return { success: false, error: 'A path or a row to start at reads part of one action’s input: name one action with it.' };
257
+ }
258
+ const offered = new Map(pageActions(shared.deps.currentPage()).actions.map((a) => [a.entry.id, a]));
259
+ const wanted = ids.flatMap((id) => offered.get(id) ?? []);
260
+ const unknown = ids.filter((id) => !offered.has(id));
261
+ let definitions;
262
+ try {
263
+ definitions = await definitionsOf(wanted, shared, ctx);
264
+ }
265
+ catch (err) {
266
+ if (err instanceof DefinitionUnavailable)
267
+ return { success: false, error: err.message };
268
+ throw err;
269
+ }
270
+ const described = wanted.flatMap((action) => {
271
+ const definition = definitions.get(action);
272
+ if (!definition) {
273
+ unknown.push(action.entry.id);
274
+ return [];
275
+ }
276
+ const view = describeSchema(definition.input, { path, from });
277
+ return [
278
+ {
279
+ action: definition.id,
280
+ ...(definition.title ? { title: definition.title } : {}),
281
+ surface: action.surface.name,
282
+ description: definition.description,
283
+ ...(definition.usage ? { whenToUse: definition.usage } : {}),
284
+ ...(definition.preconditions ? { preconditions: definition.preconditions } : {}),
285
+ ...(definition.effect === 'transaction' || definition.confirm ? { needsApproval: true } : {}),
286
+ ...(definition.async
287
+ ? { waitsForItsWork: definition.estimatedDuration ? `yes, about ${definition.estimatedDuration}` : 'yes' }
288
+ : {}),
289
+ ...('error' in view
290
+ ? { error: view.error }
291
+ : view.whole
292
+ ? { [path ? 'part' : 'input']: JSON.parse(view.text) }
293
+ : { [path ? 'partOutline' : 'inputOutline']: view.text }),
294
+ },
295
+ ];
296
+ });
297
+ const notOffered = unknown.length
298
+ ? { notOnThePage: unknown, note: 'These are not actions of the page as it is now: read the index for what it offers.' }
299
+ : {};
300
+ return described.length > 0 || unknown.length === 0
301
+ ? { success: true, data: { actions: described, ...notOffered } }
302
+ : { success: false, error: `None of these is an action of the page as it is now: ${unknown.join(', ')}. Read the index for what it offers.` };
55
303
  },
56
304
  };
57
305
  }
58
- async function runAction(surface, action, deps, sequence, input, ctx) {
306
+ // ─── ui_read ─────────────────────────────────────────────────────────────────
307
+ function readTool(shared) {
308
+ return {
309
+ name: UI_READ_TOOL,
310
+ kind: 'ui',
311
+ effect: 'view',
312
+ description: 'Reads part of the page’s state: an observation of a surface, or the part of it at `path`. For a list it returns ' +
313
+ 'rows from `offset`, `limit` at a time, with the total and where the next page begins. Use it for what the page ' +
314
+ 'state says was cut or left out, and to see the page again after an answer came without its state. ' +
315
+ 'When a surface has its own tools to query or inspect its rows, prefer those: they find rows by what they are.',
316
+ inputSchema: {
317
+ type: 'object',
318
+ properties: {
319
+ surface: { type: 'string', description: 'The surface’s id, as the page state gives it.' },
320
+ observation: { type: 'string', description: 'The observation’s id.' },
321
+ path: { type: 'string', description: 'A JSON Pointer into the value ("/lists/rows"). Leave out for the whole value.' },
322
+ offset: { type: 'integer', minimum: 0, description: 'For a list: the first row to return. Default 0.' },
323
+ limit: { type: 'integer', minimum: 1, maximum: 200, description: 'For a list: how many rows. Default 50.' },
324
+ },
325
+ required: ['surface', 'observation'],
326
+ additionalProperties: false,
327
+ sideEffects: false,
328
+ },
329
+ execute(input, ctx) {
330
+ const slot = ctx.uiSlot ?? shared.sequence.reserve();
331
+ return slot.run(async () => {
332
+ const room = ctx.socketRoom;
333
+ if (!room)
334
+ throw new Error('[agent-sdk] Reading the page needs the turn’s room');
335
+ const request = {
336
+ requestId: ctx.toolCallId ?? `read-${randomUUID()}`,
337
+ surfaceId: OUI_RUNTIME_SURFACE,
338
+ actionId: OUI_READ_ACTION,
339
+ params: input,
340
+ timestamp: Date.now(),
341
+ };
342
+ const answered = await dispatchUntilAnswered(shared.deps, room, request, ctx);
343
+ if (!answered.result) {
344
+ shared.sight.lost('the page did not answer a read');
345
+ return {
346
+ success: false,
347
+ error: 'The page did not answer. It may be closed or offline: tell the user the page is not responding.',
348
+ };
349
+ }
350
+ const result = answered.result;
351
+ if (!result.success)
352
+ return { success: false, error: result.error?.message ?? 'The page could not read that.' };
353
+ // The page answered with what it holds: it can be seen again.
354
+ shared.sight.seen();
355
+ return { success: true, data: result.data };
356
+ });
357
+ },
358
+ };
359
+ }
360
+ // ─── Running an action ───────────────────────────────────────────────────────
361
+ async function runAction(action, shared, input, ctx) {
362
+ const { deps, sequence, sight } = shared;
363
+ const { surface, entry } = action;
59
364
  const requestId = ctx.toolCallId;
60
365
  const room = ctx.socketRoom;
61
366
  if (!requestId || !room) {
62
- throw new Error(`[agent-sdk] UI tool ${action.id} needs the tool call id and the turn's room`);
367
+ throw new Error(`[agent-sdk] UI tool ${entry.id} needs the tool call id and the turn's room`);
63
368
  }
64
369
  // The page as the actions before this one left it.
65
- const before = sequence.latestSurfaces() ?? deps.currentSurfaces();
66
- // The surfaces the worker holds, so the answer repeats them only if they changed (oui-spec §7.3.4).
370
+ const before = sequence.latestSurfaces() ?? deps.currentPage();
371
+ // What the worker holds of the page, so the answer repeats it only if it changed (oui-spec §7.3.4).
67
372
  const knownSurfaces = sequence.knownHash();
68
373
  const request = {
69
374
  requestId,
70
375
  surfaceId: surface.id,
71
- actionId: action.id,
376
+ actionId: entry.id,
72
377
  params: input,
73
378
  timestamp: Date.now(),
74
379
  ...(ctx.approval ? { approval: ctx.approval } : {}),
@@ -78,34 +383,48 @@ async function runAction(surface, action, deps, sequence, input, ctx) {
78
383
  if (!answered.result) {
79
384
  const within = `within ${Math.round(deps.resultTimeoutMs / 1000)}s`;
80
385
  const sent = `${answered.sends} time${answered.sends === 1 ? '' : 's'}`;
386
+ // It may have run, and what the page shows now is unknown.
387
+ if (answered.received || !answered.receipts)
388
+ sight.lost(`the page did not answer "${entry.id}"`);
81
389
  return {
82
390
  success: false,
83
391
  error: (answered.received
84
- ? `The page received "${action.id}" but its answer did not arrive ${within} (the request was sent ${sent}). ` +
85
- 'It may have run: check what the page shows before trying again.'
392
+ ? `The page received "${entry.id}" but its answer did not arrive ${within} (the request was sent ${sent}). ` +
393
+ 'It may have run: read what the page shows before doing anything else.'
86
394
  : answered.receipts
87
- ? `No open page received "${action.id}" ${within} (sent ${sent}): the person's page may be closed or offline. ` +
395
+ ? `No open page received "${entry.id}" ${within} (sent ${sent}): the person's page may be closed or offline. ` +
88
396
  'It did not run there. Tell the user the page is not responding.'
89
- : `The page did not answer "${action.id}" ${within}, though the request was sent to it ${sent}. ` +
90
- 'It may or may not have run: check what the page shows before trying again, and tell the user if it is not responding.'),
397
+ : `The page did not answer "${entry.id}" ${within}, though the request was sent to it ${sent}. ` +
398
+ 'It may or may not have run: read what the page shows before doing anything else, and tell the user if it is not responding.'),
91
399
  };
92
400
  }
93
- const result = withSurfaces(answered.result, sequence);
94
- deps.onResult(result);
401
+ const first = withPage(answered.result, shared);
402
+ deps.onResult(first.result, first.page);
95
403
  // Work that outlives the call (a GPU job, an export) was only started: wait
96
404
  // for its outcome, so the model tells the person it is done only once it is.
97
- const final = result.success && result.interim ? await awaitOutcome(action, deps, requestId, ctx) : null;
98
- const outcome = final && final !== 'running' ? withSurfaces(final, sequence) : final;
99
- if (outcome && outcome !== 'running')
100
- deps.onResult(outcome);
101
- const answer = outcome && outcome !== 'running' ? outcome : result;
405
+ const final = first.result.success && first.result.interim ? await awaitOutcome(entry, deps, requestId, ctx) : null;
406
+ const outcome = final && final !== 'running' ? withPage(final, shared) : null;
407
+ if (outcome)
408
+ deps.onResult(outcome.result, outcome.page);
409
+ const answer = outcome ?? first;
410
+ measure(deps, entry.id, answer.result);
411
+ if (answer.result.delivery?.omitted.includes('observations')) {
412
+ sight.lost(`the answer to "${entry.id}" came without the page's state (${answer.result.delivery.reason})`);
413
+ }
414
+ else {
415
+ sight.seen();
416
+ }
417
+ // A picture in the answer goes to the model as a picture, not as base64 text.
418
+ const lifted = liftAnswerImage(answer.result.data);
419
+ const shown = lifted.data === answer.result.data ? answer.result : { ...answer.result, data: lifted.data };
102
420
  const data = {
103
- ...forModel(answer, before, deps.maxObservationChars ?? DEFAULT_MAX_OBSERVATION_CHARS, surface.id),
104
- ...(outcome === 'running' ? { status: 'running', note: stillRunningNote(action) } : {}),
421
+ ...forModel(shown, before, answer.page, deps.maxObservationChars ?? DEFAULT_PAGE_STATE_CHARS, surface.id),
422
+ ...(final === 'running' ? { status: 'running', note: stillRunningNote(entry) } : {}),
105
423
  };
106
- return answer.success
107
- ? { success: true, data }
108
- : { success: false, data, error: answer.error?.message ?? `"${action.id}" failed` };
424
+ const image = lifted.image ? { image: lifted.image } : {};
425
+ return answer.result.success
426
+ ? { success: true, data, ...image }
427
+ : { success: false, data, error: answer.result.error?.message ?? `"${entry.id}" failed`, ...image };
109
428
  }
110
429
  /**
111
430
  * Send the request to the turn's room, and send it again only while it may not
@@ -153,22 +472,28 @@ async function dispatchUntilAnswered(deps, room, request, ctx) {
153
472
  }
154
473
  }
155
474
  /**
156
- * An answer with the surfaces it stands for: those it carries, or those the
157
- * worker holds under the hash it reports (an answer repeats surfaces only when
158
- * they changed). Recorded as the page's surfaces from now on.
475
+ * An answer with the page it stands for: the index it carries, the one derived
476
+ * from the definitions it carries, or the one the worker holds under the hash
477
+ * it reports (an answer repeats what the page offers only when that changed).
478
+ * Recorded as the page from now on.
159
479
  */
160
- function withSurfaces(result, sequence) {
161
- const surfaces = sequence.resolve(result);
162
- sequence.record(surfaces, result.surfacesHash);
163
- return surfaces && !result.surfaces ? { ...result, surfaces } : result;
480
+ function withPage(result, shared) {
481
+ const carried = Array.isArray(result.index) && result.index.every(isSurfaceIndex)
482
+ ? pageFromIndex(result.index)
483
+ : Array.isArray(result.surfaces) && result.surfaces.every(isFullSurface)
484
+ ? pageFromSurfaces(result.surfaces, shared.held)
485
+ : undefined;
486
+ const page = shared.sequence.resolve({ ...(carried ? { page: carried } : {}), surfacesHash: result.surfacesHash });
487
+ shared.sequence.record(page, result.surfacesHash);
488
+ return { result, page };
164
489
  }
165
490
  /**
166
491
  * The final answer to a started action: its outcome once the work is done or
167
492
  * has failed, or `'running'` when it has not finished by its own limit (its
168
493
  * polling's `maxDurationMs`) or by when the turn must stop waiting.
169
494
  */
170
- async function awaitOutcome(action, deps, requestId, ctx) {
171
- const until = Math.min(Date.now() + (action.polling?.maxDurationMs ?? DEFAULT_JOB_WAIT_MS), deps.waitDeadline?.() ?? Infinity);
495
+ async function awaitOutcome(entry, deps, requestId, ctx) {
496
+ const until = Math.min(Date.now() + (entry.maxDurationMs ?? DEFAULT_JOB_WAIT_MS), deps.waitDeadline?.() ?? Infinity);
172
497
  const timeoutMs = until - Date.now();
173
498
  if (timeoutMs <= 0)
174
499
  return 'running';
@@ -183,36 +508,44 @@ async function awaitOutcome(action, deps, requestId, ctx) {
183
508
  return 'running';
184
509
  return final;
185
510
  }
186
- function stillRunningNote(action) {
187
- return (`"${action.id}" started and has not finished yet, so it is not done: tell the person it is still running, ` +
511
+ function stillRunningNote(entry) {
512
+ return (`"${entry.id}" started and has not finished yet, so it is not done: tell the person it is still running, ` +
188
513
  'never that it is done. Its status and result appear in the page state when it finishes.');
189
514
  }
190
- function describe(surface, action) {
191
- let desc = `[UI · ${surface.name}] ${action.description}`;
192
- if (action.usage)
193
- desc += `\n\nWhen to use: ${action.usage}`;
194
- if (action.preconditions)
195
- desc += `\n\nPreconditions: ${action.preconditions}`;
196
- if (action.async) {
197
- desc +=
198
- '\n\nAsync: waits for the work to finish and returns its outcome; if it is still running when the wait ends, ' +
199
- 'it says so (status "running"), and the outcome appears in the page state when it finishes.';
200
- if (action.estimatedDuration)
201
- desc += ` Estimated duration: ${action.estimatedDuration}.`;
202
- }
203
- return desc;
515
+ /** Report an answer's size by part, and whether it had to be shortened (ADR-0245 §2.6). */
516
+ function measure(deps, actionId, result) {
517
+ if (!deps.log)
518
+ return;
519
+ const offered = result.index ?? result.surfaces;
520
+ deps.log(result.delivery?.trimmed ? 'warn' : 'info', 'UI answer', {
521
+ requestId: result.requestId,
522
+ action: actionId,
523
+ bytes: {
524
+ total: jsonBytes(result),
525
+ offered: jsonBytes(offered),
526
+ observations: jsonBytes(result.observations),
527
+ data: jsonBytes(result.data),
528
+ },
529
+ form: result.index ? 'index' : result.surfaces ? 'full' : 'unchanged',
530
+ ...(offered ? { actions: (result.index ?? []).reduce((n, s) => n + s.index.length, 0) + (result.surfaces ?? []).reduce((n, s) => n + s.actions.length, 0) } : {}),
531
+ ...(result.fit ? { fitted: { lists: result.fit.observations?.length ?? 0, dataLeftOut: !!result.fit.data } } : {}),
532
+ ...(result.delivery?.trimmed ? { trimmed: result.delivery.omitted, refusedBecause: result.delivery.reason } : {}),
533
+ });
204
534
  }
205
535
  /**
206
- * What the model sees: the outcome, and what the page offers now. Tool changes
207
- * are named so the model knows the page it is on, not just that something
208
- * happened.
536
+ * What the model sees: the outcome, what the page offers now where that
537
+ * changed, and what it shows. A new surface comes with its index; an action
538
+ * added to or removed from a surface is named, so the model knows the page it
539
+ * is on, not just that something happened.
209
540
  */
210
- function forModel(result, before, maxObservationChars, actingSurfaceId) {
211
- const after = result.surfaces ?? before;
212
- const beforeActions = new Set(before.flatMap((s) => s.actions.map((a) => a.id)));
213
- const afterActions = new Set(after.flatMap((s) => s.actions.map((a) => a.id)));
214
- const added = [...afterActions].filter((id) => !beforeActions.has(id));
215
- const removed = [...beforeActions].filter((id) => !afterActions.has(id));
541
+ function forModel(result, before, page, maxObservationChars, actingSurfaceId) {
542
+ const after = page ?? before;
543
+ const was = new Set(before.map((s) => s.id));
544
+ const opened = after.filter((s) => !was.has(s.id));
545
+ // Within the surfaces that stayed, what was added and what went.
546
+ const stayed = (surfaces) => surfaces.filter((s) => was.has(s.id) && after.some((a) => a.id === s.id));
547
+ const { added, removed } = indexDiff(stayed(before), stayed(after));
548
+ const closed = before.filter((s) => !after.some((a) => a.id === s.id)).map((s) => s.name);
216
549
  return {
217
550
  ...(result.data !== undefined ? { result: result.data } : {}),
218
551
  ...(result.error ? { error: result.error } : {}),
@@ -221,24 +554,72 @@ function forModel(result, before, maxObservationChars, actingSurfaceId) {
221
554
  ? { note: 'The page was still loading when this was reported; what it offers may still change.' }
222
555
  : {}),
223
556
  ...(result.delivery?.trimmed ? { delivery: deliveryNote(result.delivery) } : {}),
557
+ ...(result.fit?.data ? { resultLeftOut: dataNote(result.fit.data) } : {}),
224
558
  page: {
225
559
  surfaces: after.map((s) => s.name),
226
- ...(added.length ? { toolsAdded: added } : {}),
227
- ...(removed.length ? { toolsRemoved: removed } : {}),
560
+ ...(opened.length ? { nowOffers: indexText(opened) } : {}),
561
+ ...(closed.length ? { noLongerOnScreen: closed } : {}),
562
+ ...(added.length ? { actionsAdded: added } : {}),
563
+ ...(removed.length ? { actionsRemoved: removed } : {}),
228
564
  },
229
565
  ...(result.observations && Object.keys(result.observations).length
230
- ? { state: boundObservations(result.observations, maxObservationChars, actingSurfaceId) }
566
+ ? {
567
+ state: boundObservations(result.observations, maxObservationChars, actingSurfaceId, {
568
+ schemas: observationSchemas(after),
569
+ changed: changedRefs(result.data),
570
+ }),
571
+ }
231
572
  : {}),
573
+ ...(result.fit?.observations?.length ? { notShown: fitNotes(result.fit.observations) } : {}),
232
574
  };
233
575
  }
576
+ /**
577
+ * The rows an action says it changed (a room result's `changed`, ADR-0244
578
+ * §2.5), by what addresses them: kept whole in the page state it comes with.
579
+ */
580
+ function changedRefs(data) {
581
+ const changed = data?.changed;
582
+ if (!Array.isArray(changed))
583
+ return [];
584
+ return changed.flatMap((row) => {
585
+ const ref = row?.ref;
586
+ return typeof ref === 'string' ? [ref] : [];
587
+ });
588
+ }
589
+ /** What the model is told about a result too large to send: it ran, and its result must be read another way. */
590
+ function dataNote(data) {
591
+ return (`The action ran, but what it returned was too large to send (${data.bytes} bytes; an answer carries ${data.limit}). ` +
592
+ 'Its outcome above stands. Read what it produced from the page state, or with a tool that returns it in parts.');
593
+ }
594
+ /** What of the page's state the client left out to fit its answer, and how to read each part (oui-spec §7.3.9). */
595
+ export function fitNotes(cuts) {
596
+ return cuts.map((cut) => {
597
+ const where = `${cut.surface}.${cut.observation}${cut.path}`;
598
+ const read = (extra) => `${UI_READ_TOOL}(${JSON.stringify({ surface: cut.surface, observation: cut.observation, ...(cut.path ? { path: cut.path } : {}), ...extra })})`;
599
+ if (cut.kind === 'list') {
600
+ return `${where} has ${cut.total} rows; the page sent the first ${cut.kept}. Read more with ${read({ offset: cut.kept })}, or with the surface's own query tool.`;
601
+ }
602
+ if (cut.kind === 'text') {
603
+ return `${where} is ${cut.total} characters; the page sent the first ${cut.kept}. Read it whole with ${read({})}.`;
604
+ }
605
+ return `${where} (${cut.total} bytes) was left out to fit the answer. Read it with ${read({})}.`;
606
+ });
607
+ }
234
608
  /**
235
609
  * What the model is told about an answer the tab had to trim to deliver
236
610
  * (oui-spec §7.3.6): the outcome is what happened, and what it lacks.
237
611
  */
238
612
  function deliveryNote(delivery) {
239
- const lacks = delivery.omitted.map((field) => (field === 'data' ? 'what the action returned' : `the page's ${field}`));
613
+ const lacks = delivery.omitted.map((field) => field === 'data'
614
+ ? 'what the action returned'
615
+ : field === 'observations'
616
+ ? 'the page’s state'
617
+ : 'what the page now offers');
618
+ const blind = delivery.omitted.includes('observations');
240
619
  return (`The page ran this, and its outcome here is what happened, but its full answer was refused on the way ` +
241
620
  `(${delivery.reason}), so it came without ${lacks.join(', ')}. ` +
242
- "Don't assume what the page shows: read its state from the next action's answer before relying on it.");
621
+ (blind
622
+ ? `You cannot see the page now: read it (${UI_READ_TOOL}, or a surface's own read tools) before anything else. Nothing that changes the page runs until you have.`
623
+ : 'The page state below is current.'));
243
624
  }
244
625
  //# sourceMappingURL=ui-tools.js.map