Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"./utils/pricing": "./dist/utils/pricing.js",
"./utils/telemetry": "./dist/utils/telemetry.js",
"./analysis/prompts": "./dist/analysis/prompts.js",
"./analysis/loop-detector": "./dist/analysis/loop-detector.js",
"./analysis/prompt-types": "./dist/analysis/prompt-types.js",
"./analysis/prompt-constants": "./dist/analysis/prompt-constants.js",
"./analysis/message-format": "./dist/analysis/message-format.js",
Expand Down
29 changes: 26 additions & 3 deletions cli/src/analysis/__tests__/loop-detector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,39 @@ describe('detectRageLoopHeuristic', () => {
mockMessage('5', '2026-04-24T10:00:40Z', JSON.stringify({ inputTokens: 150000 })),
];

// Add semantic repetition
messages[3].content = 'repeat content';
messages[4].content = 'repeat content';
// Repeat a substantive failure; empty [] tool-call fields are not evidence.
messages[2].content = 'Timeout when deploying exactly the same revision';
messages[3].content = 'Timeout when deploying exactly the same revision';
messages[4].content = 'Timeout when deploying exactly the same revision';

const result = detectRageLoopHeuristic(messages);
expect(result.detected).toBe(true);
expect(result.reasoning).toContain('static token count (150k)');
expect(result.turnRange).toEqual(['Turn#0', 'Turn#4']);
});

it('does not classify empty tool calls and short, generic responses as a rage loop', () => {
const messages = Array.from({ length: 5 }, (_, i) =>
mockMessage(String(i), `2026-04-24T10:00:${String(i * 10).padStart(2, '0')}Z`, JSON.stringify({ inputTokens: 150000 }), 'OK')
);
expect(detectRageLoopHeuristic(messages).detected).toBe(false);
});

it('does not treat out-of-order timestamps as a rapid loop', () => {
const messages = Array.from({ length: 5 }, (_, i) =>
mockMessage(String(i), `2026-04-24T10:00:${String(i * 10).padStart(2, '0')}Z`, JSON.stringify({ inputTokens: 150000 }), 'Timeout when deploying exactly the same revision')
);
messages[2].timestamp = '2026-04-24T09:00:00Z';
expect(detectRageLoopHeuristic(messages).detected).toBe(false);
});

it('classifies repeated low-context errors separately from rage loops', () => {
const messages = Array.from({ length: 5 }, (_, i) =>
mockMessage(String(i), `2026-04-24T10:00:${String(i * 10).padStart(2, '0')}Z`, JSON.stringify({ inputTokens: 8000 }), 'Timeout when deploying exactly the same revision')
);
expect(detectRageLoopHeuristic(messages).detected).toBe(false);
});

it('should not detect a loop if token count is growing', () => {
const messages: SQLiteMessageRow[] = [
mockMessage('1', '2026-04-24T10:00:00Z', JSON.stringify({ inputTokens: 100000 })),
Expand Down
8 changes: 8 additions & 0 deletions cli/src/analysis/__tests__/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,14 @@ describe('buildCacheableConversationBlock', () => {
// ──────────────────────────────────────────────────────

describe('buildSessionAnalysisInstructions', () => {
it('emits an advisory rage-loop hint only when detector reports a suspected loop', () => {
const signal = { detected: true, confidence: 0.8, reasoning: 'Repeated commands in static context', turnRange: ['Turn#0', 'Turn#4'] as [string, string] };
const prompted = buildSessionAnalysisInstructions('proj', null, undefined, signal);
expect(prompted).toContain('<rage_loop>');
expect(prompted).toContain('Turn#0 to Turn#4');
expect(buildSessionAnalysisInstructions('proj', null)).not.toContain('<rage_loop>');
});

it('includes project name in the instructions', () => {
const result = buildSessionAnalysisInstructions('my-app', null);
expect(result).toContain('<project_name>my-app</project_name>');
Expand Down
19 changes: 13 additions & 6 deletions cli/src/analysis/loop-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function detectRageLoopHeuristic(messages: SQLiteMessageRow[]): RageLoopS

if (prevMsg) {
const delta = (new Date(msg.timestamp).getTime() - new Date(prevMsg.timestamp).getTime()) / 1000;
if (delta < 60) {
if (Number.isFinite(delta) && delta >= 0 && delta < 60) {
currentCluster.push(msg);
} else {
if (currentCluster.length >= 4) clusters.push([...currentCluster]);
Expand All @@ -46,7 +46,7 @@ export function detectRageLoopHeuristic(messages: SQLiteMessageRow[]): RageLoopS
const tokens = cluster.map(m => {
try {
const usage = JSON.parse(m.usage || '{}');
return usage.inputTokens || usage.totalTokens || 0;
return usage.inputTokens || usage.input_tokens || usage.totalTokens || usage.total_tokens || 0;
} catch {
return 0;
}
Expand All @@ -63,8 +63,15 @@ export function detectRageLoopHeuristic(messages: SQLiteMessageRow[]): RageLoopS
const repetitions = cluster.filter((m, i) => {
if (i === 0) return false;
const prev = cluster[i - 1];
return m.content.slice(0, 100) === prev.content.slice(0, 100) ||
m.tool_calls === prev.tool_calls;
// Empty tool-call arrays are common and cannot demonstrate repetition.
const content = m.content.trim().replace(/\s+/g, ' ').slice(0, 120);
const prevContent = prev.content.trim().replace(/\s+/g, ' ').slice(0, 120);
const sameContent = content.length >= 12 && content === prevContent;
// Only compare actual tool invocations, not null or [] placeholders.
const calls = m.tool_calls?.trim();
const prevCalls = prev.tool_calls?.trim();
const sameCalls = Boolean(calls && calls !== '[]' && calls === prevCalls);
return sameContent || sameCalls;
});

if (repetitions.length >= 2) {
Expand All @@ -73,8 +80,8 @@ export function detectRageLoopHeuristic(messages: SQLiteMessageRow[]): RageLoopS

return {
detected: true,
confidence: 0.85,
reasoning: `Detected ${cluster.length} messages in ${Math.round((new Date(cluster[cluster.length-1].timestamp).getTime() - new Date(cluster[0].timestamp).getTime())/1000)}s with static token count (${Math.round(avg/1000)}k) and repetitive signals.`,
confidence: 0.8,
reasoning: `Suspected loop (verify stalled progress): ${cluster.length} messages in ${Math.round((new Date(cluster[cluster.length-1].timestamp).getTime() - new Date(cluster[0].timestamp).getTime())/1000)}s with static token count (${Math.round(avg/1000)}k) and repetitive signals.`,
turnRange: [`Turn#${firstTurn}`, `Turn#${lastTurn}`]
};
}
Expand Down
1 change: 1 addition & 0 deletions cli/src/analysis/schemas/session-analysis.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"context-loss",
"scope-creep",
"repeated-mistakes",
"rage-loop",
"documentation-gap",
"tooling-limitation"
]
Expand Down
8 changes: 5 additions & 3 deletions server/src/llm/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
import { calculateAnalysisCost } from './analysis-pricing.js';
import { saveAnalysisUsage } from './analysis-usage-db.js';
import { getDb } from '@code-insights/cli/db/client';
import { detectRageLoopHeuristic } from '@code-insights/cli/analysis/loop-detector';
import { loadConfig } from '@code-insights/cli/utils/config';
import * as sqliteVec from 'sqlite-vec';
import { embedOne, DEFAULT_EMBEDDING_CONFIG } from '@code-insights/cli/embeddings/client';
Expand Down Expand Up @@ -93,6 +94,7 @@ export async function analyzeSession(
const formattedMessages = formatMessagesForAnalysis(messages);
const estimatedTokens = client.estimateTokens(formattedMessages);
const sessionMeta = buildSessionMeta(session);
const loopSignal = detectRageLoopHeuristic(messages);

// Retrieve related insights for RAG context (AutoRefine pattern)
const retrievalConfig = getRetrievalConfig();
Expand Down Expand Up @@ -129,7 +131,7 @@ export async function analyzeSession(
{ role: 'system', content: SHARED_ANALYST_SYSTEM_PROMPT },
{ role: 'user', content: [
buildCacheableConversationBlock(chunkFormatted),
{ type: 'text' as const, text: buildSessionAnalysisInstructions(session.project_name, session.summary, sessionMeta, undefined, relatedInsights) },
{ type: 'text' as const, text: buildSessionAnalysisInstructions(session.project_name, session.summary, sessionMeta, detectRageLoopHeuristic(chunk), relatedInsights) },
] },
], { signal: options?.signal });

Expand Down Expand Up @@ -171,7 +173,7 @@ export async function analyzeSession(
{ role: 'system', content: SHARED_ANALYST_SYSTEM_PROMPT },
{ role: 'user', content: [
buildCacheableConversationBlock(facetMessages),
{ type: 'text' as const, text: buildFacetOnlyInstructions(session.project_name, session.summary, sessionMeta, undefined, relatedInsights) },
{ type: 'text' as const, text: buildFacetOnlyInstructions(session.project_name, session.summary, sessionMeta, loopSignal, relatedInsights) },
] },
], { signal: options?.signal });

Expand Down Expand Up @@ -205,7 +207,7 @@ export async function analyzeSession(
{ role: 'system', content: SHARED_ANALYST_SYSTEM_PROMPT },
{ role: 'user', content: [
buildCacheableConversationBlock(formattedMessages),
{ type: 'text' as const, text: buildSessionAnalysisInstructions(session.project_name, session.summary, sessionMeta, undefined, relatedInsights) },
{ type: 'text' as const, text: buildSessionAnalysisInstructions(session.project_name, session.summary, sessionMeta, loopSignal, relatedInsights) },
] },
], { signal: options?.signal });

Expand Down
3 changes: 2 additions & 1 deletion server/src/llm/facet-extraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type SessionData,
} from './analysis-db.js';
import { getMaxInputTokens, buildSessionMeta } from './analysis-internal.js';
import { detectRageLoopHeuristic } from '@code-insights/cli/analysis/loop-detector';

/**
* Extract facets only for a session that already has insights (backfill).
Expand Down Expand Up @@ -52,7 +53,7 @@ export async function extractFacetsOnly(
{ role: 'system', content: SHARED_ANALYST_SYSTEM_PROMPT },
{ role: 'user', content: [
buildCacheableConversationBlock(formattedMessages),
{ type: 'text' as const, text: buildFacetOnlyInstructions(session.project_name, session.summary, sessionMeta) },
{ type: 'text' as const, text: buildFacetOnlyInstructions(session.project_name, session.summary, sessionMeta, detectRageLoopHeuristic(messages)) },
] },
], { signal: options?.signal });

Expand Down
Loading