halo-agent 2.0.1 → 2.0.3
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/orchestrator.js +94 -12
- package/package.json +1 -1
- package/scanAccessibility.js +11 -2
- package/smartFill.js +140 -24
package/orchestrator.js
CHANGED
|
@@ -460,7 +460,7 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
460
460
|
}
|
|
461
461
|
|
|
462
462
|
const confirmScreenshot = await page.screenshot({ type: 'jpeg', quality: 70 });
|
|
463
|
-
|
|
463
|
+
let confirmKey = await uploadScreenshot(config, confirmScreenshot, `confirm_${queueId}.jpg`);
|
|
464
464
|
|
|
465
465
|
// Verify-then-DONE: trusting waitForURL alone was wrong (the Chalk bug —
|
|
466
466
|
// Ashby rendered "Missing entry for required field: Name, Email, ..."
|
|
@@ -493,9 +493,66 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
493
493
|
if (verdict.submitted === false) {
|
|
494
494
|
const reason = verdict.error_message || 'Submission did not confirm — form may still have errors';
|
|
495
495
|
console.warn(`[orchestrator] Submission NOT verified. Reason: ${reason}`);
|
|
496
|
+
|
|
497
|
+
// Fill-validate-retry: when the page shows inline validation errors
|
|
498
|
+
// (red banners + highlighted fields), the right move is to rescan +
|
|
499
|
+
// re-plan + fix + re-submit ONCE. Most validation failures are
|
|
500
|
+
// recoverable: a missed required checkbox, an empty optional field
|
|
501
|
+
// that turned out to be required, an LLM picked a too-creative answer.
|
|
502
|
+
// Bounded to ONE retry to prevent infinite loops on truly stuck forms.
|
|
503
|
+
const alreadyRetried = !!ctx.submitRetryAttempted;
|
|
504
|
+
if (!alreadyRetried) {
|
|
505
|
+
console.log('[orchestrator] Attempting fill-validate-retry: re-scanning page for highlighted errors...');
|
|
506
|
+
ctx.submitRetryAttempted = true;
|
|
507
|
+
await reportStatus('IN_PROGRESS', {
|
|
508
|
+
step: 'RETRY_FILL',
|
|
509
|
+
step_detail: `ATS rejected: ${reason.slice(0, 100)} — fixing & retrying`,
|
|
510
|
+
});
|
|
511
|
+
// Re-fill. The new scan will pick up red-highlighted required fields
|
|
512
|
+
// (their AX 'invalid' or 'required' state will tell the planner to
|
|
513
|
+
// re-attempt them). Already-filled correct fields stay put.
|
|
514
|
+
try {
|
|
515
|
+
await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
|
|
516
|
+
await waitForStableDOM(page, 1500);
|
|
517
|
+
const retrySubmitBtn = await findSubmitButton(page);
|
|
518
|
+
if (retrySubmitBtn) {
|
|
519
|
+
console.log('[orchestrator] Retry: clicking submit again...');
|
|
520
|
+
await retrySubmitBtn.click();
|
|
521
|
+
try { await page.waitForURL(/thank|confirm|success|applied|submitted/i, { timeout: 12000 }); } catch { await page.waitForTimeout(2500); }
|
|
522
|
+
// Re-verify
|
|
523
|
+
const retryUrl = page.url();
|
|
524
|
+
const retryShot = await page.screenshot({ type: 'jpeg', quality: 70 }).catch(() => null);
|
|
525
|
+
const retryShotKey = retryShot ? await uploadScreenshot(config, retryShot, `confirm_retry_${queueId}.jpg`) : confirmKey;
|
|
526
|
+
let retryVerdict = { submitted: null, error_message: null, source: 'unavailable' };
|
|
527
|
+
try {
|
|
528
|
+
const rRes = await fetch(`${config.apiUrl}/agent/verify-submit`, {
|
|
529
|
+
method: 'POST',
|
|
530
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.token}` },
|
|
531
|
+
body: JSON.stringify({ queue_id: queueId, page_url: retryUrl }),
|
|
532
|
+
});
|
|
533
|
+
if (rRes.ok) retryVerdict = await rRes.json();
|
|
534
|
+
} catch {}
|
|
535
|
+
if (retryVerdict.submitted === true) {
|
|
536
|
+
await reportStatus('DONE', { confirmation_screenshot_r2_key: retryShotKey || null, fields_filled: cumulativeFilled });
|
|
537
|
+
await clearCheckpoint(config, queueId);
|
|
538
|
+
console.log(`[orchestrator] Done (retry-after-validation-error): ${queueItem.company} - ${queueItem.title}`);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
// Retry verifier also unsure — fall through to NEEDS_ATTENTION with both screenshots
|
|
542
|
+
console.warn(`[orchestrator] Retry verdict: ${retryVerdict.submitted}; source=${retryVerdict.source}`);
|
|
543
|
+
confirmKey = retryShotKey || confirmKey;
|
|
544
|
+
} else {
|
|
545
|
+
console.warn('[orchestrator] Retry: no submit button visible after re-fill — page may have navigated.');
|
|
546
|
+
}
|
|
547
|
+
} catch (e) {
|
|
548
|
+
console.warn(`[orchestrator] Retry pass threw: ${e.message}`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// No retry available (or retry didn't succeed) — surface for human.
|
|
496
553
|
await reportStatus('NEEDS_ATTENTION', {
|
|
497
554
|
review_screenshot_r2_key: confirmKey || null,
|
|
498
|
-
needs_attention_reason: `Submit clicked but
|
|
555
|
+
needs_attention_reason: `Submit clicked but ATS rejected it: ${reason}`,
|
|
499
556
|
intervention_type: 'submit_failed',
|
|
500
557
|
step: 'VERIFY',
|
|
501
558
|
step_detail: reason.slice(0, 200),
|
|
@@ -505,15 +562,25 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
505
562
|
}
|
|
506
563
|
|
|
507
564
|
if (verdict.submitted === null) {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
565
|
+
const autoSubmit = config.autoSubmit || aep.agent_config?.auto_submit;
|
|
566
|
+
if (autoSubmit) {
|
|
567
|
+
// Auto-submit ON + verifier unavailable: trust the click AND make
|
|
568
|
+
// the screenshot the audit trail. The receipt detail surfaces this
|
|
569
|
+
// screenshot prominently — if it shows a red banner, the user
|
|
570
|
+
// clicks "Not submitted" on the receipt and we re-queue.
|
|
571
|
+
// This is the user-chosen policy: false-positives surface visually,
|
|
572
|
+
// not as a blocked REVIEWING row. Faster loop, audit by eyeball.
|
|
573
|
+
console.log(`[orchestrator] Verifier unavailable (source: ${verdict.source}); auto-submit ON — trusting click, screenshot is the receipt.`);
|
|
574
|
+
await reportStatus('DONE', {
|
|
575
|
+
confirmation_screenshot_r2_key: confirmKey || null,
|
|
576
|
+
fields_filled: cumulativeFilled,
|
|
577
|
+
step: 'DONE',
|
|
578
|
+
step_detail: 'Submitted (verifier unavailable, trust-on-click)',
|
|
579
|
+
});
|
|
580
|
+
await clearCheckpoint(config, queueId);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
// No auto-submit → bounce to REVIEWING so the user eyeballs first.
|
|
517
584
|
console.warn(`[orchestrator] Could not verify submission (source: ${verdict.source}). REVIEWING — please eyeball the screenshot + click Submit.`);
|
|
518
585
|
await reportStatus('REVIEWING', {
|
|
519
586
|
review_screenshot_r2_key: confirmKey || null,
|
|
@@ -532,7 +599,17 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
532
599
|
|
|
533
600
|
console.log(`[orchestrator] Done (verified): ${queueItem.company} - ${queueItem.title} · firecrawl-verified`);
|
|
534
601
|
|
|
535
|
-
// Post fill session data to backend for learning loop
|
|
602
|
+
// Post fill session data to backend for learning loop + receipt audit
|
|
603
|
+
// trail. filled_actions becomes the per-field decision list the user
|
|
604
|
+
// sees on Applications detail — "agent typed X for First Name because
|
|
605
|
+
// facts.identity.first_name", etc. Truncated to keep payload sane on
|
|
606
|
+
// long forms.
|
|
607
|
+
const filledActions = Array.from(ctx.answeredFields.entries()).slice(0, 80).map(([label, data]) => ({
|
|
608
|
+
label: String(label).slice(0, 200),
|
|
609
|
+
action: 'filled',
|
|
610
|
+
value: String(data?.value || '').slice(0, 500),
|
|
611
|
+
source: data?.source || '',
|
|
612
|
+
}));
|
|
536
613
|
await postFillSession(config, {
|
|
537
614
|
job_id: jobId,
|
|
538
615
|
ats_type: ats_type || 'unknown',
|
|
@@ -540,6 +617,11 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
540
617
|
fields_filled: cumulativeFilled,
|
|
541
618
|
skipped_fields: ctx.skippedFields,
|
|
542
619
|
conditional_fields: ctx.conditionalTriggers,
|
|
620
|
+
filled_actions: filledActions,
|
|
621
|
+
// Pass through which actual files were uploaded — backend writes to
|
|
622
|
+
// submissions.resume_pdf_r2_key / cover_letter_pdf_r2_key for receipt.
|
|
623
|
+
resume_pdf_r2_key: aep?.recommended_resume?.pdf_r2_key || null,
|
|
624
|
+
cover_letter_pdf_r2_key: aep?.cover_letter_pdf?.pdf_r2_key || null,
|
|
543
625
|
}).catch(() => {}); // non-critical
|
|
544
626
|
|
|
545
627
|
} catch (err) {
|
package/package.json
CHANGED
package/scanAccessibility.js
CHANGED
|
@@ -253,8 +253,17 @@ async function enrichFromDom(page, axFields) {
|
|
|
253
253
|
if (el.id) selectorHint = `#${cssEscape(el.id)}`;
|
|
254
254
|
else if (el.name) selectorHint = `${tag}[name="${el.name.replace(/"/g, '\\"')}"]`;
|
|
255
255
|
|
|
256
|
-
// Current value (
|
|
257
|
-
|
|
256
|
+
// Current value (used for filledAlready detection downstream).
|
|
257
|
+
// SPECIAL CASE for checkboxes/radios: their .value attribute defaults
|
|
258
|
+
// to "on" even when UNCHECKED. Using that as a "filled" signal
|
|
259
|
+
// silently skipped Reddit's required consent checkbox. Check .checked
|
|
260
|
+
// instead — that's the only honest signal of state.
|
|
261
|
+
let currentValue = '';
|
|
262
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
263
|
+
currentValue = el.checked ? 'checked' : '';
|
|
264
|
+
} else {
|
|
265
|
+
currentValue = (el.value || (isContentEditable ? safeText(el.innerText) : '') || '').trim();
|
|
266
|
+
}
|
|
258
267
|
|
|
259
268
|
// Native <select> options
|
|
260
269
|
let options = null;
|
package/smartFill.js
CHANGED
|
@@ -55,6 +55,21 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
|
|
|
55
55
|
|
|
56
56
|
case 'type': {
|
|
57
57
|
if (!item.value) return { ok: true, reason: 'skip: empty value' };
|
|
58
|
+
// If the field is actually a combobox/dropdown (scanAccessibility
|
|
59
|
+
// tagged it that way), the planner shouldn't have typed — the value
|
|
60
|
+
// we got is the user's INTENT (e.g. "Straight"), and we need to
|
|
61
|
+
// pick the closest dropdown option ("Heterosexual"). This is the
|
|
62
|
+
// case where the planner saw no options[] (because they're
|
|
63
|
+
// hidden until clicked) and fell back to type. Recover by treating
|
|
64
|
+
// it as click_option.
|
|
65
|
+
if (field.role === 'combobox' || field.role === 'listbox') {
|
|
66
|
+
const result = await openAndPickOption(page, locator, item.value, {
|
|
67
|
+
config: ctx.config, jobId: ctx.jobId, label: field.label,
|
|
68
|
+
});
|
|
69
|
+
if (result.ok) return result;
|
|
70
|
+
// Fall through to text-fill if no option matched at all
|
|
71
|
+
// (some Greenhouse comboboxes accept free-text in addition to options).
|
|
72
|
+
}
|
|
58
73
|
// Typeahead path: open suggestion list, pick first match.
|
|
59
74
|
if (field.isTypeahead) {
|
|
60
75
|
return await typeAndPickSuggestion(page, locator, item.value);
|
|
@@ -76,29 +91,9 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
|
|
|
76
91
|
}
|
|
77
92
|
|
|
78
93
|
case 'click_option': {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
await page.waitForTimeout(300);
|
|
83
|
-
// Look for an option whose visible text equals the planner's pick.
|
|
84
|
-
const optionSel = '[role="option"], [role="menuitem"], .select__option, li[class*="option"]';
|
|
85
|
-
const opts = page.locator(optionSel);
|
|
86
|
-
const count = await opts.count().catch(() => 0);
|
|
87
|
-
if (count === 0) return { ok: false, reason: 'dropdown opened but no options' };
|
|
88
|
-
const texts = await opts.allTextContents().catch(() => []);
|
|
89
|
-
const v = String(item.value).toLowerCase().trim();
|
|
90
|
-
let idx = texts.findIndex((t) => t.toLowerCase().trim() === v);
|
|
91
|
-
if (idx === -1) idx = texts.findIndex((t) => t.toLowerCase().includes(v) || v.includes(t.toLowerCase()));
|
|
92
|
-
if (idx === -1) {
|
|
93
|
-
await page.keyboard.press('Escape').catch(() => {});
|
|
94
|
-
return { ok: false, reason: `option "${item.value}" not in dropdown (have: ${texts.slice(0, 5).join(', ')})` };
|
|
95
|
-
}
|
|
96
|
-
await opts.nth(idx).click({ timeout: 2000 });
|
|
97
|
-
return { ok: true, reason: `clicked option: ${texts[idx]}` };
|
|
98
|
-
} catch (e) {
|
|
99
|
-
await page.keyboard.press('Escape').catch(() => {});
|
|
100
|
-
return { ok: false, reason: `click_option failed: ${e.message}` };
|
|
101
|
-
}
|
|
94
|
+
return await openAndPickOption(page, locator, item.value, {
|
|
95
|
+
config: ctx.config, jobId: ctx.jobId, label: field.label,
|
|
96
|
+
});
|
|
102
97
|
}
|
|
103
98
|
|
|
104
99
|
case 'set_checkbox': {
|
|
@@ -182,6 +177,122 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
|
|
|
182
177
|
* keyboard.type, then native-setter — but with verify-after-write to catch
|
|
183
178
|
* React rejecting our value.
|
|
184
179
|
*/
|
|
180
|
+
/**
|
|
181
|
+
* Open a custom dropdown and pick the closest option matching `value`.
|
|
182
|
+
* Handles the two architectural pitfalls today's Reddit run exposed:
|
|
183
|
+
*
|
|
184
|
+
* 1. Stale option lists. Greenhouse's intl-tel-input mounts a
|
|
185
|
+
* <ul role="listbox"> of country dial codes the first time the
|
|
186
|
+
* Phone field is touched, and never unmounts it. A naive
|
|
187
|
+
* page.locator('[role="option"]') matches those FIRST, so we
|
|
188
|
+
* end up trying to click "Afghanistan" when the user wanted
|
|
189
|
+
* "Heterosexual." Fix: snapshot the page's option count BEFORE
|
|
190
|
+
* opening this dropdown, then only look at options that
|
|
191
|
+
* appeared AFTER the click — they belong to OUR dropdown.
|
|
192
|
+
*
|
|
193
|
+
* 2. Off-screen / hidden options. allTextContents returns text for
|
|
194
|
+
* hidden options too. Filter to visible-only before matching.
|
|
195
|
+
*
|
|
196
|
+
* Falls back to LLM synonym matching when local exact/substring
|
|
197
|
+
* matching fails, so "Straight" still picks "Heterosexual."
|
|
198
|
+
*/
|
|
199
|
+
async function openAndPickOption(page, triggerLocator, value, llmCtx) {
|
|
200
|
+
try {
|
|
201
|
+
// Snapshot option-list state BEFORE opening so we can identify
|
|
202
|
+
// the new options.
|
|
203
|
+
const optionSel = '[role="option"], [role="menuitem"], .select__option, li[class*="option"]';
|
|
204
|
+
const beforeCount = await page.locator(optionSel).count().catch(() => 0);
|
|
205
|
+
|
|
206
|
+
await triggerLocator.click({ timeout: 2500 });
|
|
207
|
+
await page.waitForTimeout(350);
|
|
208
|
+
|
|
209
|
+
// Newly mounted options live at indexes >= beforeCount. If no new
|
|
210
|
+
// ones appeared, the dropdown may have rendered options earlier
|
|
211
|
+
// (already-open select). Fall through to scanning all visible.
|
|
212
|
+
const allOpts = page.locator(optionSel);
|
|
213
|
+
const totalCount = await allOpts.count().catch(() => 0);
|
|
214
|
+
const newCount = totalCount - beforeCount;
|
|
215
|
+
|
|
216
|
+
// Collect candidate {text, idx} pairs — prefer new options, but if
|
|
217
|
+
// none, scan all visible ones.
|
|
218
|
+
const startIdx = newCount > 0 ? beforeCount : 0;
|
|
219
|
+
const endIdx = totalCount;
|
|
220
|
+
const candidates = [];
|
|
221
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
222
|
+
const opt = allOpts.nth(i);
|
|
223
|
+
const visible = await opt.isVisible({ timeout: 200 }).catch(() => false);
|
|
224
|
+
if (!visible) continue;
|
|
225
|
+
const text = (await opt.textContent().catch(() => '') || '').replace(/\s+/g, ' ').trim();
|
|
226
|
+
if (!text) continue;
|
|
227
|
+
candidates.push({ idx: i, text });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (candidates.length === 0) {
|
|
231
|
+
await page.keyboard.press('Escape').catch(() => {});
|
|
232
|
+
return { ok: false, reason: 'dropdown opened but no visible options found' };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// STEP 1: exact match (case-insensitive)
|
|
236
|
+
const v = String(value).toLowerCase().trim();
|
|
237
|
+
let pick = candidates.find((c) => c.text.toLowerCase() === v);
|
|
238
|
+
|
|
239
|
+
// STEP 2: substring either direction (handles "Asian" → "South Asian")
|
|
240
|
+
if (!pick) pick = candidates.find((c) => {
|
|
241
|
+
const t = c.text.toLowerCase();
|
|
242
|
+
return t.includes(v) || v.includes(t);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// STEP 3: token overlap (handles "Hispanic" → "Hispanic or Latino")
|
|
246
|
+
if (!pick) {
|
|
247
|
+
const vTokens = v.split(/\s+/).filter((t) => t.length > 2);
|
|
248
|
+
pick = candidates.find((c) => {
|
|
249
|
+
const tTokens = c.text.toLowerCase().split(/\s+/);
|
|
250
|
+
return vTokens.some((vt) => tTokens.some((tt) => tt.includes(vt) || vt.includes(tt)));
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!pick) {
|
|
255
|
+
// No local match — try LLM pick if we have context. This is the
|
|
256
|
+
// "Straight" → "Heterosexual" path when synonyms don't appear in
|
|
257
|
+
// canonical facts.
|
|
258
|
+
if (llmCtx && llmCtx.config && llmCtx.jobId && llmCtx.label) {
|
|
259
|
+
try {
|
|
260
|
+
const res = await fetch(`${llmCtx.config.apiUrl}/smartfill/field-answer`, {
|
|
261
|
+
method: 'POST',
|
|
262
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${llmCtx.config.token}` },
|
|
263
|
+
body: JSON.stringify({
|
|
264
|
+
job_id: llmCtx.jobId,
|
|
265
|
+
field_label: llmCtx.label,
|
|
266
|
+
field_type: 'select',
|
|
267
|
+
options: candidates.map((c) => c.text),
|
|
268
|
+
previously_answered: [{ label: 'user intent', value: String(value) }],
|
|
269
|
+
}),
|
|
270
|
+
});
|
|
271
|
+
if (res.ok) {
|
|
272
|
+
const { value: picked } = await res.json();
|
|
273
|
+
if (picked && picked !== 'SKIP') {
|
|
274
|
+
const pv = picked.toLowerCase().trim();
|
|
275
|
+
pick = candidates.find((c) => c.text.toLowerCase() === pv)
|
|
276
|
+
|| candidates.find((c) => c.text.toLowerCase().includes(pv) || pv.includes(c.text.toLowerCase()));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} catch {}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!pick) {
|
|
284
|
+
await page.keyboard.press('Escape').catch(() => {});
|
|
285
|
+
return { ok: false, reason: `no option matched "${value}" (had ${candidates.length}: ${candidates.slice(0, 4).map((c) => c.text).join(' / ')}${candidates.length > 4 ? '...' : ''})` };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
await allOpts.nth(pick.idx).click({ timeout: 2000 });
|
|
289
|
+
return { ok: true, reason: `clicked option: ${pick.text}` };
|
|
290
|
+
} catch (e) {
|
|
291
|
+
await page.keyboard.press('Escape').catch(() => {});
|
|
292
|
+
return { ok: false, reason: `openAndPickOption failed: ${e.message}` };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
185
296
|
async function reactSafeType(page, locator, value) {
|
|
186
297
|
const v = String(value);
|
|
187
298
|
try {
|
|
@@ -325,7 +436,12 @@ async function smartFillPage(page, aep, options) {
|
|
|
325
436
|
continue;
|
|
326
437
|
}
|
|
327
438
|
const labelShort = (f.label || f.selectorHint || '?').slice(0, 50);
|
|
328
|
-
const result = await executePlanItem(page, item, fieldByMmid, {
|
|
439
|
+
const result = await executePlanItem(page, item, fieldByMmid, {
|
|
440
|
+
resumePath, coverLetterPath,
|
|
441
|
+
// Threaded so openAndPickOption can hit /smartfill/field-answer for
|
|
442
|
+
// synonym matching when local fuzzy fails ("Straight" → "Heterosexual").
|
|
443
|
+
config, jobId,
|
|
444
|
+
});
|
|
329
445
|
if (result.ok === 'ask_user') {
|
|
330
446
|
askUserReasons.push(`${labelShort}: ${result.reason}`);
|
|
331
447
|
console.warn(`[smartFill] ASK USER: "${labelShort}" — ${result.reason}`);
|