claude-flow
Version:
Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration
260 lines • 11.2 kB
JavaScript
/**
* GAIA Convergence Layer — deterministic finalization for saturated agent loops.
*
* Detects three failure modes that cause empty FINAL_ANSWER extraction:
* 1. max_turns hit without a final_answer call
* 2. Loop detected (same tool + same args called 3× in a 5-turn window)
* 3. Token budget exceeded (>120k tokens of conversation context)
*
* On detection, runs a FORCED COMMIT phase:
* - Injects a stripped-down summary prompt: "Based on all observations, answer with
* FINAL_ANSWER: X. Do NOT explore further."
* - Makes 1 final API call with strict instruction and no tools.
* - If still no FINAL_ANSWER in the response, runs the Stage 1 extraction cascade
* against ALL prior assistant messages (last to first), returns first non-empty hit.
* - If still empty: returns null but logs the failure mode.
*
* This is NOT a new cognition layer — it is a convergence controller.
* It STRIPS information rather than adding it: one final chance to commit,
* no tools available, prior context summarized rather than appended.
*
* Architecture principle (iter 60 post-mortem):
* "More information can reduce agent reliability. Past a certain point —
* retrieval depth, context size, browsing breadth, tool diversity — information
* increases trajectory entropy. The system becomes less likely to finalize coherently."
* This layer is the entropy-reducer.
*
* Refs: #2156, iter 60 (19/25 empty FINAL_ANSWER failures), iter 62
*/
import * as crypto from 'node:crypto';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Token threshold (sum of input tokens across all turns) that triggers overflow detection. */
export const TOKEN_OVERFLOW_THRESHOLD = 120_000;
/** Number of repeated identical tool+args calls in a window that signals a loop. */
export const LOOP_REPEAT_THRESHOLD = 3;
/** Sliding window size (turns) for loop detection. */
export const LOOP_WINDOW_SIZE = 5;
/** Pattern Claude must output to signal it has a final answer. */
const FINAL_ANSWER_RE = /FINAL_ANSWER:\s*(.+)/i;
// ---------------------------------------------------------------------------
// argsHash — deterministic fingerprint of a tool call
// ---------------------------------------------------------------------------
/**
* Produce a stable hash of a tool call's name + args for loop detection.
*
* Uses SHA-256 truncated to 16 hex chars — collision probability is
* negligible for the small call volumes in a single agent run.
*
* The hash is deterministic: same toolName + same args always → same hash.
* Different args always → different hash (within SHA-256 collision bounds).
*/
export function argsHash(toolName, args) {
const payload = toolName + '::' + JSON.stringify(args);
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 16);
}
// ---------------------------------------------------------------------------
// checkConvergenceTriggers
// ---------------------------------------------------------------------------
/**
* Evaluate the current ConvergenceState and return the first failure mode
* detected, or null if no trigger has fired.
*
* Evaluation order:
* 1. max_turns — turnCount >= maxTurns
* 2. token_overflow — totalTokens >= TOKEN_OVERFLOW_THRESHOLD
* 3. loop — same tool+argsHash appears >= LOOP_REPEAT_THRESHOLD times
* in the last LOOP_WINDOW_SIZE entries of toolCalls
*
* Only the FIRST matching trigger is returned (stops at first detection).
* The caller is responsible for setting state.detectedFailureMode.
*/
export function checkConvergenceTriggers(state, maxTurns) {
// 1. max_turns
if (state.turnCount >= maxTurns) {
return 'max_turns';
}
// 2. token_overflow
if (state.totalTokens >= TOKEN_OVERFLOW_THRESHOLD) {
return 'token_overflow';
}
// 3. loop — check the sliding window
const window = state.toolCalls.slice(-LOOP_WINDOW_SIZE);
if (window.length >= LOOP_REPEAT_THRESHOLD) {
const callKey = (c) => c.name + '::' + c.argsHash;
const counts = new Map();
for (const call of window) {
const key = callKey(call);
const count = (counts.get(key) ?? 0) + 1;
counts.set(key, count);
if (count >= LOOP_REPEAT_THRESHOLD) {
return 'loop';
}
}
}
return null;
}
// ---------------------------------------------------------------------------
// buildForcedCommitPrompt
// ---------------------------------------------------------------------------
/**
* Build the stripped-down "commit now" prompt injected as a final user turn.
*
* Deliberately short and directive — this is an entropy-reducer, not a
* context-enricher. Adding more information at this point would worsen the
* problem we are solving.
*/
function buildForcedCommitPrompt(triggerMode) {
return (`[CONVERGENCE: ${triggerMode.toUpperCase()}]\n` +
`You must commit to a final answer NOW. Do NOT call any more tools.\n` +
`Based only on the information you have already gathered:\n` +
`- Summarize your best answer in one line.\n` +
`- Output it in EXACTLY this format: FINAL_ANSWER: <your answer>\n` +
`- If you are uncertain, give your best estimate — do not say "I don't know".\n` +
`You have ONE response. No further tool calls are permitted.`);
}
// ---------------------------------------------------------------------------
// extractFinalAnswerFromText — Stage 1 extraction against raw text
// ---------------------------------------------------------------------------
/**
* Run Stage 1 extraction (FINAL_ANSWER: pattern) against a raw text string.
* Returns the matched answer or null.
*/
export function extractFinalAnswerFromText(text) {
const match = FINAL_ANSWER_RE.exec(text);
if (match && match[1] && match[1].trim()) {
return match[1].trim();
}
return null;
}
// ---------------------------------------------------------------------------
// extractFromPriorMessages — fallback scan of message history
// ---------------------------------------------------------------------------
/**
* Scan prior assistant messages from last to first, looking for FINAL_ANSWER.
*
* This is the fallback path when the forced-commit API call still does not
* produce a FINAL_ANSWER. We search backwards because the most recent
* assistant message is most likely to contain the best answer.
*/
export function extractFromPriorMessages(messages) {
// Iterate in reverse, assistant messages only
const assistantMessages = messages
.filter((m) => m.role === 'assistant')
.reverse();
for (const msg of assistantMessages) {
const text = typeof msg.content === 'string'
? msg.content
: extractTextFromContent(msg.content);
if (!text)
continue;
const answer = extractFinalAnswerFromText(text);
if (answer)
return answer;
}
return null;
}
/**
* Extract plain text from a content block array (Anthropic API format).
* Returns concatenated text from all text-type blocks, or empty string.
*/
function extractTextFromContent(content) {
if (!Array.isArray(content))
return '';
const parts = [];
for (const block of content) {
if (block && typeof block === 'object' && block.type === 'text') {
const text = block.text;
if (text)
parts.push(text);
}
}
return parts.join('\n');
}
// ---------------------------------------------------------------------------
// forceCommit
// ---------------------------------------------------------------------------
/**
* Run the forced-commit phase for a saturated agent loop.
*
* Steps:
* 1. Append a stripped directive prompt to the message history.
* 2. Call the model once with NO tools — forces text-only response.
* 3. If FINAL_ANSWER found in the response → return it.
* 4. If not → scan prior assistant messages last-to-first for FINAL_ANSWER.
* 5. If still empty → return { answer: null, usedFallback: false }.
*
* The callModel callback must return the raw text response (assistant turn
* text blocks concatenated). It should be called WITHOUT tool definitions
* so the model cannot call tools in this turn.
*
* @param messages Current conversation history (mutated: 1 user turn appended)
* @param callModel Callback that makes a single API call and returns the response text
* @param triggerMode The failure mode that triggered this forced commit
*/
export async function forceCommit(messages, callModel, triggerMode = 'max_turns') {
const trigger = triggerMode ?? 'max_turns';
// Step 1: append the forced-commit directive
const commitMessages = [
...messages,
{ role: 'user', content: buildForcedCommitPrompt(trigger) },
];
// Step 2: call the model once, no tools
let responseText = '';
try {
responseText = await callModel(commitMessages);
}
catch {
// If the API call itself fails, fall through to fallback scan
responseText = '';
}
// Step 3: try to extract from the forced-commit response
if (responseText) {
const answer = extractFinalAnswerFromText(responseText);
if (answer) {
return { answer, usedFallback: false, triggerMode: triggerMode };
}
}
// Step 4: scan prior messages last-to-first
const fallbackAnswer = extractFromPriorMessages(messages);
if (fallbackAnswer) {
return { answer: fallbackAnswer, usedFallback: true, triggerMode: triggerMode };
}
// Step 5: graceful failure
return { answer: null, usedFallback: false, triggerMode: triggerMode };
}
// ---------------------------------------------------------------------------
// createConvergenceState — factory for a fresh state object
// ---------------------------------------------------------------------------
/** Create a new ConvergenceState with zero counters. */
export function createConvergenceState() {
return {
turnCount: 0,
totalTokens: 0,
toolCalls: [],
detectedFailureMode: null,
};
}
// ---------------------------------------------------------------------------
// recordTurn — update state after a completed turn
// ---------------------------------------------------------------------------
/**
* Update ConvergenceState after each agent turn completes.
*
* @param state Mutable state object (modified in place)
* @param inputTokens Tokens consumed in this turn
* @param toolCallsThisTurn Tool calls made in this turn (name + args)
*/
export function recordTurn(state, inputTokens, toolCallsThisTurn) {
state.turnCount += 1;
state.totalTokens += inputTokens;
for (const tc of toolCallsThisTurn) {
state.toolCalls.push({
name: tc.name,
argsHash: argsHash(tc.name, tc.args),
turn: state.turnCount,
});
}
}
//# sourceMappingURL=gaia-convergence.js.map