@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.
- package/dist/approvals/continuation.d.ts +14 -1
- package/dist/approvals/continuation.d.ts.map +1 -1
- package/dist/approvals/continuation.js +12 -2
- package/dist/approvals/continuation.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator.js +383 -128
- package/dist/orchestrator.js.map +1 -1
- package/dist/prompt/clock.d.ts +34 -0
- package/dist/prompt/clock.d.ts.map +1 -0
- package/dist/prompt/clock.js +63 -0
- package/dist/prompt/clock.js.map +1 -0
- package/dist/prompt/rules/ui-control.d.ts +3 -3
- package/dist/prompt/rules/ui-control.d.ts.map +1 -1
- package/dist/prompt/rules/ui-control.js +45 -22
- package/dist/prompt/rules/ui-control.js.map +1 -1
- package/dist/runtime/turn-runner.d.ts.map +1 -1
- package/dist/runtime/turn-runner.js +3 -1
- package/dist/runtime/turn-runner.js.map +1 -1
- package/dist/runtime/types.d.ts +5 -3
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/tools/builtin-tools.js +11 -4
- package/dist/tools/builtin-tools.js.map +1 -1
- package/dist/tools/types.d.ts +17 -0
- package/dist/tools/types.d.ts.map +1 -1
- package/dist/tools/types.js.map +1 -1
- package/dist/turn-ledger.d.ts +30 -0
- package/dist/turn-ledger.d.ts.map +1 -0
- package/dist/turn-ledger.js +45 -0
- package/dist/turn-ledger.js.map +1 -0
- package/dist/types.d.ts +9 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/answer-image.d.ts +32 -0
- package/dist/ui/answer-image.d.ts.map +1 -0
- package/dist/ui/answer-image.js +50 -0
- package/dist/ui/answer-image.js.map +1 -0
- package/dist/ui/observations.d.ts +29 -5
- package/dist/ui/observations.d.ts.map +1 -1
- package/dist/ui/observations.js +122 -46
- package/dist/ui/observations.js.map +1 -1
- package/dist/ui/outline.d.ts +49 -0
- package/dist/ui/outline.d.ts.map +1 -0
- package/dist/ui/outline.js +229 -0
- package/dist/ui/outline.js.map +1 -0
- package/dist/ui/page-index.d.ts +74 -0
- package/dist/ui/page-index.d.ts.map +1 -0
- package/dist/ui/page-index.js +137 -0
- package/dist/ui/page-index.js.map +1 -0
- package/dist/ui/snapshot.d.ts +24 -12
- package/dist/ui/snapshot.d.ts.map +1 -1
- package/dist/ui/snapshot.js +34 -39
- package/dist/ui/snapshot.js.map +1 -1
- package/dist/ui/ui-sequence.d.ts +9 -9
- package/dist/ui/ui-sequence.d.ts.map +1 -1
- package/dist/ui/ui-sequence.js +2 -2
- package/dist/ui/ui-sequence.js.map +1 -1
- package/dist/ui/ui-tools.d.ts +64 -14
- package/dist/ui/ui-tools.d.ts.map +1 -1
- package/dist/ui/ui-tools.js +466 -92
- package/dist/ui/ui-tools.js.map +1 -1
- package/package.json +6 -6
package/dist/ui/ui-tools.js
CHANGED
|
@@ -1,6 +1,72 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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:
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
consequence:
|
|
48
|
-
...(
|
|
49
|
-
...(
|
|
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(() =>
|
|
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
|
-
|
|
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 ${
|
|
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.
|
|
66
|
-
//
|
|
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:
|
|
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 "${
|
|
85
|
-
'It may have run:
|
|
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 "${
|
|
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 "${
|
|
90
|
-
'It may or may not have run:
|
|
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
|
|
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(
|
|
98
|
-
const outcome = final && final !== 'running' ?
|
|
99
|
-
if (outcome
|
|
100
|
-
deps.onResult(outcome);
|
|
101
|
-
const answer = outcome
|
|
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(
|
|
104
|
-
...(
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
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(
|
|
171
|
-
const until = Math.min(Date.now() + (
|
|
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(
|
|
187
|
-
return (`"${
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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,
|
|
207
|
-
*
|
|
208
|
-
*
|
|
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 =
|
|
212
|
-
const
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
const
|
|
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
|
-
...(
|
|
227
|
-
...(
|
|
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
|
-
? {
|
|
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) =>
|
|
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
|
-
|
|
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
|