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
69 changes: 69 additions & 0 deletions src/agent/__tests__/investigation-orchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@
// Create mock LLM that returns appropriate responses based on call order
// The order is: triage -> hypothesis generation -> evidence eval (repeat) -> conclusion -> remediation
mockLLM = {
complete: vi.fn().mockImplementation(async (prompt: string) => {

Check warning on line 129 in src/agent/__tests__/investigation-orchestrator.test.ts

View workflow job for this annotation

GitHub Actions / Test

'prompt' is defined but never used. Allowed unused args must match /^_/u
llmCallIndex++;

// First call is triage
Expand Down Expand Up @@ -161,7 +161,7 @@

// Create mock tool executor
mockToolExecutor = {
execute: vi.fn().mockImplementation(async (tool: string, params: Record<string, unknown>) => {

Check warning on line 164 in src/agent/__tests__/investigation-orchestrator.test.ts

View workflow job for this annotation

GitHub Actions / Test

'params' is defined but never used. Allowed unused args must match /^_/u
if (tool === 'cloudwatch_alarms') {
return [{ alarmName: 'HighLatency', state: 'ALARM' }];
}
Expand Down Expand Up @@ -746,6 +746,75 @@
expect(remediationPrompt).toContain('Available Code Fix Candidates');
expect(remediationPrompt).toContain('https://github.com/acme/platform/pull/42');
});

it('should truncate oversized tool payloads before composing triage and evidence prompts', async () => {
let callIndex = 0;
const prompts: string[] = [];
const hugePayload = 'X'.repeat(10000);

const complete = vi.fn().mockImplementation(async (prompt: string) => {
prompts.push(prompt);
callIndex++;
if (callIndex === 1) return mockTriageResponse;
if (callIndex === 2) return mockHypothesisResponse;
if (callIndex === 3) return mockEvidenceEvaluationConfirm;
if (callIndex === 4) return mockConclusionResponse;
if (callIndex === 5) return mockRemediationResponse;
return mockEvidenceEvaluationPrune;
});
const llm: LLMClient = { complete };

const execute = vi.fn().mockImplementation(async (tool: string) => {
if (tool === 'search_knowledge') {
return {
documentCount: 1,
runbooks: [{ title: 'Large Runbook', content: hugePayload }],
};
}
if (tool === 'cloudwatch_alarms') {
return [{ alarmName: 'HighLatency', stateValue: 'ALARM', description: hugePayload }];
}
if (tool === 'cloudwatch_logs') {
return [{ message: hugePayload }];
}
if (tool === 'datadog') {
return { triggeredMonitors: [{ name: 'Latency Monitor', details: hugePayload }] };
}
if (tool === 'aws_query') {
return {
totalResources: 1,
results: {
lambda: {
count: 1,
resources: [{ id: 'fn_1', raw: { details: hugePayload } }],
},
},
};
}
return { success: true };
});
const toolExecutor: ToolExecutor = { execute };

const orchestrator = createOrchestrator(llm, toolExecutor, {
availableTools: [
'search_knowledge',
'cloudwatch_alarms',
'cloudwatch_logs',
'datadog',
'aws_query',
],
});

await orchestrator.investigate('Investigate prompt truncation behavior');

const triagePrompt = prompts[0] || '';
const evidencePrompt = prompts[2] || '';

expect(triagePrompt).toContain('truncated');
expect(evidencePrompt).toContain('truncated');
expect(triagePrompt.length).toBeLessThan(12000);
expect(evidencePrompt.length).toBeLessThan(15000);
});
});

describe('log analysis', () => {
Expand Down
107 changes: 104 additions & 3 deletions src/agent/investigation-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,30 @@ export interface InvestigationOptions {
fetchRelevantRunbooks?: (context: RemediationContext) => Promise<string[]>;
}

interface PromptPayloadLimits {
maxChars: number;
maxDepth: number;
maxArrayItems: number;
maxObjectKeys: number;
maxStringLength: number;
}

const TRIAGE_PROMPT_LIMITS: PromptPayloadLimits = {
maxChars: 2800,
maxDepth: 4,
maxArrayItems: 12,
maxObjectKeys: 20,
maxStringLength: 320,
};

const EVIDENCE_PROMPT_LIMITS: PromptPayloadLimits = {
maxChars: 3600,
maxDepth: 5,
maxArrayItems: 15,
maxObjectKeys: 25,
maxStringLength: 400,
};

/**
* Investigation result
*/
Expand Down Expand Up @@ -628,6 +652,77 @@ export class InvestigationOrchestrator {
return formatted;
}

private normalizePromptPayload(
value: unknown,
limits: PromptPayloadLimits,
depth: number = 0
): unknown {
if (value === null || value === undefined) {
return value;
}

if (typeof value === 'string') {
if (value.length <= limits.maxStringLength) {
return value;
}
const remaining = value.length - limits.maxStringLength;
return `${value.slice(0, limits.maxStringLength)}... [${remaining} chars truncated]`;
}

if (typeof value === 'number' || typeof value === 'boolean') {
return value;
}

if (depth >= limits.maxDepth) {
return '[truncated: max depth reached]';
}

if (Array.isArray(value)) {
const normalizedItems = value
.slice(0, limits.maxArrayItems)
.map((item) => this.normalizePromptPayload(item, limits, depth + 1));
if (value.length > limits.maxArrayItems) {
normalizedItems.push(`[... ${value.length - limits.maxArrayItems} more items omitted]`);
}
return normalizedItems;
}

if (typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>);
const normalized: Record<string, unknown> = {};

for (const [key, entryValue] of entries.slice(0, limits.maxObjectKeys)) {
normalized[key] = this.normalizePromptPayload(entryValue, limits, depth + 1);
}

if (entries.length > limits.maxObjectKeys) {
normalized._truncatedKeys = `${entries.length - limits.maxObjectKeys} keys omitted`;
}

return normalized;
}

return String(value);
}

private summarizePromptPayload(value: unknown, limits: PromptPayloadLimits): string {
const normalized = this.normalizePromptPayload(value, limits);
let serialized: string;

try {
serialized = JSON.stringify(normalized, null, 2);
} catch {
serialized = String(value);
}

if (serialized.length <= limits.maxChars) {
return serialized;
}

const remaining = serialized.length - limits.maxChars;
return `${serialized.slice(0, limits.maxChars)}\n... [${remaining} chars truncated]`;
}

private getQueryExecutionConcurrency(totalQueries: number): number {
const configured = this.options.queryExecutionConcurrency ?? 3;
if (!Number.isFinite(configured)) {
Expand Down Expand Up @@ -829,7 +924,9 @@ export class InvestigationOrchestrator {
const result = await this.toolExecutor.execute(source.tool, source.params);
this.emit({ type: 'query_complete', query: triageQuery, result });
if (result && typeof result === 'object' && !(result as Record<string, unknown>).error) {
contextParts.push(`${source.label}: ${JSON.stringify(result)}`);
contextParts.push(
`${source.label}: ${this.summarizePromptPayload(result, TRIAGE_PROMPT_LIMITS)}`
);
break;
}
} catch (error) {
Expand Down Expand Up @@ -885,7 +982,9 @@ export class InvestigationOrchestrator {
this.emit({ type: 'query_complete', query: triageQuery, result });
this.updateCloudWatchHints(source.tool, result, source.params);
if (result) {
contextParts.push(`${source.label}: ${JSON.stringify(result)}`);
contextParts.push(
`${source.label}: ${this.summarizePromptPayload(result, TRIAGE_PROMPT_LIMITS)}`
);
if (this.hasMeaningfulTriageSignal(source.tool, result)) {
break;
}
Expand Down Expand Up @@ -1058,7 +1157,9 @@ export class InvestigationOrchestrator {
// Format evidence for LLM
const evidenceLines: string[] = [];
for (const [queryId, result] of queryResults) {
evidenceLines.push(`Query ${queryId}:\n${JSON.stringify(result, null, 2)}`);
evidenceLines.push(
`Query ${queryId}:\n${this.summarizePromptPayload(result, EVIDENCE_PROMPT_LIMITS)}`
);
}

const prompt = fillPrompt(PROMPTS.evaluateEvidence, {
Expand Down
Loading