@martian-engineering/lossless-claw 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/summarize.ts CHANGED
@@ -20,8 +20,22 @@ 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
+
23
36
  type SummaryMode = "normal" | "aggressive";
24
37
 
38
+ const DEFAULT_LEAF_TARGET_TOKENS = 2400;
25
39
  const DEFAULT_CONDENSED_TARGET_TOKENS = 2000;
26
40
  const LCM_SUMMARIZER_SYSTEM_PROMPT =
27
41
  "You are a context-compaction summarization engine. Follow user instructions exactly and return plain text summary content only.";
@@ -188,6 +202,15 @@ function collectBlockTypes(value: unknown, out: Set<string>): void {
188
202
  }
189
203
  }
190
204
 
205
+ /** Treat provider reasoning/thinking payloads as diagnostics, not summary text. */
206
+ function isReasoningLikeType(type: unknown): boolean {
207
+ if (typeof type !== "string") {
208
+ return false;
209
+ }
210
+ const normalized = type.trim().toLowerCase();
211
+ return normalized.includes("reasoning") || normalized.includes("thinking");
212
+ }
213
+
191
214
  /** Collect text payloads from common provider response shapes. */
192
215
  function collectTextLikeFields(value: unknown, out: string[]): void {
193
216
  if (Array.isArray(value)) {
@@ -200,7 +223,11 @@ function collectTextLikeFields(value: unknown, out: string[]): void {
200
223
  return;
201
224
  }
202
225
 
203
- for (const key of ["text", "output_text", "thinking"]) {
226
+ if (isReasoningLikeType(value.type)) {
227
+ return;
228
+ }
229
+
230
+ for (const key of ["text", "output_text"]) {
204
231
  appendTextValue(value[key], out);
205
232
  }
206
233
  for (const key of ["content", "summary", "output", "message", "response"]) {
@@ -517,6 +544,15 @@ function extractResponseDiagnostics(result: unknown): string {
517
544
  if (typeof result.provider === "string" && result.provider.trim()) {
518
545
  parts.push(`resp_provider=${result.provider.trim()}`);
519
546
  }
547
+ if (typeof result.status === "string" && result.status.trim()) {
548
+ parts.push(`status=${result.status.trim()}`);
549
+ }
550
+ if (isRecord(result.incomplete_details) && typeof result.incomplete_details.reason === "string") {
551
+ const reason = result.incomplete_details.reason.trim();
552
+ if (reason) {
553
+ parts.push(`incomplete_reason=${reason}`);
554
+ }
555
+ }
520
556
  for (const key of [
521
557
  "request_provider",
522
558
  "request_model",
@@ -580,6 +616,50 @@ function extractResponseDiagnostics(result: unknown): string {
580
616
  return parts.join("; ");
581
617
  }
582
618
 
619
+ /** Collect retry-worthy "incomplete" signals from Responses-style envelopes/items. */
620
+ function collectIncompleteResponseSignals(
621
+ value: unknown,
622
+ out: Set<string>,
623
+ label = "response",
624
+ depth = 0,
625
+ ): void {
626
+ if (depth >= DIAGNOSTIC_MAX_DEPTH) {
627
+ return;
628
+ }
629
+ if (Array.isArray(value)) {
630
+ value.slice(0, DIAGNOSTIC_MAX_ARRAY_ITEMS).forEach((entry, index) => {
631
+ collectIncompleteResponseSignals(entry, out, `${label}[${index}]`, depth + 1);
632
+ });
633
+ return;
634
+ }
635
+ if (!isRecord(value)) {
636
+ return;
637
+ }
638
+
639
+ if (typeof value.status === "string" && value.status.trim().toLowerCase() === "incomplete") {
640
+ out.add(`${label}.status=incomplete`);
641
+ }
642
+ if (isRecord(value.incomplete_details) && typeof value.incomplete_details.reason === "string") {
643
+ const reason = value.incomplete_details.reason.trim();
644
+ if (reason) {
645
+ out.add(`${label}.reason=${reason}`);
646
+ }
647
+ }
648
+
649
+ for (const key of ["content", "output", "message", "response", "items"] as const) {
650
+ if (key in value) {
651
+ collectIncompleteResponseSignals(value[key], out, `${label}.${key}`, depth + 1);
652
+ }
653
+ }
654
+ }
655
+
656
+ /** Extract retry-worthy incomplete-response diagnostics for provider envelopes/items. */
657
+ function extractIncompleteResponseSignals(value: unknown): string[] {
658
+ const signals = new Set<string>();
659
+ collectIncompleteResponseSignals(value, signals);
660
+ return [...signals].sort((a, b) => a.localeCompare(b));
661
+ }
662
+
583
663
  /**
584
664
  * Resolve a practical target token count for leaf and condensed summaries.
585
665
  * Aggressive leaf mode intentionally aims lower so compaction converges faster.
@@ -588,6 +668,7 @@ function resolveTargetTokens(params: {
588
668
  inputTokens: number;
589
669
  mode: SummaryMode;
590
670
  isCondensed: boolean;
671
+ leafTargetTokens: number;
591
672
  condensedTargetTokens: number;
592
673
  }): number {
593
674
  if (params.isCondensed) {
@@ -595,10 +676,12 @@ function resolveTargetTokens(params: {
595
676
  }
596
677
 
597
678
  const { inputTokens, mode } = params;
679
+ const leafTargetTokens = Math.max(192, params.leafTargetTokens);
598
680
  if (mode === "aggressive") {
599
- return Math.max(96, Math.min(640, Math.floor(inputTokens * 0.2)));
681
+ const aggressiveCap = Math.max(96, Math.min(leafTargetTokens, Math.floor(leafTargetTokens * 0.55)));
682
+ return Math.max(96, Math.min(aggressiveCap, Math.floor(inputTokens * 0.2)));
600
683
  }
601
- return Math.max(192, Math.min(1200, Math.floor(inputTokens * 0.35)));
684
+ return Math.max(192, Math.min(leafTargetTokens, Math.floor(inputTokens * 0.35)));
602
685
  }
603
686
 
604
687
  /**
@@ -815,30 +898,47 @@ function buildDeterministicFallbackSummary(text: string, targetTokens: number):
815
898
  return `${trimmed.slice(0, maxChars)}\n[LCM fallback summary; truncated for context management]`;
816
899
  }
817
900
 
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();
901
+ /** Normalize model refs from string or `{ primary }` config shapes. */
902
+ function readModelRef(value: unknown): string {
903
+ if (typeof value === "string") {
904
+ return value.trim();
905
+ }
906
+ const primary = (value as { primary?: unknown } | undefined)?.primary;
907
+ return typeof primary === "string" ? primary.trim() : "";
908
+ }
909
+
910
+ /** Avoid retrying the same resolved provider/model pair across fallback levels. */
911
+ function dedupeResolvedCandidates(
912
+ candidates: ResolvedSummaryCandidate[],
913
+ ): ResolvedSummaryCandidate[] {
914
+ const seen = new Set<string>();
915
+ const ordered: ResolvedSummaryCandidate[] = [];
916
+ for (const candidate of candidates) {
917
+ const key = `${candidate.provider}\u0000${candidate.model}`;
918
+ if (seen.has(key)) {
919
+ continue;
832
920
  }
833
- const primary = (value as { primary?: unknown } | undefined)?.primary;
834
- return typeof primary === "string" ? primary.trim() : "";
835
- };
921
+ seen.add(key);
922
+ ordered.push(candidate);
923
+ }
924
+ return ordered;
925
+ }
836
926
 
927
+ /** Resolve ordered summarizer candidates from env, plugin config, defaults, and session hints. */
928
+ function resolveSummaryCandidates(params: {
929
+ deps: LcmDependencies;
930
+ legacyParams: LcmSummarizerLegacyParams;
931
+ }): ResolvedSummaryCandidate[] {
932
+ const providerHint =
933
+ typeof params.legacyParams.provider === "string" ? params.legacyParams.provider.trim() : "";
934
+ const modelHint =
935
+ typeof params.legacyParams.model === "string" ? params.legacyParams.model.trim() : "";
837
936
  const runtimeConfig =
838
937
  params.legacyParams.config && typeof params.legacyParams.config === "object"
839
938
  ? (params.legacyParams.config as {
840
939
  agents?: {
841
940
  defaults?: {
941
+ model?: unknown;
842
942
  compaction?: {
843
943
  model?: unknown;
844
944
  };
@@ -853,91 +953,121 @@ export async function createLcmSummarizeFromLegacyParams(params: {
853
953
  };
854
954
  })
855
955
  : undefined;
856
-
857
956
  const nestedPluginConfig = runtimeConfig?.plugins?.entries?.["lossless-claw"]?.config;
858
957
 
859
- const summaryLevels = [
958
+ const resolutionCandidates: SummaryResolutionCandidate[] = [
860
959
  {
861
960
  levelName: "environment variables",
862
- model: process.env.LCM_SUMMARY_MODEL?.trim() ?? "",
863
- provider: process.env.LCM_SUMMARY_PROVIDER?.trim() ?? "",
961
+ modelRef: process.env.LCM_SUMMARY_MODEL?.trim() ?? "",
962
+ providerHint:
963
+ process.env.LCM_SUMMARY_PROVIDER?.trim() ||
964
+ (providerHint || undefined),
965
+ hasExplicitProvider: Boolean(process.env.LCM_SUMMARY_PROVIDER?.trim()),
966
+ useLegacyAuthProfile: false,
864
967
  },
865
968
  {
866
969
  levelName: "plugin config (lossless-claw)",
867
- model: readModelRef(nestedPluginConfig?.summaryModel),
868
- provider: typeof nestedPluginConfig?.summaryProvider === "string" ? nestedPluginConfig.summaryProvider.trim() : "",
970
+ modelRef: readModelRef(nestedPluginConfig?.summaryModel),
971
+ providerHint:
972
+ (typeof nestedPluginConfig?.summaryProvider === "string"
973
+ ? nestedPluginConfig.summaryProvider.trim()
974
+ : "") || (providerHint || undefined),
975
+ hasExplicitProvider: Boolean(
976
+ typeof nestedPluginConfig?.summaryProvider === "string" &&
977
+ nestedPluginConfig.summaryProvider.trim(),
978
+ ),
979
+ useLegacyAuthProfile: false,
869
980
  },
870
981
  {
871
982
  levelName: "OpenClaw agents.defaults.compaction.model",
872
- model: readModelRef(runtimeConfig?.agents?.defaults?.compaction?.model),
873
- provider: "",
983
+ modelRef: readModelRef(runtimeConfig?.agents?.defaults?.compaction?.model),
984
+ providerHint: undefined,
985
+ hasExplicitProvider: false,
986
+ useLegacyAuthProfile: false,
987
+ },
988
+ {
989
+ levelName: "OpenClaw agents.defaults.model",
990
+ modelRef: readModelRef(runtimeConfig?.agents?.defaults?.model),
991
+ providerHint: undefined,
992
+ hasExplicitProvider: false,
993
+ useLegacyAuthProfile: false,
994
+ },
995
+ {
996
+ levelName: "legacy runtime/session model",
997
+ modelRef: modelHint,
998
+ providerHint: providerHint || undefined,
999
+ hasExplicitProvider: Boolean(providerHint),
1000
+ useLegacyAuthProfile: true,
874
1001
  },
875
1002
  ];
876
1003
 
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;
1004
+ const resolvedCandidates: ResolvedSummaryCandidate[] = [];
1005
+ for (const candidate of resolutionCandidates) {
1006
+ if (!candidate.modelRef) {
1007
+ continue;
883
1008
  }
884
- if (level.provider) {
885
- resolvedSummary = { model: level.model, provider: level.provider };
886
- break;
1009
+ if (!candidate.modelRef.includes("/") && !candidate.hasExplicitProvider) {
1010
+ params.deps.log.warn(
1011
+ `[lcm] summaryModel "${candidate.modelRef}" at "${candidate.levelName}" has no summaryProvider or provider prefix. Will attempt resolution without provider.`,
1012
+ );
1013
+ }
1014
+ try {
1015
+ const resolved = params.deps.resolveModel(candidate.modelRef, candidate.providerHint);
1016
+ if (resolved.provider && resolved.model) {
1017
+ resolvedCandidates.push({
1018
+ ...candidate,
1019
+ provider: resolved.provider,
1020
+ model: resolved.model,
1021
+ });
1022
+ }
1023
+ } catch (err) {
1024
+ console.error(
1025
+ `[lcm] createLcmSummarize: resolveModel FAILED at ${candidate.levelName}:`,
1026
+ err instanceof Error ? err.message : err,
1027
+ );
887
1028
  }
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
1029
  }
894
1030
 
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);
1031
+ return dedupeResolvedCandidates(resolvedCandidates);
1032
+ }
908
1033
 
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);
1034
+ /**
1035
+ * Builds a model-backed LCM summarize callback from runtime legacy params.
1036
+ *
1037
+ * Returns `undefined` when model/provider context is unavailable so callers can
1038
+ * choose a fallback summarizer.
1039
+ */
1040
+ export async function createLcmSummarizeFromLegacyParams(params: {
1041
+ deps: LcmDependencies;
1042
+ legacyParams: LcmSummarizerLegacyParams;
1043
+ customInstructions?: string;
1044
+ }): Promise<{ fn: LcmSummarizeFn; model: string } | undefined> {
1045
+ const resolvedCandidates = resolveSummaryCandidates(params);
1046
+ if (resolvedCandidates.length === 0) {
1047
+ console.error("[lcm] createLcmSummarize: no summary model candidates resolved");
914
1048
  return undefined;
915
1049
  }
916
1050
 
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
1051
  const legacyAuthProfileId =
923
1052
  typeof params.legacyParams.authProfileId === "string" &&
924
1053
  params.legacyParams.authProfileId.trim()
925
1054
  ? params.legacyParams.authProfileId.trim()
926
1055
  : 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
1056
  const agentDir =
931
1057
  typeof params.legacyParams.agentDir === "string" && params.legacyParams.agentDir.trim()
932
1058
  ? params.legacyParams.agentDir.trim()
933
1059
  : undefined;
934
- const providerApi = resolveProviderApiFromLegacyConfig(params.legacyParams.config, provider);
935
1060
 
936
1061
  const condensedTargetTokens =
937
1062
  Number.isFinite(params.deps.config.condensedTargetTokens) &&
938
1063
  params.deps.config.condensedTargetTokens > 0
939
1064
  ? params.deps.config.condensedTargetTokens
940
1065
  : DEFAULT_CONDENSED_TARGET_TOKENS;
1066
+ const leafTargetTokens =
1067
+ Number.isFinite(params.deps.config.leafTargetTokens) &&
1068
+ params.deps.config.leafTargetTokens > 0
1069
+ ? params.deps.config.leafTargetTokens
1070
+ : DEFAULT_LEAF_TARGET_TOKENS;
941
1071
 
942
1072
  const fn: LcmSummarizeFn = async (
943
1073
  text: string,
@@ -950,15 +1080,11 @@ export async function createLcmSummarizeFromLegacyParams(params: {
950
1080
 
951
1081
  const mode: SummaryMode = aggressive ? "aggressive" : "normal";
952
1082
  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
1083
  const targetTokens = resolveTargetTokens({
959
1084
  inputTokens: estimateTokens(text),
960
1085
  mode,
961
1086
  isCondensed,
1087
+ leafTargetTokens,
962
1088
  condensedTargetTokens,
963
1089
  });
964
1090
  const prompt = isCondensed
@@ -980,96 +1106,30 @@ export async function createLcmSummarizeFromLegacyParams(params: {
980
1106
  customInstructions: params.customInstructions,
981
1107
  });
982
1108
 
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,
1109
+ let lastAuthError: LcmProviderAuthError | undefined;
1110
+
1111
+ for (let index = 0; index < resolvedCandidates.length; index += 1) {
1112
+ const candidate = resolvedCandidates[index]!;
1113
+ const provider = candidate.provider;
1114
+ const model = candidate.model;
1115
+ const nextCandidate = index < resolvedCandidates.length - 1 ? resolvedCandidates[index + 1]! : undefined;
1116
+ const authProfileId = candidate.useLegacyAuthProfile ? legacyAuthProfileId : undefined;
1117
+ const providerApi = resolveProviderApiFromLegacyConfig(params.legacyParams.config, provider);
1118
+ const lookupOptions = {
1119
+ profileId: authProfileId,
991
1120
  agentDir,
992
1121
  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({
1122
+ };
1123
+
1124
+ const runSummarizerCall = async (
1125
+ requestApiKey: string | undefined,
1126
+ label: string,
1127
+ reasoning?: string,
1128
+ ) =>
1129
+ withTimeout(params.deps.complete({
1070
1130
  provider,
1071
1131
  model,
1072
- apiKey,
1132
+ apiKey: requestApiKey,
1073
1133
  providerApi,
1074
1134
  authProfileId,
1075
1135
  agentDir,
@@ -1082,68 +1142,250 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1082
1142
  },
1083
1143
  ],
1084
1144
  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
- }
1145
+ ...(reasoning ? { reasoning } : {}),
1146
+ }), SUMMARIZER_TIMEOUT_MS, label);
1147
+
1148
+ const retryWithoutModelAuth = async (
1149
+ failure: ProviderAuthFailure,
1150
+ reasoning?: string,
1151
+ ): Promise<Awaited<ReturnType<typeof params.deps.complete>>> => {
1152
+ const initialAuthError = new LcmProviderAuthError({ provider, model, failure });
1153
+ console.warn(initialAuthError.message);
1154
+ console.warn(
1155
+ `[lcm] summarizer auth retry: retrying ${provider}/${model} without runtime.modelAuth credentials.`,
1156
+ );
1092
1157
 
1093
- const retryNormalized = normalizeCompletionSummary(retryResult.content);
1094
- summary = retryNormalized.summary;
1158
+ const directApiKey = await params.deps.getApiKey(provider, model, {
1159
+ ...lookupOptions,
1160
+ skipModelAuth: true,
1161
+ });
1162
+ if (!directApiKey) {
1163
+ console.warn(
1164
+ `[lcm] summarizer auth retry unavailable: no direct credentials found for ${provider}/${model}.`,
1165
+ );
1166
+ throw initialAuthError;
1167
+ }
1095
1168
 
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`,
1169
+ try {
1170
+ const directResult = await runSummarizerCall(directApiKey, "auth-retry", reasoning);
1171
+ const directFailure = extractProviderAuthFailure(directResult);
1172
+ if (directFailure) {
1173
+ const retryAuthError = new LcmProviderAuthError({
1174
+ provider,
1175
+ model,
1176
+ failure: directFailure,
1177
+ });
1178
+ console.warn(retryAuthError.message);
1179
+ throw retryAuthError;
1180
+ }
1181
+ console.warn(
1182
+ `[lcm] summarizer auth retry succeeded; provider=${provider}; model=${model}; source=direct-credentials`,
1101
1183
  );
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);
1184
+ return directResult;
1185
+ } catch (directErr) {
1186
+ if (directErr instanceof LcmProviderAuthError) {
1187
+ throw directErr;
1113
1188
  }
1114
- console.error(`${retryParts.join("; ")}; falling back to truncation`);
1189
+ const directFailure = extractProviderAuthFailure(directErr);
1190
+ if (directFailure) {
1191
+ const retryAuthError = new LcmProviderAuthError({
1192
+ provider,
1193
+ model,
1194
+ failure: directFailure,
1195
+ });
1196
+ console.warn(retryAuthError.message);
1197
+ throw retryAuthError;
1198
+ }
1199
+ throw directErr;
1115
1200
  }
1116
- } catch (retryErr) {
1117
- const retryAuthFailure = extractProviderAuthFailure(retryErr);
1118
- if (retryAuthFailure) {
1119
- console.warn(buildProviderAuthWarning({ provider, model, failure: retryAuthFailure }));
1120
- return "";
1201
+ };
1202
+
1203
+ const attemptSummarizerCall = async (
1204
+ label: string,
1205
+ reasoning?: string,
1206
+ ): Promise<Awaited<ReturnType<typeof params.deps.complete>>> => {
1207
+ const apiKey = await params.deps.getApiKey(provider, model, lookupOptions);
1208
+ try {
1209
+ const result = await runSummarizerCall(apiKey, label, reasoning);
1210
+ const authFailure = extractProviderAuthFailure(result);
1211
+ if (!authFailure) {
1212
+ return result;
1213
+ }
1214
+ return retryWithoutModelAuth(authFailure, reasoning);
1215
+ } catch (err) {
1216
+ const authFailure = extractProviderAuthFailure(err);
1217
+ if (!authFailure) {
1218
+ throw err;
1219
+ }
1220
+ return retryWithoutModelAuth(authFailure, reasoning);
1121
1221
  }
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");
1222
+ };
1223
+
1224
+ let result: Awaited<ReturnType<typeof params.deps.complete>>;
1225
+ try {
1226
+ result = await attemptSummarizerCall("initial");
1227
+ } catch (err) {
1228
+ if (err instanceof LcmProviderAuthError) {
1229
+ lastAuthError = err;
1230
+ if (nextCandidate) {
1231
+ console.warn(
1232
+ `[lcm] summarizer auth fallback: retrying with ${nextCandidate.provider}/${nextCandidate.model} after ${provider}/${model} failed auth.`,
1233
+ );
1234
+ continue;
1235
+ }
1236
+ throw lastAuthError;
1237
+ }
1238
+ const errMsg = err instanceof Error ? err.message : String(err);
1239
+ const isTimeout = errMsg.includes("summarizer timeout");
1125
1240
  console.warn(
1126
- `[lcm] retry ${isRetryTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${SUMMARIZER_TIMEOUT_MS}ms; error=${retryErrMsg}; falling back to truncation`,
1241
+ `[lcm] summarizer ${isTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${SUMMARIZER_TIMEOUT_MS}ms; error=${errMsg}`,
1127
1242
  );
1243
+ if (nextCandidate) {
1244
+ console.warn(
1245
+ `[lcm] summarizer candidate fallback: retrying with ${nextCandidate.provider}/${nextCandidate.model} after ${provider}/${model} ${isTimeout ? "timed out" : "failed"}.`,
1246
+ );
1247
+ continue;
1248
+ }
1249
+ if (err instanceof SummarizerTimeoutError) {
1250
+ console.error(
1251
+ `[lcm] summarizer timed out; provider=${provider}; model=${model}; source=fallback`,
1252
+ );
1253
+ return buildDeterministicFallbackSummary(text, targetTokens);
1254
+ }
1255
+ return "";
1128
1256
  }
1129
- }
1130
1257
 
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
- }
1258
+ const normalized = normalizeCompletionSummary(result.content);
1259
+ let summary = normalized.summary;
1260
+ let summarySource: "content" | "envelope" | "retry" | "fallback" = "content";
1261
+
1262
+ // --- Empty-summary hardening: envelope → retry → deterministic fallback ---
1263
+ if (!summary) {
1264
+ // Envelope-aware extraction: some providers place summary text in
1265
+ // top-level response fields (output, message, response) rather than
1266
+ // inside the content array. Re-run normalization against the full
1267
+ // response envelope before spending an API call on a retry.
1268
+ const envelopeNormalized = normalizeCompletionSummary(result);
1269
+ if (envelopeNormalized.summary) {
1270
+ summary = envelopeNormalized.summary;
1271
+ summarySource = "envelope";
1272
+ console.error(
1273
+ `[lcm] recovered summary from response envelope; provider=${provider}; model=${model}; ` +
1274
+ `block_types=${formatBlockTypes(envelopeNormalized.blockTypes)}; source=envelope`,
1275
+ );
1276
+ }
1277
+ }
1138
1278
 
1139
- if (summarySource !== "content") {
1140
- console.error(
1141
- `[lcm] summary resolved via non-content path; provider=${provider}; model=${model}; source=${summarySource}`,
1142
- );
1279
+ const incompleteSignals = extractIncompleteResponseSignals(result);
1280
+ const initialSummary = summary;
1281
+ const shouldRetryIncompleteSummary = summary.length > 0 && incompleteSignals.length > 0;
1282
+
1283
+ if (!summary || shouldRetryIncompleteSummary) {
1284
+ const responseDiag = extractResponseDiagnostics(result);
1285
+ const diagParts = [
1286
+ shouldRetryIncompleteSummary
1287
+ ? `[lcm] incomplete summary response on first attempt`
1288
+ : `[lcm] empty normalized summary on first attempt`,
1289
+ `provider=${provider}`,
1290
+ `model=${model}`,
1291
+ `block_types=${formatBlockTypes(normalized.blockTypes)}`,
1292
+ `response_blocks=${result.content.length}`,
1293
+ ];
1294
+ if (incompleteSignals.length > 0) {
1295
+ diagParts.push(`incomplete=${incompleteSignals.join(",")}`);
1296
+ }
1297
+ if (responseDiag) {
1298
+ diagParts.push(responseDiag);
1299
+ }
1300
+ console.error(`${diagParts.join("; ")}; retrying with conservative settings`);
1301
+
1302
+ // Single retry with conservative parameters: low temperature and low
1303
+ // reasoning budget to coax a textual response from providers that
1304
+ // sometimes return reasoning-only or empty blocks on the first pass.
1305
+ try {
1306
+ const retryResult = await attemptSummarizerCall("retry", "low");
1307
+ const retryNormalized = normalizeCompletionSummary(retryResult.content);
1308
+ const retryEnvelopeNormalized = retryNormalized.summary
1309
+ ? retryNormalized
1310
+ : normalizeCompletionSummary(retryResult);
1311
+ summary = retryEnvelopeNormalized.summary;
1312
+
1313
+ if (summary) {
1314
+ summarySource = "retry";
1315
+ console.error(
1316
+ `[lcm] retry succeeded; provider=${provider}; model=${model}; ` +
1317
+ `block_types=${formatBlockTypes(retryEnvelopeNormalized.blockTypes)}; source=retry`,
1318
+ );
1319
+ } else {
1320
+ const retryDiag = extractResponseDiagnostics(retryResult);
1321
+ const retryParts = [
1322
+ `[lcm] retry also returned empty summary`,
1323
+ `provider=${provider}`,
1324
+ `model=${model}`,
1325
+ `block_types=${formatBlockTypes(retryEnvelopeNormalized.blockTypes)}`,
1326
+ `response_blocks=${retryResult.content.length}`,
1327
+ ];
1328
+ if (retryDiag) {
1329
+ retryParts.push(retryDiag);
1330
+ }
1331
+ if (nextCandidate) {
1332
+ console.warn(
1333
+ `${retryParts.join("; ")}; retrying with ${nextCandidate.provider}/${nextCandidate.model}`,
1334
+ );
1335
+ continue;
1336
+ }
1337
+ console.error(`${retryParts.join("; ")}; falling back to truncation`);
1338
+ summary = initialSummary;
1339
+ }
1340
+ } catch (retryErr) {
1341
+ if (retryErr instanceof LcmProviderAuthError) {
1342
+ lastAuthError = retryErr;
1343
+ if (nextCandidate) {
1344
+ console.warn(
1345
+ `[lcm] summarizer auth fallback: retrying with ${nextCandidate.provider}/${nextCandidate.model} after ${provider}/${model} failed auth.`,
1346
+ );
1347
+ continue;
1348
+ }
1349
+ throw lastAuthError;
1350
+ }
1351
+ // Retry is best-effort; log and proceed to deterministic fallback.
1352
+ const retryErrMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
1353
+ const isRetryTimeout = retryErrMsg.includes("summarizer timeout");
1354
+ if (nextCandidate) {
1355
+ console.warn(
1356
+ `[lcm] retry ${isRetryTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${SUMMARIZER_TIMEOUT_MS}ms; error=${retryErrMsg}; retrying with ${nextCandidate.provider}/${nextCandidate.model}`,
1357
+ );
1358
+ continue;
1359
+ }
1360
+ console.warn(
1361
+ `[lcm] retry ${isRetryTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${SUMMARIZER_TIMEOUT_MS}ms; error=${retryErrMsg}; falling back to truncation`,
1362
+ );
1363
+ summary = initialSummary;
1364
+ }
1365
+ }
1366
+
1367
+ if (!summary) {
1368
+ summarySource = "fallback";
1369
+ console.error(
1370
+ `[lcm] all extraction attempts exhausted; provider=${provider}; model=${model}; source=fallback`,
1371
+ );
1372
+ return buildDeterministicFallbackSummary(text, targetTokens);
1373
+ }
1374
+
1375
+ if (summarySource !== "content") {
1376
+ console.error(
1377
+ `[lcm] summary resolved via non-content path; provider=${provider}; model=${model}; source=${summarySource}`,
1378
+ );
1379
+ }
1380
+
1381
+ return summary;
1143
1382
  }
1144
1383
 
1145
- return summary;
1384
+ if (lastAuthError) {
1385
+ throw lastAuthError;
1386
+ }
1387
+ return "";
1146
1388
  };
1147
1389
 
1148
- return { fn, model };
1390
+ return { fn, model: resolvedCandidates[0]!.model };
1149
1391
  }