Skip to content

Commit 0da2ee3

Browse files
authored
Merge pull request #2663 from alectimison-maker/feat/adapter-workflow-schema
feat(adapters): add structured workflow profiles
2 parents c1a01e7 + dfdf15c commit 0da2ee3

5 files changed

Lines changed: 751 additions & 1 deletion

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/**
2+
* Optional machine-readable workflow metadata for site adapters.
3+
*
4+
* This module is deliberately browser-free so the schema can be validated in
5+
* Node and kept identical across Chrome and Firefox. Adapter notes remain the
6+
* model-facing guidance; workflow profiles are an additive contract for future
7+
* state-aware consumers.
8+
*/
9+
10+
export const ADAPTER_WORKFLOW_SCHEMA = 'webbrain-adapter-workflow/1';
11+
12+
export const ADAPTER_WORKFLOW_STATES = Object.freeze([
13+
'access_gate',
14+
'search',
15+
'selection',
16+
'review',
17+
'commit',
18+
'payment',
19+
'fulfillment',
20+
'after_sales',
21+
]);
22+
23+
const WORKFLOW_STATE_SET = new Set(ADAPTER_WORKFLOW_STATES);
24+
const WORKFLOW_FIELDS = new Set(['schema', 'states']);
25+
const STATE_FIELDS = new Set([
26+
'evidence',
27+
'readOnly',
28+
'requiresConfirmation',
29+
'terminalFor',
30+
]);
31+
const MAX_PROFILE_ITEMS = 16;
32+
const MAX_EVIDENCE_ITEMS = 8;
33+
const MAX_EVIDENCE_LENGTH = 240;
34+
35+
function isPlainObject(value) {
36+
return !!value && typeof value === 'object' && !Array.isArray(value);
37+
}
38+
39+
function invalid(error) {
40+
return { ok: false, error };
41+
}
42+
43+
function validateTokenList(value, field, pattern) {
44+
if (!Array.isArray(value) || value.length === 0) {
45+
return invalid(`\`${field}\` must be a non-empty array.`);
46+
}
47+
if (value.length > MAX_PROFILE_ITEMS) {
48+
return invalid(`\`${field}\` must contain at most ${MAX_PROFILE_ITEMS} items.`);
49+
}
50+
const seen = new Set();
51+
for (const item of value) {
52+
if (typeof item !== 'string' || item !== item.trim() || !pattern.test(item)) {
53+
return invalid(`\`${field}\` entries must be stable, trimmed identifiers.`);
54+
}
55+
const key = item.toLowerCase();
56+
if (seen.has(key)) return invalid(`\`${field}\` must not contain duplicate entries.`);
57+
seen.add(key);
58+
}
59+
return { ok: true };
60+
}
61+
62+
function validateEvidence(stateName, evidence) {
63+
if (!Array.isArray(evidence) || evidence.length === 0) {
64+
return invalid(`Workflow state \`${stateName}\` evidence must be a non-empty array.`);
65+
}
66+
if (evidence.length > MAX_EVIDENCE_ITEMS) {
67+
return invalid(`Workflow state \`${stateName}\` evidence must contain at most ${MAX_EVIDENCE_ITEMS} items.`);
68+
}
69+
const seen = new Set();
70+
for (const item of evidence) {
71+
if (typeof item !== 'string' || item !== item.trim() || !item || item.length > MAX_EVIDENCE_LENGTH) {
72+
return invalid(`Workflow state \`${stateName}\` evidence entries must be trimmed strings of 1-${MAX_EVIDENCE_LENGTH} characters.`);
73+
}
74+
const key = item.toLowerCase();
75+
if (seen.has(key)) {
76+
return invalid(`Workflow state \`${stateName}\` evidence must not contain duplicate entries.`);
77+
}
78+
seen.add(key);
79+
}
80+
return { ok: true };
81+
}
82+
83+
/**
84+
* Validate the optional structured portion of an adapter record.
85+
*
86+
* Existing adapters without workflow metadata remain valid. Once any of the
87+
* profile fields is present, regions, jobs, and workflow are all required so a
88+
* consumer never receives a partial profile.
89+
*/
90+
export function validateAdapterWorkflowProfile(adapter) {
91+
if (!isPlainObject(adapter)) return invalid('Adapter workflow profile must be an object.');
92+
93+
const hasProfile = adapter.regions !== undefined
94+
|| adapter.jobs !== undefined
95+
|| adapter.workflow !== undefined;
96+
if (!hasProfile) return { ok: true };
97+
98+
const regions = validateTokenList(adapter.regions, 'regions', /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/);
99+
if (!regions.ok) return regions;
100+
const jobs = validateTokenList(adapter.jobs, 'jobs', /^[a-z][a-z0-9-]{0,63}$/);
101+
if (!jobs.ok) return jobs;
102+
103+
const workflow = adapter.workflow;
104+
if (!isPlainObject(workflow)) return invalid('`workflow` must be an object.');
105+
if (workflow.schema !== ADAPTER_WORKFLOW_SCHEMA) {
106+
return invalid(`\`workflow.schema\` must be \`${ADAPTER_WORKFLOW_SCHEMA}\`.`);
107+
}
108+
for (const field of Object.keys(workflow)) {
109+
if (!WORKFLOW_FIELDS.has(field)) return invalid(`\`workflow\` has unknown field \`${field}\`.`);
110+
}
111+
if (!isPlainObject(workflow.states) || Object.keys(workflow.states).length === 0) {
112+
return invalid('`workflow.states` must be a non-empty object.');
113+
}
114+
115+
const knownJobs = new Set(adapter.jobs);
116+
const jobsWithTerminalState = new Set();
117+
for (const [stateName, state] of Object.entries(workflow.states)) {
118+
if (!WORKFLOW_STATE_SET.has(stateName)) {
119+
return invalid(`Unknown workflow state \`${stateName}\`.`);
120+
}
121+
if (!isPlainObject(state)) return invalid(`Workflow state \`${stateName}\` must be an object.`);
122+
123+
for (const field of Object.keys(state)) {
124+
if (!STATE_FIELDS.has(field)) {
125+
return invalid(`Workflow state \`${stateName}\` has unknown field \`${field}\`.`);
126+
}
127+
}
128+
129+
const evidence = validateEvidence(stateName, state.evidence);
130+
if (!evidence.ok) return evidence;
131+
132+
for (const field of ['readOnly', 'requiresConfirmation']) {
133+
if (state[field] !== undefined && typeof state[field] !== 'boolean') {
134+
return invalid(`Workflow state \`${stateName}\` field \`${field}\` must be boolean.`);
135+
}
136+
}
137+
if (state.readOnly === true && state.requiresConfirmation === true) {
138+
return invalid(`Workflow state \`${stateName}\` cannot be read-only and require confirmation.`);
139+
}
140+
if ((stateName === 'commit' || stateName === 'payment') && state.requiresConfirmation !== true) {
141+
return invalid(`Workflow state \`${stateName}\` must set requiresConfirmation to true.`);
142+
}
143+
144+
if (state.terminalFor !== undefined) {
145+
if (!Array.isArray(state.terminalFor) || state.terminalFor.length === 0) {
146+
return invalid(`Workflow state \`${stateName}\` terminalFor must be a non-empty array.`);
147+
}
148+
const seenTerminalJobs = new Set();
149+
for (const job of state.terminalFor) {
150+
if (typeof job !== 'string' || !knownJobs.has(job)) {
151+
return invalid(`Workflow state \`${stateName}\` terminalFor references unknown job \`${String(job)}\`.`);
152+
}
153+
if (seenTerminalJobs.has(job)) {
154+
return invalid(`Workflow state \`${stateName}\` terminalFor must not contain duplicate jobs.`);
155+
}
156+
seenTerminalJobs.add(job);
157+
jobsWithTerminalState.add(job);
158+
}
159+
}
160+
}
161+
162+
for (const job of adapter.jobs) {
163+
if (!jobsWithTerminalState.has(job)) {
164+
return invalid(`Workflow job \`${job}\` must have a successful terminal state with evidence.`);
165+
}
166+
}
167+
return { ok: true };
168+
}

src/chrome/src/agent/adapters.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import {
2+
ADAPTER_WORKFLOW_SCHEMA,
3+
validateAdapterWorkflowProfile,
4+
} from './adapter-workflow.js';
5+
16
/**
27
* Site Adapters — per-site notes the agent receives when operating on a
38
* known high-traffic site. The goal is NOT to encode every selector (those
@@ -10,6 +15,9 @@
1015
* - category: 'general' | 'finance' — finance gets an extra safety warning
1116
* - notes: short bulleted guidance, injected into the first user message
1217
* - fullPageCapture?.infiniteScroll(url): optional machine-readable capture policy
18+
* - regions?: stable region identifiers for structured adapter discovery
19+
* - jobs?: stable job identifiers covered by the optional workflow profile
20+
* - workflow?: versioned state, evidence, confirmation, and terminal metadata
1321
*
1422
* Keep notes SHORT (4–8 bullets max). They cost tokens on every first turn.
1523
* Only encode things the model can't trivially figure out from reading the page.
@@ -16427,6 +16435,46 @@ const ADAPTERS = [
1642716435
{
1642816436
name: 'railway-12306',
1642916437
category: 'general',
16438+
regions: ['CN'],
16439+
jobs: ['rail-booking'],
16440+
workflow: {
16441+
schema: ADAPTER_WORKFLOW_SCHEMA,
16442+
states: {
16443+
access_gate: {
16444+
readOnly: true,
16445+
evidence: ['A QR, SMS, identity, or anti-bot challenge is visible.'],
16446+
},
16447+
search: {
16448+
readOnly: true,
16449+
evidence: ['The departure station, arrival station, and travel date are visible.'],
16450+
},
16451+
selection: {
16452+
readOnly: true,
16453+
evidence: ['The selected train number, stations, date, and seat class are visible.'],
16454+
},
16455+
review: {
16456+
readOnly: true,
16457+
evidence: ['The passenger, ticket type, itinerary, seat class, and total are visible.'],
16458+
},
16459+
commit: {
16460+
requiresConfirmation: true,
16461+
evidence: ['An order number, queue result, or pending-order status is visible.'],
16462+
},
16463+
payment: {
16464+
requiresConfirmation: true,
16465+
evidence: ['The official payment page or payment status is visible.'],
16466+
},
16467+
fulfillment: {
16468+
readOnly: true,
16469+
evidence: ['An order number and successful paid or ticket-issued status are visible.'],
16470+
terminalFor: ['rail-booking'],
16471+
},
16472+
after_sales: {
16473+
requiresConfirmation: true,
16474+
evidence: ['The change or refund review and its terms are visible.'],
16475+
},
16476+
},
16477+
},
1643016478
matches: (url) => /^https?:\/\/(?:(?:www|kyfw|passport|epay|mobile|cx|dynamic|travel)\.)?12306\.cn\//.test(url),
1643116479
notes: `
1643216480
- Treat 12306.cn and its www, kyfw, passport, epay, mobile, cx, dynamic, and travel hosts as China Railway's official flow as of 2026-08. A step can hand off between them (for example kyfw to epay); that is still official, while any host outside 12306.cn is not. Start from the ticket form's "出发地", "到达地", and "出发日期" controls; choose the exact station when a city has multiple stations and re-read both endpoints after using the swap control.
@@ -17151,3 +17199,42 @@ export function getFullPageCapturePolicy(url) {
1715117199
export function listAdapters() {
1715217200
return ADAPTERS.map(a => ({ name: a.name, category: a.category }));
1715317201
}
17202+
17203+
/**
17204+
* List adapters that have migrated to the optional structured workflow schema.
17205+
* Invalid static metadata is a developer error and fails loudly here; ordinary
17206+
* adapter matching and notes injection remain unaffected.
17207+
*/
17208+
export function listAdapterWorkflowProfiles() {
17209+
const profiles = [];
17210+
for (const adapter of ADAPTERS) {
17211+
const hasProfile = adapter.regions !== undefined
17212+
|| adapter.jobs !== undefined
17213+
|| adapter.workflow !== undefined;
17214+
if (!hasProfile) continue;
17215+
17216+
const validation = validateAdapterWorkflowProfile(adapter);
17217+
if (!validation.ok) {
17218+
throw new Error(`Invalid workflow profile for adapter \`${adapter.name}\`: ${validation.error}`);
17219+
}
17220+
profiles.push({
17221+
name: adapter.name,
17222+
regions: [...adapter.regions],
17223+
jobs: [...adapter.jobs],
17224+
workflow: {
17225+
schema: adapter.workflow.schema,
17226+
states: Object.fromEntries(
17227+
Object.entries(adapter.workflow.states).map(([stateName, state]) => [
17228+
stateName,
17229+
{
17230+
...state,
17231+
evidence: [...state.evidence],
17232+
...(state.terminalFor === undefined ? {} : { terminalFor: [...state.terminalFor] }),
17233+
},
17234+
]),
17235+
),
17236+
},
17237+
});
17238+
}
17239+
return profiles;
17240+
}

0 commit comments

Comments
 (0)