halo-agent 2.0.2 → 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 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
- const confirmKey = await uploadScreenshot(config, confirmScreenshot, `confirm_${queueId}.jpg`);
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 Ashby/ATS rejected it: ${reason}`,
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
- // 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -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 (skip already-filled)
257
- const currentValue = (el.value || (isContentEditable ? safeText(el.innerText) : '') || '').trim();
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;