Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions src/chrome/src/agent/adapter-workflow.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* Optional machine-readable workflow metadata for site adapters.
*
* This module is deliberately browser-free so the schema can be validated in
* Node and kept identical across Chrome and Firefox. Adapter notes remain the
* model-facing guidance; workflow profiles are an additive contract for future
* state-aware consumers.
*/

export const ADAPTER_WORKFLOW_SCHEMA = 'webbrain-adapter-workflow/1';

export const ADAPTER_WORKFLOW_STATES = Object.freeze([
'access_gate',
'search',
'selection',
'review',
'commit',
'payment',
'fulfillment',
'after_sales',
]);

const WORKFLOW_STATE_SET = new Set(ADAPTER_WORKFLOW_STATES);
const WORKFLOW_FIELDS = new Set(['schema', 'states']);
const STATE_FIELDS = new Set([
'evidence',
'readOnly',
'requiresConfirmation',
'terminalFor',
]);
const MAX_PROFILE_ITEMS = 16;
const MAX_EVIDENCE_ITEMS = 8;
const MAX_EVIDENCE_LENGTH = 240;

function isPlainObject(value) {
return !!value && typeof value === 'object' && !Array.isArray(value);
}

function invalid(error) {
return { ok: false, error };
}

function validateTokenList(value, field, pattern) {
if (!Array.isArray(value) || value.length === 0) {
return invalid(`\`${field}\` must be a non-empty array.`);
}
if (value.length > MAX_PROFILE_ITEMS) {
return invalid(`\`${field}\` must contain at most ${MAX_PROFILE_ITEMS} items.`);
}
const seen = new Set();
for (const item of value) {
if (typeof item !== 'string' || item !== item.trim() || !pattern.test(item)) {
return invalid(`\`${field}\` entries must be stable, trimmed identifiers.`);
}
const key = item.toLowerCase();
if (seen.has(key)) return invalid(`\`${field}\` must not contain duplicate entries.`);
seen.add(key);
}
return { ok: true };
}

function validateEvidence(stateName, evidence) {
if (!Array.isArray(evidence) || evidence.length === 0) {
return invalid(`Workflow state \`${stateName}\` evidence must be a non-empty array.`);
}
if (evidence.length > MAX_EVIDENCE_ITEMS) {
return invalid(`Workflow state \`${stateName}\` evidence must contain at most ${MAX_EVIDENCE_ITEMS} items.`);
}
const seen = new Set();
for (const item of evidence) {
if (typeof item !== 'string' || item !== item.trim() || !item || item.length > MAX_EVIDENCE_LENGTH) {
return invalid(`Workflow state \`${stateName}\` evidence entries must be trimmed strings of 1-${MAX_EVIDENCE_LENGTH} characters.`);
}
const key = item.toLowerCase();
if (seen.has(key)) {
return invalid(`Workflow state \`${stateName}\` evidence must not contain duplicate entries.`);
}
seen.add(key);
}
return { ok: true };
}

/**
* Validate the optional structured portion of an adapter record.
*
* Existing adapters without workflow metadata remain valid. Once any of the
* profile fields is present, regions, jobs, and workflow are all required so a
* consumer never receives a partial profile.
*/
export function validateAdapterWorkflowProfile(adapter) {
if (!isPlainObject(adapter)) return invalid('Adapter workflow profile must be an object.');

const hasProfile = adapter.regions !== undefined
|| adapter.jobs !== undefined
|| adapter.workflow !== undefined;
if (!hasProfile) return { ok: true };

const regions = validateTokenList(adapter.regions, 'regions', /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/);
if (!regions.ok) return regions;
const jobs = validateTokenList(adapter.jobs, 'jobs', /^[a-z][a-z0-9-]{0,63}$/);
if (!jobs.ok) return jobs;

const workflow = adapter.workflow;
if (!isPlainObject(workflow)) return invalid('`workflow` must be an object.');
if (workflow.schema !== ADAPTER_WORKFLOW_SCHEMA) {
return invalid(`\`workflow.schema\` must be \`${ADAPTER_WORKFLOW_SCHEMA}\`.`);
}
for (const field of Object.keys(workflow)) {
if (!WORKFLOW_FIELDS.has(field)) return invalid(`\`workflow\` has unknown field \`${field}\`.`);
}
if (!isPlainObject(workflow.states) || Object.keys(workflow.states).length === 0) {
return invalid('`workflow.states` must be a non-empty object.');
}

const knownJobs = new Set(adapter.jobs);
const jobsWithTerminalState = new Set();
for (const [stateName, state] of Object.entries(workflow.states)) {
if (!WORKFLOW_STATE_SET.has(stateName)) {
return invalid(`Unknown workflow state \`${stateName}\`.`);
}
if (!isPlainObject(state)) return invalid(`Workflow state \`${stateName}\` must be an object.`);

for (const field of Object.keys(state)) {
if (!STATE_FIELDS.has(field)) {
return invalid(`Workflow state \`${stateName}\` has unknown field \`${field}\`.`);
}
}

const evidence = validateEvidence(stateName, state.evidence);
if (!evidence.ok) return evidence;

for (const field of ['readOnly', 'requiresConfirmation']) {
if (state[field] !== undefined && typeof state[field] !== 'boolean') {
return invalid(`Workflow state \`${stateName}\` field \`${field}\` must be boolean.`);
}
}
if (state.readOnly === true && state.requiresConfirmation === true) {
return invalid(`Workflow state \`${stateName}\` cannot be read-only and require confirmation.`);
}
if ((stateName === 'commit' || stateName === 'payment') && state.requiresConfirmation !== true) {
return invalid(`Workflow state \`${stateName}\` must set requiresConfirmation to true.`);
}

if (state.terminalFor !== undefined) {
if (!Array.isArray(state.terminalFor) || state.terminalFor.length === 0) {
return invalid(`Workflow state \`${stateName}\` terminalFor must be a non-empty array.`);
}
const seenTerminalJobs = new Set();
for (const job of state.terminalFor) {
if (typeof job !== 'string' || !knownJobs.has(job)) {
return invalid(`Workflow state \`${stateName}\` terminalFor references unknown job \`${String(job)}\`.`);
}
if (seenTerminalJobs.has(job)) {
return invalid(`Workflow state \`${stateName}\` terminalFor must not contain duplicate jobs.`);
}
seenTerminalJobs.add(job);
jobsWithTerminalState.add(job);
}
}
}

for (const job of adapter.jobs) {
if (!jobsWithTerminalState.has(job)) {
return invalid(`Workflow job \`${job}\` must have a successful terminal state with evidence.`);
}
}
return { ok: true };
}
87 changes: 87 additions & 0 deletions src/chrome/src/agent/adapters.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
ADAPTER_WORKFLOW_SCHEMA,
validateAdapterWorkflowProfile,
} from './adapter-workflow.js';

/**
* Site Adapters — per-site notes the agent receives when operating on a
* known high-traffic site. The goal is NOT to encode every selector (those
Expand All @@ -10,6 +15,9 @@
* - category: 'general' | 'finance' — finance gets an extra safety warning
* - notes: short bulleted guidance, injected into the first user message
* - fullPageCapture?.infiniteScroll(url): optional machine-readable capture policy
* - regions?: stable region identifiers for structured adapter discovery
* - jobs?: stable job identifiers covered by the optional workflow profile
* - workflow?: versioned state, evidence, confirmation, and terminal metadata
*
* Keep notes SHORT (4–8 bullets max). They cost tokens on every first turn.
* Only encode things the model can't trivially figure out from reading the page.
Expand Down Expand Up @@ -16374,6 +16382,46 @@ const ADAPTERS = [
{
name: 'railway-12306',
category: 'general',
regions: ['CN'],
jobs: ['rail-booking'],
workflow: {
schema: ADAPTER_WORKFLOW_SCHEMA,
states: {
access_gate: {
readOnly: true,
evidence: ['A QR, SMS, identity, or anti-bot challenge is visible.'],
},
search: {
readOnly: true,
evidence: ['The departure station, arrival station, and travel date are visible.'],
},
selection: {
readOnly: true,
evidence: ['The selected train number, stations, date, and seat class are visible.'],
},
review: {
readOnly: true,
evidence: ['The passenger, ticket type, itinerary, seat class, and total are visible.'],
},
commit: {
requiresConfirmation: true,
evidence: ['An order number, queue result, or pending-order status is visible.'],
},
payment: {
requiresConfirmation: true,
evidence: ['The official payment page or payment status is visible.'],
},
fulfillment: {
readOnly: true,
evidence: ['An order number and successful paid or ticket-issued status are visible.'],
terminalFor: ['rail-booking'],
},
after_sales: {
requiresConfirmation: true,
evidence: ['The change or refund review and its terms are visible.'],
},
},
},
matches: (url) => /^https?:\/\/(?:(?:www|kyfw|passport|epay|mobile|cx|dynamic|travel)\.)?12306\.cn\//.test(url),
notes: `
- 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.
Expand Down Expand Up @@ -17019,3 +17067,42 @@ export function getFullPageCapturePolicy(url) {
export function listAdapters() {
return ADAPTERS.map(a => ({ name: a.name, category: a.category }));
}

/**
* List adapters that have migrated to the optional structured workflow schema.
* Invalid static metadata is a developer error and fails loudly here; ordinary
* adapter matching and notes injection remain unaffected.
*/
export function listAdapterWorkflowProfiles() {
const profiles = [];
for (const adapter of ADAPTERS) {
const hasProfile = adapter.regions !== undefined
|| adapter.jobs !== undefined
|| adapter.workflow !== undefined;
if (!hasProfile) continue;

const validation = validateAdapterWorkflowProfile(adapter);
if (!validation.ok) {
throw new Error(`Invalid workflow profile for adapter \`${adapter.name}\`: ${validation.error}`);
}
profiles.push({
name: adapter.name,
regions: [...adapter.regions],
jobs: [...adapter.jobs],
workflow: {
schema: adapter.workflow.schema,
states: Object.fromEntries(
Object.entries(adapter.workflow.states).map(([stateName, state]) => [
stateName,
{
...state,
evidence: [...state.evidence],
...(state.terminalFor === undefined ? {} : { terminalFor: [...state.terminalFor] }),
},
]),
),
},
});
}
return profiles;
}
Loading
Loading