@martian-engineering/lossless-claw 0.5.1 → 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.";
@@ -42,6 +56,28 @@ type ProviderAuthFailure = {
42
56
  missingModelRequestScope: boolean;
43
57
  };
44
58
 
59
+ /**
60
+ * Signals that the summarizer hit a provider-auth failure and callers should
61
+ * avoid treating the result like an empty summary.
62
+ */
63
+ export class LcmProviderAuthError extends Error {
64
+ readonly provider: string;
65
+ readonly model: string;
66
+ readonly failure: ProviderAuthFailure;
67
+
68
+ constructor(params: {
69
+ provider: string;
70
+ model: string;
71
+ failure: ProviderAuthFailure;
72
+ }) {
73
+ super(buildProviderAuthWarning(params));
74
+ this.name = "LcmProviderAuthError";
75
+ this.provider = params.provider;
76
+ this.model = params.model;
77
+ this.failure = params.failure;
78
+ }
79
+ }
80
+
45
81
  /**
46
82
  * Default timeout for a single summarizer LLM call. Long enough for large
47
83
  * context windows on slower providers, short enough to prevent the gateway
@@ -166,6 +202,15 @@ function collectBlockTypes(value: unknown, out: Set<string>): void {
166
202
  }
167
203
  }
168
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
+
169
214
  /** Collect text payloads from common provider response shapes. */
170
215
  function collectTextLikeFields(value: unknown, out: string[]): void {
171
216
  if (Array.isArray(value)) {
@@ -178,7 +223,11 @@ function collectTextLikeFields(value: unknown, out: string[]): void {
178
223
  return;
179
224
  }
180
225
 
181
- 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"]) {
182
231
  appendTextValue(value[key], out);
183
232
  }
184
233
  for (const key of ["content", "summary", "output", "message", "response"]) {
@@ -495,6 +544,15 @@ function extractResponseDiagnostics(result: unknown): string {
495
544
  if (typeof result.provider === "string" && result.provider.trim()) {
496
545
  parts.push(`resp_provider=${result.provider.trim()}`);
497
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
+ }
498
556
  for (const key of [
499
557
  "request_provider",
500
558
  "request_model",
@@ -558,6 +616,50 @@ function extractResponseDiagnostics(result: unknown): string {
558
616
  return parts.join("; ");
559
617
  }
560
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
+
561
663
  /**
562
664
  * Resolve a practical target token count for leaf and condensed summaries.
563
665
  * Aggressive leaf mode intentionally aims lower so compaction converges faster.
@@ -566,6 +668,7 @@ function resolveTargetTokens(params: {
566
668
  inputTokens: number;
567
669
  mode: SummaryMode;
568
670
  isCondensed: boolean;
671
+ leafTargetTokens: number;
569
672
  condensedTargetTokens: number;
570
673
  }): number {
571
674
  if (params.isCondensed) {
@@ -573,10 +676,12 @@ function resolveTargetTokens(params: {
573
676
  }
574
677
 
575
678
  const { inputTokens, mode } = params;
679
+ const leafTargetTokens = Math.max(192, params.leafTargetTokens);
576
680
  if (mode === "aggressive") {
577
- 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)));
578
683
  }
579
- 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)));
580
685
  }
581
686
 
582
687
  /**
@@ -793,30 +898,47 @@ function buildDeterministicFallbackSummary(text: string, targetTokens: number):
793
898
  return `${trimmed.slice(0, maxChars)}\n[LCM fallback summary; truncated for context management]`;
794
899
  }
795
900
 
796
- /**
797
- * Builds a model-backed LCM summarize callback from runtime legacy params.
798
- *
799
- * Returns `undefined` when model/provider context is unavailable so callers can
800
- * choose a fallback summarizer.
801
- */
802
- export async function createLcmSummarizeFromLegacyParams(params: {
803
- deps: LcmDependencies;
804
- legacyParams: LcmSummarizerLegacyParams;
805
- customInstructions?: string;
806
- }): Promise<{ fn: LcmSummarizeFn; model: string } | undefined> {
807
- const readModelRef = (value: unknown): string => {
808
- if (typeof value === "string") {
809
- 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;
810
920
  }
811
- const primary = (value as { primary?: unknown } | undefined)?.primary;
812
- return typeof primary === "string" ? primary.trim() : "";
813
- };
921
+ seen.add(key);
922
+ ordered.push(candidate);
923
+ }
924
+ return ordered;
925
+ }
814
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() : "";
815
936
  const runtimeConfig =
816
937
  params.legacyParams.config && typeof params.legacyParams.config === "object"
817
938
  ? (params.legacyParams.config as {
818
939
  agents?: {
819
940
  defaults?: {
941
+ model?: unknown;
820
942
  compaction?: {
821
943
  model?: unknown;
822
944
  };
@@ -831,91 +953,121 @@ export async function createLcmSummarizeFromLegacyParams(params: {
831
953
  };
832
954
  })
833
955
  : undefined;
834
-
835
956
  const nestedPluginConfig = runtimeConfig?.plugins?.entries?.["lossless-claw"]?.config;
836
957
 
837
- const summaryLevels = [
958
+ const resolutionCandidates: SummaryResolutionCandidate[] = [
838
959
  {
839
960
  levelName: "environment variables",
840
- model: process.env.LCM_SUMMARY_MODEL?.trim() ?? "",
841
- 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,
842
967
  },
843
968
  {
844
969
  levelName: "plugin config (lossless-claw)",
845
- model: readModelRef(nestedPluginConfig?.summaryModel),
846
- 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,
847
980
  },
848
981
  {
849
982
  levelName: "OpenClaw agents.defaults.compaction.model",
850
- model: readModelRef(runtimeConfig?.agents?.defaults?.compaction?.model),
851
- 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,
852
1001
  },
853
1002
  ];
854
1003
 
855
- let resolvedSummary: { model: string; provider: string | undefined } | undefined;
856
- for (const level of summaryLevels) {
857
- if (!level.model) continue;
858
- if (level.model.includes("/")) {
859
- resolvedSummary = { model: level.model, provider: undefined };
860
- break;
1004
+ const resolvedCandidates: ResolvedSummaryCandidate[] = [];
1005
+ for (const candidate of resolutionCandidates) {
1006
+ if (!candidate.modelRef) {
1007
+ continue;
861
1008
  }
862
- if (level.provider) {
863
- resolvedSummary = { model: level.model, provider: level.provider };
864
- 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
+ );
865
1028
  }
866
- params.deps.log.warn(
867
- `[lcm] summaryModel "${level.model}" at "${level.levelName}" has no summaryProvider or provider prefix. Will attempt resolution without provider.`
868
- );
869
- resolvedSummary = { model: level.model, provider: undefined };
870
- break;
871
1029
  }
872
1030
 
873
- const providerHint =
874
- typeof params.legacyParams.provider === "string" ? params.legacyParams.provider.trim() : "";
875
- const modelHint =
876
- typeof params.legacyParams.model === "string" ? params.legacyParams.model.trim() : "";
877
- const modelRef = resolvedSummary?.model || modelHint || undefined;
878
-
879
- const resolveProviderHint =
880
- resolvedSummary !== undefined
881
- ? (
882
- resolvedSummary.provider ||
883
- (!resolvedSummary.model.includes("/") ? (providerHint || undefined) : undefined)
884
- )
885
- : (providerHint || undefined);
1031
+ return dedupeResolvedCandidates(resolvedCandidates);
1032
+ }
886
1033
 
887
- let resolved: { provider: string; model: string };
888
- try {
889
- resolved = params.deps.resolveModel(modelRef, resolveProviderHint);
890
- } catch (err) {
891
- 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");
892
1048
  return undefined;
893
1049
  }
894
1050
 
895
- const { provider, model } = resolved;
896
- if (!provider || !model) {
897
- console.error(`[lcm] createLcmSummarize: empty provider="${provider}" or model="${model}"`);
898
- return undefined;
899
- }
900
1051
  const legacyAuthProfileId =
901
1052
  typeof params.legacyParams.authProfileId === "string" &&
902
1053
  params.legacyParams.authProfileId.trim()
903
1054
  ? params.legacyParams.authProfileId.trim()
904
1055
  : undefined;
905
- // When LCM selects a dedicated summarizer model/provider, do not leak the
906
- // active session's auth profile into that separate credential lookup.
907
- const authProfileId = resolvedSummary === undefined ? legacyAuthProfileId : undefined;
908
1056
  const agentDir =
909
1057
  typeof params.legacyParams.agentDir === "string" && params.legacyParams.agentDir.trim()
910
1058
  ? params.legacyParams.agentDir.trim()
911
1059
  : undefined;
912
- const providerApi = resolveProviderApiFromLegacyConfig(params.legacyParams.config, provider);
913
1060
 
914
1061
  const condensedTargetTokens =
915
1062
  Number.isFinite(params.deps.config.condensedTargetTokens) &&
916
1063
  params.deps.config.condensedTargetTokens > 0
917
1064
  ? params.deps.config.condensedTargetTokens
918
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;
919
1071
 
920
1072
  const fn: LcmSummarizeFn = async (
921
1073
  text: string,
@@ -928,15 +1080,11 @@ export async function createLcmSummarizeFromLegacyParams(params: {
928
1080
 
929
1081
  const mode: SummaryMode = aggressive ? "aggressive" : "normal";
930
1082
  const isCondensed = options?.isCondensed === true;
931
- const apiKey = await params.deps.getApiKey(provider, model, {
932
- profileId: authProfileId,
933
- agentDir,
934
- runtimeConfig: params.legacyParams.config,
935
- });
936
1083
  const targetTokens = resolveTargetTokens({
937
1084
  inputTokens: estimateTokens(text),
938
1085
  mode,
939
1086
  isCondensed,
1087
+ leafTargetTokens,
940
1088
  condensedTargetTokens,
941
1089
  });
942
1090
  const prompt = isCondensed
@@ -958,95 +1106,30 @@ export async function createLcmSummarizeFromLegacyParams(params: {
958
1106
  customInstructions: params.customInstructions,
959
1107
  });
960
1108
 
961
- let result: Awaited<ReturnType<typeof params.deps.complete>>;
962
- try {
963
- result = await withTimeout(params.deps.complete({
964
- provider,
965
- model,
966
- apiKey,
967
- providerApi,
968
- 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,
969
1120
  agentDir,
970
1121
  runtimeConfig: params.legacyParams.config,
971
- system: LCM_SUMMARIZER_SYSTEM_PROMPT,
972
- messages: [
973
- {
974
- role: "user",
975
- content: prompt,
976
- },
977
- ],
978
- maxTokens: targetTokens,
979
- temperature: aggressive ? 0.1 : 0.2,
980
- }), SUMMARIZER_TIMEOUT_MS, "initial");
981
- } catch (err) {
982
- const authFailure = extractProviderAuthFailure(err);
983
- if (authFailure) {
984
- console.warn(buildProviderAuthWarning({ provider, model, failure: authFailure }));
985
- return "";
986
- }
987
- const errMsg = err instanceof Error ? err.message : String(err);
988
- const isTimeout = errMsg.includes("summarizer timeout");
989
- console.warn(
990
- `[lcm] summarizer ${isTimeout ? "timed out" : "failed"}; provider=${provider}; model=${model}; timeout=${SUMMARIZER_TIMEOUT_MS}ms; error=${errMsg}`,
991
- );
992
- if (err instanceof SummarizerTimeoutError) {
993
- console.error(
994
- `[lcm] summarizer timed out; provider=${provider}; model=${model}; source=fallback`,
995
- );
996
- return buildDeterministicFallbackSummary(text, targetTokens);
997
- }
998
- return "";
999
- }
1000
-
1001
- const authFailure = extractProviderAuthFailure(result);
1002
- if (authFailure) {
1003
- console.warn(buildProviderAuthWarning({ provider, model, failure: authFailure }));
1004
- return "";
1005
- }
1006
-
1007
- const normalized = normalizeCompletionSummary(result.content);
1008
- let summary = normalized.summary;
1009
- let summarySource: "content" | "envelope" | "retry" | "fallback" = "content";
1010
-
1011
- // --- Empty-summary hardening: envelope → retry → deterministic fallback ---
1012
- if (!summary) {
1013
- // Envelope-aware extraction: some providers place summary text in
1014
- // top-level response fields (output, message, response) rather than
1015
- // inside the content array. Re-run normalization against the full
1016
- // response envelope before spending an API call on a retry.
1017
- const envelopeNormalized = normalizeCompletionSummary(result);
1018
- if (envelopeNormalized.summary) {
1019
- summary = envelopeNormalized.summary;
1020
- summarySource = "envelope";
1021
- console.error(
1022
- `[lcm] recovered summary from response envelope; provider=${provider}; model=${model}; ` +
1023
- `block_types=${formatBlockTypes(envelopeNormalized.blockTypes)}; source=envelope`,
1024
- );
1025
- }
1026
- }
1027
-
1028
- if (!summary) {
1029
- const responseDiag = extractResponseDiagnostics(result);
1030
- const diagParts = [
1031
- `[lcm] empty normalized summary on first attempt`,
1032
- `provider=${provider}`,
1033
- `model=${model}`,
1034
- `block_types=${formatBlockTypes(normalized.blockTypes)}`,
1035
- `response_blocks=${result.content.length}`,
1036
- ];
1037
- if (responseDiag) {
1038
- diagParts.push(responseDiag);
1039
- }
1040
- console.error(`${diagParts.join("; ")}; retrying with conservative settings`);
1041
-
1042
- // Single retry with conservative parameters: low temperature and low
1043
- // reasoning budget to coax a textual response from providers that
1044
- // sometimes return reasoning-only or empty blocks on the first pass.
1045
- try {
1046
- 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({
1047
1130
  provider,
1048
1131
  model,
1049
- apiKey,
1132
+ apiKey: requestApiKey,
1050
1133
  providerApi,
1051
1134
  authProfileId,
1052
1135
  agentDir,
@@ -1059,69 +1142,250 @@ export async function createLcmSummarizeFromLegacyParams(params: {
1059
1142
  },
1060
1143
  ],
1061
1144
  maxTokens: targetTokens,
1062
- temperature: 0.05,
1063
- reasoning: "low",
1064
- }), SUMMARIZER_TIMEOUT_MS, "retry");
1065
- const retryAuthFailure = extractProviderAuthFailure(retryResult);
1066
- if (retryAuthFailure) {
1067
- console.warn(buildProviderAuthWarning({ provider, model, failure: retryAuthFailure }));
1068
- return "";
1069
- }
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
+ );
1070
1157
 
1071
- const retryNormalized = normalizeCompletionSummary(retryResult.content);
1072
- 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
+ }
1073
1168
 
1074
- if (summary) {
1075
- summarySource = "retry";
1076
- console.error(
1077
- `[lcm] retry succeeded; provider=${provider}; model=${model}; ` +
1078
- `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`,
1079
1183
  );
1080
- } else {
1081
- const retryDiag = extractResponseDiagnostics(retryResult);
1082
- const retryParts = [
1083
- `[lcm] retry also returned empty summary`,
1084
- `provider=${provider}`,
1085
- `model=${model}`,
1086
- `block_types=${formatBlockTypes(retryNormalized.blockTypes)}`,
1087
- `response_blocks=${retryResult.content.length}`,
1088
- ];
1089
- if (retryDiag) {
1090
- retryParts.push(retryDiag);
1184
+ return directResult;
1185
+ } catch (directErr) {
1186
+ if (directErr instanceof LcmProviderAuthError) {
1187
+ throw directErr;
1188
+ }
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;
1091
1198
  }
1092
- console.error(`${retryParts.join("; ")}; falling back to truncation`);
1199
+ throw directErr;
1093
1200
  }
1094
- } catch (retryErr) {
1095
- const retryAuthFailure = extractProviderAuthFailure(retryErr);
1096
- if (retryAuthFailure) {
1097
- console.warn(buildProviderAuthWarning({ provider, model, failure: retryAuthFailure }));
1098
- 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);
1099
1221
  }
1100
- // Retry is best-effort; log and proceed to deterministic fallback.
1101
- const retryErrMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
1102
- 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");
1103
1240
  console.warn(
1104
- `[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}`,
1105
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 "";
1106
1256
  }
1107
- }
1108
1257
 
1109
- if (!summary) {
1110
- summarySource = "fallback";
1111
- console.error(
1112
- `[lcm] all extraction attempts exhausted; provider=${provider}; model=${model}; source=fallback`,
1113
- );
1114
- return buildDeterministicFallbackSummary(text, targetTokens);
1115
- }
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
+ }
1116
1278
 
1117
- if (summarySource !== "content") {
1118
- console.error(
1119
- `[lcm] summary resolved via non-content path; provider=${provider}; model=${model}; source=${summarySource}`,
1120
- );
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;
1121
1382
  }
1122
1383
 
1123
- return summary;
1384
+ if (lastAuthError) {
1385
+ throw lastAuthError;
1386
+ }
1387
+ return "";
1124
1388
  };
1125
1389
 
1126
- return { fn, model };
1390
+ return { fn, model: resolvedCandidates[0]!.model };
1127
1391
  }