@reconcrap/boss-recommend-mcp 2.1.12 → 2.1.14

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.
@@ -12,6 +12,7 @@ const SCREEN_CONFIG_TEMPLATE_DEFAULTS = Object.freeze({
12
12
  model: "gpt-4.1-mini"
13
13
  });
14
14
  const LLM_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "auto", "current"]);
15
+ const LLM_SCREENING_STRATEGIES = new Set(["single_pass", "fast_first_verified"]);
15
16
 
16
17
  export const TARGET_COUNT_CANONICAL_ALL = "all";
17
18
  export const TARGET_COUNT_ACCEPTED_EXAMPLES = [TARGET_COUNT_CANONICAL_ALL, -1, 20, "全部候选人"];
@@ -322,6 +323,11 @@ function normalizeLlmThinkingLevel(raw, fallback = "low") {
322
323
  return LLM_THINKING_LEVELS.has(normalized) ? normalized : fallback;
323
324
  }
324
325
 
326
+ function normalizeLlmScreeningStrategy(raw, fallback = "single_pass") {
327
+ const normalized = normalizeText(raw).toLowerCase();
328
+ return LLM_SCREENING_STRATEGIES.has(normalized) ? normalized : fallback;
329
+ }
330
+
325
331
  function firstConfiguredValue(...values) {
326
332
  for (const value of values) {
327
333
  if (value === undefined || value === null) continue;
@@ -352,6 +358,26 @@ function normalizeScreeningLlmModel(config = {}, rawEntry = {}, index = 0) {
352
358
  firstConfiguredValue(entry.llmThinkingLevel, entry.thinkingLevel, entry.reasoningEffort, config.llmThinkingLevel, config.thinkingLevel, config.reasoningEffort),
353
359
  "low"
354
360
  ),
361
+ llmScreeningStrategy: normalizeLlmScreeningStrategy(
362
+ firstConfiguredValue(entry.llmScreeningStrategy, entry.screeningStrategy, entry.screening_strategy, config.llmScreeningStrategy, config.screeningStrategy, config.screening_strategy),
363
+ "single_pass"
364
+ ),
365
+ llmFastThinkingLevel: normalizeLlmThinkingLevel(
366
+ firstConfiguredValue(entry.llmFastThinkingLevel, entry.fastThinkingLevel, entry.fast_thinking_level, config.llmFastThinkingLevel, config.fastThinkingLevel, config.fast_thinking_level),
367
+ "current"
368
+ ),
369
+ llmVerifyThinkingLevel: normalizeLlmThinkingLevel(
370
+ firstConfiguredValue(entry.llmVerifyThinkingLevel, entry.verifyThinkingLevel, entry.verify_thinking_level, config.llmVerifyThinkingLevel, config.verifyThinkingLevel, config.verify_thinking_level),
371
+ "low"
372
+ ),
373
+ llmFastMaxTokens: parsePositiveInteger(
374
+ firstConfiguredValue(entry.llmFastMaxTokens, entry.fastMaxTokens, entry.fast_max_tokens, config.llmFastMaxTokens, config.fastMaxTokens, config.fast_max_tokens),
375
+ null
376
+ ),
377
+ llmVerifyMaxTokens: parsePositiveInteger(
378
+ firstConfiguredValue(entry.llmVerifyMaxTokens, entry.verifyMaxTokens, entry.verify_max_tokens, config.llmVerifyMaxTokens, config.verifyMaxTokens, config.verify_max_tokens),
379
+ null
380
+ ),
355
381
  llmTimeoutMs: parsePositiveInteger(firstConfiguredValue(entry.llmTimeoutMs, entry.timeoutMs, config.llmTimeoutMs, config.timeoutMs), null),
356
382
  llmMaxRetries: parsePositiveInteger(firstConfiguredValue(entry.llmMaxRetries, entry.maxRetries, config.llmMaxRetries, config.maxRetries), null),
357
383
  llmMaxTokens: parsePositiveInteger(firstConfiguredValue(entry.llmMaxTokens, entry.maxTokens, config.llmMaxTokens, config.maxTokens), null),
@@ -16,9 +16,11 @@ export const LEGACY_RESULT_HEADER = [
16
16
  "简历来源",
17
17
  "原始判定通过",
18
18
  "最终判定通过",
19
- "证据总数",
20
- "证据命中数",
21
- "证据门控降级",
19
+ "LLM thinking_level",
20
+ "LLM screening_strategy",
21
+ "LLM decision_source",
22
+ "LLM verified",
23
+ "LLM verification_reason",
22
24
  "错误码",
23
25
  "错误信息",
24
26
  "候选人ID",
@@ -182,12 +184,6 @@ function firstBoolean(...values) {
182
184
  return "";
183
185
  }
184
186
 
185
- function evidenceCount(llm = {}) {
186
- if (Number.isFinite(llm.evidence_count)) return llm.evidence_count;
187
- if (Array.isArray(llm.evidence)) return llm.evidence.length;
188
- return "";
189
- }
190
-
191
187
  function actionResultText(row = {}) {
192
188
  const action = row.post_action || row.action || {};
193
189
  if (action.requested === true && !action.skipped) {
@@ -258,10 +254,10 @@ export function legacyScreenResultRow(row = {}) {
258
254
  ? "passed"
259
255
  : "skipped";
260
256
  const cot = firstText(
261
- llm.reasoning_content,
262
- llm.raw_reasoning_content,
263
257
  llm.decision_cot,
264
258
  llm.cot,
259
+ llm.reasoning_content,
260
+ llm.raw_reasoning_content,
265
261
  llm.raw_model_output,
266
262
  llm.raw_content,
267
263
  row.decision_cot,
@@ -276,7 +272,6 @@ export function legacyScreenResultRow(row = {}) {
276
272
  candidate.source,
277
273
  screening.candidate?.source
278
274
  );
279
- const totalEvidence = evidenceCount(llm);
280
275
  return [
281
276
  identity.name,
282
277
  identity.school,
@@ -290,10 +285,12 @@ export function legacyScreenResultRow(row = {}) {
290
285
  cvSource,
291
286
  rawPassed,
292
287
  finalPassed,
293
- totalEvidence,
294
- totalEvidence,
295
- "",
296
- row.error_code || error.code || error.name || (llm.error ? "LLM_SCREENING_ERROR" : ""),
288
+ firstText(llm.provider?.thinking_level),
289
+ firstText(llm.screening_strategy),
290
+ firstText(llm.decision_source),
291
+ typeof llm.verified === "boolean" ? llm.verified : "",
292
+ firstText(llm.verification_reason),
293
+ row.error_code || error.code || error.name || llm.error_code || (llm.error ? "LLM_SCREENING_ERROR" : ""),
297
294
  row.error_message || error.message || llm.error || "",
298
295
  candidate.id || row.candidate_id || "",
299
296
  timingValue(row, "total_ms"),
@@ -38,6 +38,9 @@ const GENDER_CODE_MAP = {
38
38
  };
39
39
 
40
40
  const LLM_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "auto", "current"]);
41
+ const LLM_SCREENING_STRATEGIES = new Set(["single_pass", "fast_first_verified"]);
42
+ const FAST_FIRST_DEFAULT_FAST_MAX_TOKENS = 384;
43
+ const FATAL_LLM_PROVIDER_MAX_RETRIES = 2;
41
44
 
42
45
  function nowIso() {
43
46
  return new Date().toISOString();
@@ -48,6 +51,11 @@ function normalizeLlmThinkingLevel(value) {
48
51
  return LLM_THINKING_LEVELS.has(normalized) ? normalized : "";
49
52
  }
50
53
 
54
+ function normalizeLlmScreeningStrategy(value) {
55
+ const normalized = normalizeText(value).toLowerCase();
56
+ return LLM_SCREENING_STRATEGIES.has(normalized) ? normalized : "single_pass";
57
+ }
58
+
51
59
  function normalizeBaseUrl(baseUrl) {
52
60
  return String(baseUrl || "").replace(/\/+$/, "");
53
61
  }
@@ -126,6 +134,65 @@ function compactLlmProviderFailure(error, providerConfig = {}, providerIndex = 0
126
134
  };
127
135
  }
128
136
 
137
+ export function classifyFatalLlmProviderError(error) {
138
+ if (!error) return null;
139
+ if (error.llm_fatal_provider_error) {
140
+ return {
141
+ code: error.code || "LLM_FATAL_PROVIDER_ERROR",
142
+ reason: error.llm_fatal_reason || "fatal_provider_error"
143
+ };
144
+ }
145
+ const status = Number(error?.status);
146
+ const message = String(error?.message || error || "");
147
+ const providerCode = normalizeText(error?.provider_error_code).toLowerCase();
148
+ const providerType = normalizeText(error?.provider_error_type).toLowerCase();
149
+ const searchable = `${providerCode} ${providerType} ${message}`.toLowerCase();
150
+ if (/(?:budget[_\s-]*exceeded|budget has been exceeded|max budget|spend cap|hard limit|credit limit)/i.test(searchable)) {
151
+ return { code: "LLM_BUDGET_EXCEEDED", reason: "budget_exceeded" };
152
+ }
153
+ if (/(?:insufficient[_\s-]*quota|quota[_\s-]*(?:exceeded|exhausted)|(?:exceeded|exhausted).*quota|out of quota|usage quota|token quota|monthly quota|rate limit quota)/i.test(searchable)) {
154
+ return { code: "LLM_QUOTA_EXCEEDED", reason: "quota_exceeded" };
155
+ }
156
+ if (/(?:insufficient[_\s-]*(?:balance|credit|credits)|no credits|credit balance|billing|payment required|unpaid|account balance)/i.test(searchable)) {
157
+ return { code: "LLM_BILLING_REQUIRED", reason: "billing_required" };
158
+ }
159
+ if (status === 401 || /(?:unauthorized|unauthorised|invalid api key|incorrect api key|authentication failed|invalid authentication|api key invalid|no api key)/i.test(searchable)) {
160
+ return { code: "LLM_AUTH_FAILED", reason: "auth_failed" };
161
+ }
162
+ if (status === 403 || /(?:forbidden|permission denied|access denied|not authorized|not authorised|model access denied)/i.test(searchable)) {
163
+ return { code: "LLM_PERMISSION_DENIED", reason: "permission_denied" };
164
+ }
165
+ return null;
166
+ }
167
+
168
+ export function isFatalLlmProviderError(error) {
169
+ return Boolean(classifyFatalLlmProviderError(error));
170
+ }
171
+
172
+ export function createFatalLlmRunError(error, { domain = "", candidate = null } = {}) {
173
+ const classification = classifyFatalLlmProviderError(error) || {
174
+ code: "LLM_FATAL_PROVIDER_ERROR",
175
+ reason: "fatal_provider_error"
176
+ };
177
+ const attempts = Number(error?.llm_attempt_count) || 0;
178
+ const suffix = attempts ? ` after ${attempts} attempts` : "";
179
+ const fatal = new Error(`Fatal LLM provider error${suffix}: ${error?.message || String(error || "unknown")}`);
180
+ fatal.name = "FatalLlmProviderError";
181
+ fatal.code = classification.code;
182
+ fatal.llm_fatal_provider_error = true;
183
+ fatal.llm_fatal_reason = classification.reason;
184
+ fatal.llm_attempt_count = attempts;
185
+ fatal.status = Number.isFinite(Number(error?.status)) ? Number(error.status) : null;
186
+ fatal.provider_error_code = error?.provider_error_code || null;
187
+ fatal.provider_error_type = error?.provider_error_type || null;
188
+ fatal.provider_error_message = error?.provider_error_message || null;
189
+ fatal.domain = domain || null;
190
+ fatal.candidate_id = candidate?.id || null;
191
+ fatal.candidate_name = candidate?.identity?.name || null;
192
+ fatal.cause = error || null;
193
+ return fatal;
194
+ }
195
+
129
196
  function isVolcengineModel(baseUrl, model) {
130
197
  return /volces|volcengine|ark\.cn|doubao|seed/i.test(`${baseUrl || ""} ${model || ""}`);
131
198
  }
@@ -168,7 +235,7 @@ function parseFiniteNumber(value, fallback = null) {
168
235
  return Number.isFinite(parsed) ? parsed : fallback;
169
236
  }
170
237
 
171
- function resolveLlmOutputTokenBudget(config = {}, thinkingLevel = "") {
238
+ function resolveLlmOutputTokenBudget(config = {}, thinkingLevel = "", options = {}) {
172
239
  const explicit = parsePositiveNumber(
173
240
  config.llmMaxCompletionTokens
174
241
  ?? config.maxCompletionTokens
@@ -177,10 +244,16 @@ function resolveLlmOutputTokenBudget(config = {}, thinkingLevel = "") {
177
244
  null
178
245
  );
179
246
  if (explicit) return Math.max(1, Math.floor(explicit));
247
+ if (options.requireReviewDecision) return 384;
180
248
  const normalizedThinking = normalizeLlmThinkingLevel(thinkingLevel || "low") || "low";
181
249
  return normalizedThinking === "off" || normalizedThinking === "minimal" ? 64 : 512;
182
250
  }
183
251
 
252
+ function parsePositiveInteger(value, fallback = null) {
253
+ const parsed = parsePositiveNumber(value, fallback);
254
+ return parsed ? Math.max(1, Math.floor(parsed)) : fallback;
255
+ }
256
+
184
257
  export function normalizeText(input) {
185
258
  return String(input || "").replace(/\s+/g, " ").trim();
186
259
  }
@@ -1451,6 +1524,7 @@ export function compactScreeningLlmResult(llmResult) {
1451
1524
  ok: Boolean(llmResult.ok),
1452
1525
  provider: llmResult.provider || null,
1453
1526
  passed: llmResult.passed,
1527
+ review_required: typeof llmResult.review_required === "boolean" ? llmResult.review_required : null,
1454
1528
  cot: llmResult.cot || llmResult.decision_cot || "",
1455
1529
  reasoning_content: llmResult.reasoning_content || "",
1456
1530
  raw_model_output: llmResult.raw_model_output || "",
@@ -1461,6 +1535,17 @@ export function compactScreeningLlmResult(llmResult) {
1461
1535
  attempt_count: llmResult.attempt_count || 0,
1462
1536
  fallback_count: llmResult.fallback_count || 0,
1463
1537
  llm_model_failures: Array.isArray(llmResult.llm_model_failures) ? llmResult.llm_model_failures : [],
1538
+ screening_strategy: llmResult.screening_strategy || "",
1539
+ fast_thinking_level: llmResult.fast_thinking_level || "",
1540
+ verify_thinking_level: llmResult.verify_thinking_level || "",
1541
+ verified: typeof llmResult.verified === "boolean" ? llmResult.verified : null,
1542
+ verification_reason: llmResult.verification_reason || "",
1543
+ decision_source: llmResult.decision_source || "",
1544
+ fast_result: llmResult.fast_result || null,
1545
+ verify_result: llmResult.verify_result || null,
1546
+ error_code: llmResult.error_code || null,
1547
+ fatal: Boolean(llmResult.fatal),
1548
+ fatal_reason: llmResult.fatal_reason || "",
1464
1549
  error: llmResult.error || null,
1465
1550
  screened_at: llmResult.screened_at || null
1466
1551
  };
@@ -1477,11 +1562,12 @@ export function llmResultToScreening(llmResult, candidate) {
1477
1562
  }
1478
1563
 
1479
1564
  export function isRecoverableLlmScreeningError(error) {
1480
- return /(?:LLM response missing boolean passed decision|LLM response was not valid JSON)/i
1565
+ return /(?:LLM response missing boolean passed decision|LLM response missing brief summary|LLM response missing boolean review_required decision|LLM response was not valid JSON)/i
1481
1566
  .test(String(error?.message || error || ""));
1482
1567
  }
1483
1568
 
1484
1569
  export function createFailedLlmScreeningResult(error) {
1570
+ const fatalClassification = classifyFatalLlmProviderError(error);
1485
1571
  return {
1486
1572
  ok: false,
1487
1573
  passed: false,
@@ -1496,6 +1582,9 @@ export function createFailedLlmScreeningResult(error) {
1496
1582
  attempt_count: Number(error?.llm_attempt_count) || 0,
1497
1583
  fallback_count: Array.isArray(error?.llm_model_failures) ? error.llm_model_failures.length : 0,
1498
1584
  llm_model_failures: Array.isArray(error?.llm_model_failures) ? error.llm_model_failures : [],
1585
+ error_code: fatalClassification?.code || error?.code || null,
1586
+ fatal: Boolean(fatalClassification),
1587
+ fatal_reason: fatalClassification?.reason || "",
1499
1588
  error: error?.message || String(error || "unknown"),
1500
1589
  screened_at: nowIso()
1501
1590
  };
@@ -1504,6 +1593,8 @@ export function createFailedLlmScreeningResult(error) {
1504
1593
  export function buildScreeningLlmMessages({
1505
1594
  candidate,
1506
1595
  criteria,
1596
+ thinkingLevel = "low",
1597
+ requireReviewDecision = false,
1507
1598
  imageEvidence = null,
1508
1599
  imagePaths = [],
1509
1600
  imageInputs = null,
@@ -1512,6 +1603,9 @@ export function buildScreeningLlmMessages({
1512
1603
  }) {
1513
1604
  const safeCriteria = normalizeText(criteria || "判断候选人是否符合本次招聘筛选标准");
1514
1605
  const safeText = String(candidate?.text?.raw || candidate?.text || "");
1606
+ const normalizedThinkingLevel = normalizeLlmThinkingLevel(thinkingLevel) || "low";
1607
+ const requestReviewDecision = Boolean(requireReviewDecision);
1608
+ const requestSummary = requestReviewDecision || normalizedThinkingLevel === "current";
1515
1609
  const images = Array.isArray(imageInputs)
1516
1610
  ? imageInputs
1517
1611
  : buildScreeningLlmImageInputs({
@@ -1520,18 +1614,44 @@ export function buildScreeningLlmMessages({
1520
1614
  maxImages,
1521
1615
  detail: imageDetail
1522
1616
  });
1617
+ const outputShape = requestReviewDecision
1618
+ ? "最后只返回 JSON,格式为:"
1619
+ + "{\"passed\": true/false, \"summary\": \"少于100个中文词的筛选总结\", \"review_required\": true/false}"
1620
+ : requestSummary
1621
+ ? "7) 只返回 JSON,格式为:"
1622
+ + "{\"passed\": true/false, \"summary\": \"少于100个中文词的筛选总结\"}"
1623
+ : "7) 只返回 JSON,格式为:"
1624
+ + "{\"passed\": true/false}";
1625
+ const failFastInstructions = [
1626
+ "3) 按筛选标准原文顺序拆解并检查硬性淘汰项;一旦某项可确定不满足,必须立即返回 passed=false,不要继续评估后续条件。",
1627
+ "4) 若筛选标准规定证据不足、业务线无法判断、任期无法判断、学校无法判断等情况不通过,这类缺失证据就是可确定不满足。",
1628
+ "5) 只有当前淘汰项本身存在截图不清、信息冲突、或可能被后续可见简历内容澄清时,才继续阅读以核实该项。"
1629
+ ].join("\n");
1630
+ const fastReviewInstructions = requestReviewDecision
1631
+ ? [
1632
+ "7) review_required 必须是布尔值;当证据缺失、证据冲突、截图/文本不完整或不清晰、结论接近规则边界、或你依赖假设时设为 true;若结论明确且无需更深推理核验,设为 false。",
1633
+ "8) 只有当某个硬性不通过条件有直接、明确、无可见反向证据的简历证据时,才可返回 review_required=false。",
1634
+ "9) 如果简历中存在任何可能满足当前被否定条件的反向证据、边界证据或可计入经历,即使你倾向 passed=false,也必须 review_required=true。",
1635
+ "10) 反向证据包括但不限于:可能合格的学校/学历、可能合格的产品或行业、可能合格的职责或指标、可能合格的海外/语言证据、或需要精确计算的任期/年限证据。",
1636
+ "11) 不要因为候选人其他经历不匹配,就忽略某一段可能匹配的经历;只要任一可见经历可能满足被否定条件,就必须交给复核。",
1637
+ "12) 当 review_required=false 时,summary 必须点明确切的决定性硬性失败,并说明没有可见反向证据或边界证据。"
1638
+ ].join("\n")
1639
+ : "";
1523
1640
  const prompt =
1524
1641
  `请根据以下标准判断候选人是否通过筛选。\n\n筛选标准:\n${safeCriteria}\n\n`
1525
1642
  + `候选人信息:\n${safeText || "候选人的完整简历信息在后续截图中,请按截图顺序阅读。"}\n\n`
1526
1643
  + (images.length
1527
- ? `候选人简历截图共 ${images.length} 张,按从上到下的滚动顺序排列。若截图是拼接长图,请按图内从上到下顺序完整阅读;不要跳过任何一段简历内容。\n\n`
1644
+ ? `候选人简历截图共 ${images.length} 张,按从上到下的滚动顺序排列。若截图是拼接长图,请按图内从上到下顺序阅读;若已出现明确硬性淘汰项,可停止后续评估。\n\n`
1528
1645
  : "")
1529
1646
  + "要求:\n"
1530
1647
  + "1) 只能依据候选人信息或截图中真实出现的内容判断。\n"
1531
1648
  + "2) 若证据不足或截图无法确认,必须返回 passed=false。\n"
1532
- + "3) 不要输出评估原因、证据列表、解释或额外文字。\n"
1533
- + "4) 只返回 JSON,格式为:"
1534
- + "{\"passed\": true/false}";
1649
+ + failFastInstructions + "\n"
1650
+ + (requestSummary
1651
+ ? "6) summary 必须为少于100个中文词的简短筛选总结,可包含核心依据和主要风险;不要输出推理过程。\n"
1652
+ : "6) 不要输出评估原因、证据列表、解释或额外文字。\n")
1653
+ + (fastReviewInstructions ? `${fastReviewInstructions}\n` : "")
1654
+ + outputShape;
1535
1655
  const userContent = images.length
1536
1656
  ? [
1537
1657
  { type: "text", text: prompt },
@@ -1545,8 +1665,12 @@ export function buildScreeningLlmMessages({
1545
1665
  {
1546
1666
  role: "system",
1547
1667
  content:
1548
- "你是一位严谨的招聘筛选助手。必须完整阅读输入内容,严禁编造不存在的候选人经历。"
1549
- + "只能返回严格 JSON,不要输出原因、证据或额外文字。"
1668
+ "你是一位严谨的招聘筛选助手。必须按筛选标准顺序严格阅读和判断,严禁编造不存在的候选人经历;一旦确定命中硬性淘汰项,可立即给出最终不通过结论。"
1669
+ + (requestReviewDecision
1670
+ ? "只能返回严格 JSON。必须包含 passed、summary 和 review_required;summary 用中文,少于100个词,只概括筛选结论、核心依据和主要风险,不要输出推理过程;review_required 只能是 true 或 false。只有在硬性失败直接明确且无可见反向证据时,review_required 才能为 false。"
1671
+ : requestSummary
1672
+ ? "只能返回严格 JSON。必须包含 passed 和 summary;summary 用中文,少于100个词,只概括筛选结论、核心依据和主要风险,不要输出推理过程。"
1673
+ : "只能返回严格 JSON,不要输出原因、证据或额外文字。")
1550
1674
  },
1551
1675
  {
1552
1676
  role: "user",
@@ -1578,6 +1702,7 @@ async function callScreeningLlmWithProvider({
1578
1702
  criteria,
1579
1703
  config = {},
1580
1704
  timeoutMs = 60000,
1705
+ requireReviewDecision = false,
1581
1706
  imageEvidence = null,
1582
1707
  imagePaths = [],
1583
1708
  maxImages = 8,
@@ -1600,7 +1725,7 @@ async function callScreeningLlmWithProvider({
1600
1725
  }
1601
1726
 
1602
1727
  const thinkingLevel = config.llmThinkingLevel || config.thinkingLevel || config.reasoningEffort || "low";
1603
- const outputTokenBudget = resolveLlmOutputTokenBudget(config, thinkingLevel);
1728
+ const outputTokenBudget = resolveLlmOutputTokenBudget(config, thinkingLevel, { requireReviewDecision });
1604
1729
  const payload = {
1605
1730
  model,
1606
1731
  temperature: parseFiniteNumber(config.temperature, 0.1),
@@ -1608,6 +1733,8 @@ async function callScreeningLlmWithProvider({
1608
1733
  messages: buildScreeningLlmMessages({
1609
1734
  candidate,
1610
1735
  criteria,
1736
+ thinkingLevel,
1737
+ requireReviewDecision,
1611
1738
  imageInputs
1612
1739
  })
1613
1740
  };
@@ -1630,7 +1757,10 @@ async function callScreeningLlmWithProvider({
1630
1757
  const maxRetries = normalizeLlmMaxRetries(config.llmMaxRetries ?? config.maxRetries);
1631
1758
  const maxAttempts = maxRetries + 1;
1632
1759
  let lastError = null;
1633
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
1760
+ let attempt = 0;
1761
+ let fatalProviderAttempts = 0;
1762
+ while (true) {
1763
+ attempt += 1;
1634
1764
  const controller = new AbortController();
1635
1765
  const timer = setTimeout(() => controller.abort(), effectiveTimeoutMs);
1636
1766
  try {
@@ -1651,6 +1781,10 @@ async function callScreeningLlmWithProvider({
1651
1781
  if (!response.ok) {
1652
1782
  const error = new Error(`LLM request failed: ${response.status} ${responseText.slice(0, 400)}`);
1653
1783
  error.status = response.status;
1784
+ const providerError = tryParseJson(responseText)?.error || {};
1785
+ error.provider_error_code = providerError.code || null;
1786
+ error.provider_error_type = providerError.type || null;
1787
+ error.provider_error_message = providerError.message || null;
1654
1788
  throw error;
1655
1789
  }
1656
1790
  const json = tryParseJson(responseText);
@@ -1665,20 +1799,33 @@ async function callScreeningLlmWithProvider({
1665
1799
  if (passed === null) {
1666
1800
  throw new Error(`LLM response missing boolean passed decision: ${content.slice(0, 240)}`);
1667
1801
  }
1802
+ const normalizedThinkingLevel = normalizeLlmThinkingLevel(thinkingLevel) || "low";
1803
+ const summary = normalizeBlockText(parsed?.summary || parsed?.screen_summary || parsed?.brief_summary);
1804
+ if ((normalizedThinkingLevel === "current" || requireReviewDecision) && !summary) {
1805
+ throw new Error(`LLM response missing brief summary for current thinking level or fast-first strategy: ${content.slice(0, 240)}`);
1806
+ }
1807
+ const reviewRequired = requireReviewDecision
1808
+ ? parsePassedDecision(parsed?.review_required ?? parsed?.reviewRequired ?? parsed?.needs_review ?? parsed?.needsReview)
1809
+ : null;
1810
+ if (requireReviewDecision && reviewRequired === null) {
1811
+ throw new Error(`LLM response missing boolean review_required decision: ${content.slice(0, 240)}`);
1812
+ }
1668
1813
  const evidence = Array.isArray(parsed?.evidence)
1669
1814
  ? parsed.evidence.map(normalizeText).filter(Boolean)
1670
1815
  : [];
1671
- const decisionCot = firstReasoningText([
1672
- parsed?.cot,
1673
- parsed?.decision_cot,
1674
- parsed?.reasoning,
1675
- parsed?.chain_of_thought,
1676
- reasoningContent
1677
- ].map(normalizeBlockText).filter(Boolean)) || reasoningContent;
1816
+ const decisionCot = (normalizedThinkingLevel === "current" || requireReviewDecision)
1817
+ ? summary
1818
+ : (firstReasoningText([
1819
+ parsed?.cot,
1820
+ parsed?.decision_cot,
1821
+ parsed?.reasoning,
1822
+ parsed?.chain_of_thought,
1823
+ reasoningContent
1824
+ ].map(normalizeBlockText).filter(Boolean)) || reasoningContent);
1678
1825
  const providerName = normalizeText(config.llmProviderName || config.name || config.label || config.id);
1679
1826
  const providerIndex = Number.isFinite(Number(config.llmProviderIndex)) ? Number(config.llmProviderIndex) : 0;
1680
1827
  const providerCount = Number.isFinite(Number(config.llmProviderCount)) ? Number(config.llmProviderCount) : 1;
1681
- return {
1828
+ const result = {
1682
1829
  ok: true,
1683
1830
  provider: {
1684
1831
  baseUrl: redactBaseUrl(baseUrl),
@@ -1693,6 +1840,7 @@ async function callScreeningLlmWithProvider({
1693
1840
  max_completion_tokens: payload.max_completion_tokens || null
1694
1841
  },
1695
1842
  passed,
1843
+ review_required: reviewRequired,
1696
1844
  reason: "",
1697
1845
  evidence,
1698
1846
  cot: decisionCot,
@@ -1708,9 +1856,26 @@ async function callScreeningLlmWithProvider({
1708
1856
  provider_attempt_count: attempt,
1709
1857
  screened_at: nowIso()
1710
1858
  };
1859
+ return result;
1711
1860
  } catch (error) {
1712
1861
  lastError = error;
1713
- if (attempt >= maxAttempts || !isRetryableLlmRequestError(error)) {
1862
+ const fatalClassification = classifyFatalLlmProviderError(error);
1863
+ if (fatalClassification) {
1864
+ fatalProviderAttempts += 1;
1865
+ error.code = fatalClassification.code;
1866
+ error.llm_fatal_provider_error = true;
1867
+ error.llm_fatal_reason = fatalClassification.reason;
1868
+ if (fatalProviderAttempts <= FATAL_LLM_PROVIDER_MAX_RETRIES) {
1869
+ await sleepMs(Math.min(2500, 500 * fatalProviderAttempts));
1870
+ continue;
1871
+ }
1872
+ error.image_input_count = imageInputs.length;
1873
+ error.image_inputs = summarizeLlmImageInputs(imageInputs);
1874
+ error.llm_attempt_count = attempt;
1875
+ throw error;
1876
+ }
1877
+ const retryable = isRetryableLlmRequestError(error) || isRecoverableLlmScreeningError(error);
1878
+ if (attempt >= maxAttempts || !retryable) {
1714
1879
  error.image_input_count = imageInputs.length;
1715
1880
  error.image_inputs = summarizeLlmImageInputs(imageInputs);
1716
1881
  error.llm_attempt_count = attempt;
@@ -1728,7 +1893,110 @@ async function callScreeningLlmWithProvider({
1728
1893
  throw lastError;
1729
1894
  }
1730
1895
 
1731
- export async function callScreeningLlm(args = {}) {
1896
+ function compactStrategyLlmResult(result) {
1897
+ if (!result) return null;
1898
+ const compact = compactScreeningLlmResult(result);
1899
+ if (compact) {
1900
+ compact.fast_result = null;
1901
+ compact.verify_result = null;
1902
+ }
1903
+ return compact;
1904
+ }
1905
+
1906
+ function attachFastFirstScreeningMetadata(result, {
1907
+ fastThinkingLevel = "current",
1908
+ verifyThinkingLevel = "low",
1909
+ verified = false,
1910
+ verificationReason = "",
1911
+ decisionSource = "fast",
1912
+ fastResult = null,
1913
+ verifyResult = null
1914
+ } = {}) {
1915
+ return {
1916
+ ...result,
1917
+ screening_strategy: "fast_first_verified",
1918
+ fast_thinking_level: fastThinkingLevel,
1919
+ verify_thinking_level: verifyThinkingLevel,
1920
+ verified: Boolean(verified),
1921
+ verification_reason: verificationReason,
1922
+ decision_source: decisionSource,
1923
+ fast_result: compactStrategyLlmResult(fastResult),
1924
+ verify_result: compactStrategyLlmResult(verifyResult)
1925
+ };
1926
+ }
1927
+
1928
+ function normalizeStrategyThinkingLevel(value, fallback) {
1929
+ return normalizeLlmThinkingLevel(value) || normalizeLlmThinkingLevel(fallback) || fallback;
1930
+ }
1931
+
1932
+ function resolveStrategyPassMaxTokens(entry = {}, inherited = {}, pass = "fast", fallback = null) {
1933
+ const value = pass === "verify"
1934
+ ? firstConfiguredValue(
1935
+ entry.llmVerifyMaxTokens,
1936
+ entry.verifyMaxTokens,
1937
+ entry.verify_max_tokens,
1938
+ inherited.llmVerifyMaxTokens,
1939
+ inherited.verifyMaxTokens,
1940
+ inherited.verify_max_tokens
1941
+ )
1942
+ : firstConfiguredValue(
1943
+ entry.llmFastMaxTokens,
1944
+ entry.fastMaxTokens,
1945
+ entry.fast_max_tokens,
1946
+ inherited.llmFastMaxTokens,
1947
+ inherited.fastMaxTokens,
1948
+ inherited.fast_max_tokens
1949
+ );
1950
+ return parsePositiveInteger(value, fallback);
1951
+ }
1952
+
1953
+ function applyForcedOutputTokenBudget(config = {}, outputTokenBudget = null) {
1954
+ const budget = parsePositiveInteger(outputTokenBudget, null);
1955
+ if (!budget) return config;
1956
+ const next = {
1957
+ ...config,
1958
+ llmMaxTokens: budget,
1959
+ maxTokens: budget
1960
+ };
1961
+ delete next.llmMaxCompletionTokens;
1962
+ delete next.maxCompletionTokens;
1963
+ return next;
1964
+ }
1965
+
1966
+ function withForcedLlmThinkingLevel(config = {}, thinkingLevel = "low", options = {}) {
1967
+ const normalizedThinkingLevel = normalizeStrategyThinkingLevel(thinkingLevel, "low");
1968
+ const forceEntry = (entry) => {
1969
+ const objectEntry = typeof entry === "string" ? { model: entry } : { ...(entry || {}) };
1970
+ const forced = {
1971
+ ...objectEntry,
1972
+ llmThinkingLevel: normalizedThinkingLevel,
1973
+ thinkingLevel: normalizedThinkingLevel,
1974
+ reasoningEffort: normalizedThinkingLevel
1975
+ };
1976
+ const entryBudget = options.pass
1977
+ ? resolveStrategyPassMaxTokens(objectEntry, config, options.pass, options.defaultMaxTokens)
1978
+ : options.outputTokenBudget;
1979
+ return applyForcedOutputTokenBudget(forced, entryBudget);
1980
+ };
1981
+ const baseBudget = options.pass
1982
+ ? resolveStrategyPassMaxTokens(config, {}, options.pass, options.defaultMaxTokens)
1983
+ : options.outputTokenBudget;
1984
+ const next = applyForcedOutputTokenBudget({
1985
+ ...(config || {}),
1986
+ llmThinkingLevel: normalizedThinkingLevel,
1987
+ thinkingLevel: normalizedThinkingLevel,
1988
+ reasoningEffort: normalizedThinkingLevel
1989
+ }, baseBudget);
1990
+ if (Array.isArray(config?.llmModels)) {
1991
+ next.llmModels = config.llmModels.map(forceEntry);
1992
+ }
1993
+ if (Array.isArray(config?.models)) {
1994
+ next.models = config.models.map(forceEntry);
1995
+ }
1996
+ return next;
1997
+ }
1998
+
1999
+ async function callSinglePassScreeningLlm(args = {}) {
1732
2000
  const providers = normalizeLlmProviderConfigs(args.config || {});
1733
2001
  if (providers.length <= 1) {
1734
2002
  return callScreeningLlmWithProvider({
@@ -1762,6 +2030,7 @@ export async function callScreeningLlm(args = {}) {
1762
2030
  fallback_count: providerFailures.length
1763
2031
  };
1764
2032
  } catch (error) {
2033
+ if (isFatalLlmProviderError(error)) throw error;
1765
2034
  lastError = error;
1766
2035
  providerFailures.push(compactLlmProviderFailure(error, providerConfig, index));
1767
2036
  if (index < providers.length - 1) {
@@ -1782,3 +2051,85 @@ export async function callScreeningLlm(args = {}) {
1782
2051
  finalError.image_inputs = lastError?.image_inputs || [];
1783
2052
  throw finalError;
1784
2053
  }
2054
+
2055
+ async function callFastFirstVerifiedScreeningLlm(args = {}) {
2056
+ const config = args.config || {};
2057
+ const fastThinkingLevel = normalizeStrategyThinkingLevel(config.llmFastThinkingLevel ?? config.fastThinkingLevel, "current");
2058
+ const verifyThinkingLevel = normalizeStrategyThinkingLevel(config.llmVerifyThinkingLevel ?? config.verifyThinkingLevel, "low");
2059
+ const fastArgs = {
2060
+ ...args,
2061
+ config: withForcedLlmThinkingLevel(config, fastThinkingLevel, {
2062
+ pass: "fast",
2063
+ defaultMaxTokens: FAST_FIRST_DEFAULT_FAST_MAX_TOKENS
2064
+ }),
2065
+ requireReviewDecision: true
2066
+ };
2067
+ let fastResult = null;
2068
+ let verifyReason = "";
2069
+ try {
2070
+ fastResult = await callSinglePassScreeningLlm(fastArgs);
2071
+ if (fastResult.passed === true) {
2072
+ verifyReason = fastResult.review_required === true ? "fast_passed_review_required" : "fast_passed";
2073
+ } else if (fastResult.review_required === true) {
2074
+ verifyReason = "fast_review_required";
2075
+ }
2076
+ } catch (error) {
2077
+ if (isFatalLlmProviderError(error)) throw error;
2078
+ fastResult = createFailedLlmScreeningResult(error);
2079
+ verifyReason = "fast_invalid_response";
2080
+ }
2081
+
2082
+ if (!verifyReason) {
2083
+ return attachFastFirstScreeningMetadata(fastResult, {
2084
+ fastThinkingLevel,
2085
+ verifyThinkingLevel,
2086
+ verified: false,
2087
+ decisionSource: "fast",
2088
+ fastResult
2089
+ });
2090
+ }
2091
+
2092
+ const verifyArgs = {
2093
+ ...args,
2094
+ config: withForcedLlmThinkingLevel(config, verifyThinkingLevel, {
2095
+ pass: "verify"
2096
+ }),
2097
+ requireReviewDecision: false
2098
+ };
2099
+ try {
2100
+ const verifyResult = await callSinglePassScreeningLlm(verifyArgs);
2101
+ return attachFastFirstScreeningMetadata(verifyResult, {
2102
+ fastThinkingLevel,
2103
+ verifyThinkingLevel,
2104
+ verified: true,
2105
+ verificationReason: verifyReason,
2106
+ decisionSource: "verify",
2107
+ fastResult,
2108
+ verifyResult
2109
+ });
2110
+ } catch (error) {
2111
+ if (isFatalLlmProviderError(error)) throw error;
2112
+ const failedVerifyResult = createFailedLlmScreeningResult(error);
2113
+ return attachFastFirstScreeningMetadata(failedVerifyResult, {
2114
+ fastThinkingLevel,
2115
+ verifyThinkingLevel,
2116
+ verified: true,
2117
+ verificationReason: verifyReason,
2118
+ decisionSource: "verify_error",
2119
+ fastResult,
2120
+ verifyResult: failedVerifyResult
2121
+ });
2122
+ }
2123
+ }
2124
+
2125
+ export async function callScreeningLlm(args = {}) {
2126
+ const strategy = normalizeLlmScreeningStrategy(
2127
+ args.config?.llmScreeningStrategy
2128
+ ?? args.config?.screeningStrategy
2129
+ ?? args.config?.screening_strategy
2130
+ );
2131
+ if (strategy === "fast_first_verified") {
2132
+ return callFastFirstVerifiedScreeningLlm(args);
2133
+ }
2134
+ return callSinglePassScreeningLlm(args);
2135
+ }