@reconcrap/boss-recommend-mcp 2.0.28 → 2.0.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.0.28",
3
+ "version": "2.0.30",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { createRunLifecycleManager } from "../../core/run/index.js";
2
4
  import {
3
5
  addTiming,
@@ -39,6 +41,7 @@ import {
39
41
  closeRecommendDetail,
40
42
  createRecommendDetailNetworkRecorder,
41
43
  extractRecommendDetailCandidate,
44
+ isStaleRecommendNodeError,
42
45
  openRecommendCardDetailWithFreshRetry,
43
46
  waitForRecommendDetailNetworkEvents
44
47
  } from "./detail.js";
@@ -376,6 +379,88 @@ function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
376
379
  };
377
380
  }
378
381
 
382
+ export function isRecoverableImageCaptureError(error) {
383
+ const code = String(error?.code || "");
384
+ if (code === "IMAGE_CAPTURE_TIMEOUT" || code === "IMAGE_CAPTURE_TOTAL_TIMEOUT") return true;
385
+ if (isStaleRecommendNodeError(error)) return true;
386
+ return /Image fallback capture timed out/i.test(String(error?.message || error || ""));
387
+ }
388
+
389
+ function collectPartialImageEvidencePaths(basePath = "", extension = "jpg", maxCount = 12) {
390
+ const resolved = String(basePath || "").trim();
391
+ if (!resolved) return [];
392
+ const parsed = path.parse(resolved);
393
+ const ext = parsed.ext || `.${String(extension || "jpg").replace(/^\./, "") || "jpg"}`;
394
+ const files = [];
395
+ for (let index = 0; index < Math.max(1, Number(maxCount) || 1); index += 1) {
396
+ const page = String(index + 1).padStart(2, "0");
397
+ const candidatePath = path.join(parsed.dir, `${parsed.name}-page-${page}${ext}`);
398
+ if (fs.existsSync(candidatePath)) files.push(candidatePath);
399
+ }
400
+ return files;
401
+ }
402
+
403
+ export function createRecoverableImageCaptureEvidence(error, {
404
+ elapsedMs = 0,
405
+ filePath = "",
406
+ extension = "jpg",
407
+ maxScreenshots = 8
408
+ } = {}) {
409
+ const filePaths = collectPartialImageEvidencePaths(filePath, extension, maxScreenshots);
410
+ return {
411
+ schema_version: 1,
412
+ ok: false,
413
+ source: "image-scroll-sequence",
414
+ elapsed_ms: Math.max(0, Math.round(Number(error?.elapsed_ms ?? elapsedMs) || 0)),
415
+ capture_count: filePaths.length,
416
+ screenshot_count: filePaths.length,
417
+ unique_screenshot_count: filePaths.length,
418
+ dropped_duplicate_count: 0,
419
+ total_byte_length: 0,
420
+ original_total_byte_length: 0,
421
+ llm_screenshot_count: 0,
422
+ llm_total_byte_length: 0,
423
+ llm_original_total_byte_length: 0,
424
+ llm_composition_error: null,
425
+ error_code: error?.code || (isStaleRecommendNodeError(error) ? "IMAGE_CAPTURE_STALE_NODE" : "IMAGE_CAPTURE_FAILED"),
426
+ error: error?.message || String(error || "Image capture failed"),
427
+ file_paths: filePaths,
428
+ llm_file_paths: []
429
+ };
430
+ }
431
+
432
+ function createImageCaptureFailureScreening(candidate, error) {
433
+ return {
434
+ status: "fail",
435
+ passed: false,
436
+ score: 0,
437
+ reasons: ["image_capture_failed"],
438
+ error: compactError(error, "IMAGE_CAPTURE_FAILED"),
439
+ candidate
440
+ };
441
+ }
442
+
443
+ export function isRecoverableRecommendDetailError(error) {
444
+ return isStaleRecommendNodeError(error);
445
+ }
446
+
447
+ function compactRecoverableDetailError(error) {
448
+ return compactError(error, isStaleRecommendNodeError(error) ? "DETAIL_STALE_NODE" : "DETAIL_OPEN_FAILED");
449
+ }
450
+
451
+ function createRecoverableDetailFailureScreening(candidate, error) {
452
+ return {
453
+ status: "fail",
454
+ passed: false,
455
+ score: 0,
456
+ reasons: isStaleRecommendNodeError(error)
457
+ ? ["detail_open_failed", "stale_node"]
458
+ : ["detail_open_failed"],
459
+ error: compactRecoverableDetailError(error),
460
+ candidate
461
+ };
462
+ }
463
+
379
464
  export async function runRecommendWorkflow({
380
465
  client,
381
466
  targetUrl = "",
@@ -684,124 +769,149 @@ export async function runRecommendWorkflow({
684
769
 
685
770
  let screeningCandidate = cardCandidate;
686
771
  let detailResult = null;
772
+ let recoverableDetailError = null;
687
773
  if (index < effectiveDetailLimit) {
688
- await runControl.waitIfPaused();
689
- runControl.throwIfCanceled();
690
- runControl.setPhase("recommend:detail");
691
- rootState = await ensureRecommendViewport(rootState, "detail");
692
- networkRecorder.clear();
693
- const openedDetail = await openRecommendCardDetailWithFreshRetry(client, {
694
- cardNodeId,
695
- candidateKey,
696
- cardCandidate,
697
- rootState,
698
- targetUrl,
699
- maxAttempts: 2
700
- });
701
- addTiming(timings, "candidate_click_ms", openedDetail.timings?.candidate_click_ms);
702
- addTiming(timings, "detail_open_ms", openedDetail.timings?.detail_open_ms);
703
- cardNodeId = openedDetail.card_node_id || cardNodeId;
704
- cardCandidate = openedDetail.card_candidate || cardCandidate;
705
- screeningCandidate = cardCandidate;
706
- const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
707
- const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
708
- waitForRecommendDetailNetworkEvents,
709
- networkRecorder,
710
- {
711
- waitPlan,
712
- minCount: 1,
713
- requireLoaded: true,
714
- intervalMs: 120
774
+ try {
775
+ await runControl.waitIfPaused();
776
+ runControl.throwIfCanceled();
777
+ runControl.setPhase("recommend:detail");
778
+ rootState = await ensureRecommendViewport(rootState, "detail");
779
+ networkRecorder.clear();
780
+ const openedDetail = await openRecommendCardDetailWithFreshRetry(client, {
781
+ cardNodeId,
782
+ candidateKey,
783
+ cardCandidate,
784
+ rootState,
785
+ targetUrl,
786
+ retryTimeoutMs: 8000,
787
+ maxAttempts: 3
788
+ });
789
+ addTiming(timings, "candidate_click_ms", openedDetail.timings?.candidate_click_ms);
790
+ addTiming(timings, "detail_open_ms", openedDetail.timings?.detail_open_ms);
791
+ cardNodeId = openedDetail.card_node_id || cardNodeId;
792
+ cardCandidate = openedDetail.card_candidate || cardCandidate;
793
+ screeningCandidate = cardCandidate;
794
+ const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
795
+ const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
796
+ waitForRecommendDetailNetworkEvents,
797
+ networkRecorder,
798
+ {
799
+ waitPlan,
800
+ minCount: 1,
801
+ requireLoaded: true,
802
+ intervalMs: 120
803
+ }
804
+ ));
805
+ if (networkWait?.elapsed_ms != null) {
806
+ timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
715
807
  }
716
- ));
717
- if (networkWait?.elapsed_ms != null) {
718
- timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
719
- }
720
- detailResult = await extractRecommendDetailCandidate(client, {
721
- cardCandidate,
722
- cardNodeId,
723
- detailState: openedDetail.detail_state,
724
- networkEvents: networkRecorder.events,
725
- targetUrl,
726
- closeDetail: false,
727
- networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
728
- networkParseIntervalMs: 250
729
- });
730
- addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
731
-
732
- const parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
733
- let source = "network";
734
- let imageEvidence = null;
735
- if (parsedNetworkProfileCount > 0) {
736
- recordCvNetworkHit(cvAcquisitionState, {
737
- parsedNetworkProfileCount,
738
- waitResult: networkWait
808
+ detailResult = await extractRecommendDetailCandidate(client, {
809
+ cardCandidate,
810
+ cardNodeId,
811
+ detailState: openedDetail.detail_state,
812
+ networkEvents: networkRecorder.events,
813
+ targetUrl,
814
+ closeDetail: false,
815
+ networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
816
+ networkParseIntervalMs: 250
739
817
  });
740
- } else {
741
- const captureNodeId = openedDetail.detail_state?.popup?.node_id
742
- || openedDetail.detail_state?.resumeIframe?.node_id
743
- || null;
744
- if (captureNodeId) {
745
- imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
746
- filePath: imageEvidenceFilePath({
818
+ addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
819
+
820
+ const parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
821
+ let source = "network";
822
+ let imageEvidence = null;
823
+ if (parsedNetworkProfileCount > 0) {
824
+ recordCvNetworkHit(cvAcquisitionState, {
825
+ parsedNetworkProfileCount,
826
+ waitResult: networkWait
827
+ });
828
+ } else {
829
+ const captureNodeId = openedDetail.detail_state?.popup?.node_id
830
+ || openedDetail.detail_state?.resumeIframe?.node_id
831
+ || null;
832
+ if (captureNodeId) {
833
+ const imageEvidencePath = imageEvidenceFilePath({
747
834
  imageOutputDir,
748
835
  domain: "recommend",
749
836
  runId: runControl?.runId,
750
837
  index,
751
838
  extension: "jpg"
752
- }),
753
- format: "jpeg",
754
- quality: 72,
755
- optimize: true,
756
- resizeMaxWidth: 1100,
757
- captureViewport: true,
758
- padding: 4,
759
- maxScreenshots: maxImagePages,
760
- wheelDeltaY: imageWheelDeltaY,
761
- settleMs: 350,
762
- scrollMethod: "dom-anchor-fallback-input",
763
- stepTimeoutMs: 45000,
764
- totalTimeoutMs: 90000,
765
- duplicateStopCount: 1,
766
- skipDuplicateScreenshots: true,
767
- composeForLlm: true,
768
- llmPagesPerImage: 3,
769
- llmResizeMaxWidth: 1100,
770
- llmQuality: 72,
771
- metadata: {
772
- domain: "recommend",
773
- capture_mode: "scroll_sequence",
774
- acquisition_reason: "network_miss_image_fallback",
775
- run_candidate_index: index,
776
- candidate_key: candidateKey
839
+ });
840
+ try {
841
+ imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
842
+ filePath: imageEvidencePath,
843
+ format: "jpeg",
844
+ quality: 72,
845
+ optimize: true,
846
+ resizeMaxWidth: 1100,
847
+ captureViewport: true,
848
+ padding: 4,
849
+ maxScreenshots: maxImagePages,
850
+ wheelDeltaY: imageWheelDeltaY,
851
+ settleMs: 350,
852
+ scrollMethod: "dom-anchor-fallback-input",
853
+ stepTimeoutMs: 45000,
854
+ totalTimeoutMs: 90000,
855
+ duplicateStopCount: 1,
856
+ skipDuplicateScreenshots: true,
857
+ composeForLlm: true,
858
+ llmPagesPerImage: 3,
859
+ llmResizeMaxWidth: 1100,
860
+ llmQuality: 72,
861
+ metadata: {
862
+ domain: "recommend",
863
+ capture_mode: "scroll_sequence",
864
+ acquisition_reason: "network_miss_image_fallback",
865
+ run_candidate_index: index,
866
+ candidate_key: candidateKey
867
+ }
868
+ }));
869
+ source = "image";
870
+ } catch (error) {
871
+ if (!isRecoverableImageCaptureError(error)) throw error;
872
+ imageEvidence = createRecoverableImageCaptureEvidence(error, {
873
+ elapsedMs: timings.screenshot_capture_ms,
874
+ filePath: imageEvidencePath,
875
+ extension: "jpg",
876
+ maxScreenshots: maxImagePages
877
+ });
878
+ source = "image_capture_failed";
777
879
  }
778
- }));
779
- source = "image";
780
- recordCvImageFallback(cvAcquisitionState, {
781
- parsedNetworkProfileCount,
782
- waitResult: networkWait,
783
- imageEvidence
784
- });
785
- } else {
786
- source = "missing_capture_node";
787
- recordCvNetworkMiss(cvAcquisitionState, {
788
- reason: "network_miss_no_capture_node",
789
- parsedNetworkProfileCount,
790
- waitResult: networkWait
791
- });
880
+ recordCvImageFallback(cvAcquisitionState, {
881
+ reason: source === "image_capture_failed"
882
+ ? "network_miss_image_capture_failed"
883
+ : "network_miss_image_fallback",
884
+ parsedNetworkProfileCount,
885
+ waitResult: networkWait,
886
+ imageEvidence
887
+ });
888
+ } else {
889
+ source = "missing_capture_node";
890
+ recordCvNetworkMiss(cvAcquisitionState, {
891
+ reason: "network_miss_no_capture_node",
892
+ parsedNetworkProfileCount,
893
+ waitResult: networkWait
894
+ });
895
+ }
792
896
  }
793
- }
794
897
 
795
- detailResult.image_evidence = imageEvidence;
796
- detailResult.cv_acquisition = {
797
- source,
798
- mode_after: compactCvAcquisitionState(cvAcquisitionState).mode,
799
- wait_plan: waitPlan,
800
- network_wait: networkWait,
801
- parsed_network_profile_count: parsedNetworkProfileCount,
802
- image_evidence: summarizeImageEvidence(imageEvidence)
803
- };
804
- screeningCandidate = detailResult.candidate;
898
+ detailResult.image_evidence = imageEvidence;
899
+ detailResult.cv_acquisition = {
900
+ source,
901
+ mode_after: compactCvAcquisitionState(cvAcquisitionState).mode,
902
+ wait_plan: waitPlan,
903
+ network_wait: networkWait,
904
+ parsed_network_profile_count: parsedNetworkProfileCount,
905
+ image_evidence: summarizeImageEvidence(imageEvidence)
906
+ };
907
+ screeningCandidate = detailResult.candidate;
908
+ } catch (error) {
909
+ if (!isRecoverableRecommendDetailError(error)) throw error;
910
+ recoverableDetailError = error;
911
+ detailResult = null;
912
+ timings.detail_recovered_error = compactRecoverableDetailError(error);
913
+ await closeRecommendDetail(client, { attemptsLimit: 2 }).catch(() => null);
914
+ }
805
915
  }
806
916
 
807
917
  await runControl.waitIfPaused();
@@ -809,7 +919,9 @@ export async function runRecommendWorkflow({
809
919
  runControl.setPhase("recommend:screening");
810
920
  let llmResult = null;
811
921
  if (useLlmScreening) {
812
- if (!llmConfig) {
922
+ if (recoverableDetailError || detailResult?.image_evidence?.ok === false) {
923
+ llmResult = null;
924
+ } else if (!llmConfig) {
813
925
  llmResult = createMissingLlmConfigResult();
814
926
  } else {
815
927
  try {
@@ -831,9 +943,16 @@ export async function runRecommendWorkflow({
831
943
  }
832
944
  if (detailResult) detailResult.llm_result = llmResult;
833
945
  }
834
- const screening = useLlmScreening
835
- ? llmResultToScreening(llmResult, screeningCandidate)
836
- : screenCandidate(screeningCandidate, { criteria });
946
+ const screening = recoverableDetailError
947
+ ? createRecoverableDetailFailureScreening(screeningCandidate, recoverableDetailError)
948
+ : detailResult?.image_evidence?.ok === false
949
+ ? createImageCaptureFailureScreening(screeningCandidate, {
950
+ code: detailResult.image_evidence.error_code,
951
+ message: detailResult.image_evidence.error
952
+ })
953
+ : useLlmScreening
954
+ ? llmResultToScreening(llmResult, screeningCandidate)
955
+ : screenCandidate(screeningCandidate, { criteria });
837
956
  let actionDiscovery = null;
838
957
  let postActionResult = null;
839
958
  if (postActionEnabled && detailResult) {
@@ -875,6 +994,14 @@ export async function runRecommendWorkflow({
875
994
  screening: compactScreening(screening),
876
995
  action_discovery: compactActionDiscovery(actionDiscovery),
877
996
  post_action: postActionResult,
997
+ error: recoverableDetailError
998
+ ? compactRecoverableDetailError(recoverableDetailError)
999
+ : detailResult?.image_evidence?.ok === false
1000
+ ? compactError({
1001
+ code: detailResult.image_evidence.error_code,
1002
+ message: detailResult.image_evidence.error
1003
+ }, "IMAGE_CAPTURE_FAILED")
1004
+ : null,
878
1005
  timings
879
1006
  };
880
1007
  results.push(compactResult);
@@ -896,6 +1023,9 @@ export async function runRecommendWorkflow({
896
1023
  llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
897
1024
  greet_count: greetCount,
898
1025
  post_action_clicked: results.filter((item) => item.post_action?.action_clicked).length,
1026
+ image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
1027
+ detail_open_failed: results.filter((item) => item.error?.code === "DETAIL_STALE_NODE" || item.error?.code === "DETAIL_OPEN_FAILED").length,
1028
+ transient_recovered: results.filter((item) => item.error?.code === "DETAIL_STALE_NODE" || item.error?.code === "IMAGE_CAPTURE_STALE_NODE" || item.error?.code === "IMAGE_CAPTURE_TIMEOUT" || item.error?.code === "IMAGE_CAPTURE_TOTAL_TIMEOUT").length,
899
1029
  unique_seen: compactInfiniteListState(listState).seen_count,
900
1030
  scroll_count: compactInfiniteListState(listState).scroll_count,
901
1031
  refresh_rounds: refreshRounds,
@@ -920,6 +1050,7 @@ export async function runRecommendWorkflow({
920
1050
  score: screening.score
921
1051
  },
922
1052
  llm_screening: compactScreeningLlmResult(llmResult),
1053
+ error: compactResult.error,
923
1054
  post_action: postActionResult
924
1055
  }
925
1056
  });
@@ -958,6 +1089,9 @@ export async function runRecommendWorkflow({
958
1089
  screened: results.length,
959
1090
  detail_opened: results.filter((item) => item.detail).length,
960
1091
  llm_screened: results.filter((item) => item.detail?.llm_screening || item.llm_screening).length,
1092
+ detail_open_failed: results.filter((item) => item.error?.code === "DETAIL_STALE_NODE" || item.error?.code === "DETAIL_OPEN_FAILED").length,
1093
+ image_capture_failed: results.filter((item) => item.detail?.image_evidence?.ok === false).length,
1094
+ transient_recovered: results.filter((item) => item.error?.code === "DETAIL_STALE_NODE" || item.error?.code === "IMAGE_CAPTURE_STALE_NODE" || item.error?.code === "IMAGE_CAPTURE_TIMEOUT" || item.error?.code === "IMAGE_CAPTURE_TOTAL_TIMEOUT").length,
961
1095
  passed: results.filter((item) => item.screening.passed).length,
962
1096
  greet_count: greetCount,
963
1097
  post_action_clicked: results.filter((item) => item.post_action?.action_clicked).length,
@@ -288,6 +288,66 @@ function completionReason(status) {
288
288
  return null;
289
289
  }
290
290
 
291
+ function normalizeErrorText(error = {}) {
292
+ return normalizeText([
293
+ error?.code || "",
294
+ error?.message || error || ""
295
+ ].join(" "));
296
+ }
297
+
298
+ function classifyRecommendRecovery(error = {}) {
299
+ const text = normalizeErrorText(error);
300
+ if (!text) return null;
301
+ if (/BOSS_LOGIN_REQUIRED/i.test(text)) return "login_required";
302
+ if (/Could not find node with given id|No node with given id|Node is detached|Cannot find node|DETAIL_STALE_NODE|IMAGE_CAPTURE_STALE_NODE/i.test(text)) {
303
+ return "transient_stale_dom";
304
+ }
305
+ if (/IMAGE_CAPTURE_TIMEOUT|IMAGE_CAPTURE_TOTAL_TIMEOUT|Image fallback capture timed out/i.test(text)) {
306
+ return "transient_image_capture";
307
+ }
308
+ if (/(?:aborted|abort|timeout|timed out|fetch failed|socket|network|ECONNRESET|ETIMEDOUT|EAI_AGAIN)/i.test(text)) {
309
+ return "transient_network_or_llm";
310
+ }
311
+ return null;
312
+ }
313
+
314
+ function buildConstrainedAgentRecovery(snapshot = {}, meta = {}, artifacts = null) {
315
+ const error = snapshot?.error || snapshot?.result?.error || null;
316
+ const classification = classifyRecommendRecovery(error);
317
+ if (!classification) return null;
318
+ const canRestartSameRequest = classification !== "login_required";
319
+ return {
320
+ policy_version: 1,
321
+ classification,
322
+ safe_for_outer_ai_agent: true,
323
+ recommended_action: canRestartSameRequest
324
+ ? "restart_same_recommend_request_only"
325
+ : "ask_user_to_login_then_retry_same_recommend_request",
326
+ package_requirement: "@reconcrap/boss-recommend-mcp@>=2.0.30",
327
+ run_id: snapshot?.runId || snapshot?.run_id || null,
328
+ retryable: true,
329
+ same_request_sources: {
330
+ instruction: "run.context.instruction",
331
+ confirmation: "run.context.confirmation",
332
+ overrides: "run.context.overrides",
333
+ follow_up: "run.context.follow_up"
334
+ },
335
+ constraints: [
336
+ "Do not change instruction, criteria, filters, job, page_scope, target_count, post_action, or max_greet_count.",
337
+ "Do not switch to search/recruit/chat and do not add follow_up.chat.",
338
+ "Do not summarize, translate, or rewrite criteria.",
339
+ "Do not ask the user to reconfirm business choices unless Boss login is required or the stored context is missing.",
340
+ "Use the same Chrome debug port and recommend page route."
341
+ ],
342
+ artifacts: artifacts ? {
343
+ run_state_path: artifacts.run_state_path || null,
344
+ checkpoint_path: artifacts.checkpoint_path || null,
345
+ report_json: artifacts.report_json || null,
346
+ output_csv: artifacts.output_csv || null
347
+ } : null
348
+ };
349
+ }
350
+
291
351
  function ensureRecommendRunArtifacts(snapshot) {
292
352
  const artifacts = getRecommendRunArtifacts(snapshot?.runId || snapshot?.run_id);
293
353
  if (!artifacts) return null;
@@ -317,6 +377,9 @@ function ensureRecommendRunArtifacts(snapshot) {
317
377
  progress: snapshot.progress || {},
318
378
  context: snapshot.context || {},
319
379
  checkpoint,
380
+ error: snapshot.error || null,
381
+ last_message: snapshot.error?.message || snapshot.phase || snapshot.stage || null,
382
+ recovery: buildConstrainedAgentRecovery(snapshot, meta, artifacts),
320
383
  summary: artifactSummary,
321
384
  generated_at: new Date().toISOString()
322
385
  });
@@ -395,6 +458,7 @@ function buildLegacyRecommendResult(snapshot) {
395
458
  screen_params: clonePlain(meta.parsed?.screenParams || {}, {}),
396
459
  target_count_semantics: TARGET_COUNT_SEMANTICS,
397
460
  error: snapshot.error || null,
461
+ recovery: buildConstrainedAgentRecovery(snapshot, meta, artifacts),
398
462
  results: resultRows
399
463
  };
400
464
  }
@@ -409,6 +473,7 @@ function normalizeRunSnapshot(snapshot) {
409
473
  TERMINAL_STATUSES.has(snapshot.status)
410
474
  || snapshot.status === RUN_STATUS_PAUSED
411
475
  ) ? buildLegacyRecommendResult({ ...snapshot, progress }) : null;
476
+ const recovery = buildConstrainedAgentRecovery(snapshot, meta, artifacts);
412
477
  const oldContext = {
413
478
  workspace_root: meta.workspaceRoot || null,
414
479
  instruction: meta.args?.instruction || "",
@@ -449,6 +514,7 @@ function normalizeRunSnapshot(snapshot) {
449
514
  last_resumed_at: meta.lastResumedAt || null,
450
515
  last_paused_at: snapshot.status === RUN_STATUS_PAUSED ? snapshot.updatedAt : null
451
516
  },
517
+ recovery,
452
518
  result: legacyResult,
453
519
  artifacts
454
520
  };
@@ -481,6 +547,7 @@ function persistRecommendRunSnapshot(snapshot, {
481
547
  control: normalized.control,
482
548
  resume: normalized.resume,
483
549
  error: normalized.error,
550
+ recovery: normalized.recovery,
484
551
  result: normalized.result,
485
552
  summary: normalized.summary,
486
553
  artifacts: normalized.artifacts