Skip to content
Merged
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
11 changes: 8 additions & 3 deletions src/engine/answerGenerator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CopilotAnswer, CopilotAction, CopilotReferences, ToolResult, LlmClient, Correlation, Anomaly, MetricReference, LogReference, MetricExpression, LogExpression, QueryScope } from "../types.js";
import { CopilotAnswer, CopilotAction, CopilotReferences, ToolResult, LlmClient, LlmMessage, Correlation, Anomaly, MetricReference, LogReference, MetricExpression, LogExpression, QueryScope } from "../types.js";
import { buildFinalAnswerPrompt } from "../prompts.js";
import { HandlerUtils } from "./handlers/utils.js";
import { CorrelationDetector } from "./correlationDetector.js";
Expand All @@ -17,6 +17,7 @@ export async function synthesizeCopilotAnswer(
results: ToolResult[],
chatId: string,
llm: LlmClient,
conversationHistory: LlmMessage[] = [],
): Promise<CopilotAnswer> {
const fallback = createFallbackAnswer(question, results, chatId);
if (!results.length) return fallback;
Expand All @@ -32,8 +33,12 @@ export async function synthesizeCopilotAnswer(
// Create a comprehensive prompt for the LLM, including insights
const prompt = createSynthesisPrompt(question, results, correlations, anomalies);

// Get LLM analysis
const response = await llm.chat([{ role: "user", content: prompt }], []);
// Get LLM analysis (include conversation history for multi-turn context)
const messages: LlmMessage[] = [
...conversationHistory,
{ role: "user", content: prompt },
];
const response = await llm.chat(messages, []);

if (!response || !response.content) {
console.warn(`[Copilot][${chatId}] LLM synthesis failed, using fallback`);
Expand Down
27 changes: 11 additions & 16 deletions src/engine/contextManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,40 +90,35 @@ export function fitMessagesInContext(
return messages; // Fits, no truncation needed
}

// Separate messages by type
// Separate system messages (always kept, highest priority)
const systemMessages = messages.filter((m) => m.role === "system");
const userMessages = messages.filter((m) => m.role === "user");
const nonSystemMessages = messages.filter((m) => m.role !== "system");

// Always keep system messages (highest priority)
// Always keep system messages
const result: LlmMessage[] = [...systemMessages];
let budget =
config.maxContextTokens -
systemMessages.reduce((sum, msg) => sum + estimateTokens(msg.content), 0);

// Keep most recent user message (critical for context)
const lastUserMessage = userMessages[userMessages.length - 1];
if (lastUserMessage) {
result.push(lastUserMessage);
budget -= estimateTokens(lastUserMessage.content);
}

// Add recent messages with remaining budget
const recentMessages = messages
.slice(-3)
.filter((m) => m.role !== "system" && m !== lastUserMessage);
// Keep most recent messages first (regardless of role: user, assistant, tool)
// Work backwards from most recent to fill budget
const reversedNonSystem = [...nonSystemMessages].reverse();

for (const msg of recentMessages) {
for (const msg of reversedNonSystem) {
const tokens = estimateTokens(msg.content);
if (tokens <= budget) {
result.push(msg);
budget -= tokens;
} else if (budget > 100) {
// Truncate this message to fit
// Truncate this message to fit remaining budget
const truncated = {
...msg,
content: truncateToTokens(msg.content, budget),
};
result.push(truncated);
budget = 0;
break;
} else {
break;
}
}
Expand Down
71 changes: 62 additions & 9 deletions src/engine/copilotEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,12 +417,16 @@ export class CopilotEngine {
}

// Validate and fix calls (ensures required args like start/end/step are present for metrics)
plannedCalls = await this.planRefiner.refineCalls(
plannedCalls,
this.mcp.getTools(),
conversationTurns,
allResults,
);
// Note: On first iteration, refineCalls is already called inside applyHeuristics,
// so we only need to call it explicitly on follow-up iterations.
if (!isFirstIteration) {
plannedCalls = await this.planRefiner.refineCalls(
plannedCalls,
this.mcp.getTools(),
conversationTurns,
allResults,
);
}

// Limit calls
plannedCalls = this.limitToolCalls(plannedCalls);
Expand Down Expand Up @@ -461,11 +465,59 @@ export class CopilotEngine {

// D. Accumulate
allResults.push(...results);

// D2. Run follow-up heuristics after first iteration results too
// (Previously follow-ups only ran on iteration ≥2, missing orchestration plan lookups
// when problems were detected on the first tool execution)
let iterationResultsForEntityExtraction = results;

if (isFirstIteration && results.length > 0) {
const formattedForFollowUp = this.formatResultsForPlanning(allResults);
const availableToolNames = new Set(this.mcp.getTools().map((t) => t.name));
const firstIterationFollowUps = (await this.followUpEngine.applyFollowUps(
formattedForFollowUp,
chatId,
conversationTurns,
questionForPlanning,
plannedCalls,
)).filter((c) => availableToolNames.has(c.name));

// 1. Refine the follow-ups to fill missing/default arguments (like start/end times)
const refinedFollowUps = await this.planRefiner.refineCalls(
firstIterationFollowUps,
this.mcp.getTools(),
conversationTurns,
allResults,
);

// 2. Ensure total executed calls do not exceed the per-iteration limit
const remainingCapacity = Math.max(0, MAX_TOOL_CALLS_PER_ITERATION - plannedCalls.length);
const limitedFollowUps = refinedFollowUps.slice(0, remainingCapacity);

if (limitedFollowUps.length > 0) {
console.log(
`[Copilot][${chatId}] First-iteration follow-ups (refined/limited): ${limitedFollowUps.length} call(s): ${limitedFollowUps.map((c) => c.name).join(", ")}`,
);

const followUpResults = await this.runToolCallsWithCache(
limitedFollowUps,
chatId,
Comment thread
yusufaytas marked this conversation as resolved.
this.mcp.getTools(),
trace,
);
Comment thread
yusufaytas marked this conversation as resolved.
allResults.push(...followUpResults);
iterationResultsForEntityExtraction = [
...results,
...followUpResults,
];
}
}

isFirstIteration = false;

// E. Extract entities from results
const extractedEntities = await this.entityExtractor.extractFromResults(
results,
iterationResultsForEntityExtraction,
chatId,
conversationTurns,
);
Expand Down Expand Up @@ -537,12 +589,13 @@ export class CopilotEngine {
);
}

// Step 5: Synthesize final answer
// Step 5: Synthesize final answer (use resolved question and conversation history for context)
const answer = await synthesizeCopilotAnswer(
question,
questionForPlanning,
allResults,
chatId,
this.config.llm,
conversationHistory,
);

// If calls were planned but no results were collected, tool calls were likely
Expand Down
23 changes: 4 additions & 19 deletions src/engine/followUpEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,47 +221,32 @@ export class FollowUpEngine {
"active",
"investigating",
]);
const resolvedStatuses = new Set([
"resolved",
"closed",
"mitigated",
"remediated",
]);

for (const record of this.collectRecords(result)) {
const status = record.status;
if (typeof status === "string") {
const normalized = status.toLowerCase();
if (activeStatuses.has(normalized)) return true;
if (resolvedStatuses.has(normalized)) continue;
return true;
}
if (record.id) {
return true;
// Unknown or resolved statuses are not considered active
}
// Records without a status are not considered active
}

return false;
}

private hasActiveAlert(result: JsonValue): boolean {
const activeStatuses = new Set(["firing", "acknowledged", "triggered", "open"]);
const resolvedStatuses = new Set(["resolved", "closed", "cleared"]);
for (const record of this.collectRecords(result)) {
const status = record.status;
if (typeof status === "string") {
const normalized = status.toLowerCase();
if (activeStatuses.has(normalized)) {
return true;
}
if (resolvedStatuses.has(normalized)) {
continue;
}
return true;
}
if (record.id) {
return true;
// Unknown or resolved statuses are not considered active
}
// Records without a status are not considered active
}
return false;
}
Expand Down
94 changes: 43 additions & 51 deletions src/engine/referenceBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,61 +393,53 @@ export function buildReferences(
}
}

let query: string | undefined;

// MCP logQuerySchema: expression is object with search field
if (args.expression && typeof args.expression === "object" && !Array.isArray(args.expression)) {
const expr = args.expression as JsonObject;
// MCP logExpressionSchema: search is z.string().optional()
if (typeof expr.search === "string") query = expr.search;

// If we have filters but no search string, use "Filtered Logs" or similar placeholder
// or just allow empty search. The Reference structure allows undefined search if we change types,
// but let's check strict types. LogReference.expression.search is optional in types.ts?
// Let's check types again.
// types.ts: export interface LogExpression { search?: string; ... }
// So we can create a LogReference without search string if filters exist.
}

// Check if we have enough to make a reference (search OR filters)
const hasSearch = typeof query === "string";
const exprObj = (args.expression as JsonObject) || {};
const hasFilters = Array.isArray(exprObj.filters) && exprObj.filters.length > 0;

if (hasSearch || hasFilters) {
// Construct a simple LogReference
const log: LogReference = {
expression: {},
};
if (hasSearch) log.expression.search = query;

// Copy filters if present
if (hasFilters) {
// We can blindly copy filters if structure matches, or leave emptiness.
// Ideally we copy them to be accurate.
// defined in types as: filters?: { field: string; operator: string; value: string }[];
// Let's copy them correctly.
log.expression.filters = exprObj.filters as { field: string; operator: string; value: string }[];
// Only extract log references from log tools
if (capabilityType === "log") {
let query: string | undefined;

// MCP logQuerySchema: expression is object with search field
if (args.expression && typeof args.expression === "object" && !Array.isArray(args.expression)) {
const expr = args.expression as JsonObject;
// MCP logExpressionSchema: search is z.string().optional()
if (typeof expr.search === "string") query = expr.search;
}

if (typeof args.start === "string" && args.start.trim())
log.start = args.start.trim();
if (typeof args.end === "string" && args.end.trim())
log.end = args.end.trim();
if (typeof args.service === "string" && args.service.trim())
log.scope = { service: args.service.trim() };
// Check if we have enough to make a reference (search OR filters)
const hasSearch = typeof query === "string";
const exprObj = (args.expression as JsonObject) || {};
const hasFilters = Array.isArray(exprObj.filters) && exprObj.filters.length > 0;

// Handle object scope (MCP uses scope object)
if (
args.scope &&
typeof args.scope === "object" &&
!Array.isArray(args.scope) &&
(args.scope as JsonObject).service
) {
log.scope = log.scope ?? { service: (args.scope as JsonObject).service as string };
}
if (hasSearch || hasFilters) {
// Construct a simple LogReference
const log: LogReference = {
expression: {},
};
if (hasSearch) log.expression.search = query;

// Copy filters if present
if (hasFilters) {
log.expression.filters = exprObj.filters as { field: string; operator: string; value: string }[];
}

if (typeof args.start === "string" && args.start.trim())
log.start = args.start.trim();
if (typeof args.end === "string" && args.end.trim())
log.end = args.end.trim();
if (typeof args.service === "string" && args.service.trim())
log.scope = { service: args.service.trim() };

// Handle object scope (MCP uses scope object)
if (
args.scope &&
typeof args.scope === "object" &&
!Array.isArray(args.scope) &&
(args.scope as JsonObject).service
) {
log.scope = log.scope ?? { service: (args.scope as JsonObject).service as string };
}

logs.push(log);
logs.push(log);
}
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/engine/referenceResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ export class ReferenceResolver {
conversationHistory?: ConversationTurn[],
): Promise<Map<string, string>> {
const resolutions = new Map<string, string>();
const normalized = question.toLowerCase();

// Extract potential reference patterns from the question
const referencePatterns = this.extractReferencePatterns(normalized);
// Extract potential reference patterns from the original question
// (handlers receive original casing; replacement uses case-insensitive regex)
const referencePatterns = this.extractReferencePatterns(question);

for (const { text, entityType } of referencePatterns) {
if (this.referenceRegistry.hasHandlers(entityType)) {
Expand Down
13 changes: 12 additions & 1 deletion src/engine/resultCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,20 @@ export class ResultCache {
set(call: ToolCall, result: ToolResult, scopeKey: string = "global"): void {
const key = createScopedCacheKey(scopeKey, call);

// Remove existing entry if present
// Remove existing exact-key entry if present
if (this.cache.has(key)) {
this.accessOrder = this.accessOrder.filter((k) => k !== key);
} else {
// Also remove any fuzzy-matched entry under a different key to prevent duplicates
for (const [existingKey, candidate] of this.cache.entries()) {
if (candidate.scopeKey !== scopeKey) continue;
if (candidate.call.name !== call.name) continue;
if (isFuzzyEqual(call.arguments || {}, candidate.call.arguments || {})) {
this.cache.delete(existingKey);
this.accessOrder = this.accessOrder.filter((k) => k !== existingKey);
break;
}
}
}

// Evict oldest entry if cache is full
Expand Down
Loading
Loading