halo-agent 2.2.0 → 2.2.1

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.
@@ -286,4 +286,183 @@ async function detectFormErrorsInRoot(page) {
286
286
  });
287
287
  }
288
288
 
289
- module.exports = { detectFormErrors };
289
+ /**
290
+ * Proactive required-field emptiness check.
291
+ *
292
+ * detectFormErrors is REACTIVE — it only reports a problem if the ATS has
293
+ * already rendered an error (aria-invalid, "is required" text, error class).
294
+ * On Ashby, a required combobox the agent failed to fill stays empty WITHOUT
295
+ * the form rendering a visible error until/unless submit is attempted — and
296
+ * sometimes not even then in the DOM snapshot we capture. So a reactive check
297
+ * sees a clean page and the agent auto-submits an incomplete form (the Plaid
298
+ * failure).
299
+ *
300
+ * This is the ground-truth, ATS-agnostic guard: walk every field the form
301
+ * marks REQUIRED (asterisked label, `required` attr, aria-required="true")
302
+ * and return the ones that are empty — using the same isEmptyField logic
303
+ * detectFormErrors uses, so react-select / Ashby comboboxes are judged by
304
+ * their rendered value chip, not the hidden input's .value.
305
+ *
306
+ * The orchestrator calls this BEFORE submitting: if any required field is
307
+ * empty, never auto-submit — route to REVIEWING with the list.
308
+ *
309
+ * Output: { hasEmptyRequired: boolean, emptyFields: [{label, selector?}] }
310
+ */
311
+ async function detectRequiredEmpties(page) {
312
+ const roots = [page, ...page.frames().filter((f) => {
313
+ const u = f.url();
314
+ return f !== page.mainFrame() && u && DFE_EMBED_HOSTS.test(u) && !/google\.com|gstatic\.com|recaptcha/i.test(u);
315
+ })];
316
+ let merged = { hasEmptyRequired: false, emptyFields: [] };
317
+ for (const root of roots) {
318
+ const r = await detectRequiredEmptiesInRoot(root).catch(() => null);
319
+ if (r) {
320
+ merged.hasEmptyRequired = merged.hasEmptyRequired || r.hasEmptyRequired;
321
+ merged.emptyFields.push(...(r.emptyFields || []));
322
+ }
323
+ }
324
+ // Dedup by label
325
+ const seen = new Set();
326
+ merged.emptyFields = merged.emptyFields.filter((f) => {
327
+ const k = (f.label || '').toLowerCase().trim();
328
+ if (!k || seen.has(k)) return false;
329
+ seen.add(k); return true;
330
+ });
331
+ return merged;
332
+ }
333
+
334
+ async function detectRequiredEmptiesInRoot(page) {
335
+ return await page.evaluate(() => {
336
+ function visibleText(el) {
337
+ if (!el) return '';
338
+ const rect = el.getBoundingClientRect();
339
+ if (rect.width === 0 && rect.height === 0) return '';
340
+ const t = (el.innerText || el.textContent || '').trim();
341
+ return t ? t.replace(/\s+/g, ' ').slice(0, 300) : '';
342
+ }
343
+ function nearestLabel(el) {
344
+ if (el.id) {
345
+ const lbl = document.querySelector(`label[for="${el.id}"]`);
346
+ if (lbl) return visibleText(lbl).replace(/\*$/, '').trim();
347
+ }
348
+ if (el.labels && el.labels[0]) return visibleText(el.labels[0]).replace(/\*$/, '').trim();
349
+ const al = el.getAttribute('aria-labelledby');
350
+ if (al) {
351
+ const t = al.split(/\s+/).map((id) => document.getElementById(id)).filter(Boolean).map(visibleText).join(' ').trim();
352
+ if (t) return t;
353
+ }
354
+ let p = el.parentElement; let hops = 0;
355
+ while (p && hops < 6) {
356
+ const lbl = p.querySelector('label, legend, h3, h4');
357
+ if (lbl && !lbl.contains(el)) {
358
+ const t = visibleText(lbl).replace(/\*$/, '').trim();
359
+ if (t && t.length < 200) return t;
360
+ }
361
+ p = p.parentElement; hops += 1;
362
+ }
363
+ return el.getAttribute('aria-label') || el.placeholder || el.name || el.id || '(unknown)';
364
+ }
365
+ // Same emptiness logic as detectFormErrors (kept in sync deliberately).
366
+ function isEmptyField(el) {
367
+ const tag = el.tagName.toLowerCase();
368
+ const type = (el.type || '').toLowerCase();
369
+ if (type === 'checkbox' || type === 'radio') return !el.checked;
370
+ if (tag === 'select') {
371
+ if (!el.value) return true;
372
+ const selOpt = el.options[el.selectedIndex];
373
+ if (selOpt && !selOpt.value) return true;
374
+ return false;
375
+ }
376
+ if (type === 'file') return !el.files || el.files.length === 0;
377
+ if (el.isContentEditable) return !(el.innerText || '').trim();
378
+ if (el.getAttribute('role') === 'combobox' || tag === 'select') {
379
+ let wrap = el.closest('[class*="select__control"]');
380
+ if (!wrap) { let p = el; for (let i = 0; i < 8 && p; i++, p = p.parentElement) { if (/select__control|select-control/i.test(p.className || '')) { wrap = p; break; } } }
381
+ if (!wrap) wrap = el.closest('[class*="select"], [class*="combobox"], [class*="Select"]') || el.parentElement;
382
+ if (wrap) {
383
+ const valueEl = wrap.querySelector('[class*="single-value"], [class*="multi-value"], [class*="multiValue"], [class*="singleValue"], [class*="chip"], [class*="tag"]');
384
+ if (valueEl && (valueEl.innerText || valueEl.textContent || '').trim()) return false;
385
+ const wrapText = (wrap.innerText || '').trim();
386
+ const placeholderEl = wrap.querySelector('[class*="placeholder"]');
387
+ const placeholderText = placeholderEl ? (placeholderEl.innerText || '').trim() : '';
388
+ if (wrapText && wrapText !== placeholderText && !/^select\b|^choose\b|^start typing|^\.\.\./i.test(wrapText)) return false;
389
+ }
390
+ const v = (el.value || el.innerText || '').trim();
391
+ return !v;
392
+ }
393
+ return !(el.value || '').trim();
394
+ }
395
+
396
+ // A radiogroup / checkbox-set counts as "filled" if ANY member is checked.
397
+ // We track required groups by name so we don't flag every individual radio.
398
+ function isRequired(el) {
399
+ if (el.required) return true;
400
+ if (el.getAttribute('aria-required') === 'true') return true;
401
+ // Asterisked label is the most common ATS convention (Ashby, Greenhouse).
402
+ const lbl = nearestLabel(el);
403
+ // The raw label may have had its trailing * stripped by nearestLabel, so
404
+ // also look at the surrounding label text for an asterisk.
405
+ let p = el.parentElement; let hops = 0;
406
+ while (p && hops < 5) {
407
+ const labelEl = p.querySelector('label, legend, [class*="label"]');
408
+ if (labelEl) {
409
+ const raw = (labelEl.innerText || labelEl.textContent || '');
410
+ if (/\*\s*$/.test(raw.trim()) || /\brequired\b/i.test(raw)) return true;
411
+ }
412
+ p = p.parentElement; hops += 1;
413
+ }
414
+ return false;
415
+ }
416
+
417
+ const fields = Array.from(document.querySelectorAll(
418
+ 'input:not([type=hidden]):not([type=submit]):not([type=button]), textarea, select, [role="combobox"], [contenteditable="true"]'
419
+ ));
420
+ const emptyFields = [];
421
+ const seen = new Set();
422
+ const radioGroupsChecked = new Map(); // name -> bool any-checked
423
+ const radioGroupRequired = new Map(); // name -> required
424
+
425
+ // First pass: tally radio/checkbox groups by name.
426
+ for (const el of fields) {
427
+ const type = (el.type || '').toLowerCase();
428
+ if ((type === 'radio' || type === 'checkbox') && el.name) {
429
+ if (el.checked) radioGroupsChecked.set(el.name, true);
430
+ else if (!radioGroupsChecked.has(el.name)) radioGroupsChecked.set(el.name, false);
431
+ if (isRequired(el)) radioGroupRequired.set(el.name, true);
432
+ }
433
+ }
434
+
435
+ for (const el of fields) {
436
+ const type = (el.type || '').toLowerCase();
437
+ // Skip non-rendered fields entirely (display:none templates etc).
438
+ const rect = el.getBoundingClientRect();
439
+ const inDom = rect.width > 0 || rect.height > 0 || el.offsetParent !== null;
440
+ if (!inDom && type !== 'file') continue; // file inputs are often 0x0 by design
441
+
442
+ // Radio/checkbox handled at group level.
443
+ if ((type === 'radio' || type === 'checkbox') && el.name) {
444
+ if (!radioGroupRequired.get(el.name)) continue;
445
+ if (radioGroupsChecked.get(el.name)) continue; // satisfied
446
+ const key = 'group:' + el.name;
447
+ if (seen.has(key)) continue;
448
+ seen.add(key);
449
+ if (!radioGroupsChecked.get(el.name)) {
450
+ emptyFields.push({ label: nearestLabel(el).slice(0, 200), selector: `[name="${el.name}"]` });
451
+ }
452
+ continue;
453
+ }
454
+
455
+ if (!isRequired(el)) continue;
456
+ if (!isEmptyField(el)) continue;
457
+ const label = nearestLabel(el).slice(0, 200);
458
+ const key = label.toLowerCase();
459
+ if (seen.has(key)) continue;
460
+ seen.add(key);
461
+ emptyFields.push({ label, selector: el.id ? `#${el.id}` : (el.name ? `[name="${el.name}"]` : null) });
462
+ }
463
+
464
+ return { hasEmptyRequired: emptyFields.length > 0, emptyFields };
465
+ });
466
+ }
467
+
468
+ module.exports = { detectFormErrors, detectRequiredEmpties };
package/orchestrator.js CHANGED
@@ -13,7 +13,7 @@ const path = require('path');
13
13
  const fs = require('fs');
14
14
  const { fillFields: legacyFillFields, uploadFile, findNextButton, findSubmitButton, waitForStableDOM, snapshotFieldLabels } = require('./filler');
15
15
  const { smartFillPage } = require('./smartFill');
16
- const { detectFormErrors } = require('./detectFormErrors');
16
+ const { detectFormErrors, detectRequiredEmpties } = require('./detectFormErrors');
17
17
 
18
18
  // Switchable filler — smart by default, can be killed via config.useSmartFill=false.
19
19
  // smartFill.js internally falls back to legacyFillFields if /smartfill/plan-fill
@@ -432,23 +432,59 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
432
432
 
433
433
  console.log(`[orchestrator] Reached review/submit page. Waiting for confirmation...`);
434
434
 
435
+ // ─────────────────────────────────────────────────────────────────────
436
+ // HONESTY GUARD (the Plaid/Ashby fix). Before we even consider
437
+ // auto-submitting, check — proactively, not waiting for the ATS to
438
+ // render an error — whether any REQUIRED field is still empty. The
439
+ // Plaid run auto-submitted with Location + dates blank because:
440
+ // (a) the comboboxes never filled (element-not-visible, now fixed), and
441
+ // (b) detectFormErrors is reactive — a clean-looking DOM let
442
+ // auto-submit "trust the click."
443
+ // If required fields are empty, auto-submit is OFF for this job no matter
444
+ // what the config/agent_config says: we route to REVIEWING so the human
445
+ // sees exactly which fields are blank. Never silently submit incomplete.
446
+ // ─────────────────────────────────────────────────────────────────────
447
+ const requiredCheck = await detectRequiredEmpties(page).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
448
+ let forceReview = false;
449
+ if (requiredCheck.hasEmptyRequired) {
450
+ const list = requiredCheck.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
451
+ console.warn(`[orchestrator] ${requiredCheck.emptyFields.length} required field(s) still EMPTY: ${list}`);
452
+ console.warn(`[orchestrator] Overriding auto-submit → REVIEWING. Will not submit an incomplete form.`);
453
+ forceReview = true;
454
+ }
455
+
435
456
  // Take a screenshot of the review page
436
457
  const reviewScreenshot = await page.screenshot({ type: 'jpeg', quality: 70 });
437
458
  const reviewKey = await uploadScreenshot(config, reviewScreenshot, `review_${queueId}.jpg`);
438
459
 
439
- await reportStatus('REVIEWING', {
440
- review_screenshot_r2_key: reviewKey || null,
441
- step: 'REVIEWING',
442
- step_detail: `${cumulativeFilled} fields filled · awaiting your confirm`,
443
- });
444
-
445
- // Wait for user to confirm submission from dashboard
446
- // OR auto-submit if config.autoSubmit is true
447
- if (config.autoSubmit || aep.agent_config?.auto_submit) {
448
- const timeout = (aep.agent_config?.review_timeout_seconds || 30) * 1000;
449
- await page.waitForTimeout(timeout);
450
- } else {
460
+ if (forceReview) {
461
+ const list = requiredCheck.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
462
+ await reportStatus('NEEDS_ATTENTION', {
463
+ review_screenshot_r2_key: reviewKey || null,
464
+ needs_attention_reason: `${requiredCheck.emptyFields.length} required field(s) could not be filled: ${list}. Fill them in Chrome, then click Submit / Resume.`,
465
+ intervention_type: 'incomplete_fields',
466
+ step: 'REVIEWING',
467
+ step_detail: `Empty required: ${list}`.slice(0, 200),
468
+ fields_filled: cumulativeFilled,
469
+ });
470
+ // Wait for the human to fill + confirm, exactly like the no-auto-submit
471
+ // path. They resolve the blanks in Chrome, then click Submit/Resume.
451
472
  await waitForSubmitConfirmation(config, queueId);
473
+ } else {
474
+ await reportStatus('REVIEWING', {
475
+ review_screenshot_r2_key: reviewKey || null,
476
+ step: 'REVIEWING',
477
+ step_detail: `${cumulativeFilled} fields filled · awaiting your confirm`,
478
+ });
479
+
480
+ // Wait for user to confirm submission from dashboard
481
+ // OR auto-submit if config.autoSubmit is true AND the form is complete.
482
+ if (config.autoSubmit || aep.agent_config?.auto_submit) {
483
+ const timeout = (aep.agent_config?.review_timeout_seconds || 30) * 1000;
484
+ await page.waitForTimeout(timeout);
485
+ } else {
486
+ await waitForSubmitConfirmation(config, queueId);
487
+ }
452
488
  }
453
489
 
454
490
  // STEP 6: SUBMITTING
@@ -601,10 +637,31 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
601
637
  let finalState = 'DONE';
602
638
  if (verdict.submitted === null) {
603
639
  const autoSubmit = config.autoSubmit || aep.agent_config?.auto_submit;
604
- if (autoSubmit) {
605
- // Auto-submit ON + verifier unavailable: trust the click; the
606
- // screenshot becomes the audit trail.
607
- console.log(`[orchestrator] Verifier unavailable (source: ${verdict.source}); auto-submit ON trusting click, screenshot is the receipt.`);
640
+
641
+ // Even with auto-submit ON, don't "trust the click" blindly. Two cheap
642
+ // local signals tell us the submit probably did NOT go through:
643
+ // 1. The URL never changed to a confirmation pattern (still on the
644
+ // form). A successful ATS submit almost always redirects.
645
+ // 2. Required fields are STILL empty after the click — proof the form
646
+ // bounced us (this is the Plaid case: blank Location/dates, no
647
+ // redirect, verifier down → it had said "trusting click").
648
+ // If either holds, we refuse the silent DONE and route to REVIEWING.
649
+ const stillOnForm = !/thank|confirm|success|applied|submitted/i.test(verdictUrl);
650
+ const postCheck = await detectRequiredEmpties(page).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
651
+
652
+ if (postCheck.hasEmptyRequired) {
653
+ const list = postCheck.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
654
+ console.warn(`[orchestrator] After submit, required field(s) STILL empty: ${list}. Not a real submit — REVIEWING.`);
655
+ finalState = 'REVIEWING';
656
+ } else if (autoSubmit && !stillOnForm) {
657
+ // Form complete + URL redirected away + verifier just unavailable:
658
+ // this is the defensible "trust the click" case. Screenshot is the receipt.
659
+ console.log(`[orchestrator] Verifier unavailable (source: ${verdict.source}); form complete + redirected — trusting click, screenshot is the receipt.`);
660
+ } else if (autoSubmit && stillOnForm) {
661
+ // Auto-submit ON but no redirect and no obvious empties: ambiguous.
662
+ // Don't claim DONE on a page that still looks like the form.
663
+ console.warn(`[orchestrator] Auto-submit ON but still on form URL and verifier unavailable — REVIEWING rather than a blind DONE.`);
664
+ finalState = 'REVIEWING';
608
665
  } else {
609
666
  // No auto-submit → REVIEWING so the user eyeballs first.
610
667
  console.warn(`[orchestrator] Could not verify submission (source: ${verdict.source}). REVIEWING — please eyeball the screenshot + click Submit.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
5
5
  "main": "index.js",
6
6
  "bin": {
package/smartFill.js CHANGED
@@ -58,7 +58,50 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
58
58
  visible = await locator.isVisible({ timeout: 800 }).catch(() => false);
59
59
  }
60
60
  if (!visible) {
61
- return { ok: false, reason: `element not visible (mmid=${item.mmid}${field.frameUrl ? ', in-frame' : ''})` };
61
+ // The scanned element being invisible doesn't always mean "give up." Many
62
+ // ATSes (Ashby, react-select) render the REAL combobox <input> at 0x0 and
63
+ // expose only a clickable WRAPPER (".select__control", a styled div, the
64
+ // value-display button). scanAccessibility tags the inner input, so the
65
+ // executor would FAIL "element not visible" and the required field stays
66
+ // empty — exactly the Ashby Location/School/date failure. Recover by
67
+ // re-pointing the locator at the nearest visible interactive ancestor and
68
+ // letting the dropdown handlers below open it.
69
+ const looksLikeDropdown = field.role === 'combobox' || field.role === 'listbox'
70
+ || /select|combobox|dropdown|month|year|listbox/i.test(String(field.selectorHint || ''))
71
+ || (Array.isArray(field.options) && field.options.length > 0);
72
+ const isOpenable = looksLikeDropdown
73
+ || item.action === 'click_option' || item.action === 'select_option';
74
+ if (isOpenable) {
75
+ const wrapperSel = await locator.evaluate((el) => {
76
+ // Climb to the closest ancestor that's actually rendered (non-zero box)
77
+ // and looks interactive (select-control class, role, or just a sized
78
+ // container holding this input). Tag it so Playwright can target it.
79
+ const sized = (n) => { const r = n.getBoundingClientRect?.(); return r && (r.width > 0 || r.height > 0); };
80
+ let p = el;
81
+ for (let i = 0; i < 8 && p; i++, p = p.parentElement) {
82
+ const cls = String(p.className || '');
83
+ const looksControl = /select__control|select-control|control|combobox|dropdown|trigger|css-/i.test(cls)
84
+ || p.getAttribute?.('role') === 'combobox'
85
+ || p.getAttribute?.('aria-haspopup');
86
+ if (sized(p) && (looksControl || p !== el)) {
87
+ const cid = 'halo-wrap-' + Math.random().toString(36).slice(2, 8);
88
+ p.setAttribute('data-halo-wrap', cid);
89
+ return cid;
90
+ }
91
+ }
92
+ return null;
93
+ }).catch(() => null);
94
+ if (wrapperSel) {
95
+ const wrap = root.locator(`[data-halo-wrap="${wrapperSel}"]`).first();
96
+ if (await wrap.isVisible({ timeout: 600 }).catch(() => false)) {
97
+ locator = wrap;
98
+ visible = true;
99
+ }
100
+ }
101
+ }
102
+ if (!visible) {
103
+ return { ok: false, reason: `element not visible (mmid=${item.mmid}${field.frameUrl ? ', in-frame' : ''})` };
104
+ }
62
105
  }
63
106
 
64
107
  switch (item.action) {