Skip to content

Commit 71919f3

Browse files
committed
fix(workflows): close review gaps for save, replay, and telemetry
Reject brittle wait_for_element selectors and weak semantic targets at compile/normalize time, redact clarify/submit payloads during replay, rewrite parameter markers in fallback prompts, align replay cleanup with processMessage, and map remaining save-failure reasons in the UI.
1 parent 7b4b9bc commit 71919f3

39 files changed

Lines changed: 384 additions & 28 deletions

src/chrome/src/agent/agent.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
findWorkflowTarget,
5151
parseAccessibilityTreeDescriptors,
5252
redactWorkflowArgsForTelemetry,
53+
redactWorkflowClarifyForTelemetry,
5354
redactWorkflowResultForTelemetry,
5455
resolveWorkflowArgs,
5556
validateWorkflowStepResult,
@@ -12193,7 +12194,12 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1219312194
throw new Error('Saved workflow is missing or invalid.');
1219412195
}
1219512196
await this._hydrate(tabId);
12197+
// Align pre-run cleanup with processMessage so a prior Act turn cannot
12198+
// leak click-AX CDP fallbacks, plan guards, or active-skill state into
12199+
// deterministic replay (or the reverse on the next turn).
12200+
this._resetActiveSkillsForRun(tabId, { refreshPrompt: false });
1219612201
this._clearRunLoopState(tabId);
12202+
this._clickAxCdpFallbacks?.delete(tabId);
1219712203
this.abortFlags.delete(tabId);
1219812204
this._prepareClarificationAuthorizationForRun(tabId);
1219912205
this.permissions.beginTurn(tabId);
@@ -12315,7 +12321,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1231512321
onUpdate(type, { ...data, result: redactWorkflowResultForTelemetry(step.tool, data.result), workflowReplay: true });
1231612322
return;
1231712323
}
12318-
onUpdate(type, data);
12324+
if (type === 'clarify') {
12325+
onUpdate(type, redactWorkflowClarifyForTelemetry(data));
12326+
return;
12327+
}
12328+
onUpdate(type, { ...(data && typeof data === 'object' ? data : {}), workflowReplay: true });
1231912329
};
1232012330
const beforeUrl = stepUrl;
1232112331
const batch = await this._executeToolBatch(
@@ -12374,8 +12384,12 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1237412384
return { status: 'completed', summary, matchedSteps, estimatedLlmCallsSaved: matchedSteps };
1237512385
} finally {
1237612386
await trace.endRun(traceRunId, { status: traceStatus, finalContent });
12387+
this.currentCostState.delete(tabId);
12388+
this._planExecutionGuards.delete(tabId);
12389+
this._resetActiveSkillsForRun(tabId);
1237712390
this._runningTabs.delete(tabId);
1237812391
this._clearRunLoopState(tabId);
12392+
this._clickAxCdpFallbacks?.delete(tabId);
1237912393
this._clearCompletionInvariant(tabId, completionRunToken);
1238012394
}
1238112395
}

src/chrome/src/agent/workflows.js

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ const MAX_WORKFLOWS = 100;
1717
const MAX_STEPS = 100;
1818
const MAX_PARAMETERS = 50;
1919
const MAX_TEXT = 240;
20+
// findWorkflowTarget and compile-time strength checks share this floor so a
21+
// saved target that can never match uniquely is rejected at save time.
22+
export const WORKFLOW_TARGET_MATCH_THRESHOLD = 7;
2023

2124
export const REPLAYABLE_WORKFLOW_TOOLS = new Set([
2225
'navigate',
@@ -244,12 +247,14 @@ function compileStepArgs(name, rawArgs, target, parameters, warnings) {
244247
return { url };
245248
}
246249
if (name === 'go_back' || name === 'go_forward') return {};
247-
if (name === 'click_ax') return target ? {} : null;
250+
if (name === 'click_ax') return isReplayableWorkflowTarget(target) ? {} : null;
248251
if (name === 'set_checked') {
249-
return target && typeof args.checked === 'boolean' ? { checked: args.checked } : null;
252+
return isReplayableWorkflowTarget(target) && typeof args.checked === 'boolean'
253+
? { checked: args.checked }
254+
: null;
250255
}
251256
if (name === 'type_ax' || name === 'set_field') {
252-
if (!target || parameters.length >= MAX_PARAMETERS) return null;
257+
if (!isReplayableWorkflowTarget(target) || parameters.length >= MAX_PARAMETERS) return null;
253258
const base = parameterBase(target, `input_${parameters.length + 1}`);
254259
const id = uniqueParameterId(base, parameters);
255260
parameters.push({
@@ -284,13 +289,13 @@ function compileStepArgs(name, rawArgs, target, parameters, warnings) {
284289
};
285290
}
286291
if (name === 'wait_for_element') {
287-
const selector = cleanText(args.selector);
292+
// Same fail-closed rule as click: bare CSS selectors are brittle across
293+
// layout changes and must not be stored for deterministic replay.
288294
const text = cleanText(args.text);
289295
const timeout = Number(args.timeout);
290-
if (!selector && !text) return null;
296+
if (!text) return null;
291297
return {
292-
...(selector ? { selector } : {}),
293-
...(text ? { text } : {}),
298+
text,
294299
...(Number.isFinite(timeout) ? { timeout: Math.max(100, Math.min(30000, Math.round(timeout))) } : {}),
295300
};
296301
}
@@ -456,10 +461,22 @@ export function normalizeSavedWorkflow(input, options = {}) {
456461
if (!text) continue;
457462
args = { text };
458463
}
464+
if (tool === 'wait_for_element') {
465+
const text = cleanText(args.text);
466+
if (!text) continue;
467+
const timeout = Number(args.timeout);
468+
args = {
469+
text,
470+
...(Number.isFinite(timeout) ? { timeout: Math.max(100, Math.min(30000, Math.round(timeout))) } : {}),
471+
};
472+
}
459473
if ((tool === 'type_ax' || tool === 'set_field') && !args.text?.[WORKFLOW_PARAM_REF_KEY]) continue;
460474
const target = normalizeTarget(raw?.target);
461475
const scope = normalizeWorkflowScope(raw?.scope);
462-
if (['click_ax', 'set_checked', 'type_ax', 'set_field'].includes(tool) && !target) continue;
476+
if (['click_ax', 'set_checked', 'type_ax', 'set_field'].includes(tool)
477+
&& !isReplayableWorkflowTarget(target)) {
478+
continue;
479+
}
463480
steps.push({
464481
id: cleanId(raw?.id, `step_${steps.length + 1}`),
465482
tool,
@@ -563,6 +580,53 @@ export function redactWorkflowResultForTelemetry(tool, result) {
563580
return redact(result);
564581
}
565582

583+
function redactWorkflowSubmitField(field) {
584+
if (!field || typeof field !== 'object') return field;
585+
const out = { ...field };
586+
for (const key of ['value', 'text', 'actual', 'currentValue', 'newValue', 'oldValue']) {
587+
if (Object.hasOwn(out, key) && out[key] != null && out[key] !== '') {
588+
out[key] = '[workflow parameter redacted]';
589+
}
590+
}
591+
return out;
592+
}
593+
594+
/**
595+
* Submit-confirmation and similar clarify events can carry live form field
596+
* values that were just typed as workflow parameters. Keep host/tool labels
597+
* for the user prompt, but strip values and free-text summaries from UI
598+
* telemetry during replay.
599+
*/
600+
export function redactWorkflowClarifyForTelemetry(data = {}) {
601+
const out = { ...(data && typeof data === 'object' ? data : {}), workflowReplay: true };
602+
if (out.submitConfirmation && typeof out.submitConfirmation === 'object') {
603+
const sc = { ...out.submitConfirmation };
604+
if (sc.summary) sc.summary = '[workflow form summary redacted]';
605+
if (Array.isArray(sc.fields)) sc.fields = sc.fields.map(redactWorkflowSubmitField);
606+
if (Array.isArray(sc.changedFields)) {
607+
sc.changedFields = sc.changedFields.map(redactWorkflowSubmitField);
608+
}
609+
out.submitConfirmation = sc;
610+
}
611+
return out;
612+
}
613+
614+
function rewriteWorkflowArgsForFallback(args) {
615+
const rewrite = (value, depth = 0) => {
616+
if (depth > 6) return value;
617+
if (Array.isArray(value)) return value.map((item) => rewrite(item, depth + 1));
618+
if (!value || typeof value !== 'object') return value;
619+
if (Object.keys(value).length === 1 && Object.hasOwn(value, WORKFLOW_PARAM_REF_KEY)) {
620+
const id = cleanId(value[WORKFLOW_PARAM_REF_KEY], 'value');
621+
return `<ask user for parameter: ${id}>`;
622+
}
623+
return Object.fromEntries(
624+
Object.entries(value).map(([key, item]) => [key, rewrite(item, depth + 1)]),
625+
);
626+
};
627+
return rewrite(args || {});
628+
}
629+
566630
export function validateWorkflowStepResult(expected, result, context = {}) {
567631
if (!result || typeof result !== 'object') {
568632
return { ok: false, reason: 'missing_result', outcomeUnknown: true };
@@ -600,7 +664,9 @@ export function workflowFallbackPrompt(workflow, failedStepIndex, reason = 'targ
600664
const remaining = (workflow?.steps || []).slice(index).map((step) => ({
601665
tool: step.tool,
602666
target: step.target || null,
603-
args: step.args || {},
667+
// Rewrite internal $workflowParam markers so local models do not treat
668+
// the object shape as literal type_ax / set_field text.
669+
args: rewriteWorkflowArgsForFallback(step.args || {}),
604670
expected: step.expected || null,
605671
}));
606672
const parameters = (workflow?.parameters || []).map((parameter) => ({
@@ -636,16 +702,21 @@ export function scoreWorkflowTarget(target, candidate) {
636702
addExact('name', 7);
637703
addExact('href', 7);
638704
addExact('placeholder', 5);
639-
addExact('selector', 5);
705+
// selector is intentionally not scored: TARGET_FIELDS never stores it, and
706+
// brittle CSS evidence must not count toward a deterministic match.
640707
addExact('type', 3);
641708
addExact('role', 2);
642709
return score;
643710
}
644711

712+
export function isReplayableWorkflowTarget(target) {
713+
return scoreWorkflowTarget(target, target) >= WORKFLOW_TARGET_MATCH_THRESHOLD;
714+
}
715+
645716
export function findWorkflowTarget(target, candidates) {
646717
const ranked = (Array.isArray(candidates) ? candidates : [])
647718
.map((candidate) => ({ candidate, score: scoreWorkflowTarget(target, candidate) }))
648-
.filter((entry) => entry.score >= 7)
719+
.filter((entry) => entry.score >= WORKFLOW_TARGET_MATCH_THRESHOLD)
649720
.sort((a, b) => b.score - a.score);
650721
if (!ranked.length) return { status: 'miss', candidate: null, score: 0 };
651722
if (ranked[1] && ranked[1].score === ranked[0].score) {

src/chrome/src/ui/locales/ar.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,9 @@ export default {
748748
"sp.workflows.reason.no_steps": "لا يحتوي هذا التشغيل على إجراءات يمكن حفظها بأمان لإعادة التشغيل.",
749749
"sp.workflows.reason.not_found": "لا توجد مهمة سير عمل محفوظة بهذا المعرّف.",
750750

751+
"sp.workflows.reason.name_required": "قدّم اسمًا لسير العمل: /workflow --save <name>.",
752+
"sp.workflows.reason.http_start_url": "يجب أن يبدأ التشغيل المسجل على صفحة http(s) قبل حفظه.",
753+
"sp.workflows.reason.normalization_failed": "تعذّر حفظ سير العمل لأن خطواته غير آمنة لإعادة التشغيل.",
751754
"st.tab.memory": "الذاكرة",
752755
"st.display.plan_review.label": "توقفات مراجعة الخطة",
753756
"st.display.plan_review.desc": "اختر متى تتوقف الخطة الصالحة للموافقة عليها. افتراضيًا، تتوقف فقط عندما يبلّغ المخطِّط عن ثقة أقل من الحد.",

src/chrome/src/ui/locales/en.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,9 @@ export default {
339339
'sp.workflows.reason.no_trace': 'No successful recorded run was found in this conversation. Enable tracing, complete the task, then save it.',
340340
'sp.workflows.reason.no_steps': 'That run has no actions that can be safely saved for replay.',
341341
'sp.workflows.reason.not_found': 'No saved workflow has that ID.',
342+
'sp.workflows.reason.name_required': 'Provide a workflow name: /workflow --save <name>.',
343+
'sp.workflows.reason.http_start_url': 'The recorded run must start on an http(s) page before it can be saved.',
344+
'sp.workflows.reason.normalization_failed': 'That workflow could not be saved because its steps were not safe to replay.',
342345

343346
// --- Tool labels -------------------------------------------------------
344347
'tool.read_page': 'Reading page',

src/chrome/src/ui/locales/es.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,9 @@ export default {
748748
"sp.workflows.reason.no_steps": "Esa ejecución no contiene acciones que puedan guardarse de forma segura.",
749749
"sp.workflows.reason.not_found": "No existe un flujo de trabajo guardado con ese ID.",
750750

751+
"sp.workflows.reason.name_required": "Indica un nombre de flujo: /workflow --save <name>.",
752+
"sp.workflows.reason.http_start_url": "La ejecución registrada debe empezar en una página http(s) para poder guardarla.",
753+
"sp.workflows.reason.normalization_failed": "No se pudo guardar ese flujo porque sus pasos no son seguros para repetir.",
751754
"st.tab.memory": "Memoria",
752755
"st.display.plan_review.label": "Pausas de revisión del plan",
753756
"st.display.plan_review.desc": "Elige cuándo un plan válido se detiene para pedir aprobación. De forma predeterminada, solo se detiene cuando el planificador indica una confianza inferior al umbral.",

src/chrome/src/ui/locales/fr.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,9 @@ export default {
748748
"sp.workflows.reason.no_steps": "Cette exécution ne contient aucune action pouvant être enregistrée de manière sûre.",
749749
"sp.workflows.reason.not_found": "Aucun workflow enregistré ne possède cet ID.",
750750

751+
"sp.workflows.reason.name_required": "Indiquez un nom de workflow : /workflow --save <name>.",
752+
"sp.workflows.reason.http_start_url": "L’exécution enregistrée doit commencer sur une page http(s) avant d’être sauvegardée.",
753+
"sp.workflows.reason.normalization_failed": "Ce workflow n’a pas pu être enregistré car ses étapes ne sont pas sûres à rejouer.",
751754
"st.tab.memory": "Mémoire",
752755
"st.display.plan_review.label": "Arrêts pour révision du plan",
753756
"st.display.plan_review.desc": "Choisissez quand un plan valide doit s’arrêter pour approbation. Par défaut, il ne s’arrête que lorsque le planificateur indique une confiance inférieure au seuil.",

src/chrome/src/ui/locales/he.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,9 @@ export default {
393393
"sp.workflows.reason.no_steps": "בהרצה זו אין פעולות שניתן לשמור בבטחה להפעלה חוזרת.",
394394
"sp.workflows.reason.not_found": "אין תהליך עבודה שמור עם מזהה זה.",
395395

396+
"sp.workflows.reason.name_required": "ספקו שם לתהליך העבודה: /workflow --save <name>.",
397+
"sp.workflows.reason.http_start_url": "ההרצה המוקלטת חייבת להתחיל בעמוד http(s) לפני שניתן לשמור אותה.",
398+
"sp.workflows.reason.normalization_failed": "לא ניתן לשמור את תהליך העבודה כי השלבים שלו אינם בטוחים להפעלה חוזרת.",
396399
"st.tab.memory": "זיכרון",
397400
"st.tab.profile": "פרופיל",
398401
"st.tab.account": "חשבון",

src/chrome/src/ui/locales/id.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,9 @@ export default {
748748
"sp.workflows.reason.no_steps": "Proses tersebut tidak memiliki tindakan yang aman untuk disimpan dan dijalankan ulang.",
749749
"sp.workflows.reason.not_found": "Tidak ada alur kerja tersimpan dengan ID tersebut.",
750750

751+
"sp.workflows.reason.name_required": "Berikan nama alur kerja: /workflow --save <name>.",
752+
"sp.workflows.reason.http_start_url": "Proses yang direkam harus dimulai di halaman http(s) sebelum disimpan.",
753+
"sp.workflows.reason.normalization_failed": "Alur kerja itu tidak dapat disimpan karena langkahnya tidak aman untuk dijalankan ulang.",
751754
"st.tab.memory": "Memori",
752755
"st.display.plan_review.label": "Jeda peninjauan rencana",
753756
"st.display.plan_review.desc": "Pilih kapan rencana yang valid dijeda untuk persetujuan. Secara default, rencana hanya dijeda ketika perencana melaporkan keyakinan di bawah ambang.",

src/chrome/src/ui/locales/ja.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,9 @@ export default {
748748
"sp.workflows.reason.no_steps": "この実行には安全に保存して再実行できる操作がありません。",
749749
"sp.workflows.reason.not_found": "そのIDの保存済みワークフローはありません。",
750750

751+
"sp.workflows.reason.name_required": "ワークフロー名を指定してください: /workflow --save <name>。",
752+
"sp.workflows.reason.http_start_url": "保存する前に、記録済み実行は http(s) ページで開始している必要があります。",
753+
"sp.workflows.reason.normalization_failed": "再生に安全でないステップがあるため、そのワークフローは保存できませんでした。",
751754
"st.tab.memory": "メモリ",
752755
"st.display.plan_review.label": "プランレビューの停止条件",
753756
"st.display.plan_review.desc": "有効なプランを承認待ちで停止する条件を選択します。既定では、プランナーが報告した信頼度がしきい値未満の場合のみ停止します。",

src/chrome/src/ui/locales/ko.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,9 @@ export default {
748748
"sp.workflows.reason.no_steps": "이 실행에는 안전하게 저장하여 다시 실행할 수 있는 동작이 없습니다.",
749749
"sp.workflows.reason.not_found": "해당 ID의 저장된 워크플로가 없습니다.",
750750

751+
"sp.workflows.reason.name_required": "워크플로 이름을 입력하세요: /workflow --save <name>.",
752+
"sp.workflows.reason.http_start_url": "저장하려면 기록된 실행이 http(s) 페이지에서 시작해야 합니다.",
753+
"sp.workflows.reason.normalization_failed": "재생하기에 안전하지 않은 단계가 있어 해당 워크플로를 저장할 수 없습니다.",
751754
"st.tab.memory": "메모리",
752755
"st.display.plan_review.label": "계획 검토 중지 조건",
753756
"st.display.plan_review.desc": "유효한 계획이 언제 승인을 위해 멈출지 선택합니다. 기본적으로 플래너가 보고한 신뢰도가 임계값보다 낮을 때만 멈춥니다.",

0 commit comments

Comments
 (0)