surf-cli 2.19.0 → 2.20.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.
@@ -0,0 +1,398 @@
1
+ const crypto = require("node:crypto");
2
+ const { SEMANTIC_POLICY, find, verify } = require("./semantic-core.cjs");
3
+ const {
4
+ buildLogicalCandidates,
5
+ canonicalSameOriginDestination,
6
+ confirmedActionResponse,
7
+ expectedIdentity,
8
+ providerState,
9
+ semanticObservationFrom,
10
+ } = require("./semantic-cli.cjs");
11
+
12
+ const WORKFLOW_POLICY = Object.freeze({
13
+ defaultDeadlineMs: 60_000,
14
+ maxDeadlineMs: 120_000,
15
+ defaultProviderCalls: 32,
16
+ maxProviderCalls: 64,
17
+ defaultSearchObservations: 12,
18
+ maxSearchObservations: 32,
19
+ maxSteps: 32,
20
+ verificationMs: 5_000,
21
+ noProgressObservations: 2,
22
+ });
23
+
24
+ function failure(reason, detail = {}) {
25
+ return { kind: "failure", status: "blocked", reason, ...detail };
26
+ }
27
+
28
+ function success(status, detail = {}) {
29
+ return { kind: "success", status, ...detail };
30
+ }
31
+
32
+ function parseBoundary(response, label) {
33
+ if (response?.error) throw Object.assign(new Error(response.error.message || `${label} failed`), { code: response.error.code });
34
+ if (response?.result?.content) {
35
+ const text = response.result.content.find((item) => item.type === "text")?.text;
36
+ try { return JSON.parse(text); } catch { throw new Error(`browser returned an invalid ${label} response`); }
37
+ }
38
+ return response;
39
+ }
40
+
41
+ function samePinnedScope(left, right) {
42
+ return left.browserEpoch === right.browserEpoch && left.tabId === right.tabId && left.frameId === right.frameId;
43
+ }
44
+
45
+ function publicBinding(binding) {
46
+ return { handle: binding.handle, role: binding.candidate.role || null, name: binding.candidate.name || null, type: binding.candidate.type || null };
47
+ }
48
+
49
+ function scanCoverage(intervals, scrollHeight, truncated, invalidated = false) {
50
+ const validHeight = Number.isFinite(scrollHeight) && scrollHeight >= 0;
51
+ const validIntervals = validHeight && intervals.every(({ start, end }) =>
52
+ Number.isFinite(start) && Number.isFinite(end) && start >= 0 && start <= end && end <= scrollHeight);
53
+ const merged = [];
54
+ if (validIntervals) {
55
+ for (const interval of [...intervals].sort((left, right) => left.start - right.start || left.end - right.end)) {
56
+ const previous = merged.at(-1);
57
+ if (previous && interval.start <= previous.end) previous.end = Math.max(previous.end, interval.end);
58
+ else merged.push({ ...interval });
59
+ }
60
+ }
61
+ const atBottom = validIntervals && intervals.some(({ end }) => end >= scrollHeight);
62
+ const spansScope = merged.length === 1 && merged[0].start === 0 && merged[0].end >= scrollHeight;
63
+ return { intervals, atBottom, complete: atBottom && spansScope && !truncated && !invalidated, truncated };
64
+ }
65
+
66
+ function createSemanticWorkflowRuntime(dependencies) {
67
+ const { request, evaluate, attemptStore = null, createAttemptStore, now = () => Date.now(), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = dependencies;
68
+ if (typeof request !== "function" || typeof evaluate !== "function") throw new TypeError("semantic workflow requires request and evaluate boundaries");
69
+
70
+ function createContext(options = {}) {
71
+ const deadlineMs = options.deadlineMs ?? WORKFLOW_POLICY.defaultDeadlineMs;
72
+ const maxProviderCalls = options.maxProviderCalls ?? WORKFLOW_POLICY.defaultProviderCalls;
73
+ if (!Number.isInteger(deadlineMs) || deadlineMs < 1 || deadlineMs > WORKFLOW_POLICY.maxDeadlineMs) throw new Error("invalid semantic workflow deadline");
74
+ if (!Number.isInteger(maxProviderCalls) || maxProviderCalls < 1 || maxProviderCalls > WORKFLOW_POLICY.maxProviderCalls) throw new Error("invalid semantic provider-call limit");
75
+ const inputs = { ...(options.inputs || {}) };
76
+ if (Object.keys(inputs).length > SEMANTIC_POLICY.limits.inputSlots) throw new Error("semantic input-slot limit exceeded");
77
+ for (const value of Object.values(inputs)) {
78
+ if (Buffer.byteLength(String(value), "utf8") > SEMANTIC_POLICY.limits.inputValueBytes) throw new Error("semantic input value exceeds its byte limit");
79
+ }
80
+ const runId = options.runId || crypto.randomUUID();
81
+ const workflowDigest = options.workflowDigest || "unknown";
82
+ return {
83
+ runId, workflowDigest,
84
+ deadline: now() + deadlineMs, maxProviderCalls, bindings: new Map(), inputs,
85
+ limits: { deadlineMs, maxProviderCalls },
86
+ pinned: null, acquired: false, steps: 0, completedSteps: [],
87
+ attemptStore: attemptStore || createAttemptStore?.({ runId, workflowDigest }),
88
+ usage: { providerCalls: 0, inputTokens: 0, outputTokens: 0, partial: false, browserCommands: 0, semanticDecisions: 0, observations: 0 },
89
+ };
90
+ }
91
+
92
+ const remaining = (context) => Math.max(0, context.deadline - now());
93
+ async function browser(context, tool, args) {
94
+ if (remaining(context) < 1) throw Object.assign(new Error("semantic workflow deadline exhausted"), { code: "budget_exhausted" });
95
+ context.usage.browserCommands++;
96
+ return request(tool, args, remaining(context), context.pinned);
97
+ }
98
+ async function observe(context) {
99
+ const observation = semanticObservationFrom(await browser(context, "page.read", { semanticObservation: true }));
100
+ context.usage.observations++;
101
+ if (!context.pinned) context.pinned = { ...observation.identity };
102
+ else if (!samePinnedScope(context.pinned, observation.identity)) throw Object.assign(new Error("pinned browser scope changed"), { code: "stale_identity" });
103
+ return observation;
104
+ }
105
+ async function evaluator(context, state, questions, options = {}) {
106
+ if (++context.usage.providerCalls > context.maxProviderCalls) throw Object.assign(new Error("semantic provider-call budget exhausted"), { code: "budget_exhausted" });
107
+ if (remaining(context) < 1) throw Object.assign(new Error("semantic workflow deadline exhausted"), { code: "budget_exhausted" });
108
+ context.usage.semanticDecisions++;
109
+ const timeoutMs = Math.min(SEMANTIC_POLICY.timeoutMs, remaining(context));
110
+ const controller = new AbortController();
111
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
112
+ try {
113
+ const response = await evaluate(state, questions, { ...options, signal: controller.signal, timeoutMs });
114
+ context.model = response.model;
115
+ context.usage.inputTokens += response.usage?.input_tokens || 0;
116
+ context.usage.outputTokens += response.usage?.output_tokens || 0;
117
+ return response;
118
+ } catch (error) {
119
+ context.usage.partial = true;
120
+ throw error;
121
+ } finally {
122
+ clearTimeout(timer);
123
+ }
124
+ }
125
+ async function decide(context, observation, query, target, binding) {
126
+ const state = providerState(observation);
127
+ const eligibleRefs = new Set(observation.candidates.filter((candidate) => {
128
+ if ((target.role && candidate.role !== target.role) || (target.type && candidate.type !== target.type)) return false;
129
+ if (!binding) return true;
130
+ return candidate.role === binding.candidate.role &&
131
+ candidate.type === binding.candidate.type &&
132
+ candidate.name === binding.candidate.name &&
133
+ canonicalSameOriginDestination(candidate, observation.identity.fullUrl) ===
134
+ canonicalSameOriginDestination(binding.candidate, binding.fullUrl);
135
+ }).map((candidate) => candidate.ref));
136
+ const candidates = buildLogicalCandidates(observation, state.candidates).flatMap((candidate) => {
137
+ const concreteCandidates = candidate.concreteCandidates.filter((item) => eligibleRefs.has(item.id));
138
+ return concreteCandidates.length ? [{ ...candidate, concreteCandidates }] : [];
139
+ });
140
+ if (!candidates.length) return null;
141
+ const result = await find({
142
+ state: {
143
+ ...state,
144
+ candidates: candidates.map(({ id, role, name, type, text }) => ({ id, role, name, type, text })),
145
+ },
146
+ goal: query,
147
+ candidates,
148
+ evaluate: (s, q, o) => evaluator(context, s, q, o),
149
+ });
150
+ return {
151
+ ...result,
152
+ candidate: result.candidate?.concreteCandidates[0] || null,
153
+ };
154
+ }
155
+ async function scrollGeometry(context, action, scopeToken, identity) {
156
+ const value = parseBoundary(await browser(context, "semantic.scrollScope", {
157
+ action, scopeToken, semanticExpectedIdentity: identity,
158
+ ...(action === "advance" ? { maxFraction: 0.75 } : {}),
159
+ }), "scroll scope");
160
+ if (value?.success !== true || !value.geometry || typeof value.scopeToken !== "string") throw Object.assign(new Error(value?.reason || "scroll scope unavailable"), { code: value?.reason || "unsupported_control" });
161
+ if (scopeToken && value.scopeToken !== scopeToken) throw Object.assign(new Error("scroll scope changed"), { code: "stale_scroll_scope" });
162
+ return value;
163
+ }
164
+ async function resolve(context, target, write, search = {}) {
165
+ const binding = target?.binding ? context.bindings.get(target.binding) : null;
166
+ if (target?.binding && !binding) return { error: failure("invalid_binding") };
167
+ const query = binding?.query || target?.query;
168
+ if (typeof query !== "string" || !query.trim()) return { error: failure("validation_failure") };
169
+ const maximum = search.maxObservations ?? WORKFLOW_POLICY.defaultSearchObservations;
170
+ if (!Number.isInteger(maximum) || maximum < 1 || maximum > WORKFLOW_POLICY.maxSearchObservations) return { error: failure("validation_failure") };
171
+ let observation = await observe(context);
172
+ let scope = await scrollGeometry(context, "inspect", undefined, observation.identity);
173
+ const inspectedGeometry = scope.geometry;
174
+ scope = await scrollGeometry(context, "top", scope.scopeToken, observation.identity);
175
+ const initialObservationStillVisible = inspectedGeometry.intervalStart === scope.geometry.intervalStart &&
176
+ inspectedGeometry.intervalEnd === scope.geometry.intervalEnd &&
177
+ inspectedGeometry.scrollHeight === scope.geometry.scrollHeight;
178
+ const intervals = [];
179
+ const scopedScrollHeight = scope.geometry.scrollHeight;
180
+ let noProgress = 0;
181
+ let truncated = false;
182
+ let invalidated = false;
183
+ for (let index = 0; index < maximum; index++) {
184
+ if (index || !initialObservationStillVisible) observation = await observe(context);
185
+ truncated ||= Number(observation.omitted?.candidates || 0) + Number(observation.omitted?.chunks || 0) > 0;
186
+ const geometry = scope.geometry;
187
+ intervals.push({ start: geometry.intervalStart, end: geometry.intervalEnd });
188
+ invalidated ||= geometry.scrollHeight !== scopedScrollHeight;
189
+ const coverage = scanCoverage(intervals, geometry.scrollHeight, truncated, invalidated);
190
+ const decision = await decide(context, observation, query, target, binding);
191
+ if (decision?.candidate &&
192
+ (!target.role || decision.candidate.role === target.role) &&
193
+ (!target.type || decision.candidate.type === target.type)) {
194
+ if (!write || decision.decision.probability >= SEMANTIC_POLICY.thresholds.write) {
195
+ const concrete = observation.candidates.find((item) => item.ref === decision.candidate.id);
196
+ if (binding) {
197
+ const priorDestination = canonicalSameOriginDestination(binding.candidate, binding.fullUrl);
198
+ const nextDestination = canonicalSameOriginDestination(concrete, observation.identity.fullUrl);
199
+ if (concrete.role !== binding.candidate.role || concrete.type !== binding.candidate.type || concrete.name !== binding.candidate.name || priorDestination !== nextDestination) {
200
+ return { error: failure("ambiguous_target") };
201
+ }
202
+ }
203
+ return { observation, candidate: concrete, query, decision, coverage };
204
+ }
205
+ return { error: failure("low_confidence", { probability: decision.decision.probability, appliedThreshold: SEMANTIC_POLICY.thresholds.write }) };
206
+ }
207
+ if (coverage.atBottom) return { error: failure(coverage.complete ? "target_not_found" : "incomplete_search", { coverage }) };
208
+ if (index + 1 === maximum) return { error: failure("incomplete_search", { coverage }) };
209
+ const next = await scrollGeometry(context, "advance", scope.scopeToken, observation.identity);
210
+ const progressed = next.geometry.intervalStart > geometry.intervalStart && next.geometry.intervalStart <= geometry.intervalEnd;
211
+ noProgress = progressed ? 0 : noProgress + 1;
212
+ if (noProgress >= WORKFLOW_POLICY.noProgressObservations) return { error: failure("no_progress", { coverage }) };
213
+ scope = next;
214
+ }
215
+ return { error: failure("incomplete_search") };
216
+ }
217
+ async function compare(context, observation, candidate, predicate) {
218
+ return parseBoundary(await browser(context, "semantic.localCompare", {
219
+ ref: candidate.ref, predicate, semanticExpectedIdentity: expectedIdentity(observation, candidate),
220
+ }), "local comparison");
221
+ }
222
+ function localPredicate(context, predicate) {
223
+ if (!predicate || typeof predicate !== "object") return predicate;
224
+ let expected = predicate.expected ?? predicate.equals ?? predicate.contains;
225
+ if (predicate.input !== undefined) expected = context.inputs[predicate.input];
226
+ return {
227
+ kind: predicate.kind === "textExact" ? "textEquals" : predicate.kind,
228
+ ...(expected !== undefined ? { expected: predicate.kind === "checkedEquals" ? Boolean(expected) : String(expected) } : {}),
229
+ };
230
+ }
231
+ async function verifyPredicate(context, observation, candidate, predicate) {
232
+ predicate = localPredicate(context, predicate);
233
+ const until = Math.min(context.deadline, now() + WORKFLOW_POLICY.verificationMs);
234
+ const fullUrl = observation.identity.fullUrl;
235
+ do {
236
+ const result = await compare(context, observation, candidate, predicate);
237
+ if (result?.success === true && result.matches === true) return true;
238
+ if (now() >= until) return false;
239
+ await sleep(Math.min(100, until - now()));
240
+ const refreshed = await observe(context);
241
+ if (refreshed.identity.fullUrl !== fullUrl) return false;
242
+ const matching = refreshed.candidates.filter((item) =>
243
+ item.role === candidate.role && item.type === candidate.type && item.name === candidate.name);
244
+ if (matching.length !== 1) return false;
245
+ observation = refreshed;
246
+ candidate = matching[0];
247
+ } while (remaining(context) > 0);
248
+ return false;
249
+ }
250
+ async function verifyExpectation(context, resolved, expectation) {
251
+ if (expectation?.mode === "semantic") {
252
+ const observation = await observe(context);
253
+ const state = providerState(observation);
254
+ const result = await verify({
255
+ state,
256
+ outcome: expectation.claim,
257
+ evidence: state.chunks,
258
+ evaluate: (s, q, o) => evaluator(context, s, q, o),
259
+ });
260
+ return result.status === "satisfied";
261
+ }
262
+ if (expectation?.kind === "urlPath") {
263
+ const observation = await observe(context);
264
+ return new URL(observation.identity.fullUrl).pathname === expectation.equals;
265
+ }
266
+ if (expectation?.target) {
267
+ const expectedTarget = await resolve(context, expectation.target, false, { maxObservations: 1 });
268
+ if (expectedTarget.error) return false;
269
+ return verifyPredicate(context, expectedTarget.observation, expectedTarget.candidate, expectation);
270
+ }
271
+ return verifyPredicate(context, resolved.observation, resolved.candidate, expectation);
272
+ }
273
+ async function storeCall(context, name, ...args) {
274
+ if (!context.attemptStore?.[name]) throw new Error(`attempt store does not implement ${name}`);
275
+ return context.attemptStore[name](...args);
276
+ }
277
+ async function mutate(context, step, resolved, action, predicate, value) {
278
+ const record = {
279
+ stepId: step.id,
280
+ operation: step.op,
281
+ target: {
282
+ role: resolved.candidate.role,
283
+ ...(resolved.candidate.name ? { name: resolved.candidate.name } : {}),
284
+ },
285
+ budgets: { remainingMs: remaining(context) },
286
+ };
287
+ let attempt;
288
+ try { attempt = await storeCall(context, "reserve", record); await storeCall(context, "dispatchIntent", attempt.attemptId); }
289
+ catch { return failure("checkpoint_failure", { write: { state: "not_dispatched", replayAllowed: false } }); }
290
+ try {
291
+ const args = action === "fill"
292
+ ? { data: [{ ref: resolved.candidate.ref, value }], semanticExpectedIdentity: expectedIdentity(resolved.observation, resolved.candidate) }
293
+ : { ref: resolved.candidate.ref, semanticExpectedIdentity: expectedIdentity(resolved.observation, resolved.candidate) };
294
+ const response = await browser(context, action === "fill" ? "form.fill" : "click", args);
295
+ confirmedActionResponse(response);
296
+ } catch {
297
+ try { await storeCall(context, "terminal", attempt.attemptId, "outcome_unknown"); }
298
+ catch { return failure("checkpoint_failure", { write: { state: "dispatch_unknown", replayAllowed: false } }); }
299
+ return failure("outcome_unknown", { write: { state: "dispatch_unknown", replayAllowed: false } });
300
+ }
301
+ let verified;
302
+ try { verified = predicate ? await verifyExpectation(context, resolved, predicate) : false; }
303
+ catch (error) {
304
+ try { await storeCall(context, "terminal", attempt.attemptId, "acknowledged_unverified"); }
305
+ catch { return failure("checkpoint_failure", { write: { state: "acknowledged_unverified", replayAllowed: false } }); }
306
+ return failure(error.code || "verification_failed", { write: { state: "acknowledged_unverified", replayAllowed: false } });
307
+ }
308
+ try { await storeCall(context, "terminal", attempt.attemptId, verified ? "verified" : "acknowledged_unverified"); }
309
+ catch { return failure("checkpoint_failure", { write: { state: verified ? "acknowledged_verified" : "acknowledged_unverified", replayAllowed: false } }); }
310
+ return verified ? success("verified", { write: { state: "acknowledged_verified", replayAllowed: false } }) : failure("assertion_mismatch", { write: { state: "acknowledged_unverified", replayAllowed: false } });
311
+ }
312
+
313
+ async function executeStepInner(step, context) {
314
+ if (!context || !(context.bindings instanceof Map)) throw new TypeError("invalid semantic workflow context");
315
+ if (++context.steps > WORKFLOW_POLICY.maxSteps) return failure("budget_exhaustion");
316
+ if (remaining(context) < 1) return failure("budget_exhaustion");
317
+ if (!context.acquired && context.attemptStore) { await context.attemptStore.acquire(); context.acquired = true; }
318
+ try {
319
+ if (step.op === "find") {
320
+ const resolved = await resolve(context, step.target, false, step.search);
321
+ if (resolved.error) return resolved.error;
322
+ const handle = step.as || step.id;
323
+ const binding = { handle, query: resolved.query, candidate: resolved.candidate, fullUrl: resolved.observation.identity.fullUrl };
324
+ context.bindings.set(handle, binding);
325
+ return success("completed", { binding: publicBinding(binding), coverage: resolved.coverage, probability: resolved.decision.decision.probability });
326
+ }
327
+ if (step.op === "open") {
328
+ const resolved = await resolve(context, step.target, false);
329
+ if (resolved.error) return resolved.error;
330
+ const url = canonicalSameOriginDestination(resolved.candidate, resolved.observation.identity.fullUrl);
331
+ if (!url) return failure("unsupported_control");
332
+ confirmedActionResponse(await browser(context, "navigate", { url, semanticExpectedIdentity: resolved.observation.identity }));
333
+ const arrived = await observe(context);
334
+ return new URL(arrived.identity.fullUrl).href === url ? success("verified") : failure("assertion_mismatch");
335
+ }
336
+ if (step.op === "assert" && step.mode === "semantic") {
337
+ const observation = await observe(context);
338
+ const state = providerState(observation);
339
+ const result = await verify({ state, outcome: step.claim, evidence: state.chunks, evaluate: (s, q, o) => evaluator(context, s, q, o) });
340
+ return result.status === "satisfied" ? success("verified", { probability: result.decision.probability }) : failure("assertion_mismatch", { semanticStatus: result.status });
341
+ }
342
+ if (step.op === "assert") {
343
+ const resolved = await resolve(context, step.target || step.predicate?.target, false, { maxObservations: 1 });
344
+ if (resolved.error) return resolved.error;
345
+ return await verifyPredicate(context, resolved.observation, resolved.candidate, step.predicate) ? success("verified") : failure("assertion_mismatch");
346
+ }
347
+ const resolved = await resolve(context, step.target, true, step.search);
348
+ if (resolved.error) return resolved.error;
349
+ if (step.op === "ensureChecked") {
350
+ const predicate = { kind: "checkedEquals", expected: step.checked };
351
+ const comparison = await compare(context, resolved.observation, resolved.candidate, predicate);
352
+ if (comparison?.success !== true) return failure(comparison?.reason || "unsupported_control");
353
+ if (comparison.matches === true) return success("skipped_already_satisfied");
354
+ return await mutate(context, step, resolved, "click", predicate);
355
+ }
356
+ if (step.op === "fill") {
357
+ if (!Object.hasOwn(context.inputs, step.input)) return failure("validation_failure");
358
+ const value = String(context.inputs[step.input]);
359
+ const predicate = { kind: "valueEquals", expected: value };
360
+ const comparison = await compare(context, resolved.observation, resolved.candidate, predicate);
361
+ if (comparison?.success !== true) return failure(comparison?.reason || "unsupported_control");
362
+ if (comparison.matches === true) return success("skipped_already_satisfied");
363
+ return await mutate(context, step, resolved, "fill", predicate, value);
364
+ }
365
+ if (step.op === "click") {
366
+ if (!step.expect) return failure("validation_failure");
367
+ return await mutate(context, step, resolved, "click", step.expect);
368
+ }
369
+ return failure("validation_failure");
370
+ } catch (error) {
371
+ return failure(error.code === "budget_exhausted" ? "budget_exhaustion" : (error.code || "provider_error"));
372
+ }
373
+ }
374
+ async function executeStep(step, context) {
375
+ const result = await executeStepInner(step, context);
376
+ if (result.kind === "success") context.completedSteps.push(step.id);
377
+ return result;
378
+ }
379
+ async function closeContext(context, terminal = {}) {
380
+ if (!context?.acquired || !context.attemptStore) return;
381
+ try {
382
+ await context.attemptStore.checkpoint({
383
+ completedSteps: context.completedSteps,
384
+ reason: terminal.reason || null,
385
+ budgets: { remainingMs: remaining(context) },
386
+ });
387
+ } finally {
388
+ await context.attemptStore.release({
389
+ state: terminal.state || (terminal.reason ? "failed" : "completed"),
390
+ reason: terminal.reason,
391
+ });
392
+ context.acquired = false;
393
+ }
394
+ }
395
+ return { closeContext, createContext, executeStep };
396
+ }
397
+
398
+ module.exports = { WORKFLOW_POLICY, createSemanticWorkflowRuntime };
@@ -43,6 +43,7 @@ const TAB_TOOLS = new Set([
43
43
  "click", "left_click", "right_click", "double_click", "triple_click", "drag", "hover", "key", "submit",
44
44
  "type", "smart_type", "find_and_type", "form_input", "form.fill", "select", "upload", "upload_image",
45
45
  "scroll", "scroll.top", "scroll.bottom", "scroll.to", "scroll.info", "scroll_to_position",
46
+ "semantic.localCompare", "semantic.scrollScope",
46
47
  "search", "locate.role", "locate.text", "locate.label", "element.styles",
47
48
  "js", "javascript_tool", "eval",
48
49
  "wait.element", "wait.url", "wait.network", "wait.dom", "wait.load", "wait.ready", "page.readiness", "health",
@@ -280,7 +280,126 @@ function normalizeStep(step) {
280
280
  }
281
281
  const cmd = step.tool || step.cmd;
282
282
  if (typeof cmd !== "string" || !cmd) throw new Error("workflow step must have a 'tool' field");
283
- return { cmd: ALIASES[cmd] || cmd, args: step.args || {}, ...(step.as ? { as: step.as } : {}) };
283
+ return { cmd: ALIASES[cmd] || cmd, args: step.args || {}, ...(step.id ? { id: step.id } : {}), ...(step.as ? { as: step.as } : {}) };
284
+ }
285
+
286
+ const SEMANTIC_OPS = new Set(["find", "open", "ensureChecked", "fill", "click", "assert"]);
287
+ const SEMANTIC_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
288
+ const SEMANTIC_STEP_FIELDS = new Set(["id", "tool", "args", "as"]);
289
+ const SEMANTIC_ARG_FIELDS = {
290
+ find: new Set(["op", "target", "search"]),
291
+ open: new Set(["op", "target", "expect"]),
292
+ ensureChecked: new Set(["op", "target", "checked", "expect"]),
293
+ fill: new Set(["op", "target", "input", "expect"]),
294
+ click: new Set(["op", "target", "expect"]),
295
+ assert: new Set(["op", "mode", "claim", "bindings", "target", "predicate"]),
296
+ };
297
+
298
+ function assertClosedObject(value, allowed, path) {
299
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object`);
300
+ for (const key of Object.keys(value)) {
301
+ if (!allowed.has(key)) throw new Error(`${path}.${key} is not allowed`);
302
+ }
303
+ }
304
+
305
+ function assertSemanticName(value, path) {
306
+ if (typeof value !== "string" || !SEMANTIC_ID.test(value)) {
307
+ throw new Error(`${path} must be a 1-64 character identifier`);
308
+ }
309
+ }
310
+
311
+ function validateTarget(target, path, { queryOnly = false } = {}) {
312
+ assertClosedObject(target, new Set(["query", "role", "type", "binding"]), path);
313
+ const hasQuery = typeof target.query === "string" && target.query.length > 0;
314
+ const hasBinding = typeof target.binding === "string" && target.binding.length > 0;
315
+ if (hasQuery === hasBinding || (queryOnly && !hasQuery)) {
316
+ throw new Error(`${path} must contain exactly one of 'query' or 'binding'${queryOnly ? " (query required)" : ""}`);
317
+ }
318
+ if (hasBinding && (target.role !== undefined || target.type !== undefined)) {
319
+ throw new Error(`${path} binding cannot include role or type`);
320
+ }
321
+ if (target.role !== undefined && (typeof target.role !== "string" || !target.role)) throw new Error(`${path}.role must be a non-empty string`);
322
+ if (target.type !== undefined && (typeof target.type !== "string" || !target.type)) throw new Error(`${path}.type must be a non-empty string`);
323
+ }
324
+
325
+ function validateExpectation(value, path) {
326
+ assertClosedObject(value, new Set(["kind", "mode", "target", "equals", "contains", "input", "binding", "claim"]), path);
327
+ const kinds = new Set(["urlPath", "visible", "checkedEquals", "valueEquals", "textExact", "textContains"]);
328
+ if (value.mode === "semantic") {
329
+ if (typeof value.claim !== "string" || !value.claim) throw new Error(`${path}.claim must be a non-empty string`);
330
+ } else if (!kinds.has(value.kind)) throw new Error(`${path}.kind is invalid`);
331
+ if (value.target !== undefined) validateTarget(value.target, `${path}.target`);
332
+ }
333
+
334
+ function validateSemanticStep(step, index, priorBindings, ids, policy) {
335
+ const path = `steps[${index}]`;
336
+ assertClosedObject(step, SEMANTIC_STEP_FIELDS, path);
337
+ if (step.tool !== "semantic.step") throw new Error(`${path}.tool must be 'semantic.step'`);
338
+ assertSemanticName(step.id, `${path}.id`);
339
+ if (ids.has(step.id)) throw new Error(`${path}.id must be unique`);
340
+ ids.add(step.id);
341
+ if (step.as !== undefined) {
342
+ assertSemanticName(step.as, `${path}.as`);
343
+ if (priorBindings.has(step.as)) throw new Error(`${path}.as must be unique`);
344
+ }
345
+ if (!step.args || typeof step.args !== "object" || Array.isArray(step.args)) throw new Error(`${path}.args must be an object`);
346
+ const op = step.args.op;
347
+ if (!SEMANTIC_OPS.has(op)) throw new Error(`${path}.args.op is invalid`);
348
+ assertClosedObject(step.args, SEMANTIC_ARG_FIELDS[op], `${path}.args`);
349
+
350
+ if (op === "find") {
351
+ validateTarget(step.args.target, `${path}.args.target`, { queryOnly: true });
352
+ if (step.args.search !== undefined) {
353
+ assertClosedObject(step.args.search, new Set(["mode", "maxObservations"]), `${path}.args.search`);
354
+ if (step.args.search.mode !== "scroll") throw new Error(`${path}.args.search.mode must be 'scroll'`);
355
+ if (step.args.search.maxObservations !== undefined && (!Number.isInteger(step.args.search.maxObservations) || step.args.search.maxObservations < 1 || step.args.search.maxObservations > policy.maxSearchObservations)) {
356
+ throw new Error(`${path}.args.search.maxObservations must be an integer from 1 to ${policy.maxSearchObservations}`);
357
+ }
358
+ }
359
+ } else if (op !== "assert") validateTarget(step.args.target, `${path}.args.target`);
360
+
361
+ if (op === "ensureChecked" && typeof step.args.checked !== "boolean") throw new Error(`${path}.args.checked must be a boolean`);
362
+ if (op === "fill") assertSemanticName(step.args.input, `${path}.args.input`);
363
+ if (op === "click" && step.args.expect === undefined) throw new Error(`${path}.args.expect is required`);
364
+ if (step.args.expect !== undefined) validateExpectation(step.args.expect, `${path}.args.expect`);
365
+ if (op === "assert") {
366
+ if (step.args.mode !== "local" && step.args.mode !== "semantic") throw new Error(`${path}.args.mode must be 'local' or 'semantic'`);
367
+ if (step.args.mode === "semantic" && (typeof step.args.claim !== "string" || !step.args.claim)) throw new Error(`${path}.args.claim must be a non-empty string`);
368
+ if (step.args.mode === "local") validateExpectation(step.args.predicate, `${path}.args.predicate`);
369
+ if (step.args.target !== undefined) validateTarget(step.args.target, `${path}.args.target`);
370
+ }
371
+
372
+ const references = [];
373
+ if (step.args.target?.binding) references.push(step.args.target.binding);
374
+ if (step.args.expect?.target?.binding) references.push(step.args.expect.target.binding);
375
+ if (step.args.expect?.binding) references.push(step.args.expect.binding);
376
+ if (step.args.predicate?.target?.binding) references.push(step.args.predicate.target.binding);
377
+ if (step.args.predicate?.binding) references.push(step.args.predicate.binding);
378
+ if (Array.isArray(step.args.bindings)) references.push(...step.args.bindings);
379
+ else if (step.args.bindings !== undefined) throw new Error(`${path}.args.bindings must be an array`);
380
+ for (const binding of references) {
381
+ assertSemanticName(binding, `${path} binding`);
382
+ if (!priorBindings.has(binding)) throw new Error(`${path} binding '${binding}' must refer to a prior step output`);
383
+ }
384
+ if (step.as !== undefined) {
385
+ priorBindings.add(step.as);
386
+ }
387
+ }
388
+
389
+ function validateSemanticWorkflow(workflow) {
390
+ const { WORKFLOW_POLICY } = require("./semantic-workflow.cjs");
391
+ assertClosedObject(workflow.semantic, new Set(["version", "deadlineMs", "maxProviderCalls"]), "semantic");
392
+ if (workflow.semantic.version !== 1) throw new Error("semantic.version must equal 1");
393
+ if (workflow.steps.length > WORKFLOW_POLICY.maxSteps) throw new Error(`semantic workflows support at most ${WORKFLOW_POLICY.maxSteps} steps`);
394
+ if (workflow.semantic.deadlineMs !== undefined && (!Number.isInteger(workflow.semantic.deadlineMs) || workflow.semantic.deadlineMs < 1 || workflow.semantic.deadlineMs > WORKFLOW_POLICY.maxDeadlineMs)) {
395
+ throw new Error(`semantic.deadlineMs must be an integer from 1 to ${WORKFLOW_POLICY.maxDeadlineMs}`);
396
+ }
397
+ if (workflow.semantic.maxProviderCalls !== undefined && (!Number.isInteger(workflow.semantic.maxProviderCalls) || workflow.semantic.maxProviderCalls < 1 || workflow.semantic.maxProviderCalls > WORKFLOW_POLICY.maxProviderCalls)) {
398
+ throw new Error(`semantic.maxProviderCalls must be an integer from 1 to ${WORKFLOW_POLICY.maxProviderCalls}`);
399
+ }
400
+ const priorBindings = new Set();
401
+ const ids = new Set();
402
+ workflow.steps.forEach((step, index) => validateSemanticStep(step, index, priorBindings, ids, WORKFLOW_POLICY));
284
403
  }
285
404
 
286
405
  function normalizeWorkflow(workflow) {
@@ -290,6 +409,11 @@ function normalizeWorkflow(workflow) {
290
409
  if (workflow.args !== undefined && (!workflow.args || typeof workflow.args !== "object" || Array.isArray(workflow.args))) {
291
410
  throw new Error("'args' must be an object");
292
411
  }
412
+ const hasSemanticSteps = workflow.steps.some((step) => step?.tool === "semantic.step" || step?.cmd === "semantic.step");
413
+ if (workflow.semantic !== undefined || hasSemanticSteps) {
414
+ if (workflow.semantic === undefined) throw new Error("semantic workflows require semantic.version=1");
415
+ validateSemanticWorkflow(workflow);
416
+ }
293
417
  return { ...workflow, args: workflow.args || {}, steps: workflow.steps.map(normalizeStep) };
294
418
  }
295
419