Skip to content

Commit a79021f

Browse files
authored
fix(ai): stop REM repair retry overflow (#13918) (#13922)
1 parent 0ec6f32 commit a79021f

2 files changed

Lines changed: 345 additions & 57 deletions

File tree

ai/services/graph/SemanticGraphExtractor.mjs

Lines changed: 170 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
import fs from 'fs';
2-
import path from 'path';
3-
import aiConfig from '../../mcp/server/memory-core/config.mjs';
4-
import Base from '../../../src/core/Base.mjs';
5-
import {emitConsumerFriction, invokeWithGuardrail} from '../../services/memory-core/helpers/consumerFrictionHelper.mjs';
1+
import fs from 'fs';
2+
import path from 'path';
3+
import aiConfig from '../../mcp/server/memory-core/config.mjs';
4+
import Base from '../../../src/core/Base.mjs';
5+
import {
6+
bytesToTokens,
7+
emitConsumerFriction,
8+
invokeWithGuardrail
9+
} from '../../services/memory-core/helpers/consumerFrictionHelper.mjs';
610
import GraphService from '../../services/memory-core/GraphService.mjs';
711
import Json from '../../../src/util/Json.mjs';
812
import logger from '../../mcp/server/memory-core/logger.mjs';
@@ -57,6 +61,105 @@ class SemanticGraphExtractor extends Base {
5761
return this.isMemorySessionGraphNodeId(id) ? GraphService.normalizeGraphNodeId(id) : id;
5862
}
5963

64+
/**
65+
* Estimates chat-message payload size using the shared consumer-friction token heuristic.
66+
*
67+
* @summary Anchor & Echo: Keeps post-invocation retry sizing on the same estimator as the
68+
* pre-invocation guardrail, so calibration fixes land in one place.
69+
*
70+
* @param {Object[]} messages Provider chat messages
71+
* @returns {Object} `{text, bytes, tokens}` estimate for the composed provider payload
72+
* @protected
73+
*/
74+
estimateChatMessagesPayload(messages) {
75+
const text = messages.map(m => m.content).join('\n'),
76+
bytes = Buffer.byteLength(text, 'utf8');
77+
78+
return {
79+
text,
80+
bytes,
81+
tokens: bytesToTokens(bytes)
82+
};
83+
}
84+
85+
/**
86+
* Resolves provider completion finish reasons across supported raw envelopes.
87+
*
88+
* @summary Anchor & Echo: Normalizes OpenAI-compatible `finish_reason`, Ollama
89+
* `done_reason`, and Gemini `finishReason` shapes into one retry-loop predicate.
90+
*
91+
* @param {Object} result Provider generation result
92+
* @returns {String} Completion finish reason, or an empty string when unavailable
93+
* @protected
94+
*/
95+
getCompletionFinishReason(result) {
96+
const reason = result?.finish_reason ??
97+
result?.finishReason ??
98+
result?.raw?.finish_reason ??
99+
result?.raw?.finishReason ??
100+
result?.raw?.done_reason ??
101+
result?.raw?.doneReason ??
102+
result?.raw?.choices?.[0]?.finish_reason ??
103+
result?.raw?.choices?.[0]?.finishReason ??
104+
result?.raw?.candidates?.[0]?.finishReason;
105+
106+
return typeof reason === 'string' ? reason : '';
107+
}
108+
109+
/**
110+
* Tests whether a provider finish reason means the response hit an output/token cap.
111+
*
112+
* @summary Anchor & Echo: Length-capped non-empty responses are treated as overflow
113+
* evidence because schema-repair retries would append the truncated body and grow the prompt.
114+
*
115+
* @param {String} finishReason Provider finish reason
116+
* @returns {Boolean} `true` when the reason indicates token-length truncation
117+
* @protected
118+
*/
119+
isLengthTruncatedCompletion(finishReason) {
120+
return /^(length|max_tokens|token_limit)$/i.test(String(finishReason || '').trim());
121+
}
122+
123+
/**
124+
* Emits deterministic context-overflow friction for post-invocation retry aborts.
125+
*
126+
* @summary Anchor & Echo: Reuses the established ConsumerFriction channel for retry-loop
127+
* overflow evidence instead of introducing a parallel symptom path.
128+
*
129+
* @param {Object} options
130+
* @param {String} options.sessionId Session identifier
131+
* @param {String} options.consumerModel Provider model identifier
132+
* @param {Number} options.consumerContextTokens Context window in tokens
133+
* @param {Number} options.consumerSafeTokens Safe processing band in tokens
134+
* @param {Number} options.inputBytes Estimated prompt bytes
135+
* @param {Number} options.inputTokensEstimate Estimated prompt tokens
136+
* @param {String} options.note Diagnostic note
137+
* @protected
138+
*/
139+
emitRetryLoopContextOverflow({
140+
sessionId,
141+
consumerModel,
142+
consumerContextTokens,
143+
consumerSafeTokens,
144+
inputBytes,
145+
inputTokensEstimate,
146+
note
147+
}) {
148+
emitConsumerFriction({
149+
symptom : 'context-overflow',
150+
consumer : 'SemanticGraphExtractor',
151+
model : consumerModel,
152+
assetRef : sessionId,
153+
serviceDomain : 'dream-pipeline',
154+
emissionPoint : 'post-invocation-failure',
155+
inputBytes,
156+
inputTokensEstimate,
157+
contextLimitTokens : consumerContextTokens,
158+
safeProcessingLimitTokens: consumerSafeTokens,
159+
note
160+
});
161+
}
162+
60163
/**
61164
* Executes the Tri-Vector Synthesis (Semantic Graph, Open Deltas, Roadmap Strategy)
62165
* from the session memory log via JSON schema extraction.
@@ -128,7 +231,7 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
128231

129232
try {
130233
const graphProvider = resolveGraphModelProvider(aiConfig);
131-
const provider = buildGraphProvider({
234+
const provider = buildGraphProvider({
132235
modelProvider : graphProvider,
133236
ollamaConfig : aiConfig.ollama,
134237
openAiCompatibleConfig: aiConfig.openAiCompatible
@@ -141,9 +244,9 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
141244
];
142245

143246
let maxRetries = 3;
144-
let attempt = 0;
145-
let payload = null;
146-
let result = null;
247+
let attempt = 0;
248+
let payload = null;
249+
let result = null;
147250

148251
// Wrap each LLM invocation with the Consumer-Friction guardrail. The upstream
149252
// pre-check skips invocation when the composed messages' estimated token count
@@ -157,17 +260,20 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
157260
// threshold (model-role axis, not provider-namespace — remote providers like
158261
// Gemini are API-bound and don't expose these knobs; local providers
159262
// share the same caps because the limit comes from the loaded model).
160-
const consumerModel = aiConfig[graphProvider].model;
161-
const consumerContextTokens = aiConfig.localModels.chat.contextLimitTokens;
162-
const consumerSafeTokens = aiConfig.localModels.chat.safeProcessingLimitTokens;
263+
const consumerModel = aiConfig[graphProvider].model;
264+
const consumerContextTokens = aiConfig.localModels.chat.contextLimitTokens;
265+
const configuredSafeTokens = aiConfig.localModels.chat.safeProcessingLimitTokens;
266+
const consumerSafeTokens = Number.isFinite(configuredSafeTokens)
267+
? configuredSafeTokens
268+
: Math.floor(consumerContextTokens * 0.75);
163269

164270
// Per-task no-think + grammar-constrained tri-vector output. `graphReasoningEffort`
165271
// (default 'none') disables the gemma MoE's hidden thinking pass; `triVectorSchema` enforces
166272
// the A2A session_artifact/graph shape via the provider's json_schema path, which makes the
167273
// repair-retry loop below a safety net rather than the happy path. Schema mirrors the strict
168274
// shape declared in the systemInstruction above.
169275
const graphReasoningEffort = aiConfig.localModels.chat.graphReasoningEffort;
170-
const triVectorSchema = {
276+
const triVectorSchema = {
171277
type : 'object',
172278
properties: {
173279
a2a_version : {type: 'string'},
@@ -217,11 +323,13 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
217323
},
218324
required: ['a2a_version', 'session_artifact']
219325
};
326+
const repairFeedback = `Your previous response failed internal schema validation. You are missing required keys (e.g., session_artifact) or you provided malformed JSON. Please correct your output and provide ONLY the exact JSON shape requested in the instructions.`;
220327

221328
while (attempt < maxRetries && !payload) {
222329
attempt++;
223330

224-
const inputPayloadText = messages.map(m => m.content).join('\n');
331+
const inputPayload = this.estimateChatMessagesPayload(messages);
332+
const inputPayloadText = inputPayload.text;
225333
const guardrailed = await invokeWithGuardrail({
226334
invocationFn : () => provider.generate(messages, {reasoning_effort: graphReasoningEffort || undefined, responseSchema: triVectorSchema, responseSchemaName: 'triVector'}),
227335
inputPayload : inputPayloadText,
@@ -240,6 +348,23 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
240348
}
241349

242350
result = guardrailed.result;
351+
const finishReason = this.getCompletionFinishReason(result);
352+
353+
if (this.isLengthTruncatedCompletion(finishReason)) {
354+
logger.warn(`[SemanticGraphExtractor] Attempt ${attempt}: Provider reported '${finishReason}' for session ${session.meta.sessionId}; classifying as context-overflow and aborting JSON repair loop.`);
355+
356+
this.emitRetryLoopContextOverflow({
357+
sessionId : session.meta.sessionId,
358+
consumerModel,
359+
consumerContextTokens,
360+
consumerSafeTokens,
361+
inputBytes : inputPayload.bytes,
362+
inputTokensEstimate: inputPayload.tokens,
363+
note : `Provider finish_reason='${finishReason}' before schema validation. Aborting repair loop to avoid appending a truncated response. Attempt ${attempt}/${maxRetries}.`
364+
});
365+
366+
return null;
367+
}
243368

244369
// Silent context-overflow detection: provider can stream-close immediately
245370
// with an empty body when its loaded-model context window is smaller than
@@ -276,12 +401,32 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
276401
logger.warn(`[SemanticGraphExtractor] Attempt ${attempt}: Failed to validate extracted Tri-Vector A2A payload for session: ${session.meta.sessionId}`);
277402

278403
if (attempt < maxRetries) {
404+
const repairMessages = [
405+
...messages,
406+
{ role: 'assistant', content: result.content },
407+
{ role: 'user', content: repairFeedback }
408+
];
409+
const repairPayload = this.estimateChatMessagesPayload(repairMessages);
410+
411+
if (repairPayload.tokens > consumerSafeTokens) {
412+
logger.warn(`[SemanticGraphExtractor] Attempt ${attempt}: JSON repair prompt would exceed safe processing band for session ${session.meta.sessionId}; classifying as context-overflow and aborting retry loop.`);
413+
414+
this.emitRetryLoopContextOverflow({
415+
sessionId : session.meta.sessionId,
416+
consumerModel,
417+
consumerContextTokens,
418+
consumerSafeTokens,
419+
inputBytes : repairPayload.bytes,
420+
inputTokensEstimate: repairPayload.tokens,
421+
note : `Repair retry prompt estimate ${repairPayload.tokens} tokens exceeds safe band ${consumerSafeTokens}. Aborting instead of appending assistant output and repair feedback. Attempt ${attempt}/${maxRetries}.`
422+
});
423+
424+
return null;
425+
}
426+
279427
logger.warn(`[SemanticGraphExtractor] Attempt ${attempt}: Injecting autonomous JSON repair feedback loop.`);
280-
messages.push({ role: 'assistant', content: result.content });
281-
messages.push({
282-
role: 'user',
283-
content: `Your previous response failed internal schema validation. You are missing required keys (e.g., session_artifact) or you provided malformed JSON. Please correct your output and provide ONLY the exact JSON shape requested in the instructions.`
284-
});
428+
messages.push(repairMessages[repairMessages.length - 2]);
429+
messages.push(repairMessages[repairMessages.length - 1]);
285430
payload = null; // Ensure loop continues
286431
} else {
287432
logger.warn(`[SemanticGraphExtractor] --- FINAL EXHAUSTED RAW LLM DUMP ---\n${result.content}\n-----------------------------`);
@@ -326,7 +471,7 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
326471
if (node.id === 'frontier') continue;
327472

328473
let nodeType = node.type && VALID_TYPES.includes(node.type.toUpperCase()) ? node.type.toUpperCase() : 'CONCEPT';
329-
let nodeId = node.id;
474+
let nodeId = node.id;
330475

331476
// Enforce Neo native Graph ID specification (Type:Name) if hallucinated
332477
if (!nodeId.includes(':')) {
@@ -376,7 +521,7 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
376521
const targetExists = validNodeRefs.has(resolvedTarget) || GraphService.db.nodes.has(resolvedTarget);
377522

378523
if (!sourceExists || !targetExists) {
379-
const isProvenance = ['MENTIONED_IN', 'DISCUSSED_IN', 'REFERENCED_BY'].includes(edge.relationship);
524+
const isProvenance = ['MENTIONED_IN', 'DISCUSSED_IN', 'REFERENCED_BY'].includes(edge.relationship);
380525
const targetsSessionOrMemory = this.isMemorySessionGraphNodeId(resolvedTarget) || this.isMemorySessionGraphNodeId(resolvedSource);
381526

382527
if (isProvenance && targetsSessionOrMemory) {
@@ -389,7 +534,7 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
389534
*/
390535
logger.info(`[SemanticGraphExtractor] Queuing unresolved provenance edge for lazy back-fill: ${resolvedSource} -> ${resolvedTarget}`);
391536
const lazyQueueFile = aiConfig.lazyEdgesQueuePath;
392-
const edgeData = JSON.stringify({ ...edge, source: resolvedSource, target: resolvedTarget, timestamp: new Date().toISOString() }) + '\n';
537+
const edgeData = JSON.stringify({ ...edge, source: resolvedSource, target: resolvedTarget, timestamp: new Date().toISOString() }) + '\n';
393538
try {
394539
// Ensure the directory exists before appending
395540
await fs.promises.mkdir(path.dirname(lazyQueueFile), { recursive: true });
@@ -478,7 +623,7 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
478623

479624
try {
480625
const graphProvider = resolveGraphModelProvider(aiConfig);
481-
const provider = buildGraphProvider({
626+
const provider = buildGraphProvider({
482627
modelProvider : graphProvider,
483628
ollamaConfig : aiConfig.ollama,
484629
openAiCompatibleConfig: aiConfig.openAiCompatible
@@ -490,8 +635,8 @@ DO NOT output markdown, \`\`\`json blocks, or any other explanations. Provide pu
490635
];
491636

492637
let maxRetries = 2;
493-
let attempt = 0;
494-
let payload = null;
638+
let attempt = 0;
639+
let payload = null;
495640

496641
while (attempt < maxRetries && !payload) {
497642
attempt++;

0 commit comments

Comments
 (0)