halo-agent 2.0.0 → 2.0.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/orchestrator.js +47 -25
- package/package.json +1 -1
- package/smartFill.js +140 -24
package/orchestrator.js
CHANGED
|
@@ -369,15 +369,46 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
369
369
|
});
|
|
370
370
|
|
|
371
371
|
if (visionResult.submitted) {
|
|
372
|
-
// Vision
|
|
372
|
+
// Vision THINKS it submitted, but we shouldn't trust that without
|
|
373
|
+
// verifying — vision can confuse "review page rendered" with
|
|
374
|
+
// "application accepted." Route through the same verify-submit
|
|
375
|
+
// gate everything else uses. Worst case → REVIEWING, user clicks
|
|
376
|
+
// Submit. Better than a false-positive DONE.
|
|
373
377
|
const confirmShot = await page.screenshot({ type: 'jpeg', quality: 70 }).catch(() => null);
|
|
374
378
|
const confirmKey = confirmShot ? await uploadScreenshot(config, confirmShot, `confirm_${queueId}.jpg`) : null;
|
|
375
|
-
|
|
376
|
-
|
|
379
|
+
const verdictUrl = page.url();
|
|
380
|
+
let vVerdict = { submitted: null, error_message: null, source: 'unavailable' };
|
|
381
|
+
try {
|
|
382
|
+
const vRes = await fetch(`${config.apiUrl}/agent/verify-submit`, {
|
|
383
|
+
method: 'POST',
|
|
384
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.token}` },
|
|
385
|
+
body: JSON.stringify({ queue_id: queueId, page_url: verdictUrl }),
|
|
386
|
+
});
|
|
387
|
+
if (vRes.ok) vVerdict = await vRes.json();
|
|
388
|
+
} catch {}
|
|
389
|
+
if (vVerdict.submitted === true) {
|
|
390
|
+
await reportStatus('DONE', { confirmation_screenshot_r2_key: confirmKey || null, fields_filled: cumulativeFilled });
|
|
391
|
+
await clearCheckpoint(config, queueId);
|
|
392
|
+
console.log(`[orchestrator] Done via vision (verified): ${queueItem.company} - ${queueItem.title}`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (vVerdict.submitted === false) {
|
|
396
|
+
await reportStatus('NEEDS_ATTENTION', {
|
|
397
|
+
review_screenshot_r2_key: confirmKey || null,
|
|
398
|
+
needs_attention_reason: `Vision submitted but ATS rejected: ${vVerdict.error_message || 'unknown'}`,
|
|
399
|
+
intervention_type: 'submit_failed',
|
|
400
|
+
step: 'VERIFY',
|
|
401
|
+
step_detail: (vVerdict.error_message || '').slice(0, 200),
|
|
402
|
+
fields_filled: cumulativeFilled,
|
|
403
|
+
});
|
|
404
|
+
throw new Error(`Vision-submit failed verification: ${vVerdict.error_message || 'unknown'}`);
|
|
405
|
+
}
|
|
406
|
+
await reportStatus('REVIEWING', {
|
|
407
|
+
review_screenshot_r2_key: confirmKey || null,
|
|
408
|
+
step: 'REVIEWING',
|
|
409
|
+
step_detail: 'Vision attempted submit — verifier unavailable, please eyeball',
|
|
377
410
|
fields_filled: cumulativeFilled,
|
|
378
411
|
});
|
|
379
|
-
await clearCheckpoint(config, queueId);
|
|
380
|
-
console.log(`[orchestrator] Done via vision: ${queueItem.company} - ${queueItem.title}`);
|
|
381
412
|
return;
|
|
382
413
|
}
|
|
383
414
|
|
|
@@ -474,31 +505,22 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
474
505
|
}
|
|
475
506
|
|
|
476
507
|
if (verdict.submitted === null) {
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
fields_filled: cumulativeFilled,
|
|
488
|
-
});
|
|
489
|
-
await clearCheckpoint(config, queueId);
|
|
490
|
-
console.log(`[orchestrator] Done (auto-submit, unverified): ${queueItem.company} - ${queueItem.title}`);
|
|
491
|
-
return;
|
|
492
|
-
}
|
|
493
|
-
console.warn(`[orchestrator] Could not verify submission (source: ${verdict.source}). Sending to REVIEWING for your eyeball.`);
|
|
508
|
+
// EARLIER VERSION: when auto-submit was ON, we trusted the click and
|
|
509
|
+
// marked DONE. That was wrong — it produced false-positive submissions
|
|
510
|
+
// (applied=true in DB, no actual application sent). Auto-submit means
|
|
511
|
+
// "don't make me click Submit on the dashboard" — it does NOT mean
|
|
512
|
+
// "lie about delivery."
|
|
513
|
+
//
|
|
514
|
+
// Honest behavior: unverified == REVIEWING regardless of auto-submit.
|
|
515
|
+
// The screenshot is right there in the dashboard, one click confirms.
|
|
516
|
+
// Better to over-ask than to ghost-apply.
|
|
517
|
+
console.warn(`[orchestrator] Could not verify submission (source: ${verdict.source}). REVIEWING — please eyeball the screenshot + click Submit.`);
|
|
494
518
|
await reportStatus('REVIEWING', {
|
|
495
519
|
review_screenshot_r2_key: confirmKey || null,
|
|
496
520
|
step: 'REVIEWING',
|
|
497
|
-
step_detail:
|
|
521
|
+
step_detail: `Submit clicked at ${verdictUrl.slice(0, 100)} — verifier unavailable, please confirm`,
|
|
498
522
|
fields_filled: cumulativeFilled,
|
|
499
523
|
});
|
|
500
|
-
// Stop here; user clicks Submit on dashboard → /apply-queue/submit/:id
|
|
501
|
-
// will flip to DONE. Don't return — let the function return naturally.
|
|
502
524
|
return;
|
|
503
525
|
}
|
|
504
526
|
|
package/package.json
CHANGED
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}`);
|