@ouispec/agent-worker 0.1.1 → 0.2.0

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 (64) 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 +383 -128
  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/types.d.ts +17 -0
  28. package/dist/tools/types.d.ts.map +1 -1
  29. package/dist/tools/types.js.map +1 -1
  30. package/dist/turn-ledger.d.ts +30 -0
  31. package/dist/turn-ledger.d.ts.map +1 -0
  32. package/dist/turn-ledger.js +45 -0
  33. package/dist/turn-ledger.js.map +1 -0
  34. package/dist/types.d.ts +9 -2
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/ui/answer-image.d.ts +32 -0
  37. package/dist/ui/answer-image.d.ts.map +1 -0
  38. package/dist/ui/answer-image.js +50 -0
  39. package/dist/ui/answer-image.js.map +1 -0
  40. package/dist/ui/observations.d.ts +29 -5
  41. package/dist/ui/observations.d.ts.map +1 -1
  42. package/dist/ui/observations.js +122 -46
  43. package/dist/ui/observations.js.map +1 -1
  44. package/dist/ui/outline.d.ts +49 -0
  45. package/dist/ui/outline.d.ts.map +1 -0
  46. package/dist/ui/outline.js +229 -0
  47. package/dist/ui/outline.js.map +1 -0
  48. package/dist/ui/page-index.d.ts +74 -0
  49. package/dist/ui/page-index.d.ts.map +1 -0
  50. package/dist/ui/page-index.js +137 -0
  51. package/dist/ui/page-index.js.map +1 -0
  52. package/dist/ui/snapshot.d.ts +24 -12
  53. package/dist/ui/snapshot.d.ts.map +1 -1
  54. package/dist/ui/snapshot.js +34 -39
  55. package/dist/ui/snapshot.js.map +1 -1
  56. package/dist/ui/ui-sequence.d.ts +9 -9
  57. package/dist/ui/ui-sequence.d.ts.map +1 -1
  58. package/dist/ui/ui-sequence.js +2 -2
  59. package/dist/ui/ui-sequence.js.map +1 -1
  60. package/dist/ui/ui-tools.d.ts +64 -14
  61. package/dist/ui/ui-tools.d.ts.map +1 -1
  62. package/dist/ui/ui-tools.js +466 -92
  63. package/dist/ui/ui-tools.js.map +1 -1
  64. 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,292 @@ 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
+ `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.`,
225
+ inputSchema: {
226
+ type: 'object',
227
+ properties: {
228
+ actions: {
229
+ type: 'array',
230
+ items: { type: 'string' },
231
+ minItems: 1,
232
+ maxItems: MAX_DESCRIBED_ACTIONS,
233
+ description: 'The ids of the actions, as the page’s index gives them.',
234
+ },
235
+ path: {
236
+ type: 'string',
237
+ description: 'With one action: the part of its input to open, as an outline named it. A property ("style.fill"), ' +
238
+ 'a union member ("effect=glow"), a list’s items ("stops.[]").',
239
+ },
240
+ },
241
+ required: ['actions'],
242
+ additionalProperties: false,
243
+ sideEffects: false,
244
+ },
245
+ async execute(input, ctx) {
246
+ const ids = input.actions;
247
+ const path = typeof input.path === 'string' ? input.path : '';
248
+ if (path && ids.length !== 1) {
249
+ return { success: false, error: 'A path opens part of one action’s input: name one action with it.' };
250
+ }
251
+ const offered = new Map(pageActions(shared.deps.currentPage()).actions.map((a) => [a.entry.id, a]));
252
+ const wanted = ids.flatMap((id) => offered.get(id) ?? []);
253
+ const unknown = ids.filter((id) => !offered.has(id));
254
+ let definitions;
255
+ try {
256
+ definitions = await definitionsOf(wanted, shared, ctx);
257
+ }
258
+ catch (err) {
259
+ if (err instanceof DefinitionUnavailable)
260
+ return { success: false, error: err.message };
261
+ throw err;
262
+ }
263
+ const described = wanted.flatMap((action) => {
264
+ const definition = definitions.get(action);
265
+ if (!definition) {
266
+ unknown.push(action.entry.id);
267
+ return [];
268
+ }
269
+ const view = describeSchema(definition.input, { path });
270
+ return [
271
+ {
272
+ action: definition.id,
273
+ ...(definition.title ? { title: definition.title } : {}),
274
+ surface: action.surface.name,
275
+ description: definition.description,
276
+ ...(definition.usage ? { whenToUse: definition.usage } : {}),
277
+ ...(definition.preconditions ? { preconditions: definition.preconditions } : {}),
278
+ ...(definition.effect === 'transaction' || definition.confirm ? { needsApproval: true } : {}),
279
+ ...(definition.async
280
+ ? { waitsForItsWork: definition.estimatedDuration ? `yes, about ${definition.estimatedDuration}` : 'yes' }
281
+ : {}),
282
+ ...('error' in view
283
+ ? { error: view.error }
284
+ : view.whole
285
+ ? { [path ? 'part' : 'input']: JSON.parse(view.text) }
286
+ : { [path ? 'partOutline' : 'inputOutline']: view.text }),
287
+ },
288
+ ];
289
+ });
290
+ const notOffered = unknown.length
291
+ ? { notOnThePage: unknown, note: 'These are not actions of the page as it is now: read the index for what it offers.' }
292
+ : {};
293
+ return described.length > 0 || unknown.length === 0
294
+ ? { success: true, data: { actions: described, ...notOffered } }
295
+ : { 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
296
  },
56
297
  };
57
298
  }
58
- async function runAction(surface, action, deps, sequence, input, ctx) {
299
+ // ─── ui_read ─────────────────────────────────────────────────────────────────
300
+ function readTool(shared) {
301
+ return {
302
+ name: UI_READ_TOOL,
303
+ kind: 'ui',
304
+ effect: 'view',
305
+ 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 ' +
306
+ 'rows from `offset`, `limit` at a time, with the total and where the next page begins. Use it for what the page ' +
307
+ 'state says was cut or left out, and to see the page again after an answer came without its state. ' +
308
+ 'When a surface has its own tools to query or inspect its rows, prefer those: they find rows by what they are.',
309
+ inputSchema: {
310
+ type: 'object',
311
+ properties: {
312
+ surface: { type: 'string', description: 'The surface’s id, as the page state gives it.' },
313
+ observation: { type: 'string', description: 'The observation’s id.' },
314
+ path: { type: 'string', description: 'A JSON Pointer into the value ("/lists/rows"). Leave out for the whole value.' },
315
+ offset: { type: 'integer', minimum: 0, description: 'For a list: the first row to return. Default 0.' },
316
+ limit: { type: 'integer', minimum: 1, maximum: 200, description: 'For a list: how many rows. Default 50.' },
317
+ },
318
+ required: ['surface', 'observation'],
319
+ additionalProperties: false,
320
+ sideEffects: false,
321
+ },
322
+ execute(input, ctx) {
323
+ const slot = ctx.uiSlot ?? shared.sequence.reserve();
324
+ return slot.run(async () => {
325
+ const room = ctx.socketRoom;
326
+ if (!room)
327
+ throw new Error('[agent-sdk] Reading the page needs the turn’s room');
328
+ const request = {
329
+ requestId: ctx.toolCallId ?? `read-${randomUUID()}`,
330
+ surfaceId: OUI_RUNTIME_SURFACE,
331
+ actionId: OUI_READ_ACTION,
332
+ params: input,
333
+ timestamp: Date.now(),
334
+ };
335
+ const answered = await dispatchUntilAnswered(shared.deps, room, request, ctx);
336
+ if (!answered.result) {
337
+ shared.sight.lost('the page did not answer a read');
338
+ return {
339
+ success: false,
340
+ error: 'The page did not answer. It may be closed or offline: tell the user the page is not responding.',
341
+ };
342
+ }
343
+ const result = answered.result;
344
+ if (!result.success)
345
+ return { success: false, error: result.error?.message ?? 'The page could not read that.' };
346
+ // The page answered with what it holds: it can be seen again.
347
+ shared.sight.seen();
348
+ return { success: true, data: result.data };
349
+ });
350
+ },
351
+ };
352
+ }
353
+ // ─── Running an action ───────────────────────────────────────────────────────
354
+ async function runAction(action, shared, input, ctx) {
355
+ const { deps, sequence, sight } = shared;
356
+ const { surface, entry } = action;
59
357
  const requestId = ctx.toolCallId;
60
358
  const room = ctx.socketRoom;
61
359
  if (!requestId || !room) {
62
- throw new Error(`[agent-sdk] UI tool ${action.id} needs the tool call id and the turn's room`);
360
+ throw new Error(`[agent-sdk] UI tool ${entry.id} needs the tool call id and the turn's room`);
63
361
  }
64
362
  // 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).
363
+ const before = sequence.latestSurfaces() ?? deps.currentPage();
364
+ // What the worker holds of the page, so the answer repeats it only if it changed (oui-spec §7.3.4).
67
365
  const knownSurfaces = sequence.knownHash();
68
366
  const request = {
69
367
  requestId,
70
368
  surfaceId: surface.id,
71
- actionId: action.id,
369
+ actionId: entry.id,
72
370
  params: input,
73
371
  timestamp: Date.now(),
74
372
  ...(ctx.approval ? { approval: ctx.approval } : {}),
@@ -78,34 +376,48 @@ async function runAction(surface, action, deps, sequence, input, ctx) {
78
376
  if (!answered.result) {
79
377
  const within = `within ${Math.round(deps.resultTimeoutMs / 1000)}s`;
80
378
  const sent = `${answered.sends} time${answered.sends === 1 ? '' : 's'}`;
379
+ // It may have run, and what the page shows now is unknown.
380
+ if (answered.received || !answered.receipts)
381
+ sight.lost(`the page did not answer "${entry.id}"`);
81
382
  return {
82
383
  success: false,
83
384
  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.'
385
+ ? `The page received "${entry.id}" but its answer did not arrive ${within} (the request was sent ${sent}). ` +
386
+ 'It may have run: read what the page shows before doing anything else.'
86
387
  : answered.receipts
87
- ? `No open page received "${action.id}" ${within} (sent ${sent}): the person's page may be closed or offline. ` +
388
+ ? `No open page received "${entry.id}" ${within} (sent ${sent}): the person's page may be closed or offline. ` +
88
389
  '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.'),
390
+ : `The page did not answer "${entry.id}" ${within}, though the request was sent to it ${sent}. ` +
391
+ '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
392
  };
92
393
  }
93
- const result = withSurfaces(answered.result, sequence);
94
- deps.onResult(result);
394
+ const first = withPage(answered.result, shared);
395
+ deps.onResult(first.result, first.page);
95
396
  // Work that outlives the call (a GPU job, an export) was only started: wait
96
397
  // 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;
398
+ const final = first.result.success && first.result.interim ? await awaitOutcome(entry, deps, requestId, ctx) : null;
399
+ const outcome = final && final !== 'running' ? withPage(final, shared) : null;
400
+ if (outcome)
401
+ deps.onResult(outcome.result, outcome.page);
402
+ const answer = outcome ?? first;
403
+ measure(deps, entry.id, answer.result);
404
+ if (answer.result.delivery?.omitted.includes('observations')) {
405
+ sight.lost(`the answer to "${entry.id}" came without the page's state (${answer.result.delivery.reason})`);
406
+ }
407
+ else {
408
+ sight.seen();
409
+ }
410
+ // A picture in the answer goes to the model as a picture, not as base64 text.
411
+ const lifted = liftAnswerImage(answer.result.data);
412
+ const shown = lifted.data === answer.result.data ? answer.result : { ...answer.result, data: lifted.data };
102
413
  const data = {
103
- ...forModel(answer, before, deps.maxObservationChars ?? DEFAULT_MAX_OBSERVATION_CHARS, surface.id),
104
- ...(outcome === 'running' ? { status: 'running', note: stillRunningNote(action) } : {}),
414
+ ...forModel(shown, before, answer.page, deps.maxObservationChars ?? DEFAULT_PAGE_STATE_CHARS, surface.id),
415
+ ...(final === 'running' ? { status: 'running', note: stillRunningNote(entry) } : {}),
105
416
  };
106
- return answer.success
107
- ? { success: true, data }
108
- : { success: false, data, error: answer.error?.message ?? `"${action.id}" failed` };
417
+ const image = lifted.image ? { image: lifted.image } : {};
418
+ return answer.result.success
419
+ ? { success: true, data, ...image }
420
+ : { success: false, data, error: answer.result.error?.message ?? `"${entry.id}" failed`, ...image };
109
421
  }
110
422
  /**
111
423
  * Send the request to the turn's room, and send it again only while it may not
@@ -153,22 +465,28 @@ async function dispatchUntilAnswered(deps, room, request, ctx) {
153
465
  }
154
466
  }
155
467
  /**
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.
468
+ * An answer with the page it stands for: the index it carries, the one derived
469
+ * from the definitions it carries, or the one the worker holds under the hash
470
+ * it reports (an answer repeats what the page offers only when that changed).
471
+ * Recorded as the page from now on.
159
472
  */
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;
473
+ function withPage(result, shared) {
474
+ const carried = Array.isArray(result.index) && result.index.every(isSurfaceIndex)
475
+ ? pageFromIndex(result.index)
476
+ : Array.isArray(result.surfaces) && result.surfaces.every(isFullSurface)
477
+ ? pageFromSurfaces(result.surfaces, shared.held)
478
+ : undefined;
479
+ const page = shared.sequence.resolve({ ...(carried ? { page: carried } : {}), surfacesHash: result.surfacesHash });
480
+ shared.sequence.record(page, result.surfacesHash);
481
+ return { result, page };
164
482
  }
165
483
  /**
166
484
  * The final answer to a started action: its outcome once the work is done or
167
485
  * has failed, or `'running'` when it has not finished by its own limit (its
168
486
  * polling's `maxDurationMs`) or by when the turn must stop waiting.
169
487
  */
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);
488
+ async function awaitOutcome(entry, deps, requestId, ctx) {
489
+ const until = Math.min(Date.now() + (entry.maxDurationMs ?? DEFAULT_JOB_WAIT_MS), deps.waitDeadline?.() ?? Infinity);
172
490
  const timeoutMs = until - Date.now();
173
491
  if (timeoutMs <= 0)
174
492
  return 'running';
@@ -183,36 +501,44 @@ async function awaitOutcome(action, deps, requestId, ctx) {
183
501
  return 'running';
184
502
  return final;
185
503
  }
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, ` +
504
+ function stillRunningNote(entry) {
505
+ return (`"${entry.id}" started and has not finished yet, so it is not done: tell the person it is still running, ` +
188
506
  'never that it is done. Its status and result appear in the page state when it finishes.');
189
507
  }
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;
508
+ /** Report an answer's size by part, and whether it had to be shortened (ADR-0245 §2.6). */
509
+ function measure(deps, actionId, result) {
510
+ if (!deps.log)
511
+ return;
512
+ const offered = result.index ?? result.surfaces;
513
+ deps.log(result.delivery?.trimmed ? 'warn' : 'info', 'UI answer', {
514
+ requestId: result.requestId,
515
+ action: actionId,
516
+ bytes: {
517
+ total: jsonBytes(result),
518
+ offered: jsonBytes(offered),
519
+ observations: jsonBytes(result.observations),
520
+ data: jsonBytes(result.data),
521
+ },
522
+ form: result.index ? 'index' : result.surfaces ? 'full' : 'unchanged',
523
+ ...(offered ? { actions: (result.index ?? []).reduce((n, s) => n + s.index.length, 0) + (result.surfaces ?? []).reduce((n, s) => n + s.actions.length, 0) } : {}),
524
+ ...(result.fit ? { fitted: { lists: result.fit.observations?.length ?? 0, dataLeftOut: !!result.fit.data } } : {}),
525
+ ...(result.delivery?.trimmed ? { trimmed: result.delivery.omitted, refusedBecause: result.delivery.reason } : {}),
526
+ });
204
527
  }
205
528
  /**
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.
529
+ * What the model sees: the outcome, what the page offers now where that
530
+ * changed, and what it shows. A new surface comes with its index; an action
531
+ * added to or removed from a surface is named, so the model knows the page it
532
+ * is on, not just that something happened.
209
533
  */
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));
534
+ function forModel(result, before, page, maxObservationChars, actingSurfaceId) {
535
+ const after = page ?? before;
536
+ const was = new Set(before.map((s) => s.id));
537
+ const opened = after.filter((s) => !was.has(s.id));
538
+ // Within the surfaces that stayed, what was added and what went.
539
+ const stayed = (surfaces) => surfaces.filter((s) => was.has(s.id) && after.some((a) => a.id === s.id));
540
+ const { added, removed } = indexDiff(stayed(before), stayed(after));
541
+ const closed = before.filter((s) => !after.some((a) => a.id === s.id)).map((s) => s.name);
216
542
  return {
217
543
  ...(result.data !== undefined ? { result: result.data } : {}),
218
544
  ...(result.error ? { error: result.error } : {}),
@@ -221,24 +547,72 @@ function forModel(result, before, maxObservationChars, actingSurfaceId) {
221
547
  ? { note: 'The page was still loading when this was reported; what it offers may still change.' }
222
548
  : {}),
223
549
  ...(result.delivery?.trimmed ? { delivery: deliveryNote(result.delivery) } : {}),
550
+ ...(result.fit?.data ? { resultLeftOut: dataNote(result.fit.data) } : {}),
224
551
  page: {
225
552
  surfaces: after.map((s) => s.name),
226
- ...(added.length ? { toolsAdded: added } : {}),
227
- ...(removed.length ? { toolsRemoved: removed } : {}),
553
+ ...(opened.length ? { nowOffers: indexText(opened) } : {}),
554
+ ...(closed.length ? { noLongerOnScreen: closed } : {}),
555
+ ...(added.length ? { actionsAdded: added } : {}),
556
+ ...(removed.length ? { actionsRemoved: removed } : {}),
228
557
  },
229
558
  ...(result.observations && Object.keys(result.observations).length
230
- ? { state: boundObservations(result.observations, maxObservationChars, actingSurfaceId) }
559
+ ? {
560
+ state: boundObservations(result.observations, maxObservationChars, actingSurfaceId, {
561
+ schemas: observationSchemas(after),
562
+ changed: changedRefs(result.data),
563
+ }),
564
+ }
231
565
  : {}),
566
+ ...(result.fit?.observations?.length ? { notShown: fitNotes(result.fit.observations) } : {}),
232
567
  };
233
568
  }
569
+ /**
570
+ * The rows an action says it changed (a room result's `changed`, ADR-0244
571
+ * §2.5), by what addresses them: kept whole in the page state it comes with.
572
+ */
573
+ function changedRefs(data) {
574
+ const changed = data?.changed;
575
+ if (!Array.isArray(changed))
576
+ return [];
577
+ return changed.flatMap((row) => {
578
+ const ref = row?.ref;
579
+ return typeof ref === 'string' ? [ref] : [];
580
+ });
581
+ }
582
+ /** What the model is told about a result too large to send: it ran, and its result must be read another way. */
583
+ function dataNote(data) {
584
+ return (`The action ran, but what it returned was too large to send (${data.bytes} bytes; an answer carries ${data.limit}). ` +
585
+ 'Its outcome above stands. Read what it produced from the page state, or with a tool that returns it in parts.');
586
+ }
587
+ /** 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). */
588
+ export function fitNotes(cuts) {
589
+ return cuts.map((cut) => {
590
+ const where = `${cut.surface}.${cut.observation}${cut.path}`;
591
+ const read = (extra) => `${UI_READ_TOOL}(${JSON.stringify({ surface: cut.surface, observation: cut.observation, ...(cut.path ? { path: cut.path } : {}), ...extra })})`;
592
+ if (cut.kind === 'list') {
593
+ 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.`;
594
+ }
595
+ if (cut.kind === 'text') {
596
+ return `${where} is ${cut.total} characters; the page sent the first ${cut.kept}. Read it whole with ${read({})}.`;
597
+ }
598
+ return `${where} (${cut.total} bytes) was left out to fit the answer. Read it with ${read({})}.`;
599
+ });
600
+ }
234
601
  /**
235
602
  * What the model is told about an answer the tab had to trim to deliver
236
603
  * (oui-spec §7.3.6): the outcome is what happened, and what it lacks.
237
604
  */
238
605
  function deliveryNote(delivery) {
239
- const lacks = delivery.omitted.map((field) => (field === 'data' ? 'what the action returned' : `the page's ${field}`));
606
+ const lacks = delivery.omitted.map((field) => field === 'data'
607
+ ? 'what the action returned'
608
+ : field === 'observations'
609
+ ? 'the page’s state'
610
+ : 'what the page now offers');
611
+ const blind = delivery.omitted.includes('observations');
240
612
  return (`The page ran this, and its outcome here is what happened, but its full answer was refused on the way ` +
241
613
  `(${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.");
614
+ (blind
615
+ ? `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.`
616
+ : 'The page state below is current.'));
243
617
  }
244
618
  //# sourceMappingURL=ui-tools.js.map