@reconcrap/boss-recommend-mcp 2.0.29 → 2.0.31

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/README.md CHANGED
@@ -227,10 +227,35 @@ config/screening-config.example.json
227
227
  - `apiKey`
228
228
  - `model`
229
229
 
230
+ 也可以用 `llmModels` 配置多个 OpenAI-compatible 模型。运行时会先调用第一个模型;当该模型请求失败、超时、返回非 JSON、或没有返回 `{"passed": true/false}` 决策时,会自动切到下一个模型。未配置 `llmModels` 或数组为空时,继续使用上面的单模型字段,旧配置无需迁移。
231
+
232
+ ```json
233
+ {
234
+ "llmModels": [
235
+ {
236
+ "name": "primary",
237
+ "baseUrl": "https://api.openai.com/v1",
238
+ "apiKey": "sk-primary",
239
+ "model": "gpt-4.1-mini"
240
+ },
241
+ {
242
+ "name": "backup",
243
+ "baseUrl": "https://api.openai.com/v1",
244
+ "apiKey": "sk-backup",
245
+ "model": "gpt-4.1-nano"
246
+ }
247
+ ],
248
+ "greetingMessage": "Hi同学,能麻烦发下简历吗?",
249
+ "llmThinkingLevel": "low",
250
+ "llmMaxRetries": 1
251
+ }
252
+ ```
253
+
230
254
  可选字段:
231
255
 
232
256
  - `openaiOrganization`
233
257
  - `openaiProject`
258
+ - `greetingMessage`:chat 求简历流程发送的招呼语。兼容 `greetingText` / `greeting_text`;本次 run 显式传入的 `greeting_text` 优先级最高。
234
259
  - `debugPort`:未显式传 `port` 时,recommend / search / chat CDP-only MCP run 和健康检查默认连接这个 Chrome 调试端口。
235
260
  - `outputDir`:recommend / search / chat 完成后的最终 CSV 与 report JSON 会写入这里;run state / checkpoint 仍保留在各自状态目录,方便 pause/resume/cancel。
236
261
  - `llmThinkingLevel`:默认 `low`。可设为 `off/minimal/low/medium/high/auto/current`,用于控制 OpenAI-compatible LLM 的 thinking/reasoning 强度。
@@ -243,7 +268,7 @@ npm 包安装后可直接使用可执行命令 `boss-recommend-mcp`。以下示
243
268
  ```bash
244
269
  node src/cli.js install --agent trae-cn
245
270
  node src/cli.js init-config
246
- node src/cli.js config set --base-url https://api.openai.com/v1 --api-key <your-key> --model gpt-4o-mini --thinking-level off
271
+ node src/cli.js config set --base-url https://api.openai.com/v1 --api-key <your-key> --model gpt-4o-mini --thinking-level off --greeting-message "您好,方便发下简历吗?"
247
272
  node src/cli.js set-port --port 9222
248
273
  node src/cli.js doctor --agent trae-cn
249
274
  node src/cli.js launch-chrome --port 9222
@@ -283,7 +308,7 @@ node src/cli.js chat prepare-run --slow-live --port 9222
283
308
  - `profile` 可选,默认 `default`
284
309
  - `job` 与 `port` 继承 recommend run 已选岗位和调试端口
285
310
  - `baseUrl` / `apiKey` / `model` 不再单独传入,固定复用 recommend 的 `screening-config.json`
286
- - `greeting_text` 默认优先级:本次显式值 > profile 历史值 > 内置默认招呼语(`Hi同学,能麻烦发下简历吗?`)
311
+ - `greeting_text` 默认优先级:本次显式值 > `screening-config.json.greetingMessage` > 内置默认招呼语(`Hi同学,能麻烦发下简历吗?`)
287
312
  - 若缺少 `follow_up.chat` 必填项,pipeline 会返回 `NEED_INPUT`
288
313
  - 如需聊天页筛选,请调用 `prepare_boss_chat_run` 获取岗位列表,再调用 `start_boss_chat_run`。
289
314
  - `boss-chat` 状态统一写入 `~/.boss-recommend-mcp/boss-chat`(或 `BOSS_CHAT_HOME` 指定目录),不再依赖工作区 `cwd`
@@ -313,7 +338,7 @@ chat-only 交互建议:
313
338
 
314
339
  - 先调用一次 `prepare_boss_chat_run`(可不带参数),服务会先导航到 `https://www.zhipin.com/web/chat/index` 并返回 `NEED_INPUT`,其中包含岗位 `job_options` 与待补字段。
315
340
  - 然后基于 `job_options` 让用户选择 `job`,并补齐 `start_from`、`target_count`、`criteria` 后调用 `start_boss_chat_run` 启动任务。
316
- - `greeting_text` 可选;未传时会自动沿用 profile 上次输入,若无历史值则使用默认招呼语(`Hi同学,能麻烦发下简历吗?`)。
341
+ - `greeting_text` 可选;未传时使用 `screening-config.json.greetingMessage`,若未配置则使用默认招呼语(`Hi同学,能麻烦发下简历吗?`)。
317
342
  - `target_count` 支持正整数、`all`、`-1`;若用户给出 `全部候选人` / `所有候选人`,会自动按不限(扫到底)处理。
318
343
 
319
344
  Trae-CN / 长对话防循环建议:
@@ -1,7 +1,9 @@
1
1
  {
2
- "baseUrl": "https://api.openai.com/v1",
3
- "apiKey": "replace-with-openai-api-key",
2
+ "baseUrl": "https://api.openai.com/v1",
3
+ "apiKey": "replace-with-openai-api-key",
4
4
  "model": "gpt-4.1-mini",
5
+ "llmModels": [],
6
+ "greetingMessage": "Hi同学,能麻烦发下简历吗?",
5
7
  "llmThinkingLevel": "low",
6
8
  "llmTimeoutMs": 60000,
7
9
  "llmMaxTokens": 512,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reconcrap/boss-recommend-mcp",
3
- "version": "2.0.29",
3
+ "version": "2.0.31",
4
4
  "description": "Unified MCP pipeline for recommend-page filtering and screening on Boss Zhipin",
5
5
  "keywords": [
6
6
  "boss",
package/src/chat-mcp.js CHANGED
@@ -874,12 +874,14 @@ async function readChatJobOptionsFromSession(session) {
874
874
 
875
875
  function normalizeChatStartInput(args = {}, configResolution = null) {
876
876
  const target = normalizeTargetCountInput(getBossChatTargetCountValue(args));
877
+ const explicitGreetingText = normalizeText(args.greeting_text || args.greetingText || args.greeting);
878
+ const configuredGreetingText = normalizeText(configResolution?.config?.greetingMessage || configResolution?.config?.greetingText);
877
879
  return {
878
880
  profile: normalizeText(args.profile) || "default",
879
881
  job: normalizeText(args.job),
880
882
  startFrom: normalizeText(args.start_from).toLowerCase(),
881
883
  criteria: normalizeText(args.criteria),
882
- greetingText: normalizeText(args.greeting_text || args.greetingText || args.greeting),
884
+ greetingText: explicitGreetingText || configuredGreetingText,
883
885
  target,
884
886
  targetCount: target.targetCount,
885
887
  publicTargetCount: target.publicValue,
@@ -243,6 +243,55 @@ function normalizeLlmThinkingLevel(raw, fallback = "low") {
243
243
  return LLM_THINKING_LEVELS.has(normalized) ? normalized : fallback;
244
244
  }
245
245
 
246
+ function firstConfiguredValue(...values) {
247
+ for (const value of values) {
248
+ if (value === undefined || value === null) continue;
249
+ if (typeof value === "string" && !value.trim()) continue;
250
+ return value;
251
+ }
252
+ return "";
253
+ }
254
+
255
+ function resolveRawLlmModelEntries(config = {}) {
256
+ if (Array.isArray(config.llmModels) && config.llmModels.length > 0) return config.llmModels;
257
+ if (Array.isArray(config.models) && config.models.length > 0) return config.models;
258
+ return [config];
259
+ }
260
+
261
+ function normalizeScreeningLlmModel(config = {}, rawEntry = {}, index = 0) {
262
+ const entry = typeof rawEntry === "string"
263
+ ? { model: rawEntry }
264
+ : (rawEntry && typeof rawEntry === "object" && !Array.isArray(rawEntry) ? rawEntry : {});
265
+ return {
266
+ name: normalizeText(firstConfiguredValue(entry.name, entry.label, entry.id, entry.providerName, entry.provider)),
267
+ baseUrl: normalizeText(firstConfiguredValue(entry.baseUrl, entry.base_url, config.baseUrl, config.base_url)).replace(/\/+$/, ""),
268
+ apiKey: normalizeText(firstConfiguredValue(entry.apiKey, entry.api_key, config.apiKey, config.api_key)),
269
+ model: normalizeText(firstConfiguredValue(entry.model, entry.modelName, entry.model_name, typeof rawEntry === "string" ? rawEntry : "", config.model)),
270
+ openaiOrganization: normalizeText(firstConfiguredValue(entry.openaiOrganization, entry.organization, config.openaiOrganization, config.organization)),
271
+ openaiProject: normalizeText(firstConfiguredValue(entry.openaiProject, entry.project, config.openaiProject, config.project)),
272
+ llmThinkingLevel: normalizeLlmThinkingLevel(
273
+ firstConfiguredValue(entry.llmThinkingLevel, entry.thinkingLevel, entry.reasoningEffort, config.llmThinkingLevel, config.thinkingLevel, config.reasoningEffort),
274
+ "low"
275
+ ),
276
+ llmTimeoutMs: parsePositiveInteger(firstConfiguredValue(entry.llmTimeoutMs, entry.timeoutMs, config.llmTimeoutMs, config.timeoutMs), null),
277
+ llmMaxRetries: parsePositiveInteger(firstConfiguredValue(entry.llmMaxRetries, entry.maxRetries, config.llmMaxRetries, config.maxRetries), null),
278
+ llmMaxTokens: parsePositiveInteger(firstConfiguredValue(entry.llmMaxTokens, entry.maxTokens, config.llmMaxTokens, config.maxTokens), null),
279
+ llmMaxCompletionTokens: parsePositiveInteger(
280
+ firstConfiguredValue(entry.llmMaxCompletionTokens, entry.maxCompletionTokens, config.llmMaxCompletionTokens, config.maxCompletionTokens),
281
+ null
282
+ ),
283
+ llmImageLimit: parsePositiveInteger(firstConfiguredValue(entry.llmImageLimit, entry.imageLimit, config.llmImageLimit, config.imageLimit), null),
284
+ llmImageDetail: normalizeText(firstConfiguredValue(entry.llmImageDetail, entry.imageDetail, config.llmImageDetail, config.imageDetail)),
285
+ temperature: parseConfigNumber(firstConfiguredValue(entry.temperature, config.temperature), null),
286
+ topP: parseConfigNumber(firstConfiguredValue(entry.topP, entry.top_p, config.topP, config.top_p), null),
287
+ llmProviderIndex: index
288
+ };
289
+ }
290
+
291
+ function normalizeScreeningLlmModels(config = {}) {
292
+ return resolveRawLlmModelEntries(config).map((entry, index) => normalizeScreeningLlmModel(config, entry, index));
293
+ }
294
+
246
295
  function resolveConfigPathValue(raw, configDir) {
247
296
  const normalized = normalizeText(raw);
248
297
  if (!normalized) return "";
@@ -259,13 +308,14 @@ function validateScreeningConfig(config) {
259
308
  message: "screening-config.json 缺失或格式无效。请填写 baseUrl、apiKey、model。"
260
309
  };
261
310
  }
262
- const baseUrl = normalizeText(config.baseUrl).replace(/\/+$/, "");
263
- const apiKey = normalizeText(config.apiKey);
264
- const model = normalizeText(config.model);
311
+ const llmModels = normalizeScreeningLlmModels(config);
265
312
  const missing = [];
266
- if (!baseUrl) missing.push("baseUrl");
267
- if (!apiKey) missing.push("apiKey");
268
- if (!model) missing.push("model");
313
+ for (const [index, llmModel] of llmModels.entries()) {
314
+ const prefix = llmModels.length > 1 ? `llmModels[${index}]` : "";
315
+ if (!llmModel.baseUrl) missing.push(prefix ? `${prefix}.baseUrl` : "baseUrl");
316
+ if (!llmModel.apiKey) missing.push(prefix ? `${prefix}.apiKey` : "apiKey");
317
+ if (!llmModel.model) missing.push(prefix ? `${prefix}.model` : "model");
318
+ }
269
319
  if (missing.length > 0) {
270
320
  return {
271
321
  ok: false,
@@ -273,17 +323,20 @@ function validateScreeningConfig(config) {
273
323
  message: `screening-config.json 缺少必填字段:${missing.join(", ")}。`
274
324
  };
275
325
  }
276
- if (/^replace-with/i.test(apiKey) || apiKey === SCREEN_CONFIG_TEMPLATE_DEFAULTS.apiKey) {
326
+ const placeholderModel = llmModels.find((item) => /^replace-with/i.test(item.apiKey) || item.apiKey === SCREEN_CONFIG_TEMPLATE_DEFAULTS.apiKey);
327
+ if (placeholderModel) {
277
328
  return {
278
329
  ok: false,
279
330
  reason: "PLACEHOLDER_API_KEY",
280
331
  message: "screening-config.json 的 apiKey 仍是模板占位符,请填写真实 API Key。"
281
332
  };
282
333
  }
334
+ const firstModel = llmModels[0] || {};
283
335
  if (
284
- baseUrl === SCREEN_CONFIG_TEMPLATE_DEFAULTS.baseUrl
285
- && apiKey === SCREEN_CONFIG_TEMPLATE_DEFAULTS.apiKey
286
- && model === SCREEN_CONFIG_TEMPLATE_DEFAULTS.model
336
+ llmModels.length === 1
337
+ && firstModel.baseUrl === SCREEN_CONFIG_TEMPLATE_DEFAULTS.baseUrl
338
+ && firstModel.apiKey === SCREEN_CONFIG_TEMPLATE_DEFAULTS.apiKey
339
+ && firstModel.model === SCREEN_CONFIG_TEMPLATE_DEFAULTS.model
287
340
  ) {
288
341
  return {
289
342
  ok: false,
@@ -393,24 +446,28 @@ export function resolveBossScreeningConfig(workspaceRoot) {
393
446
  candidate_paths: candidatePaths
394
447
  };
395
448
  }
449
+ const llmModels = normalizeScreeningLlmModels(parsed);
450
+ const primaryLlmModel = llmModels[0] || {};
451
+ const greetingText = normalizeText(
452
+ parsed.greetingMessage
453
+ || parsed.greeting_message
454
+ || parsed.greetingText
455
+ || parsed.greeting_text
456
+ || parsed.greeting
457
+ );
396
458
  return {
397
459
  ok: true,
398
460
  config: {
399
- baseUrl: normalizeText(parsed.baseUrl).replace(/\/+$/, ""),
400
- apiKey: normalizeText(parsed.apiKey),
401
- model: normalizeText(parsed.model),
402
- openaiOrganization: normalizeText(parsed.openaiOrganization || parsed.organization),
403
- openaiProject: normalizeText(parsed.openaiProject || parsed.project),
461
+ ...primaryLlmModel,
462
+ baseUrl: primaryLlmModel.baseUrl,
463
+ apiKey: primaryLlmModel.apiKey,
464
+ model: primaryLlmModel.model,
465
+ openaiOrganization: primaryLlmModel.openaiOrganization,
466
+ openaiProject: primaryLlmModel.openaiProject,
467
+ llmModels,
468
+ greetingMessage: greetingText,
469
+ greetingText,
404
470
  debugPort: parsePositiveInteger(parsed.debugPort, 9222),
405
- llmThinkingLevel: normalizeLlmThinkingLevel(parsed.llmThinkingLevel || parsed.thinkingLevel || parsed.reasoningEffort, "low"),
406
- llmTimeoutMs: parsePositiveInteger(parsed.llmTimeoutMs || parsed.timeoutMs, null),
407
- llmMaxRetries: parsePositiveInteger(parsed.llmMaxRetries || parsed.maxRetries, null),
408
- llmMaxTokens: parsePositiveInteger(parsed.llmMaxTokens || parsed.maxTokens, null),
409
- llmMaxCompletionTokens: parsePositiveInteger(parsed.llmMaxCompletionTokens || parsed.maxCompletionTokens, null),
410
- llmImageLimit: parsePositiveInteger(parsed.llmImageLimit || parsed.imageLimit, null),
411
- llmImageDetail: normalizeText(parsed.llmImageDetail || parsed.imageDetail),
412
- temperature: parseConfigNumber(parsed.temperature, null),
413
- topP: parseConfigNumber(parsed.topP || parsed.top_p, null),
414
471
  outputDir: resolveConfigPathValue(parsed.outputDir, configDir),
415
472
  humanRestEnabled: parseConfigBoolean(parsed.humanRestEnabled, false)
416
473
  },
package/src/cli.js CHANGED
@@ -57,6 +57,7 @@ const autoSyncSkipCommands = new Set(["install", "install-skill", "where", "help
57
57
  const externalMcpTargetsEnv = "BOSS_RECOMMEND_MCP_CONFIG_TARGETS";
58
58
  const externalSkillDirsEnv = "BOSS_RECOMMEND_EXTERNAL_SKILL_DIRS";
59
59
  const installConfigDefaults = Object.freeze({
60
+ greetingMessage: "Hi同学,能麻烦发下简历吗?",
60
61
  llmThinkingLevel: "low",
61
62
  llmMaxTokens: 512,
62
63
  llmMaxRetries: 3,
@@ -1431,6 +1432,8 @@ async function setScreeningConfig(options = {}) {
1431
1432
  apiKey,
1432
1433
  model
1433
1434
  };
1435
+ delete nextConfig.llmModels;
1436
+ delete nextConfig.models;
1434
1437
  if (typeof options["thinking-level"] === "string" && options["thinking-level"].trim()) {
1435
1438
  nextConfig.llmThinkingLevel = options["thinking-level"].trim();
1436
1439
  } else if (typeof options.llmThinkingLevel === "string" && options.llmThinkingLevel.trim()) {
@@ -1445,6 +1448,16 @@ async function setScreeningConfig(options = {}) {
1445
1448
  if (typeof options["output-dir"] === "string" && options["output-dir"].trim()) {
1446
1449
  nextConfig.outputDir = options["output-dir"].trim();
1447
1450
  }
1451
+ const greetingMessage = String(
1452
+ options["greeting-message"]
1453
+ ?? options.greetingMessage
1454
+ ?? options.greeting_text
1455
+ ?? options.greetingText
1456
+ ?? ""
1457
+ ).trim();
1458
+ if (greetingMessage) {
1459
+ nextConfig.greetingMessage = greetingMessage;
1460
+ }
1448
1461
  const debugPort = parsePositivePort(options.port || options["debug-port"]);
1449
1462
  if (debugPort) {
1450
1463
  nextConfig.debugPort = debugPort;
@@ -2538,7 +2551,7 @@ function printHelp() {
2538
2551
  console.log(" boss-recommend-mcp run --detached --instruction \"...\" --overrides-file overrides.json --confirmation-file confirmation.json");
2539
2552
  console.log(" boss-recommend-mcp list-jobs --slow-live --port 9222");
2540
2553
  console.log(" boss-recommend-mcp chat prepare-run --slow-live --port 9222 # CDP-only preflight; start runs through MCP start_boss_chat_run");
2541
- console.log(" boss-recommend-mcp config set --base-url <url> --api-key <key> --model <model> [--thinking-level off|low|medium|high|current] [--openai-organization <id>] [--openai-project <id>]");
2554
+ console.log(" boss-recommend-mcp config set --base-url <url> --api-key <key> --model <model> [--thinking-level off|low|medium|high|current] [--greeting-message <text>] [--openai-organization <id>] [--openai-project <id>]");
2542
2555
  console.log(" boss-recommend-mcp install --agent trae-cn");
2543
2556
  console.log(" boss-recommend-mcp install --agent qclaw # updates ~/.qclaw/openclaw.json mcp.servers and mirrors skills");
2544
2557
  console.log(" boss-recommend-mcp doctor --agent trae-cn --page-scope featured");
@@ -58,6 +58,74 @@ function buildChatCompletionsUrl(baseUrl) {
58
58
  return `${normalized}/chat/completions`;
59
59
  }
60
60
 
61
+ function redactBaseUrl(baseUrl) {
62
+ const normalized = normalizeBaseUrl(baseUrl);
63
+ return normalized ? normalized.replace(/\/\/[^/]+/, "//[redacted-host]") : "";
64
+ }
65
+
66
+ function firstConfiguredValue(...values) {
67
+ for (const value of values) {
68
+ if (value === undefined || value === null) continue;
69
+ if (typeof value === "string" && !value.trim()) continue;
70
+ return value;
71
+ }
72
+ return "";
73
+ }
74
+
75
+ function normalizeLlmProviderEntry(rawEntry, inherited = {}, index = 0) {
76
+ const entry = typeof rawEntry === "string"
77
+ ? { model: rawEntry }
78
+ : (rawEntry && typeof rawEntry === "object" && !Array.isArray(rawEntry) ? rawEntry : {});
79
+ const providerName = firstConfiguredValue(
80
+ entry.name,
81
+ entry.label,
82
+ entry.id,
83
+ entry.providerName,
84
+ entry.provider,
85
+ ""
86
+ );
87
+ const next = {
88
+ ...inherited,
89
+ ...entry,
90
+ baseUrl: firstConfiguredValue(entry.baseUrl, entry.base_url, inherited.baseUrl, inherited.base_url),
91
+ apiKey: firstConfiguredValue(entry.apiKey, entry.api_key, inherited.apiKey, inherited.api_key),
92
+ model: firstConfiguredValue(entry.model, entry.modelName, entry.model_name, typeof rawEntry === "string" ? rawEntry : "", inherited.model),
93
+ openaiOrganization: firstConfiguredValue(entry.openaiOrganization, entry.organization, inherited.openaiOrganization, inherited.organization),
94
+ openaiProject: firstConfiguredValue(entry.openaiProject, entry.project, inherited.openaiProject, inherited.project),
95
+ topP: firstConfiguredValue(entry.topP, entry.top_p, inherited.topP, inherited.top_p),
96
+ llmProviderName: normalizeText(providerName),
97
+ llmProviderIndex: index
98
+ };
99
+ delete next.llmModels;
100
+ delete next.models;
101
+ return next;
102
+ }
103
+
104
+ function normalizeLlmProviderConfigs(config = {}) {
105
+ if (Array.isArray(config)) {
106
+ return config.map((entry, index) => normalizeLlmProviderEntry(entry, {}, index));
107
+ }
108
+ const inherited = config && typeof config === "object" && !Array.isArray(config) ? { ...config } : {};
109
+ const rawProviders = Array.isArray(inherited.llmModels) && inherited.llmModels.length > 0
110
+ ? inherited.llmModels
111
+ : (Array.isArray(inherited.models) && inherited.models.length > 0 ? inherited.models : [inherited]);
112
+ delete inherited.llmModels;
113
+ delete inherited.models;
114
+ return rawProviders.map((entry, index) => normalizeLlmProviderEntry(entry, inherited, index));
115
+ }
116
+
117
+ function compactLlmProviderFailure(error, providerConfig = {}, providerIndex = 0) {
118
+ return {
119
+ index: providerIndex + 1,
120
+ name: normalizeText(providerConfig.llmProviderName || providerConfig.name || providerConfig.label || providerConfig.id) || null,
121
+ baseUrl: redactBaseUrl(providerConfig.baseUrl),
122
+ model: normalizeText(providerConfig.model) || null,
123
+ status: Number.isFinite(Number(error?.status)) ? Number(error.status) : null,
124
+ attempts: Number(error?.llm_attempt_count) || 0,
125
+ message: String(error?.message || error || "").slice(0, 500)
126
+ };
127
+ }
128
+
61
129
  function isVolcengineModel(baseUrl, model) {
62
130
  return /volces|volcengine|ark\.cn|doubao|seed/i.test(`${baseUrl || ""} ${model || ""}`);
63
131
  }
@@ -1325,6 +1393,8 @@ export function compactScreeningLlmResult(llmResult) {
1325
1393
  finish_reason: llmResult.finish_reason || null,
1326
1394
  image_input_count: llmResult.image_input_count || 0,
1327
1395
  attempt_count: llmResult.attempt_count || 0,
1396
+ fallback_count: llmResult.fallback_count || 0,
1397
+ llm_model_failures: Array.isArray(llmResult.llm_model_failures) ? llmResult.llm_model_failures : [],
1328
1398
  error: llmResult.error || null,
1329
1399
  screened_at: llmResult.screened_at || null
1330
1400
  };
@@ -1358,6 +1428,8 @@ export function createFailedLlmScreeningResult(error) {
1358
1428
  image_input_count: Number(error?.image_input_count) || 0,
1359
1429
  image_inputs: Array.isArray(error?.image_inputs) ? error.image_inputs : [],
1360
1430
  attempt_count: Number(error?.llm_attempt_count) || 0,
1431
+ fallback_count: Array.isArray(error?.llm_model_failures) ? error.llm_model_failures.length : 0,
1432
+ llm_model_failures: Array.isArray(error?.llm_model_failures) ? error.llm_model_failures : [],
1361
1433
  error: error?.message || String(error || "unknown"),
1362
1434
  screened_at: nowIso()
1363
1435
  };
@@ -1435,7 +1507,7 @@ function sleepMs(ms) {
1435
1507
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0)));
1436
1508
  }
1437
1509
 
1438
- export async function callScreeningLlm({
1510
+ async function callScreeningLlmWithProvider({
1439
1511
  candidate,
1440
1512
  criteria,
1441
1513
  config = {},
@@ -1488,81 +1560,89 @@ export async function callScreeningLlm({
1488
1560
  thinkingLevel
1489
1561
  });
1490
1562
 
1563
+ const effectiveTimeoutMs = parsePositiveNumber(config.llmTimeoutMs ?? config.timeoutMs, timeoutMs) || timeoutMs;
1491
1564
  const maxRetries = normalizeLlmMaxRetries(config.llmMaxRetries ?? config.maxRetries);
1492
1565
  const maxAttempts = maxRetries + 1;
1493
1566
  let lastError = null;
1494
1567
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
1495
1568
  const controller = new AbortController();
1496
- const timer = setTimeout(() => controller.abort(), timeoutMs);
1569
+ const timer = setTimeout(() => controller.abort(), effectiveTimeoutMs);
1497
1570
  try {
1498
- const headers = {
1499
- "Content-Type": "application/json",
1500
- Authorization: `Bearer ${apiKey}`
1501
- };
1502
- if (config.openaiOrganization) headers["OpenAI-Organization"] = config.openaiOrganization;
1503
- if (config.openaiProject) headers["OpenAI-Project"] = config.openaiProject;
1504
-
1505
- const response = await fetch(buildChatCompletionsUrl(baseUrl), {
1506
- method: "POST",
1507
- headers,
1508
- body: JSON.stringify(payload),
1509
- signal: controller.signal
1510
- });
1511
- const responseText = await response.text();
1512
- if (!response.ok) {
1513
- const error = new Error(`LLM request failed: ${response.status} ${responseText.slice(0, 400)}`);
1514
- error.status = response.status;
1515
- throw error;
1516
- }
1517
- const json = tryParseJson(responseText);
1518
- if (!json) {
1519
- throw new Error("LLM response was not valid JSON");
1520
- }
1521
- const choice = json?.choices?.[0] || {};
1522
- const content = flattenChatMessageContent(choice?.message?.content);
1523
- const reasoningContent = collectLlmReasoningText(choice);
1524
- const parsed = tryExtractJsonObject(content) || tryExtractJsonObject(reasoningContent);
1525
- const passed = parsePassedDecision(parsed?.passed);
1526
- if (passed === null) {
1527
- throw new Error(`LLM response missing boolean passed decision: ${content.slice(0, 240)}`);
1528
- }
1529
- const evidence = Array.isArray(parsed?.evidence)
1530
- ? parsed.evidence.map(normalizeText).filter(Boolean)
1531
- : [];
1532
- const decisionCot = firstUsefulLine([
1533
- parsed?.cot,
1534
- parsed?.decision_cot,
1535
- parsed?.reasoning,
1536
- parsed?.chain_of_thought,
1537
- reasoningContent
1538
- ].map(normalizeBlockText).filter(Boolean)) || reasoningContent;
1539
- return {
1540
- ok: true,
1541
- provider: {
1542
- baseUrl: baseUrl.replace(/\/\/[^/]+/, "//[redacted-host]"),
1543
- model,
1544
- thinking_level: normalizeLlmThinkingLevel(thinkingLevel) || "low",
1545
- thinking: payload.thinking || null,
1546
- reasoning_effort: payload.reasoning_effort || null,
1547
- max_tokens: payload.max_tokens,
1548
- max_completion_tokens: payload.max_completion_tokens || null
1549
- },
1550
- passed,
1551
- reason: "",
1552
- evidence,
1553
- cot: decisionCot,
1554
- decision_cot: decisionCot,
1555
- reasoning_content: reasoningContent,
1556
- raw_model_output: content,
1557
- usage: json.usage || null,
1558
- finish_reason: choice.finish_reason || null,
1559
- raw_content_length: content.length,
1560
- image_input_count: imageInputs.length,
1561
- image_inputs: summarizeLlmImageInputs(imageInputs),
1562
- attempt_count: attempt,
1563
- screened_at: nowIso()
1564
- };
1565
- } catch (error) {
1571
+ const headers = {
1572
+ "Content-Type": "application/json",
1573
+ Authorization: `Bearer ${apiKey}`
1574
+ };
1575
+ if (config.openaiOrganization) headers["OpenAI-Organization"] = config.openaiOrganization;
1576
+ if (config.openaiProject) headers["OpenAI-Project"] = config.openaiProject;
1577
+
1578
+ const response = await fetch(buildChatCompletionsUrl(baseUrl), {
1579
+ method: "POST",
1580
+ headers,
1581
+ body: JSON.stringify(payload),
1582
+ signal: controller.signal
1583
+ });
1584
+ const responseText = await response.text();
1585
+ if (!response.ok) {
1586
+ const error = new Error(`LLM request failed: ${response.status} ${responseText.slice(0, 400)}`);
1587
+ error.status = response.status;
1588
+ throw error;
1589
+ }
1590
+ const json = tryParseJson(responseText);
1591
+ if (!json) {
1592
+ throw new Error("LLM response was not valid JSON");
1593
+ }
1594
+ const choice = json?.choices?.[0] || {};
1595
+ const content = flattenChatMessageContent(choice?.message?.content);
1596
+ const reasoningContent = collectLlmReasoningText(choice);
1597
+ const parsed = tryExtractJsonObject(content) || tryExtractJsonObject(reasoningContent);
1598
+ const passed = parsePassedDecision(parsed?.passed);
1599
+ if (passed === null) {
1600
+ throw new Error(`LLM response missing boolean passed decision: ${content.slice(0, 240)}`);
1601
+ }
1602
+ const evidence = Array.isArray(parsed?.evidence)
1603
+ ? parsed.evidence.map(normalizeText).filter(Boolean)
1604
+ : [];
1605
+ const decisionCot = firstUsefulLine([
1606
+ parsed?.cot,
1607
+ parsed?.decision_cot,
1608
+ parsed?.reasoning,
1609
+ parsed?.chain_of_thought,
1610
+ reasoningContent
1611
+ ].map(normalizeBlockText).filter(Boolean)) || reasoningContent;
1612
+ const providerName = normalizeText(config.llmProviderName || config.name || config.label || config.id);
1613
+ const providerIndex = Number.isFinite(Number(config.llmProviderIndex)) ? Number(config.llmProviderIndex) : 0;
1614
+ const providerCount = Number.isFinite(Number(config.llmProviderCount)) ? Number(config.llmProviderCount) : 1;
1615
+ return {
1616
+ ok: true,
1617
+ provider: {
1618
+ baseUrl: redactBaseUrl(baseUrl),
1619
+ model,
1620
+ name: providerName || null,
1621
+ index: providerIndex + 1,
1622
+ total: providerCount,
1623
+ thinking_level: normalizeLlmThinkingLevel(thinkingLevel) || "low",
1624
+ thinking: payload.thinking || null,
1625
+ reasoning_effort: payload.reasoning_effort || null,
1626
+ max_tokens: payload.max_tokens,
1627
+ max_completion_tokens: payload.max_completion_tokens || null
1628
+ },
1629
+ passed,
1630
+ reason: "",
1631
+ evidence,
1632
+ cot: decisionCot,
1633
+ decision_cot: decisionCot,
1634
+ reasoning_content: reasoningContent,
1635
+ raw_model_output: content,
1636
+ usage: json.usage || null,
1637
+ finish_reason: choice.finish_reason || null,
1638
+ raw_content_length: content.length,
1639
+ image_input_count: imageInputs.length,
1640
+ image_inputs: summarizeLlmImageInputs(imageInputs),
1641
+ attempt_count: attempt,
1642
+ provider_attempt_count: attempt,
1643
+ screened_at: nowIso()
1644
+ };
1645
+ } catch (error) {
1566
1646
  lastError = error;
1567
1647
  if (attempt >= maxAttempts || !isRetryableLlmRequestError(error)) {
1568
1648
  error.image_input_count = imageInputs.length;
@@ -1571,9 +1651,9 @@ export async function callScreeningLlm({
1571
1651
  throw error;
1572
1652
  }
1573
1653
  await sleepMs(Math.min(2500, 500 * attempt));
1574
- } finally {
1575
- clearTimeout(timer);
1576
- }
1654
+ } finally {
1655
+ clearTimeout(timer);
1656
+ }
1577
1657
  }
1578
1658
  lastError = lastError || new Error("LLM request failed without response");
1579
1659
  lastError.image_input_count = imageInputs.length;
@@ -1581,3 +1661,58 @@ export async function callScreeningLlm({
1581
1661
  lastError.llm_attempt_count = maxAttempts;
1582
1662
  throw lastError;
1583
1663
  }
1664
+
1665
+ export async function callScreeningLlm(args = {}) {
1666
+ const providers = normalizeLlmProviderConfigs(args.config || {});
1667
+ if (providers.length <= 1) {
1668
+ return callScreeningLlmWithProvider({
1669
+ ...args,
1670
+ config: {
1671
+ ...(providers[0] || args.config || {}),
1672
+ llmProviderCount: 1
1673
+ }
1674
+ });
1675
+ }
1676
+
1677
+ const providerFailures = [];
1678
+ let lastError = null;
1679
+ for (let index = 0; index < providers.length; index += 1) {
1680
+ const providerConfig = {
1681
+ ...providers[index],
1682
+ llmProviderIndex: index,
1683
+ llmProviderCount: providers.length
1684
+ };
1685
+ try {
1686
+ const previousAttempts = providerFailures.reduce((sum, item) => sum + (Number(item.attempts) || 0), 0);
1687
+ const result = await callScreeningLlmWithProvider({
1688
+ ...args,
1689
+ config: providerConfig
1690
+ });
1691
+ const providerAttempts = Number(result.provider_attempt_count ?? result.attempt_count) || 0;
1692
+ return {
1693
+ ...result,
1694
+ attempt_count: previousAttempts + providerAttempts,
1695
+ llm_model_failures: providerFailures,
1696
+ fallback_count: providerFailures.length
1697
+ };
1698
+ } catch (error) {
1699
+ lastError = error;
1700
+ providerFailures.push(compactLlmProviderFailure(error, providerConfig, index));
1701
+ if (index < providers.length - 1) {
1702
+ await sleepMs(Math.min(1500, 250 * (index + 1)));
1703
+ }
1704
+ }
1705
+ }
1706
+
1707
+ const totalAttempts = providerFailures.reduce((sum, item) => sum + (Number(item.attempts) || 0), 0);
1708
+ const finalError = new Error(
1709
+ `All configured LLM models failed (${providers.length}); last error: ${lastError?.message || "unknown error"}`
1710
+ );
1711
+ finalError.cause = lastError || null;
1712
+ finalError.llm_provider_failures = providerFailures;
1713
+ finalError.llm_model_failures = providerFailures;
1714
+ finalError.llm_attempt_count = totalAttempts;
1715
+ finalError.image_input_count = lastError?.image_input_count || 0;
1716
+ finalError.image_inputs = lastError?.image_inputs || [];
1717
+ throw finalError;
1718
+ }
@@ -109,6 +109,8 @@ function compactLlmResult(llmResult) {
109
109
  finish_reason: llmResult.finish_reason || null,
110
110
  image_input_count: llmResult.image_input_count || 0,
111
111
  attempt_count: llmResult.attempt_count || 0,
112
+ fallback_count: llmResult.fallback_count || 0,
113
+ llm_model_failures: Array.isArray(llmResult.llm_model_failures) ? llmResult.llm_model_failures : [],
112
114
  error: llmResult.error || null
113
115
  };
114
116
  }
@@ -287,6 +289,9 @@ function createFailedLlmResult(error) {
287
289
  decision_cot: "",
288
290
  reasoning_content: "",
289
291
  raw_model_output: "",
292
+ attempt_count: Number(error?.llm_attempt_count) || 0,
293
+ fallback_count: Array.isArray(error?.llm_model_failures) ? error.llm_model_failures.length : 0,
294
+ llm_model_failures: Array.isArray(error?.llm_model_failures) ? error.llm_model_failures : [],
290
295
  error: error?.message || String(error || "unknown"),
291
296
  screened_at: new Date().toISOString()
292
297
  };
@@ -1638,6 +1643,7 @@ export function createChatRunService({
1638
1643
  close_resume: closeResume,
1639
1644
  request_resume_for_passed: Boolean(requestResumeForPassed),
1640
1645
  dry_run_request_cv: Boolean(dryRunRequestCv),
1646
+ greeting_text: greetingText,
1641
1647
  cv_acquisition_mode: cvAcquisitionMode,
1642
1648
  call_llm_on_image: Boolean(callLlmOnImage),
1643
1649
  screening_mode: normalizedScreeningMode,
@@ -41,6 +41,7 @@ import {
41
41
  closeRecommendDetail,
42
42
  createRecommendDetailNetworkRecorder,
43
43
  extractRecommendDetailCandidate,
44
+ isStaleRecommendNodeError,
44
45
  openRecommendCardDetailWithFreshRetry,
45
46
  waitForRecommendDetailNetworkEvents
46
47
  } from "./detail.js";
@@ -381,6 +382,7 @@ function compactError(error, fallbackCode = "RECOMMEND_RUN_ERROR") {
381
382
  export function isRecoverableImageCaptureError(error) {
382
383
  const code = String(error?.code || "");
383
384
  if (code === "IMAGE_CAPTURE_TIMEOUT" || code === "IMAGE_CAPTURE_TOTAL_TIMEOUT") return true;
385
+ if (isStaleRecommendNodeError(error)) return true;
384
386
  return /Image fallback capture timed out/i.test(String(error?.message || error || ""));
385
387
  }
386
388
 
@@ -420,7 +422,7 @@ export function createRecoverableImageCaptureEvidence(error, {
420
422
  llm_total_byte_length: 0,
421
423
  llm_original_total_byte_length: 0,
422
424
  llm_composition_error: null,
423
- error_code: error?.code || "IMAGE_CAPTURE_FAILED",
425
+ error_code: error?.code || (isStaleRecommendNodeError(error) ? "IMAGE_CAPTURE_STALE_NODE" : "IMAGE_CAPTURE_FAILED"),
424
426
  error: error?.message || String(error || "Image capture failed"),
425
427
  file_paths: filePaths,
426
428
  llm_file_paths: []
@@ -438,6 +440,27 @@ function createImageCaptureFailureScreening(candidate, error) {
438
440
  };
439
441
  }
440
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
+
441
464
  export async function runRecommendWorkflow({
442
465
  client,
443
466
  targetUrl = "",
@@ -746,139 +769,149 @@ export async function runRecommendWorkflow({
746
769
 
747
770
  let screeningCandidate = cardCandidate;
748
771
  let detailResult = null;
772
+ let recoverableDetailError = null;
749
773
  if (index < effectiveDetailLimit) {
750
- await runControl.waitIfPaused();
751
- runControl.throwIfCanceled();
752
- runControl.setPhase("recommend:detail");
753
- rootState = await ensureRecommendViewport(rootState, "detail");
754
- networkRecorder.clear();
755
- const openedDetail = await openRecommendCardDetailWithFreshRetry(client, {
756
- cardNodeId,
757
- candidateKey,
758
- cardCandidate,
759
- rootState,
760
- targetUrl,
761
- maxAttempts: 2
762
- });
763
- addTiming(timings, "candidate_click_ms", openedDetail.timings?.candidate_click_ms);
764
- addTiming(timings, "detail_open_ms", openedDetail.timings?.detail_open_ms);
765
- cardNodeId = openedDetail.card_node_id || cardNodeId;
766
- cardCandidate = openedDetail.card_candidate || cardCandidate;
767
- screeningCandidate = cardCandidate;
768
- const waitPlan = getCvNetworkWaitPlan(cvAcquisitionState);
769
- const networkWait = await measureTiming(timings, "network_cv_wait_ms", () => waitForCvNetworkEvents(
770
- waitForRecommendDetailNetworkEvents,
771
- networkRecorder,
772
- {
773
- waitPlan,
774
- minCount: 1,
775
- requireLoaded: true,
776
- intervalMs: 120
777
- }
778
- ));
779
- if (networkWait?.elapsed_ms != null) {
780
- timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
781
- }
782
- detailResult = await extractRecommendDetailCandidate(client, {
783
- cardCandidate,
784
- cardNodeId,
785
- detailState: openedDetail.detail_state,
786
- networkEvents: networkRecorder.events,
787
- targetUrl,
788
- closeDetail: false,
789
- networkParseRetryMs: waitPlan.mode_before === "image" ? 500 : 2200,
790
- networkParseIntervalMs: 250
791
- });
792
- addTiming(timings, "late_network_retry_ms", detailResult.network_parse_retry_elapsed_ms);
793
-
794
- const parsedNetworkProfileCount = countParsedNetworkProfiles(detailResult);
795
- let source = "network";
796
- let imageEvidence = null;
797
- if (parsedNetworkProfileCount > 0) {
798
- recordCvNetworkHit(cvAcquisitionState, {
799
- parsedNetworkProfileCount,
800
- waitResult: networkWait
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
801
788
  });
802
- } else {
803
- const captureNodeId = openedDetail.detail_state?.popup?.node_id
804
- || openedDetail.detail_state?.resumeIframe?.node_id
805
- || null;
806
- if (captureNodeId) {
807
- const imageEvidencePath = imageEvidenceFilePath({
808
- imageOutputDir,
809
- domain: "recommend",
810
- runId: runControl?.runId,
811
- index,
812
- extension: "jpg"
813
- });
814
- try {
815
- imageEvidence = await measureTiming(timings, "screenshot_capture_ms", () => captureScrolledNodeScreenshots(client, captureNodeId, {
816
- filePath: imageEvidencePath,
817
- format: "jpeg",
818
- quality: 72,
819
- optimize: true,
820
- resizeMaxWidth: 1100,
821
- captureViewport: true,
822
- padding: 4,
823
- maxScreenshots: maxImagePages,
824
- wheelDeltaY: imageWheelDeltaY,
825
- settleMs: 350,
826
- scrollMethod: "dom-anchor-fallback-input",
827
- stepTimeoutMs: 45000,
828
- totalTimeoutMs: 90000,
829
- duplicateStopCount: 1,
830
- skipDuplicateScreenshots: true,
831
- composeForLlm: true,
832
- llmPagesPerImage: 3,
833
- llmResizeMaxWidth: 1100,
834
- llmQuality: 72,
835
- metadata: {
836
- domain: "recommend",
837
- capture_mode: "scroll_sequence",
838
- acquisition_reason: "network_miss_image_fallback",
839
- run_candidate_index: index,
840
- candidate_key: candidateKey
841
- }
842
- }));
843
- source = "image";
844
- } catch (error) {
845
- if (!isRecoverableImageCaptureError(error)) throw error;
846
- imageEvidence = createRecoverableImageCaptureEvidence(error, {
847
- elapsedMs: timings.screenshot_capture_ms,
848
- filePath: imageEvidencePath,
849
- extension: "jpg",
850
- maxScreenshots: maxImagePages
851
- });
852
- source = "image_capture_failed";
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
853
803
  }
854
- recordCvImageFallback(cvAcquisitionState, {
855
- reason: source === "image_capture_failed"
856
- ? "network_miss_image_capture_failed"
857
- : "network_miss_image_fallback",
858
- parsedNetworkProfileCount,
859
- waitResult: networkWait,
860
- imageEvidence
861
- });
862
- } else {
863
- source = "missing_capture_node";
864
- recordCvNetworkMiss(cvAcquisitionState, {
865
- reason: "network_miss_no_capture_node",
804
+ ));
805
+ if (networkWait?.elapsed_ms != null) {
806
+ timings.network_cv_wait_ms = Math.round(Number(networkWait.elapsed_ms) || 0);
807
+ }
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
817
+ });
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, {
866
825
  parsedNetworkProfileCount,
867
826
  waitResult: networkWait
868
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({
834
+ imageOutputDir,
835
+ domain: "recommend",
836
+ runId: runControl?.runId,
837
+ index,
838
+ extension: "jpg"
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";
879
+ }
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
+ }
869
896
  }
870
- }
871
897
 
872
- detailResult.image_evidence = imageEvidence;
873
- detailResult.cv_acquisition = {
874
- source,
875
- mode_after: compactCvAcquisitionState(cvAcquisitionState).mode,
876
- wait_plan: waitPlan,
877
- network_wait: networkWait,
878
- parsed_network_profile_count: parsedNetworkProfileCount,
879
- image_evidence: summarizeImageEvidence(imageEvidence)
880
- };
881
- 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
+ }
882
915
  }
883
916
 
884
917
  await runControl.waitIfPaused();
@@ -886,7 +919,7 @@ export async function runRecommendWorkflow({
886
919
  runControl.setPhase("recommend:screening");
887
920
  let llmResult = null;
888
921
  if (useLlmScreening) {
889
- if (detailResult?.image_evidence?.ok === false) {
922
+ if (recoverableDetailError || detailResult?.image_evidence?.ok === false) {
890
923
  llmResult = null;
891
924
  } else if (!llmConfig) {
892
925
  llmResult = createMissingLlmConfigResult();
@@ -910,7 +943,9 @@ export async function runRecommendWorkflow({
910
943
  }
911
944
  if (detailResult) detailResult.llm_result = llmResult;
912
945
  }
913
- const screening = detailResult?.image_evidence?.ok === false
946
+ const screening = recoverableDetailError
947
+ ? createRecoverableDetailFailureScreening(screeningCandidate, recoverableDetailError)
948
+ : detailResult?.image_evidence?.ok === false
914
949
  ? createImageCaptureFailureScreening(screeningCandidate, {
915
950
  code: detailResult.image_evidence.error_code,
916
951
  message: detailResult.image_evidence.error
@@ -959,7 +994,9 @@ export async function runRecommendWorkflow({
959
994
  screening: compactScreening(screening),
960
995
  action_discovery: compactActionDiscovery(actionDiscovery),
961
996
  post_action: postActionResult,
962
- error: detailResult?.image_evidence?.ok === false
997
+ error: recoverableDetailError
998
+ ? compactRecoverableDetailError(recoverableDetailError)
999
+ : detailResult?.image_evidence?.ok === false
963
1000
  ? compactError({
964
1001
  code: detailResult.image_evidence.error_code,
965
1002
  message: detailResult.image_evidence.error
@@ -987,6 +1024,8 @@ export async function runRecommendWorkflow({
987
1024
  greet_count: greetCount,
988
1025
  post_action_clicked: results.filter((item) => item.post_action?.action_clicked).length,
989
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,
990
1029
  unique_seen: compactInfiniteListState(listState).seen_count,
991
1030
  scroll_count: compactInfiniteListState(listState).scroll_count,
992
1031
  refresh_rounds: refreshRounds,
@@ -1050,6 +1089,9 @@ export async function runRecommendWorkflow({
1050
1089
  screened: results.length,
1051
1090
  detail_opened: results.filter((item) => item.detail).length,
1052
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,
1053
1095
  passed: results.filter((item) => item.screening.passed).length,
1054
1096
  greet_count: greetCount,
1055
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;
@@ -319,6 +379,7 @@ function ensureRecommendRunArtifacts(snapshot) {
319
379
  checkpoint,
320
380
  error: snapshot.error || null,
321
381
  last_message: snapshot.error?.message || snapshot.phase || snapshot.stage || null,
382
+ recovery: buildConstrainedAgentRecovery(snapshot, meta, artifacts),
322
383
  summary: artifactSummary,
323
384
  generated_at: new Date().toISOString()
324
385
  });
@@ -397,6 +458,7 @@ function buildLegacyRecommendResult(snapshot) {
397
458
  screen_params: clonePlain(meta.parsed?.screenParams || {}, {}),
398
459
  target_count_semantics: TARGET_COUNT_SEMANTICS,
399
460
  error: snapshot.error || null,
461
+ recovery: buildConstrainedAgentRecovery(snapshot, meta, artifacts),
400
462
  results: resultRows
401
463
  };
402
464
  }
@@ -411,6 +473,7 @@ function normalizeRunSnapshot(snapshot) {
411
473
  TERMINAL_STATUSES.has(snapshot.status)
412
474
  || snapshot.status === RUN_STATUS_PAUSED
413
475
  ) ? buildLegacyRecommendResult({ ...snapshot, progress }) : null;
476
+ const recovery = buildConstrainedAgentRecovery(snapshot, meta, artifacts);
414
477
  const oldContext = {
415
478
  workspace_root: meta.workspaceRoot || null,
416
479
  instruction: meta.args?.instruction || "",
@@ -451,6 +514,7 @@ function normalizeRunSnapshot(snapshot) {
451
514
  last_resumed_at: meta.lastResumedAt || null,
452
515
  last_paused_at: snapshot.status === RUN_STATUS_PAUSED ? snapshot.updatedAt : null
453
516
  },
517
+ recovery,
454
518
  result: legacyResult,
455
519
  artifacts
456
520
  };
@@ -483,6 +547,7 @@ function persistRecommendRunSnapshot(snapshot, {
483
547
  control: normalized.control,
484
548
  resume: normalized.resume,
485
549
  error: normalized.error,
550
+ recovery: normalized.recovery,
486
551
  result: normalized.result,
487
552
  summary: normalized.summary,
488
553
  artifacts: normalized.artifacts