Compare commits

...

12 Commits

Author SHA1 Message Date
Christian Gunderman 45e7ed1ed2 feat(core): remove history pruning to restore agent memory and improve efficiency
Analysis of Experiment 2 vs 3 showed that pruning grep_search results
caused the agent to lose context and enter redundant tool loops,
increasing turn counts by ~28%. This change restores full history
retention while keeping the readCache improvements.
2026-02-19 06:17:49 +00:00
Christian Gunderman 85ae41535f iFix errors. 2026-02-18 19:27:02 +00:00
Christian Gunderman 64b3c6d60f Re-enable read-de-dupe. 2026-02-18 18:25:25 +00:00
Christian Gunderman ad83541f7b Narrow investigation. 2026-02-18 18:16:37 +00:00
Christian Gunderman 95377789d5 Tune. 2026-02-18 16:30:24 +00:00
Christian Gunderman 90d4d79f29 Iteration 2026-02-18 07:02:56 +00:00
Christian Gunderman a7457b0156 Drop threshold. 2026-02-18 06:26:19 +00:00
Christian Gunderman 7091cef045 Fix build. 2026-02-17 22:01:44 -08:00
Christian Gunderman 8cb2bfe995 Fixes 2026-02-18 05:45:59 +00:00
Christian Gunderman 5a1f0cbc49 feat(core): Update write_file to return snippets of changed content for modified files 2026-02-17 20:01:56 -08:00
Christian Gunderman 259c4d57b5 feat(core): Include 50 lines of context in grep_search when matches <= 3 2026-02-17 19:36:59 -08:00
Christian Gunderman 047a57dee8 feat(core): Include file content in write_file tool response for immediate verification 2026-02-17 19:31:57 -08:00
9 changed files with 419 additions and 47 deletions
+7 -3
View File
@@ -156,6 +156,11 @@ function validateHistory(history: Content[]) {
* filters or recitation). Extracting valid turns from the history
* ensures that subsequent requests could be accepted by the model.
*/
/**
* Prunes the history to remove large tool outputs (like Greedy Grep context)
* after they have been seen by the model for one turn.
*/
function extractCuratedHistory(comprehensiveHistory: Content[]): Content[] {
if (comprehensiveHistory === undefined || comprehensiveHistory.length === 0) {
return [];
@@ -684,9 +689,8 @@ export class GeminiChat {
* chat session.
*/
getHistory(curated: boolean = false): Content[] {
const history = curated
? extractCuratedHistory(this.history)
: this.history;
const history = curated ? extractCuratedHistory(this.history) : this.history;
// Deep copy the history to avoid mutating the history outside of the
// chat session.
return structuredClone(history);
@@ -5,20 +5,21 @@
*/
import { vi } from 'vitest';
import { EventEmitter } from 'node:events';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { MessageBusType, type Message } from '../confirmation-bus/types.js';
/**
* Mock MessageBus for testing hook execution through MessageBus
*/
export class MockMessageBus {
private subscriptions = new Map<
MessageBusType,
Set<(message: Message) => void>
>();
export class MockMessageBus extends EventEmitter {
publishedMessages: Message[] = [];
defaultToolDecision: 'allow' | 'deny' | 'ask_user' = 'allow';
constructor() {
super();
}
/**
* Mock publish method that captures messages and simulates responses
*/
@@ -52,6 +53,7 @@ export class MockMessageBus {
// Emit the message to subscribers (mimicking real MessageBus behavior)
this.emit(message.type, message);
return Promise.resolve();
});
/**
@@ -59,11 +61,7 @@ export class MockMessageBus {
*/
subscribe = vi.fn(
<T extends Message>(type: T['type'], listener: (message: T) => void) => {
if (!this.subscriptions.has(type)) {
this.subscriptions.set(type, new Set());
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
this.subscriptions.get(type)!.add(listener as (message: Message) => void);
this.on(type, listener);
},
);
@@ -72,30 +70,16 @@ export class MockMessageBus {
*/
unsubscribe = vi.fn(
<T extends Message>(type: T['type'], listener: (message: T) => void) => {
const listeners = this.subscriptions.get(type);
if (listeners) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
listeners.delete(listener as (message: Message) => void);
}
this.off(type, listener);
},
);
/**
* Emit a message to subscribers (for testing)
*/
private emit(type: MessageBusType, message: Message) {
const listeners = this.subscriptions.get(type);
if (listeners) {
listeners.forEach((listener) => listener(message));
}
}
/**
* Clear all captured messages (for test isolation)
*/
clear() {
this.publishedMessages = [];
this.subscriptions.clear();
this.removeAllListeners();
}
}
+58
View File
@@ -410,6 +410,57 @@ interface CalculatedEdit {
matchRanges?: Array<{ start: number; end: number }>;
}
function getDiffContextSnippet(
originalContent: string,
newContent: string,
contextLines = 5,
): string {
if (!originalContent) return newContent;
const changes = Diff.diffLines(originalContent, newContent);
const newLines = newContent.split(/\r?\n/);
const ranges: Array<{ start: number; end: number }> = [];
let newLineIdx = 0;
for (const change of changes) {
if (change.added) {
ranges.push({ start: newLineIdx, end: newLineIdx + (change.count ?? 0) });
newLineIdx += change.count ?? 0;
} else if (change.removed) {
ranges.push({ start: newLineIdx, end: newLineIdx });
} else {
newLineIdx += change.count ?? 0;
}
}
if (ranges.length === 0) return newContent;
const expandedRanges = ranges.map((r) => ({
start: Math.max(0, r.start - contextLines),
end: Math.min(newLines.length, r.end + contextLines),
}));
expandedRanges.sort((a, b) => a.start - b.start);
const mergedRanges: Array<{ start: number; end: number }> = [];
if (expandedRanges.length > 0) {
let current = expandedRanges[0];
for (let i = 1; i < expandedRanges.length; i++) {
const next = expandedRanges[i];
if (next.start <= current.end) {
current.end = Math.max(current.end, next.end);
} else {
mergedRanges.push(current);
current = next;
}
}
mergedRanges.push(current);
}
const outputParts: string[] = [];
let lastEnd = 0;
for (const range of mergedRanges) {
if (range.start > lastEnd) outputParts.push('...');
outputParts.push(newLines.slice(range.start, range.end).join('\n'));
lastEnd = range.end;
}
if (lastEnd < newLines.length) outputParts.push('...');
return outputParts.join('\n');
}
class EditToolInvocation
extends BaseToolInvocation<EditToolParams, ToolResult>
implements ToolInvocation<EditToolParams, ToolResult>
@@ -876,6 +927,13 @@ class EditToolInvocation
? `Created new file: ${this.params.file_path} with provided content.`
: `Successfully modified file: ${this.params.file_path} (${editData.occurrences} replacements).`,
];
const snippet = getDiffContextSnippet(
editData.currentContent ?? '',
finalContent,
5,
);
llmSuccessMessageParts.push(`Here is the updated code:
${snippet}`);
const fuzzyFeedback = getFuzzyMatchFeedback(editData);
if (fuzzyFeedback) {
llmSuccessMessageParts.push(fuzzyFeedback);
+35 -5
View File
@@ -493,10 +493,12 @@ describe('GrepTool', () => {
// sub/fileC.txt has 1 world, so total matches = 2.
expect(result.llmContent).toContain('Found 2 matches');
expect(result.llmContent).toContain('File: fileA.txt');
expect(result.llmContent).toContain('L1: hello world');
expect(result.llmContent).not.toContain('L2: second line with world');
// Should be a match
expect(result.llmContent).toContain('> L1: hello world');
// Should NOT be a match (but might be in context)
expect(result.llmContent).not.toContain('> L2: second line with world');
expect(result.llmContent).toContain('File: sub/fileC.txt');
expect(result.llmContent).toContain('L1: another world in sub dir');
expect(result.llmContent).toContain('> L1: another world in sub dir');
});
it('should return only file paths when names_only is true', async () => {
@@ -530,8 +532,36 @@ describe('GrepTool', () => {
expect(result.llmContent).toContain('Found 1 match');
expect(result.llmContent).toContain('copyright.txt');
expect(result.llmContent).toContain('Copyright 2025 Google LLC');
expect(result.llmContent).not.toContain('Copyright 2026 Google LLC');
// Should be a match
expect(result.llmContent).toContain('> L1: Copyright 2025 Google LLC');
// Should NOT be a match (but might be in context)
expect(result.llmContent).not.toContain(
'> L2: Copyright 2026 Google LLC',
);
});
it('should include context when matches are <= 3', async () => {
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`);
lines[50] = 'Target match';
await fs.writeFile(
path.join(tempRootDir, 'context.txt'),
lines.join('\n'),
);
const params: GrepToolParams = { pattern: 'Target match' };
const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain(
'Found 1 match for pattern "Target match"',
);
expect(result.llmContent).toContain('Match at line 51:');
// Verify context before
expect(result.llmContent).toContain(' L40: Line 40');
// Verify match line
expect(result.llmContent).toContain('> L51: Target match');
// Verify context after
expect(result.llmContent).toContain(' L60: Line 60');
});
});
+58 -7
View File
@@ -75,6 +75,7 @@ export interface GrepToolParams {
*/
interface GrepMatch {
filePath: string;
absolutePath: string;
lineNumber: number;
line: string;
}
@@ -130,6 +131,7 @@ class GrepToolInvocation extends BaseToolInvocation<
return {
filePath: relativeFilePath || path.basename(absoluteFilePath),
absolutePath: absoluteFilePath,
lineNumber,
line: lineContent,
};
@@ -309,14 +311,62 @@ class GrepToolInvocation extends BaseToolInvocation<
llmContent += `:\n---\n`;
for (const filePath in matchesByFile) {
llmContent += `File: ${filePath}
if (matchCount >= 1 && matchCount <= 3 && !this.params.names_only) {
const contextBudget = matchCount === 1 ? 50 : 15;
for (const filePath in matchesByFile) {
const fileMatches = matchesByFile[filePath];
let fileLines: string[] | null = null;
try {
const content = await fsPromises.readFile(
fileMatches[0].absolutePath,
'utf8',
);
fileLines = content.split(/\r?\n/);
} catch (err) {
debugLogger.warn(
`Failed to read file for context: ${fileMatches[0].absolutePath}`,
err,
);
}
llmContent += `File: ${filePath}\n`;
for (const match of fileMatches) {
if (fileLines) {
const startLine = Math.max(0, match.lineNumber - 1 - contextBudget);
const endLine = Math.min(
fileLines.length,
match.lineNumber - 1 + contextBudget + 1,
);
const contextLines = fileLines.slice(startLine, endLine);
llmContent += `Match at line ${match.lineNumber}:\n`;
contextLines.forEach((line, index) => {
const currentLineNumber = startLine + index + 1;
if (currentLineNumber === match.lineNumber) {
llmContent += `> L${currentLineNumber}: ${line}\n`;
} else {
llmContent += ` L${currentLineNumber}: ${line}\n`;
}
});
llmContent += '\n';
} else {
const trimmedLine = match.line.trim();
llmContent += `L${match.lineNumber}: ${trimmedLine}\n`;
}
}
llmContent += '---\n';
}
} else {
for (const filePath in matchesByFile) {
llmContent += `File: ${filePath}
`;
matchesByFile[filePath].forEach((match) => {
const trimmedLine = match.line.trim();
llmContent += `L${match.lineNumber}: ${trimmedLine}\n`;
});
llmContent += '---\n';
matchesByFile[filePath].forEach((match) => {
const trimmedLine = match.line.trim();
llmContent += `L${match.lineNumber}: ${trimmedLine}\n`;
});
llmContent += '---\n';
}
}
return {
@@ -569,6 +619,7 @@ class GrepToolInvocation extends BaseToolInvocation<
filePath:
path.relative(absolutePath, fileAbsolutePath) ||
path.basename(fileAbsolutePath),
absolutePath: fileAbsolutePath,
lineNumber: index + 1,
line,
});
+36 -6
View File
@@ -160,6 +160,9 @@ ${result.llmContent}`;
/**
* Implementation of the ReadFile tool logic
*/
const readCache = new Map<string, ToolResult>();
export class ReadFileTool extends BaseDeclarativeTool<
ReadFileToolParams,
ToolResult
@@ -185,6 +188,12 @@ export class ReadFileTool extends BaseDeclarativeTool<
config.getTargetDir(),
config.getFileFilteringOptions(),
);
// Clear cache on any potential file modification
messageBus.on('tool_call_confirm', (details) => {
if (details.type === 'edit' || details.title.toLowerCase().includes('write')) {
readCache.clear();
}
});
}
protected override validateToolParamValues(
@@ -233,13 +242,34 @@ export class ReadFileTool extends BaseDeclarativeTool<
_toolName?: string,
_toolDisplayName?: string,
): ToolInvocation<ReadFileToolParams, ToolResult> {
return new ReadFileToolInvocation(
this.config,
const key = JSON.stringify({
path: path.resolve(this.config.getTargetDir(), params.file_path),
offset: params.offset,
limit: params.limit,
});
return {
toolLocations: () => [{ path: path.resolve(this.config.getTargetDir(), params.file_path), line: params.offset }],
getDescription: () => shortenPath(makeRelative(path.resolve(this.config.getTargetDir(), params.file_path), this.config.getTargetDir())),
params,
messageBus,
_toolName,
_toolDisplayName,
);
execute: async () => {
if (readCache.has(key)) {
return readCache.get(key)!;
}
const invocation = new ReadFileToolInvocation(
this.config,
params,
messageBus,
_toolName,
_toolDisplayName,
);
const result = await invocation.execute();
if (!result.error) {
readCache.set(key, result);
}
return result;
},
} as unknown as ToolInvocation<ReadFileToolParams, ToolResult>;
}
override getSchema(modelId?: string) {
+64
View File
@@ -160,6 +160,7 @@ export interface RipGrepToolParams {
*/
interface GrepMatch {
filePath: string;
absolutePath: string;
lineNumber: number;
line: string;
isContext?: boolean;
@@ -310,6 +311,68 @@ class GrepToolInvocation extends BaseToolInvocation<
const matchCount = matchesOnly.length;
const matchTerm = matchCount === 1 ? 'match' : 'matches';
// Greedy Grep: If match count is low and no context was requested, automatically return context.
if (
matchCount >= 1 &&
matchCount <= 3 &&
!this.params.names_only &&
!this.params.context &&
!this.params.before &&
!this.params.after
) {
const contextLines = matchCount === 1 ? 50 : 15;
for (const filePath in matchesByFile) {
const fileMatches = matchesByFile[filePath];
let fileLines: string[] | null = null;
try {
const content = await fsPromises.readFile(
fileMatches[0].absolutePath,
'utf8',
);
fileLines = content.split(/\r?\n/);
} catch (err) {
debugLogger.warn(
`Failed to read file for context: ${fileMatches[0].absolutePath}`,
err,
);
}
if (fileLines) {
const newFileMatches: GrepMatch[] = [];
const seenLines = new Set<number>();
for (const match of fileMatches) {
const startLine = Math.max(0, match.lineNumber - 1 - contextLines);
const endLine = Math.min(
fileLines.length,
match.lineNumber - 1 + contextLines + 1,
);
for (let i = startLine; i < endLine; i++) {
if (!seenLines.has(i + 1)) {
newFileMatches.push({
absolutePath: match.absolutePath,
filePath: match.filePath,
lineNumber: i + 1,
line: fileLines[i],
isContext: i + 1 !== match.lineNumber,
});
seenLines.add(i + 1);
} else if (i + 1 === match.lineNumber) {
const index = newFileMatches.findIndex(
(m) => m.lineNumber === i + 1,
);
if (index !== -1) {
newFileMatches[index].isContext = false;
}
}
}
}
matchesByFile[filePath] = newFileMatches.sort(
(a, b) => a.lineNumber - b.lineNumber,
);
}
}
}
const wasTruncated = matchCount >= totalMaxMatches;
if (this.params.names_only) {
@@ -500,6 +563,7 @@ class GrepToolInvocation extends BaseToolInvocation<
const relativeFilePath = path.relative(basePath, absoluteFilePath);
return {
absolutePath: absoluteFilePath,
filePath: relativeFilePath || path.basename(absoluteFilePath),
lineNumber: data.line_number,
line: data.lines.text.trimEnd(),
@@ -831,6 +831,63 @@ describe('WriteFileTool', () => {
}
},
);
it('should include the file content in llmContent', async () => {
const filePath = path.join(rootDir, 'content_check.txt');
const content = 'This is the content that should be returned.';
mockEnsureCorrectFileContent.mockResolvedValue(content);
const params = { file_path: filePath, content };
const invocation = tool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain('Here is the updated code:');
expect(result.llmContent).toContain(content);
});
it('should return only changed lines plus context for large updates', async () => {
const filePath = path.join(rootDir, 'large_update.txt');
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`);
const originalContent = lines.join('\n');
fs.writeFileSync(filePath, originalContent, 'utf8');
const newLines = [...lines];
newLines[50] = 'Line 51 Modified'; // Modify one line in the middle
const newContent = newLines.join('\n');
mockEnsureCorrectEdit.mockResolvedValue({
params: {
file_path: filePath,
old_string: originalContent,
new_string: newContent,
},
occurrences: 1,
});
const params = { file_path: filePath, content: newContent };
const invocation = tool.build(params);
// Confirm execution first
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal);
if (confirmDetails && 'onConfirm' in confirmDetails) {
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
}
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain('Here is the updated code:');
// Should contain the modified line
expect(result.llmContent).toContain('Line 51 Modified');
// Should contain context lines (e.g. Line 46, Line 56)
expect(result.llmContent).toContain('Line 46');
expect(result.llmContent).toContain('Line 56');
// Should NOT contain far away lines (e.g. Line 1, Line 100)
expect(result.llmContent).not.toContain('Line 1\n');
expect(result.llmContent).not.toContain('Line 100');
// Should indicate truncation
expect(result.llmContent).toContain('...');
});
});
describe('workspace boundary validation', () => {
+94
View File
@@ -147,6 +147,93 @@ export async function getCorrectedFileContent(
return { originalContent, correctedContent, fileExists };
}
function getDiffContextSnippet(
originalContent: string,
newContent: string,
contextLines = 5,
): string {
// If no original content, return the whole new content
if (!originalContent) {
return newContent;
}
const changes = Diff.diffLines(originalContent, newContent);
const newLines = newContent.split(/\r?\n/);
const ranges: Array<{ start: number; end: number }> = [];
let newLineIdx = 0;
for (const change of changes) {
if (change.added) {
// For added lines, the range covers the new lines
ranges.push({
start: newLineIdx,
end: newLineIdx + change.count,
});
newLineIdx += change.count;
} else if (change.removed) {
// For removed lines, we mark the point in the new file where they were removed
ranges.push({
start: newLineIdx,
end: newLineIdx,
});
// Removed lines don't advance the new file index
} else {
// Unchanged lines advance the index
newLineIdx += change.count;
}
}
// If no changes were detected (e.g. only whitespace if diffLines is loose, or identical),
// but we are here, we might just return the whole thing or say "no changes".
// However, write_file implies we wrote something. If identical, ranges is empty.
if (ranges.length === 0) {
return newContent;
}
// Expand ranges and clamp
const expandedRanges = ranges.map((r) => ({
start: Math.max(0, r.start - contextLines),
end: Math.min(newLines.length, r.end + contextLines),
}));
// Merge overlapping ranges
expandedRanges.sort((a, b) => a.start - b.start);
const mergedRanges: Array<{ start: number; end: number }> = [];
if (expandedRanges.length > 0) {
let current = expandedRanges[0];
for (let i = 1; i < expandedRanges.length; i++) {
const next = expandedRanges[i];
if (next.start <= current.end) {
current.end = Math.max(current.end, next.end);
} else {
mergedRanges.push(current);
current = next;
}
}
mergedRanges.push(current);
}
// Build output
const outputParts: string[] = [];
let lastEnd = 0;
for (const range of mergedRanges) {
if (range.start > lastEnd) {
outputParts.push('...');
}
// Slice is exclusive on end, but our 'end' is usually "line index + count", which works for slice.
// However, for single lines or correct inclusivity, let's verify.
// change.count is number of lines. index 0 + 1 line -> index 1. slice(0, 1) returns line 0. Correct.
outputParts.push(newLines.slice(range.start, range.end).join('\n'));
lastEnd = range.end;
}
if (lastEnd < newLines.length) {
outputParts.push('...');
}
return outputParts.join('\n');
}
class WriteFileToolInvocation extends BaseToolInvocation<
WriteFileToolParams,
ToolResult
@@ -356,6 +443,13 @@ class WriteFileToolInvocation extends BaseToolInvocation<
);
}
const snippet = getDiffContextSnippet(
isNewFile ? '' : originalContent,
finalContent,
5,
);
llmSuccessMessageParts.push(`Here is the updated code:\n${snippet}`);
// Log file operation for telemetry (without diff_stat to avoid double-counting)
const mimetype = getSpecificMimeType(this.resolvedPath);
const programmingLanguage = getLanguageFromFilePath(this.resolvedPath);