@martian-engineering/lossless-claw 0.5.2 → 0.6.0

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/src/summarize.ts CHANGED
@@ -20,8 +20,32 @@ export type LcmSummarizerLegacyParams = {
20
20
  authProfileId?: unknown;
21
21
  };
22
22
 
23
+ type SummaryResolutionCandidate = {
24
+ levelName: string;
25
+ modelRef: string;
26
+ providerHint?: string;
27
+ hasExplicitProvider: boolean;
28
+ useLegacyAuthProfile: boolean;
29
+ };
30
+
31
+ type ResolvedSummaryCandidate = SummaryResolutionCandidate & {
32
+ provider: string;
33
+ model: string;
34
+ };
35
+
36
+ function buildSummarizerBreakerKey(params: {
37
+ candidate: ResolvedSummaryCandidate;
38
+ legacyAuthProfileId?: string;
39
+ }): string {
40
+ const authProfileId = params.candidate.useLegacyAuthProfile
41
+ ? (params.legacyAuthProfileId ?? "-")
42
+ : "-";
43
+ return `provider:${params.candidate.provider};model:${params.candidate.model};authProfile:${authProfileId}`;
44
+ }
45
+
23
46
  type SummaryMode = "normal" | "aggressive";
24
47
 
48
+ const DEFAULT_LEAF_TARGET_TOKENS = 2400;
25
49
  const DEFAULT_CONDENSED_TARGET_TOKENS = 2000;
26
50
  const LCM_SUMMARIZER_SYSTEM_PROMPT =
27
51
  "You are a context-compaction summarization engine. Follow user instructions exactly and return plain text summary content only.";
@@ -35,6 +59,18 @@ const AUTH_ERROR_TEXT_PATTERN =
35
59
  /\b401\b|unauthorized|unauthorised|invalid[_ -]?token|invalid[_ -]?api[_ -]?key|authentication failed|authorization failed|missing scope|insufficient scope|model\.request\b/i;
36
60
  const AUTH_ERROR_STATUS_KEYS = ["status", "statusCode", "status_code"] as const;
37
61
  const AUTH_ERROR_NESTED_KEYS = ["error", "response", "cause", "details", "data", "body"] as const;
62
+ const AUTH_ERROR_TOP_LEVEL_KEYS = [
63
+ "error",
64
+ "errorMessage",
65
+ "status",
66
+ "statusCode",
67
+ "status_code",
68
+ "code",
69
+ "details",
70
+ "cause",
71
+ "data",
72
+ "body",
73
+ ] as const;
38
74
 
39
75
  type ProviderAuthFailure = {
40
76
  statusCode?: number;
@@ -188,6 +224,15 @@ function collectBlockTypes(value: unknown, out: Set<string>): void {
188
224
  }
189
225
  }
190
226
 
227
+ /** Treat provider reasoning/thinking payloads as diagnostics, not summary text. */
228
+ function isReasoningLikeType(type: unknown): boolean {
229
+ if (typeof type !== "string") {
230
+ return false;
231
+ }
232
+ const normalized = type.trim().toLowerCase();
233
+ return normalized.includes("reasoning") || normalized.includes("thinking");
234
+ }
235
+
191
236
  /** Collect text payloads from common provider response shapes. */
192
237
  function collectTextLikeFields(value: unknown, out: string[]): void {
193
238
  if (Array.isArray(value)) {
@@ -200,7 +245,11 @@ function collectTextLikeFields(value: unknown, out: string[]): void {
200
245
  return;
201
246
  }
202
247
 
203
- for (const key of ["text", "output_text", "thinking"]) {
248
+ if (isReasoningLikeType(value.type)) {
249
+ return;
250
+ }
251
+
252
+ for (const key of ["text", "output_text"]) {
204
253
  appendTextValue(value[key], out);
205
254
  }
206
255
  for (const key of ["content", "summary", "output", "message", "response"]) {
@@ -384,6 +433,21 @@ function extractAuthFailureStatusCode(value: unknown, depth = 0): number | undef
384
433
  return undefined;
385
434
  }
386
435
 
436
+ function hasTopLevelAuthInspectionKeys(value: Record<string, unknown>): boolean {
437
+ return AUTH_ERROR_TOP_LEVEL_KEYS.some((key) => key in value);
438
+ }
439
+
440
+ function looksLikeThrownError(value: Record<string, unknown>): boolean {
441
+ return (
442
+ (typeof value.name === "string" && /\berror\b/i.test(value.name)) ||
443
+ "stack" in value ||
444
+ (typeof value.message === "string" &&
445
+ !("content" in value) &&
446
+ !("response" in value) &&
447
+ !("output" in value))
448
+ );
449
+ }
450
+
387
451
  function pickAuthInspectionValue(value: unknown): unknown {
388
452
  if (!isRecord(value)) {
389
453
  return value;
@@ -393,26 +457,43 @@ function pickAuthInspectionValue(value: unknown): unknown {
393
457
  }
394
458
 
395
459
  const subset: Record<string, unknown> = {};
396
- for (const key of [
397
- "error",
398
- "errorMessage",
399
- "message",
400
- "status",
401
- "statusCode",
402
- "status_code",
403
- "code",
404
- "details",
405
- "response",
406
- "cause",
407
- ]) {
460
+ const hasTopLevelAuthKeys = hasTopLevelAuthInspectionKeys(value);
461
+ const errorLike = value instanceof Error || looksLikeThrownError(value);
462
+
463
+ for (const key of AUTH_ERROR_TOP_LEVEL_KEYS) {
408
464
  if (key in value) {
409
465
  subset[key] = value[key];
410
466
  }
411
467
  }
412
- return Object.keys(subset).length > 0 ? subset : value;
468
+
469
+ // Only inspect top-level message payloads when the envelope already looks
470
+ // error-shaped. Successful summary responses also use `message`.
471
+ if ((hasTopLevelAuthKeys || errorLike) && "message" in value) {
472
+ subset.message = value.message;
473
+ }
474
+
475
+ // `response` can carry either an error payload or successful summary text.
476
+ // Include it only when the surrounding or nested shape already looks like an
477
+ // error envelope.
478
+ if ("response" in value) {
479
+ const response = value.response;
480
+ if (
481
+ hasTopLevelAuthKeys ||
482
+ (isRecord(response) && hasTopLevelAuthInspectionKeys(response)) ||
483
+ (isRecord(response) && looksLikeThrownError(response))
484
+ ) {
485
+ subset.response = response;
486
+ }
487
+ }
488
+
489
+ return Object.keys(subset).length > 0 ? subset : {};
413
490
  }
414
491
 
415
- function extractProviderAuthFailure(value: unknown): ProviderAuthFailure | undefined {
492
+ /** @internal Exported for testing only. */
493
+ export function extractProviderAuthFailure(
494
+ value: unknown,
495
+ opts?: { requireStructuralSignal?: boolean },
496
+ ): ProviderAuthFailure | undefined {
416
497
  const inspectValue = pickAuthInspectionValue(value);
417
498
  const statusCode = extractAuthFailureStatusCode(inspectValue);
418
499
  const textParts: string[] = [];
@@ -422,7 +503,20 @@ function extractProviderAuthFailure(value: unknown): ProviderAuthFailure | undef
422
503
  const hasScopeSignal =
423
504
  missingModelRequestScope || /\b(missing|insufficient)\s+scope\b/i.test(normalizedMessage);
424
505
 
425
- if (statusCode !== 401 && !hasScopeSignal && !AUTH_ERROR_TEXT_PATTERN.test(normalizedMessage)) {
506
+ // When requireStructuralSignal is set (e.g. checking a successful API response
507
+ // rather than a caught error), only detect auth failures that have a concrete
508
+ // structural indicator (HTTP 401 status code or an explicit provider_auth error
509
+ // kind). Plain text matches in the response body are NOT sufficient — the LLM
510
+ // summary content may legitimately discuss auth errors without being one.
511
+ const hasExplicitErrorKind =
512
+ isRecord(value) && isRecord((value as Record<string, unknown>).error) &&
513
+ ((value as Record<string, unknown>).error as Record<string, unknown>).kind === "provider_auth";
514
+
515
+ if (opts?.requireStructuralSignal) {
516
+ if (statusCode !== 401 && !hasExplicitErrorKind) {
517
+ return undefined;
518
+ }
519
+ } else if (statusCode !== 401 && !hasScopeSignal && !AUTH_ERROR_TEXT_PATTERN.test(normalizedMessage)) {
426
520
  return undefined;
427
521
  }
428
522
 
@@ -517,6 +611,15 @@ function extractResponseDiagnostics(result: unknown): string {
517
611
  if (typeof result.provider === "string" && result.provider.trim()) {
518
612
  parts.push(`resp_provider=${result.provider.trim()}`);
519
613
  }
614
+ if (typeof result.status === "string" && result.status.trim()) {
615
+ parts.push(`status=${result.status.trim()}`);
616
+ }
617
+ if (isRecord(result.incomplete_details) && typeof result.incomplete_details.reason === "string") {
618
+ const reason = result.incomplete_details.reason.trim();
619
+ if (reason) {
620
+ parts.push(`incomplete_reason=${reason}`);
621
+ }
622
+ }
520
623
  for (const key of [
521
624
  "request_provider",
522
625
  "request_model",
@@ -580,6 +683,50 @@ function extractResponseDiagnostics(result: unknown): string {
580
683
  return parts.join("; ");
581
684
  }
582
685
 
686
+ /** Collect retry-worthy "incomplete" signals from Responses-style envelopes/items. */
687
+ function collectIncompleteResponseSignals(
688
+ value: unknown,
689
+ out: Set<string>,
690
+ label = "response",
691
+ depth = 0,
692
+ ): void {
693
+ if (depth >= DIAGNOSTIC_MAX_DEPTH) {
694
+ return;
695
+ }
696
+ if (Array.isArray(value)) {
697
+ value.slice(0, DIAGNOSTIC_MAX_ARRAY_ITEMS).forEach((entry, index) => {
698
+ collectIncompleteResponseSignals(entry, out, `${label}[${index}]`, depth + 1);
699
+ });
700
+ return;
701
+ }
702
+ if (!isRecord(value)) {
703
+ return;
704
+ }
705
+
706
+ if (typeof value.status === "string" && value.status.trim().toLowerCase() === "incomplete") {
707
+ out.add(`${label}.status=incomplete`);
708
+ }
709
+ if (isRecord(value.incomplete_details) && typeof value.incomplete_details.reason === "string") {
710
+ const reason = value.incomplete_details.reason.trim();
711
+ if (reason) {
712
+ out.add(`${label}.reason=${reason}`);
713
+ }
714
+ }
715
+
716
+ for (const key of ["content", "output", "message", "response", "items"] as const) {
717
+ if (key in value) {
718
+ collectIncompleteResponseSignals(value[key], out, `${label}.${key}`, depth + 1);
719
+ }
720
+ }
721
+ }
722
+
723
+ /** Extract retry-worthy incomplete-response diagnostics for provider envelopes/items. */
724
+ function extractIncompleteResponseSignals(value: unknown): string[] {
725
+ const signals = new Set<string>();
726
+ collectIncompleteResponseSignals(value, signals);
727
+ return [...signals].sort((a, b) => a.localeCompare(b));
728
+ }
729
+
583
730
  /**
584
731
  * Resolve a practical target token count for leaf and condensed summaries.
585
732
  * Aggressive leaf mode intentionally aims lower so compaction converges faster.
@@ -588,6 +735,7 @@ function resolveTargetTokens(params: {
588
735
  inputTokens: number;
589
736
  mode: SummaryMode;
590
737
  isCondensed: boolean;
738
+ leafTargetTokens: number;
591
739
  condensedTargetTokens: number;
592
740
  }): number {
593
741
  if (params.isCondensed) {
@@ -595,10 +743,12 @@ function resolveTargetTokens(params: {
595
743
  }
596
744
 
597
745
  const { inputTokens, mode } = params;
746
+ const leafTargetTokens = Math.max(192, params.leafTargetTokens);
598
747
  if (mode === "aggressive") {
599
- return Math.max(96, Math.min(640, Math.floor(inputTokens * 0.2)));
748
+ const aggressiveCap = Math.max(96, Math.min(leafTargetTokens, Math.floor(leafTargetTokens * 0.55)));
749
+ return Math.max(96, Math.min(aggressiveCap, Math.floor(inputTokens * 0.2)));
600
750
  }
601
- return Math.max(192, Math.min(1200, Math.floor(inputTokens * 0.35)));
751
+ return Math.max(192, Math.min(leafTargetTokens, Math.floor(inputTokens * 0.35)));
602
752
  }
603
753
 
604
754
  /**
@@ -815,30 +965,47 @@ function buildDeterministicFallbackSummary(text: string, targetTokens: number):
815
965
  return `${trimmed.slice(0, maxChars)}\n[LCM fallback summary; truncated for context management]`;
816
966
  }
817
967
 
818
- /**
819
- * Builds a model-backed LCM summarize callback from runtime legacy params.
820
- *
821
- * Returns `undefined` when model/provider context is unavailable so callers can
822
- * choose a fallback summarizer.
823
- */
824
- export async function createLcmSummarizeFromLegacyParams(params: {
825
- deps: LcmDependencies;
826
- legacyParams: LcmSummarizerLegacyParams;
827
- customInstructions?: string;
828
- }): Promise<{ fn: LcmSummarizeFn; model: string } | undefined> {
829
- const readModelRef = (value: unknown): string => {
830
- if (typeof value === "string") {
831
- return value.trim();
968
+ /** Normalize model refs from string or `{ primary }` config shapes. */
969
+ function readModelRef(value: unknown): string {
970
+ if (typeof value === "string") {
971
+ return value.trim();
972
+ }
973
+ const primary = (value as { primary?: unknown } | undefined)?.primary;
974
+ return typeof primary === "string" ? primary.trim() : "";
975
+ }
976
+
977
+ /** Avoid retrying the same resolved provider/model pair across fallback levels. */
978
+ function dedupeResolvedCandidates(
979
+ candidates: ResolvedSummaryCandidate[],
980
+ ): ResolvedSummaryCandidate[] {
981
+ const seen = new Set<string>();
982
+ const ordered: ResolvedSummaryCandidate[] = [];
983
+ for (const candidate of candidates) {
984
+ const key = `${candidate.provider}\u0000${candidate.model}`;
985
+ if (seen.has(key)) {
986
+ continue;
832
987
  }
833
- const primary = (value as { primary?: unknown } | undefined)?.primary;
834
- return typeof primary === "string" ? primary.trim() : "";
835
- };
988
+ seen.add(key);
989
+ ordered.push(candidate);
990
+ }
991
+ return ordered;
992
+ }
836
993
 
994
+ /** Resolve ordered summarizer candidates from env, plugin config, defaults, and session hints. */
995
+ function resolveSummaryCandidates(params: {
996
+ deps: LcmDependencies;
997
+ legacyParams: LcmSummarizerLegacyParams;
998
+ }): ResolvedSummaryCandidate[] {
999
+ const providerHint =
1000
+ typeof params.legacyParams.provider === "string" ? params.legacyParams.provider.trim() : "";
1001
+ const modelHint =
1002
+ typeof params.legacyParams.model === "string" ? params.legacyParams.model.trim() : "";
837
1003
  const runtimeConfig =
838
1004
  params.legacyParams.config && typeof params.legacyParams.config === "object"
839
1005
  ? (params.legacyParams.config as {
840
1006
  agents?: {
841
1007
  defaults?: {
1008
+ model?: unknown;
842
1009
  compaction?: {
843
1010
  model?: unknown;
844
1011
  };
@@ -853,91 +1020,121 @@ export async function createLcmSummarizeFromLegacyParams(params: {
853
1020
  };
854
1021
  })
855
1022
  : undefined;
856
-
857
1023
  const nestedPluginConfig = runtimeConfig?.plugins?.entries?.["lossless-claw"]?.config;
858
1024
 
859
- const summaryLevels = [
1025
+ const resolutionCandidates: SummaryResolutionCandidate[] = [
860
1026
  {
861
1027
  levelName: "environment variables",
862
- model: process.env.LCM_SUMMARY_MODEL?.trim() ?? "",
863
- provider: process.env.LCM_SUMMARY_PROVIDER?.trim() ?? "",
1028
+ modelRef: process.env.LCM_SUMMARY_MODEL?.trim() ?? "",
1029
+ providerHint:
1030
+ process.env.LCM_SUMMARY_PROVIDER?.trim() ||
1031
+ (providerHint || undefined),
1032
+ hasExplicitProvider: Boolean(process.env.LCM_SUMMARY_PROVIDER?.trim()),
1033
+ useLegacyAuthProfile: false,
864
1034
  },
865
1035
  {
866
1036
  levelName: "plugin config (lossless-claw)",
867
- model: readModelRef(nestedPluginConfig?.summaryModel),
868
- provider: typeof nestedPluginConfig?.summaryProvider === "string" ? nestedPluginConfig.summaryProvider.trim() : "",
1037
+ modelRef: readModelRef(nestedPluginConfig?.summaryModel),
1038
+ providerHint:
1039
+ (typeof nestedPluginConfig?.summaryProvider === "string"
1040
+ ? nestedPluginConfig.summaryProvider.trim()
1041
+ : "") || (providerHint || undefined),
1042
+ hasExplicitProvider: Boolean(
1043
+ typeof nestedPluginConfig?.summaryProvider === "string" &&
1044
+ nestedPluginConfig.summaryProvider.trim(),
1045
+ ),
1046
+ useLegacyAuthProfile: false,
869
1047
  },
870
1048
  {
871
1049
  levelName: "OpenClaw agents.defaults.compaction.model",
872
- model: readModelRef(runtimeConfig?.agents?.defaults?.compaction?.model),
873
- provider: "",
1050
+ modelRef: readModelRef(runtimeConfig?.agents?.defaults?.compaction?.model),
1051
+ providerHint: undefined,
1052
+ hasExplicitProvider: false,
1053
+ useLegacyAuthProfile: false,
1054
+ },
1055
+ {
1056
+ levelName: "OpenClaw agents.defaults.model",
1057
+ modelRef: readModelRef(runtimeConfig?.agents?.defaults?.model),
1058
+ providerHint: undefined,
1059
+ hasExplicitProvider: false,
1060
+ useLegacyAuthProfile: false,
1061
+ },
1062
+ {
1063
+ levelName: "legacy runtime/session model",
1064
+ modelRef: modelHint,
1065
+ providerHint: providerHint || undefined,
1066
+ hasExplicitProvider: Boolean(providerHint),
1067
+ useLegacyAuthProfile: true,
874
1068
  },
875
1069
  ];
876
1070
 
877
- let resolvedSummary: { model: string; provider: string | undefined } | undefined;
878
- for (const level of summaryLevels) {
879
- if (!level.model) continue;
880
- if (level.model.includes("/")) {
881
- resolvedSummary = { model: level.model, provider: undefined };
882
- break;
1071
+ const resolvedCandidates: ResolvedSummaryCandidate[] = [];
1072
+ for (const candidate of resolutionCandidates) {
1073
+ if (!candidate.modelRef) {
1074
+ continue;
883
1075
  }
884
- if (level.provider) {
885
- resolvedSummary = { model: level.model, provider: level.provider };
886
- break;
1076
+ if (!candidate.modelRef.includes("/") && !candidate.hasExplicitProvider) {
1077
+ params.deps.log.warn(
1078
+ `[lcm] summaryModel "${candidate.modelRef}" at "${candidate.levelName}" has no summaryProvider or provider prefix. Will attempt resolution without provider.`,
1079
+ );
1080
+ }
1081
+ try {
1082
+ const resolved = params.deps.resolveModel(candidate.modelRef, candidate.providerHint);
1083
+ if (resolved.provider && resolved.model) {
1084
+ resolvedCandidates.push({
1085
+ ...candidate,
1086
+ provider: resolved.provider,
1087
+ model: resolved.model,
1088
+ });
1089
+ }
1090
+ } catch (err) {
1091
+ console.error(
1092
+ `[lcm] createLcmSummarize: resolveModel FAILED at ${candidate.levelName}:`,
1093
+ err instanceof Error ? err.message : err,
1094
+ );
887
1095
  }
888
- params.deps.log.warn(
889
- `[lcm] summaryModel "${level.model}" at "${level.levelName}" has no summaryProvider or provider prefix. Will attempt resolution without provider.`
890
- );
891
- resolvedSummary = { model: level.model, provider: undefined };
892
- break;
893
1096
  }
894
1097
 
895
- const providerHint =
896
- typeof params.legacyParams.provider === "string" ? params.legacyParams.provider.trim() : "";
897
- const modelHint =
898
- typeof params.legacyParams.model === "string" ? params.legacyParams.model.trim() : "";
899
- const modelRef = resolvedSummary?.model || modelHint || undefined;
900
-
901
- const resolveProviderHint =
902
- resolvedSummary !== undefined
903
- ? (
904
- resolvedSummary.provider ||
905
- (!resolvedSummary.model.includes("/") ? (providerHint || undefined) : undefined)
906
- )
907
- : (providerHint || undefined);
1098
+ return dedupeResolvedCandidates(resolvedCandidates);
1099
+ }
908
1100
 
909
- let resolved: { provider: string; model: string };
910
- try {
911
- resolved = params.deps.resolveModel(modelRef, resolveProviderHint);
912
- } catch (err) {
913
- console.error(`[lcm] createLcmSummarize: resolveModel FAILED:`, err instanceof Error ? err.message : err);
1101
+ /**
1102
+ * Builds a model-backed LCM summarize callback from runtime legacy params.
1103
+ *
1104
+ * Returns `undefined` when model/provider context is unavailable so callers can
1105
+ * choose a fallback summarizer.
1106
+ */
1107
+ export async function createLcmSummarizeFromLegacyParams(params: {
1108
+ deps: LcmDependencies;
1109
+ legacyParams: LcmSummarizerLegacyParams;
1110
+ customInstructions?: string;
1111
+ }): Promise<{ fn: LcmSummarizeFn; model: string; breakerKey: string } | undefined> {
1112
+ const resolvedCandidates = resolveSummaryCandidates(params);
1113
+ if (resolvedCandidates.length === 0) {
1114
+ console.error("[lcm] createLcmSummarize: no summary model candidates resolved");
914
1115
  return undefined;
915
1116
  }
916
1117
 
917
- const { provider, model } = resolved;
918
- if (!provider || !model) {
919
- console.error(`[lcm] createLcmSummarize: empty provider="${provider}" or model="${model}"`);
920
- return undefined;
921
- }
922
1118
  const legacyAuthProfileId =
923
1119
  typeof params.legacyParams.authProfileId === "string" &&
924
1120
  params.legacyParams.authProfileId.trim()
925
1121
  ? params.legacyParams.authProfileId.trim()
926
1122
  : undefined;
927
- // When LCM selects a dedicated summarizer model/provider, do not leak the
928
- // active session's auth profile into that separate credential lookup.
929
- const authProfileId = resolvedSummary === undefined ? legacyAuthProfileId : undefined;
930
1123
  const agentDir =
931
1124
  typeof params.legacyParams.agentDir === "string" && params.legacyParams.agentDir.trim()
932
1125
  ? params.legacyParams.agentDir.trim()
933
1126
  : undefined;
934
- const providerApi = resolveProviderApiFromLegacyConfig(params.legacyParams.config, provider);
935
1127
 
936
1128
  const condensedTargetTokens =
937
1129
  Number.isFinite(params.deps.config.condensedTargetTokens) &&
938
1130
  params.deps.config.condensedTargetTokens > 0
939
1131
  ? params.deps.config.condensedTargetTokens
940
1132
  : DEFAULT_CONDENSED_TARGET_TOKENS;
1133
+ const leafTargetTokens =
1134
+ Number.isFinite(params.deps.config.leafTargetTokens) &&
1135
+ params.deps.config.leafTargetTokens > 0
1136
+ ? params.deps.config.leafTargetTokens
1137
+ : DEFAULT_LEAF_TARGET_TOKENS;
941
1138
 
942
1139
  const fn: LcmSummarizeFn = async (
943
1140
  text: string,
@@ -950,15 +1147,11 @@ export async function createLcmSummarizeFromLegacyParams(params: {
950
1147
 
951
1148
  const mode: SummaryMode = aggressive ? "aggressive" : "normal";
952
1149
  const isCondensed = options?.isCondensed === true;
953
- const apiKey = await params.deps.getApiKey(provider, model, {
954
- profileId: authProfileId,
955
- agentDir,
956
- runtimeConfig: params.legacyParams.config,
957
- });
958
1150
  const targetTokens = resolveTargetTokens({
959
1151
  inputTokens: estimateTokens(text),
960
1152
  mode,
961
1153
  isCondensed,
1154
+ leafTargetTokens,
962
1155
  condensedTargetTokens,
963
1156
  });
964
1157
  const prompt = isCondensed
@@ -980,96 +1173,30 @@ export async function createLcmSummarizeFromLegacyParams(params: {
980
1173
  customInstructions: params.customInstructions,
981
1174
  });
982
1175
 
983
- let result: Awaited<ReturnType<typeof params.deps.complete>>;
984
- try {
985
- result = await withTimeout(params.deps.complete({
986
- provider,
987
- model,
988
- apiKey,
989
- providerApi,
990
- authProfileId,
1176
+ let lastAuthError: LcmProviderAuthError | undefined;
1177
+
1178
+ for (let index = 0; index < resolvedCandidates.length; index += 1) {
1179
+ const candidate = resolvedCandidates[index]!;
1180
+ const provider = candidate.provider;
1181
+ const model = candidate.model;
1182
+ const nextCandidate = index < resolvedCandidates.length - 1 ? resolvedCandidates[index + 1]! : undefined;
1183
+ const authProfileId = candidate.useLegacyAuthProfile ? legacyAuthProfileId : undefined;
1184
+ const providerApi = resolveProviderApiFromLegacyConfig(params.legacyParams.config, provider);
1185
+ const lookupOptions = {
1186
+ profileId: authProfileId,
991
1187
  agentDir,
992
1188
  runtimeConfig: params.legacyParams.config,
993
- system: LCM_SUMMARIZER_SYSTEM_PROMPT,
994
- messages: [
995
- {
996
- role: "user",
997
- content: prompt,
998
- },
999
- ],
1000
- maxTokens: targetTokens,
1001
- }), SUMMARIZER_TIMEOUT_MS, "initial");
1002
- } catch (err) {
1003
- const authFailure = extractProviderAuthFailure(err);
1004
- if (authFailure) {
1005
- const authError = new LcmProviderAuthError({ provider, model, failure: authFailure });
1006
- console.warn(authError.message);
1007
- throw authError;
1008
- }
1009
- const errMsg = err instanceof Error ? err.message : String(err);
1010
- const isTimeout = errMsg.includes("summarizer timeout");
1011
- console.warn(
1012
- `[lcm] summarizer ${isTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${SUMMARIZER_TIMEOUT_MS}ms; error=${errMsg}`,
1013
- );
1014
- if (err instanceof SummarizerTimeoutError) {
1015
- console.error(
1016
- `[lcm] summarizer timed out; provider=${provider}; model=${model}; source=fallback`,
1017
- );
1018
- return buildDeterministicFallbackSummary(text, targetTokens);
1019
- }
1020
- return "";
1021
- }
1022
-
1023
- const authFailure = extractProviderAuthFailure(result);
1024
- if (authFailure) {
1025
- const authError = new LcmProviderAuthError({ provider, model, failure: authFailure });
1026
- console.warn(authError.message);
1027
- throw authError;
1028
- }
1029
-
1030
- const normalized = normalizeCompletionSummary(result.content);
1031
- let summary = normalized.summary;
1032
- let summarySource: "content" | "envelope" | "retry" | "fallback" = "content";
1033
-
1034
- // --- Empty-summary hardening: envelope → retry → deterministic fallback ---
1035
- if (!summary) {
1036
- // Envelope-aware extraction: some providers place summary text in
1037
- // top-level response fields (output, message, response) rather than
1038
- // inside the content array. Re-run normalization against the full
1039
- // response envelope before spending an API call on a retry.
1040
- const envelopeNormalized = normalizeCompletionSummary(result);
1041
- if (envelopeNormalized.summary) {
1042
- summary = envelopeNormalized.summary;
1043
- summarySource = "envelope";
1044
- console.error(
1045
- `[lcm] recovered summary from response envelope; provider=${provider}; model=${model}; ` +
1046
- `block_types=${formatBlockTypes(envelopeNormalized.blockTypes)}; source=envelope`,
1047
- );
1048
- }
1049
- }
1050
-
1051
- if (!summary) {
1052
- const responseDiag = extractResponseDiagnostics(result);
1053
- const diagParts = [
1054
- `[lcm] empty normalized summary on first attempt`,
1055
- `provider=${provider}`,
1056
- `model=${model}`,
1057
- `block_types=${formatBlockTypes(normalized.blockTypes)}`,
1058
- `response_blocks=${result.content.length}`,
1059
- ];
1060
- if (responseDiag) {
1061
- diagParts.push(responseDiag);
1062
- }
1063
- console.error(`${diagParts.join("; ")}; retrying with conservative settings`);
1064
-
1065
- // Single retry with conservative parameters: low temperature and low
1066
- // reasoning budget to coax a textual response from providers that
1067
- // sometimes return reasoning-only or empty blocks on the first pass.
1068
- try {
1069
- const retryResult = await withTimeout(params.deps.complete({
1189
+ };
1190
+
1191
+ const runSummarizerCall = async (
1192
+ requestApiKey: string | undefined,
1193
+ label: string,
1194
+ reasoning?: string,
1195
+ ) =>
1196
+ withTimeout(params.deps.complete({
1070
1197
  provider,
1071
1198
  model,
1072
- apiKey,
1199
+ apiKey: requestApiKey,
1073
1200
  providerApi,
1074
1201
  authProfileId,
1075
1202
  agentDir,
@@ -1082,68 +1209,270 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1082
1209
  },
1083
1210
  ],
1084
1211
  maxTokens: targetTokens,
1085
- reasoning: "low",
1086
- }), SUMMARIZER_TIMEOUT_MS, "retry");
1087
- const retryAuthFailure = extractProviderAuthFailure(retryResult);
1088
- if (retryAuthFailure) {
1089
- console.warn(buildProviderAuthWarning({ provider, model, failure: retryAuthFailure }));
1090
- return "";
1091
- }
1212
+ ...(reasoning ? { reasoning } : {}),
1213
+ }), SUMMARIZER_TIMEOUT_MS, label);
1214
+
1215
+ const retryWithoutModelAuth = async (
1216
+ failure: ProviderAuthFailure,
1217
+ reasoning?: string,
1218
+ ): Promise<Awaited<ReturnType<typeof params.deps.complete>>> => {
1219
+ const initialAuthError = new LcmProviderAuthError({ provider, model, failure });
1220
+ console.warn(initialAuthError.message);
1221
+ console.warn(
1222
+ `[lcm] summarizer auth retry: retrying ${provider}/${model} without runtime.modelAuth credentials.`,
1223
+ );
1092
1224
 
1093
- const retryNormalized = normalizeCompletionSummary(retryResult.content);
1094
- summary = retryNormalized.summary;
1225
+ const directApiKey = await params.deps.getApiKey(provider, model, {
1226
+ ...lookupOptions,
1227
+ skipModelAuth: true,
1228
+ });
1229
+ if (!directApiKey) {
1230
+ console.warn(
1231
+ `[lcm] summarizer auth retry unavailable: no direct credentials found for ${provider}/${model}.`,
1232
+ );
1233
+ throw initialAuthError;
1234
+ }
1095
1235
 
1096
- if (summary) {
1097
- summarySource = "retry";
1098
- console.error(
1099
- `[lcm] retry succeeded; provider=${provider}; model=${model}; ` +
1100
- `block_types=${formatBlockTypes(retryNormalized.blockTypes)}; source=retry`,
1236
+ try {
1237
+ const directResult = await runSummarizerCall(directApiKey, "auth-retry", reasoning);
1238
+ // Use requireStructuralSignal on the retry success path too — the
1239
+ // summary text may legitimately contain auth-error phrases.
1240
+ const directFailure = extractProviderAuthFailure(directResult, {
1241
+ requireStructuralSignal: true,
1242
+ });
1243
+ if (directFailure) {
1244
+ const retryAuthError = new LcmProviderAuthError({
1245
+ provider,
1246
+ model,
1247
+ failure: directFailure,
1248
+ });
1249
+ console.warn(retryAuthError.message);
1250
+ throw retryAuthError;
1251
+ }
1252
+ console.warn(
1253
+ `[lcm] summarizer auth retry succeeded; provider=${provider}; model=${model}; source=direct-credentials`,
1101
1254
  );
1102
- } else {
1103
- const retryDiag = extractResponseDiagnostics(retryResult);
1104
- const retryParts = [
1105
- `[lcm] retry also returned empty summary`,
1106
- `provider=${provider}`,
1107
- `model=${model}`,
1108
- `block_types=${formatBlockTypes(retryNormalized.blockTypes)}`,
1109
- `response_blocks=${retryResult.content.length}`,
1110
- ];
1111
- if (retryDiag) {
1112
- retryParts.push(retryDiag);
1255
+ return directResult;
1256
+ } catch (directErr) {
1257
+ if (directErr instanceof LcmProviderAuthError) {
1258
+ throw directErr;
1259
+ }
1260
+ // Catch path: real errors carry structural signals (HTTP 401, error.kind),
1261
+ // so requireStructuralSignal is safe here too.
1262
+ const directFailure = extractProviderAuthFailure(directErr, {
1263
+ requireStructuralSignal: true,
1264
+ });
1265
+ if (directFailure) {
1266
+ const retryAuthError = new LcmProviderAuthError({
1267
+ provider,
1268
+ model,
1269
+ failure: directFailure,
1270
+ });
1271
+ console.warn(retryAuthError.message);
1272
+ throw retryAuthError;
1273
+ }
1274
+ throw directErr;
1275
+ }
1276
+ };
1277
+
1278
+ const attemptSummarizerCall = async (
1279
+ label: string,
1280
+ reasoning?: string,
1281
+ ): Promise<Awaited<ReturnType<typeof params.deps.complete>>> => {
1282
+ const apiKey = await params.deps.getApiKey(provider, model, lookupOptions);
1283
+ try {
1284
+ const result = await runSummarizerCall(apiKey, label, reasoning);
1285
+ // Use requireStructuralSignal so that LLM summary text containing
1286
+ // auth-related words (e.g. "provider auth error") is NOT mistaken
1287
+ // for an actual API auth failure.
1288
+ const authFailure = extractProviderAuthFailure(result, {
1289
+ requireStructuralSignal: true,
1290
+ });
1291
+ if (!authFailure) {
1292
+ return result;
1293
+ }
1294
+ return retryWithoutModelAuth(authFailure, reasoning);
1295
+ } catch (err) {
1296
+ const authFailure = extractProviderAuthFailure(err);
1297
+ if (!authFailure) {
1298
+ throw err;
1113
1299
  }
1114
- console.error(`${retryParts.join("; ")}; falling back to truncation`);
1300
+ return retryWithoutModelAuth(authFailure, reasoning);
1115
1301
  }
1116
- } catch (retryErr) {
1117
- const retryAuthFailure = extractProviderAuthFailure(retryErr);
1118
- if (retryAuthFailure) {
1119
- console.warn(buildProviderAuthWarning({ provider, model, failure: retryAuthFailure }));
1120
- return "";
1302
+ };
1303
+
1304
+ let result: Awaited<ReturnType<typeof params.deps.complete>>;
1305
+ try {
1306
+ result = await attemptSummarizerCall("initial");
1307
+ } catch (err) {
1308
+ if (err instanceof LcmProviderAuthError) {
1309
+ lastAuthError = err;
1310
+ if (nextCandidate) {
1311
+ console.warn(
1312
+ `[lcm] summarizer auth fallback: retrying with ${nextCandidate.provider}/${nextCandidate.model} after ${provider}/${model} failed auth.`,
1313
+ );
1314
+ continue;
1315
+ }
1316
+ throw lastAuthError;
1121
1317
  }
1122
- // Retry is best-effort; log and proceed to deterministic fallback.
1123
- const retryErrMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
1124
- const isRetryTimeout = retryErrMsg.includes("summarizer timeout");
1318
+ const errMsg = err instanceof Error ? err.message : String(err);
1319
+ const isTimeout = errMsg.includes("summarizer timeout");
1125
1320
  console.warn(
1126
- `[lcm] retry ${isRetryTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${SUMMARIZER_TIMEOUT_MS}ms; error=${retryErrMsg}; falling back to truncation`,
1321
+ `[lcm] summarizer ${isTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${SUMMARIZER_TIMEOUT_MS}ms; error=${errMsg}`,
1127
1322
  );
1323
+ if (nextCandidate) {
1324
+ console.warn(
1325
+ `[lcm] summarizer candidate fallback: retrying with ${nextCandidate.provider}/${nextCandidate.model} after ${provider}/${model} ${isTimeout ? "timed out" : "failed"}.`,
1326
+ );
1327
+ continue;
1328
+ }
1329
+ if (err instanceof SummarizerTimeoutError) {
1330
+ console.error(
1331
+ `[lcm] summarizer timed out; provider=${provider}; model=${model}; source=fallback`,
1332
+ );
1333
+ return buildDeterministicFallbackSummary(text, targetTokens);
1334
+ }
1335
+ return "";
1128
1336
  }
1129
- }
1130
1337
 
1131
- if (!summary) {
1132
- summarySource = "fallback";
1133
- console.error(
1134
- `[lcm] all extraction attempts exhausted; provider=${provider}; model=${model}; source=fallback`,
1135
- );
1136
- return buildDeterministicFallbackSummary(text, targetTokens);
1137
- }
1338
+ const normalized = normalizeCompletionSummary(result.content);
1339
+ let summary = normalized.summary;
1340
+ let summarySource: "content" | "envelope" | "retry" | "fallback" = "content";
1341
+
1342
+ // --- Empty-summary hardening: envelope → retry → deterministic fallback ---
1343
+ if (!summary) {
1344
+ // Envelope-aware extraction: some providers place summary text in
1345
+ // top-level response fields (output, message, response) rather than
1346
+ // inside the content array. Re-run normalization against the full
1347
+ // response envelope before spending an API call on a retry.
1348
+ const envelopeNormalized = normalizeCompletionSummary(result);
1349
+ if (envelopeNormalized.summary) {
1350
+ summary = envelopeNormalized.summary;
1351
+ summarySource = "envelope";
1352
+ console.error(
1353
+ `[lcm] recovered summary from response envelope; provider=${provider}; model=${model}; ` +
1354
+ `block_types=${formatBlockTypes(envelopeNormalized.blockTypes)}; source=envelope`,
1355
+ );
1356
+ }
1357
+ }
1138
1358
 
1139
- if (summarySource !== "content") {
1140
- console.error(
1141
- `[lcm] summary resolved via non-content path; provider=${provider}; model=${model}; source=${summarySource}`,
1142
- );
1359
+ const incompleteSignals = extractIncompleteResponseSignals(result);
1360
+ const initialSummary = summary;
1361
+ const shouldRetryIncompleteSummary = summary.length > 0 && incompleteSignals.length > 0;
1362
+
1363
+ if (!summary || shouldRetryIncompleteSummary) {
1364
+ const responseDiag = extractResponseDiagnostics(result);
1365
+ const diagParts = [
1366
+ shouldRetryIncompleteSummary
1367
+ ? `[lcm] incomplete summary response on first attempt`
1368
+ : `[lcm] empty normalized summary on first attempt`,
1369
+ `provider=${provider}`,
1370
+ `model=${model}`,
1371
+ `block_types=${formatBlockTypes(normalized.blockTypes)}`,
1372
+ `response_blocks=${result.content.length}`,
1373
+ ];
1374
+ if (incompleteSignals.length > 0) {
1375
+ diagParts.push(`incomplete=${incompleteSignals.join(",")}`);
1376
+ }
1377
+ if (responseDiag) {
1378
+ diagParts.push(responseDiag);
1379
+ }
1380
+ console.error(`${diagParts.join("; ")}; retrying with conservative settings`);
1381
+
1382
+ // Single retry with conservative parameters: low temperature and low
1383
+ // reasoning budget to coax a textual response from providers that
1384
+ // sometimes return reasoning-only or empty blocks on the first pass.
1385
+ try {
1386
+ const retryResult = await attemptSummarizerCall("retry", "low");
1387
+ const retryNormalized = normalizeCompletionSummary(retryResult.content);
1388
+ const retryEnvelopeNormalized = retryNormalized.summary
1389
+ ? retryNormalized
1390
+ : normalizeCompletionSummary(retryResult);
1391
+ summary = retryEnvelopeNormalized.summary;
1392
+
1393
+ if (summary) {
1394
+ summarySource = "retry";
1395
+ console.error(
1396
+ `[lcm] retry succeeded; provider=${provider}; model=${model}; ` +
1397
+ `block_types=${formatBlockTypes(retryEnvelopeNormalized.blockTypes)}; source=retry`,
1398
+ );
1399
+ } else {
1400
+ const retryDiag = extractResponseDiagnostics(retryResult);
1401
+ const retryParts = [
1402
+ `[lcm] retry also returned empty summary`,
1403
+ `provider=${provider}`,
1404
+ `model=${model}`,
1405
+ `block_types=${formatBlockTypes(retryEnvelopeNormalized.blockTypes)}`,
1406
+ `response_blocks=${retryResult.content.length}`,
1407
+ ];
1408
+ if (retryDiag) {
1409
+ retryParts.push(retryDiag);
1410
+ }
1411
+ if (nextCandidate) {
1412
+ console.warn(
1413
+ `${retryParts.join("; ")}; retrying with ${nextCandidate.provider}/${nextCandidate.model}`,
1414
+ );
1415
+ continue;
1416
+ }
1417
+ console.error(`${retryParts.join("; ")}; falling back to truncation`);
1418
+ summary = initialSummary;
1419
+ }
1420
+ } catch (retryErr) {
1421
+ if (retryErr instanceof LcmProviderAuthError) {
1422
+ lastAuthError = retryErr;
1423
+ if (nextCandidate) {
1424
+ console.warn(
1425
+ `[lcm] summarizer auth fallback: retrying with ${nextCandidate.provider}/${nextCandidate.model} after ${provider}/${model} failed auth.`,
1426
+ );
1427
+ continue;
1428
+ }
1429
+ throw lastAuthError;
1430
+ }
1431
+ // Retry is best-effort; log and proceed to deterministic fallback.
1432
+ const retryErrMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
1433
+ const isRetryTimeout = retryErrMsg.includes("summarizer timeout");
1434
+ if (nextCandidate) {
1435
+ console.warn(
1436
+ `[lcm] retry ${isRetryTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${SUMMARIZER_TIMEOUT_MS}ms; error=${retryErrMsg}; retrying with ${nextCandidate.provider}/${nextCandidate.model}`,
1437
+ );
1438
+ continue;
1439
+ }
1440
+ console.warn(
1441
+ `[lcm] retry ${isRetryTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${SUMMARIZER_TIMEOUT_MS}ms; error=${retryErrMsg}; falling back to truncation`,
1442
+ );
1443
+ summary = initialSummary;
1444
+ }
1445
+ }
1446
+
1447
+ if (!summary) {
1448
+ summarySource = "fallback";
1449
+ console.error(
1450
+ `[lcm] all extraction attempts exhausted; provider=${provider}; model=${model}; source=fallback`,
1451
+ );
1452
+ return buildDeterministicFallbackSummary(text, targetTokens);
1453
+ }
1454
+
1455
+ if (summarySource !== "content") {
1456
+ console.error(
1457
+ `[lcm] summary resolved via non-content path; provider=${provider}; model=${model}; source=${summarySource}`,
1458
+ );
1459
+ }
1460
+
1461
+ return summary;
1143
1462
  }
1144
1463
 
1145
- return summary;
1464
+ if (lastAuthError) {
1465
+ throw lastAuthError;
1466
+ }
1467
+ return "";
1146
1468
  };
1147
1469
 
1148
- return { fn, model };
1470
+ return {
1471
+ fn,
1472
+ model: resolvedCandidates[0]!.model,
1473
+ breakerKey: buildSummarizerBreakerKey({
1474
+ candidate: resolvedCandidates[0]!,
1475
+ legacyAuthProfileId,
1476
+ }),
1477
+ };
1149
1478
  }