halo-agent 2.2.1 → 2.2.2
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.
- package/detectFormErrors.js +55 -12
- package/orchestrator.js +25 -11
- package/package.json +1 -1
- package/smartFill.js +17 -1
package/detectFormErrors.js
CHANGED
|
@@ -308,7 +308,7 @@ async function detectFormErrorsInRoot(page) {
|
|
|
308
308
|
*
|
|
309
309
|
* Output: { hasEmptyRequired: boolean, emptyFields: [{label, selector?}] }
|
|
310
310
|
*/
|
|
311
|
-
async function detectRequiredEmpties(page) {
|
|
311
|
+
async function detectRequiredEmpties(page, opts = {}) {
|
|
312
312
|
const roots = [page, ...page.frames().filter((f) => {
|
|
313
313
|
const u = f.url();
|
|
314
314
|
return f !== page.mainFrame() && u && DFE_EMBED_HOSTS.test(u) && !/google\.com|gstatic\.com|recaptcha/i.test(u);
|
|
@@ -321,13 +321,30 @@ async function detectRequiredEmpties(page) {
|
|
|
321
321
|
merged.emptyFields.push(...(r.emptyFields || []));
|
|
322
322
|
}
|
|
323
323
|
}
|
|
324
|
-
//
|
|
324
|
+
// Exclude labels the planner deliberately skipped (conditional-not-applicable,
|
|
325
|
+
// honeypots, optional). The planner has dependency context the DOM walk lacks,
|
|
326
|
+
// so a field it intentionally left blank is NOT an unfilled required field.
|
|
327
|
+
const excluded = new Set((opts.excludeLabels || []).map((l) => String(l || '').toLowerCase().trim()).filter(Boolean));
|
|
328
|
+
// Dedup by label, drop excluded.
|
|
325
329
|
const seen = new Set();
|
|
326
330
|
merged.emptyFields = merged.emptyFields.filter((f) => {
|
|
327
331
|
const k = (f.label || '').toLowerCase().trim();
|
|
328
332
|
if (!k || seen.has(k)) return false;
|
|
329
|
-
seen.add(k);
|
|
333
|
+
seen.add(k);
|
|
334
|
+
// If smartFill confirmed a resume upload, a file/resume field reading
|
|
335
|
+
// "empty" is a false positive: Ashby (and similar) keep the native
|
|
336
|
+
// <input type=file> at files.length===0 after upload because they stash
|
|
337
|
+
// the file in their own state and show a filename chip elsewhere. Don't
|
|
338
|
+
// block on it.
|
|
339
|
+
if (opts.resumeUploaded && f.isFile) return false;
|
|
340
|
+
// Match excluded loosely — DOM label vs planner label can differ by
|
|
341
|
+
// trailing punctuation/truncation.
|
|
342
|
+
for (const ex of excluded) {
|
|
343
|
+
if (k === ex || k.startsWith(ex) || ex.startsWith(k) || k.includes(ex) || ex.includes(k)) return false;
|
|
344
|
+
}
|
|
345
|
+
return true;
|
|
330
346
|
});
|
|
347
|
+
merged.hasEmptyRequired = merged.emptyFields.length > 0;
|
|
331
348
|
return merged;
|
|
332
349
|
}
|
|
333
350
|
|
|
@@ -396,18 +413,42 @@ async function detectRequiredEmptiesInRoot(page) {
|
|
|
396
413
|
// A radiogroup / checkbox-set counts as "filled" if ANY member is checked.
|
|
397
414
|
// We track required groups by name so we don't flag every individual radio.
|
|
398
415
|
function isRequired(el) {
|
|
416
|
+
// Programmatic markers are authoritative and field-specific.
|
|
399
417
|
if (el.required) return true;
|
|
400
418
|
if (el.getAttribute('aria-required') === 'true') return true;
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
//
|
|
404
|
-
//
|
|
419
|
+
|
|
420
|
+
// Asterisk/"required" in label text is the common ATS convention, BUT
|
|
421
|
+
// it must come from THIS field's OWN label — not a sibling's. The Plaid
|
|
422
|
+
// false-positive ("If yes, please provide further explanation below")
|
|
423
|
+
// was a conditional field flagged because a climb-5-ancestors walk
|
|
424
|
+
// scooped up a neighbouring required field's asterisk. Only trust the
|
|
425
|
+
// directly-associated label:
|
|
426
|
+
// 1. <label for=id>
|
|
427
|
+
// 2. el.labels[0]
|
|
428
|
+
// 3. the SINGLE closest wrapping label/legend that contains ONLY this
|
|
429
|
+
// control (not a shared group container with multiple inputs).
|
|
430
|
+
const labelTextOf = (lblEl) => (lblEl && (lblEl.innerText || lblEl.textContent || '')).trim();
|
|
431
|
+
const marks = (txt) => /\*\s*$/.test(txt) || /\(\s*required\s*\)/i.test(txt) || /\brequired\b\s*\*?$/i.test(txt);
|
|
432
|
+
|
|
433
|
+
if (el.id) {
|
|
434
|
+
const lbl = document.querySelector(`label[for="${el.id}"]`);
|
|
435
|
+
if (lbl) return marks(labelTextOf(lbl));
|
|
436
|
+
}
|
|
437
|
+
if (el.labels && el.labels[0]) return marks(labelTextOf(el.labels[0]));
|
|
438
|
+
|
|
439
|
+
// Closest wrapping label/legend, but ONLY if that container holds a
|
|
440
|
+
// single control (so we don't inherit a group asterisk meant for a
|
|
441
|
+
// different required field in the same fieldset).
|
|
405
442
|
let p = el.parentElement; let hops = 0;
|
|
406
|
-
while (p && hops <
|
|
407
|
-
const labelEl = p.querySelector('label, legend, [class*="label"]');
|
|
443
|
+
while (p && hops < 4) {
|
|
444
|
+
const labelEl = p.querySelector(':scope > label, :scope > legend, :scope > [class*="label"]');
|
|
408
445
|
if (labelEl) {
|
|
409
|
-
const
|
|
410
|
-
if (
|
|
446
|
+
const controlsInScope = p.querySelectorAll('input:not([type=hidden]), textarea, select, [role="combobox"], [contenteditable="true"]').length;
|
|
447
|
+
if (controlsInScope <= 1) return marks(labelTextOf(labelEl));
|
|
448
|
+
// Shared container with multiple controls — the asterisk isn't
|
|
449
|
+
// necessarily ours. Stop; treat as not-required unless a
|
|
450
|
+
// programmatic marker said otherwise (handled above).
|
|
451
|
+
return false;
|
|
411
452
|
}
|
|
412
453
|
p = p.parentElement; hops += 1;
|
|
413
454
|
}
|
|
@@ -458,7 +499,9 @@ async function detectRequiredEmptiesInRoot(page) {
|
|
|
458
499
|
const key = label.toLowerCase();
|
|
459
500
|
if (seen.has(key)) continue;
|
|
460
501
|
seen.add(key);
|
|
461
|
-
|
|
502
|
+
const isFile = type === 'file'
|
|
503
|
+
|| /resume|cv\b|cover.?letter|attach|upload/i.test(label);
|
|
504
|
+
emptyFields.push({ label, isFile, selector: el.id ? `#${el.id}` : (el.name ? `[name="${el.name}"]` : null) });
|
|
462
505
|
}
|
|
463
506
|
|
|
464
507
|
return { hasEmptyRequired: emptyFields.length > 0, emptyFields };
|
package/orchestrator.js
CHANGED
|
@@ -47,6 +47,7 @@ async function fillFields(page, aep, opts) {
|
|
|
47
47
|
askUserReasons: result.askUserReasons || [],
|
|
48
48
|
plannedActions: result.planned || 0,
|
|
49
49
|
fellBackToLegacy: !!result.fallback,
|
|
50
|
+
resumeUploaded: !!result.resumeUploaded,
|
|
50
51
|
};
|
|
51
52
|
}
|
|
52
53
|
const { detectCaptcha, solveCaptcha, injectCaptchaToken } = require('./captcha');
|
|
@@ -217,8 +218,14 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
217
218
|
}
|
|
218
219
|
}
|
|
219
220
|
|
|
220
|
-
// Upload resume if
|
|
221
|
-
|
|
221
|
+
// Upload resume ONLY if smartFill didn't already place it. This blanket
|
|
222
|
+
// fallback predates smartFill's per-field upload_file action; running it
|
|
223
|
+
// unconditionally dumped the resume into the FIRST file input it found —
|
|
224
|
+
// which on Greenhouse (resume + cover-letter inputs) put the resume into
|
|
225
|
+
// the cover-letter slot too. smartFill now reports resumeUploaded, so we
|
|
226
|
+
// only fall back when the planner genuinely missed the resume field.
|
|
227
|
+
if (tempResumeFile && !fillResult.resumeUploaded) {
|
|
228
|
+
console.log('[orchestrator] smartFill did not upload a resume — running fallback uploader.');
|
|
222
229
|
const uploaded = await uploadFile(page, 'input[type="file"], [data-testid*="resume"], button:has-text("Upload")', tempResumeFile);
|
|
223
230
|
if (uploaded) fillResult.filled = (fillResult.filled || 0) + 1;
|
|
224
231
|
}
|
|
@@ -444,7 +451,8 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
444
451
|
// what the config/agent_config says: we route to REVIEWING so the human
|
|
445
452
|
// sees exactly which fields are blank. Never silently submit incomplete.
|
|
446
453
|
// ─────────────────────────────────────────────────────────────────────
|
|
447
|
-
const
|
|
454
|
+
const skippedLabels = (ctx.skippedFields || []).map((s) => s.label);
|
|
455
|
+
const requiredCheck = await detectRequiredEmpties(page, { excludeLabels: skippedLabels, resumeUploaded: !!fillResult.resumeUploaded }).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
|
|
448
456
|
let forceReview = false;
|
|
449
457
|
if (requiredCheck.hasEmptyRequired) {
|
|
450
458
|
const list = requiredCheck.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
|
|
@@ -459,16 +467,21 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
459
467
|
|
|
460
468
|
if (forceReview) {
|
|
461
469
|
const list = requiredCheck.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
|
|
462
|
-
|
|
470
|
+
// Report REVIEWING (not NEEDS_ATTENTION): the agent is going to WAIT on
|
|
471
|
+
// the submit-confirmed flag, and POST /apply-queue/submit/:id only
|
|
472
|
+
// accepts rows in REVIEWING state. Using NEEDS_ATTENTION here meant the
|
|
473
|
+
// dashboard's "Resolved" click 400'd and the agent waited forever. The
|
|
474
|
+
// reason text still tells the user exactly which fields to fill; the
|
|
475
|
+
// "Submit" button then wakes the agent once they've filled them in Chrome.
|
|
476
|
+
await reportStatus('REVIEWING', {
|
|
463
477
|
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
478
|
step: 'REVIEWING',
|
|
467
|
-
step_detail:
|
|
479
|
+
step_detail: `${requiredCheck.emptyFields.length} required field(s) need you: ${list}. Fill them in Chrome, then click Submit.`.slice(0, 200),
|
|
480
|
+
needs_attention_reason: `${requiredCheck.emptyFields.length} required field(s) could not be auto-filled: ${list}. Fill them in your Chrome window, then click Submit.`,
|
|
468
481
|
fields_filled: cumulativeFilled,
|
|
469
482
|
});
|
|
470
|
-
// Wait for the human to fill + confirm
|
|
471
|
-
//
|
|
483
|
+
// Wait for the human to fill + confirm. They resolve the blanks in
|
|
484
|
+
// Chrome, then click Submit on the dashboard.
|
|
472
485
|
await waitForSubmitConfirmation(config, queueId);
|
|
473
486
|
} else {
|
|
474
487
|
await reportStatus('REVIEWING', {
|
|
@@ -647,7 +660,8 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
647
660
|
// redirect, verifier down → it had said "trusting click").
|
|
648
661
|
// If either holds, we refuse the silent DONE and route to REVIEWING.
|
|
649
662
|
const stillOnForm = !/thank|confirm|success|applied|submitted/i.test(verdictUrl);
|
|
650
|
-
const
|
|
663
|
+
const postSkipped = (ctx.skippedFields || []).map((s) => s.label);
|
|
664
|
+
const postCheck = await detectRequiredEmpties(page, { excludeLabels: postSkipped, resumeUploaded: !!fillResult.resumeUploaded }).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
|
|
651
665
|
|
|
652
666
|
if (postCheck.hasEmptyRequired) {
|
|
653
667
|
const list = postCheck.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
|
|
@@ -1324,7 +1338,7 @@ async function runExtensionFill({
|
|
|
1324
1338
|
fillResult.filled += precision.filled || 0;
|
|
1325
1339
|
}
|
|
1326
1340
|
|
|
1327
|
-
if (tempResumeFile) {
|
|
1341
|
+
if (tempResumeFile && !fillResult.resumeUploaded) {
|
|
1328
1342
|
const uploaded = await uploadFile(page, 'input[type="file"], [data-testid*="resume"], button:has-text("Upload")', tempResumeFile);
|
|
1329
1343
|
if (uploaded) fillResult.filled = (fillResult.filled || 0) + 1;
|
|
1330
1344
|
}
|
package/package.json
CHANGED
package/smartFill.js
CHANGED
|
@@ -781,6 +781,7 @@ async function smartFillPage(page, aep, options) {
|
|
|
781
781
|
const fieldByMmid = new Map(fields.map((f) => [String(f.mmid), f]));
|
|
782
782
|
const planByMmid = new Map(plan.map((p) => [String(p.mmid), p]));
|
|
783
783
|
let filled = 0, skipped = 0, failed = 0;
|
|
784
|
+
let resumeUploaded = false; // so the orchestrator can skip its blanket fallback
|
|
784
785
|
const askUserReasons = [];
|
|
785
786
|
|
|
786
787
|
for (const f of fields) {
|
|
@@ -807,9 +808,24 @@ async function smartFillPage(page, aep, options) {
|
|
|
807
808
|
if (item.action === 'skip') {
|
|
808
809
|
console.log(`[smartFill] skip "${labelShort}" — ${result.reason}`);
|
|
809
810
|
skipped += 1;
|
|
811
|
+
// Record the planner's deliberate skips so the orchestrator's
|
|
812
|
+
// required-empty guard can EXCLUDE them. The planner has full
|
|
813
|
+
// context (it saw "If yes…" conditional dependencies); the guard is
|
|
814
|
+
// a blind DOM walk. A field the planner intentionally left blank
|
|
815
|
+
// (conditional-not-applicable, honeypot, optional) must not be
|
|
816
|
+
// mistaken for an unfilled required field.
|
|
817
|
+
if (ctx && Array.isArray(ctx.skippedFields) && f.label) {
|
|
818
|
+
ctx.skippedFields.push({ label: f.label, reason: (item.reasoning || result.reason || '').slice(0, 160) });
|
|
819
|
+
}
|
|
810
820
|
} else {
|
|
811
821
|
console.log(`[smartFill] ${item.action} "${labelShort}" — ${result.reason}${item.reasoning ? ` (why: ${item.reasoning.slice(0, 80)})` : ''}`);
|
|
812
822
|
filled += 1;
|
|
823
|
+
// Note a successful resume upload so the orchestrator doesn't run its
|
|
824
|
+
// legacy blanket "upload resume to the first file input" fallback,
|
|
825
|
+
// which was dumping the resume into the COVER-LETTER slot too.
|
|
826
|
+
if (item.action === 'upload_file' && String(item.value).toLowerCase().trim() !== 'cover_letter') {
|
|
827
|
+
resumeUploaded = true;
|
|
828
|
+
}
|
|
813
829
|
// Track in ctx for cross-page dedup
|
|
814
830
|
if (ctx && ctx.answeredFields) {
|
|
815
831
|
ctx.answeredFields.set(f.label || f.selectorHint, {
|
|
@@ -827,7 +843,7 @@ async function smartFillPage(page, aep, options) {
|
|
|
827
843
|
}
|
|
828
844
|
}
|
|
829
845
|
|
|
830
|
-
return { filled, skipped, failed, askUserReasons, planned: plan.length, fallback: false };
|
|
846
|
+
return { filled, skipped, failed, askUserReasons, planned: plan.length, fallback: false, resumeUploaded };
|
|
831
847
|
}
|
|
832
848
|
|
|
833
849
|
module.exports = { smartFillPage };
|