Skip to content

Commit d88e5bb

Browse files
Set limit size on file for tool history log (#441)
* fix: set limit size on file for tool history log * fix(toolHistory): rolling trim instead of full wipe at size cap Address review feedback on PR #441. The original size-cap implementation had three issues: 1. flushToDisk wiped this.history = [] AFTER appendFileSync if the append pushed the file over the cap, silently dropping the batch just written. A user calling get_recent_tool_calls right after a tool ran would see an empty history despite the call succeeding. 2. The pre-append branch reset the in-memory cache to the just-written batch, discarding older entries that addCall already bounds via MAX_ENTRIES. The size cap is a *disk* policy, not a memory policy. 3. clearHistoryFileIfTooLarge returned true on truncate failure, so callers wiped in-memory state for an unchanged oversized file. On subsequent flushes the file kept growing while memory kept resetting. Beyond fixing those: switched from 'truncate to zero' to a rolling byte-budget trim. When the file exceeds 5 MiB, drop oldest entries until the kept tail fits within a 4 MiB target (with headroom so a single overflow doesn't cause every subsequent flush to re-trim). Always keeps at least the most recent entry. Smoke-tested against a pre-filled 6.39 MiB file: trims to 4.00 MiB, keeps the newest 688 records, every kept line is valid JSON, and freshly appended records survive both on disk and in memory. A 4.36 MiB file (under cap) is left untouched. --------- Co-authored-by: Eduard Ruzga <wonderwhy.er@gmail.com>
1 parent fc6b143 commit d88e5bb

1 file changed

Lines changed: 71 additions & 2 deletions

File tree

src/utils/toolHistory.ts

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ function formatLocalTimestamp(isoTimestamp: string): string {
3232
class ToolHistory {
3333
private history: ToolCallRecord[] = [];
3434
private readonly MAX_ENTRIES = 1000;
35+
private readonly MAX_HISTORY_FILE_SIZE_BYTES = 5 * 1024 * 1024;
36+
// When the file exceeds the cap we trim it down to this target instead of
37+
// all the way to zero, so a single overflow doesn't cause every subsequent
38+
// flush to re-trim.
39+
private readonly HISTORY_FILE_TRIM_TARGET_BYTES = 4 * 1024 * 1024;
3540
private readonly historyFile: string;
3641
private writeQueue: ToolCallRecord[] = [];
3742
private isWriting = false;
@@ -65,6 +70,10 @@ class ToolHistory {
6570
return;
6671
}
6772

73+
// If the file is over the cap, trim it down before reading so we
74+
// load a bounded amount.
75+
this.trimHistoryFileIfTooLarge();
76+
6877
const content = fs.readFileSync(this.historyFile, 'utf-8');
6978
const lines = content.trim().split('\n').filter(line => line.trim());
7079

@@ -90,6 +99,60 @@ class ToolHistory {
9099
}
91100
}
92101

102+
/**
103+
* Trim the on-disk history file to stay under the size cap by dropping the
104+
* oldest entries (lines) until the kept tail fits within the trim target.
105+
* Returns true only when the file was actually rewritten with a smaller
106+
* tail, so callers can fall through to their normal path on failure or
107+
* no-op rather than mutating in-memory state.
108+
*
109+
* Always keeps at least the most recent entry, even if a single record
110+
* exceeds the trim target — there is no useful state below that.
111+
*/
112+
private trimHistoryFileIfTooLarge(): boolean {
113+
let stats: fs.Stats;
114+
try {
115+
if (!fs.existsSync(this.historyFile)) {
116+
return false;
117+
}
118+
stats = fs.statSync(this.historyFile);
119+
if (stats.size <= this.MAX_HISTORY_FILE_SIZE_BYTES) {
120+
return false;
121+
}
122+
} catch (error) {
123+
return false;
124+
}
125+
126+
try {
127+
const content = fs.readFileSync(this.historyFile, 'utf-8');
128+
const lines = content.split('\n').filter(line => line.length > 0);
129+
if (lines.length === 0) {
130+
return false;
131+
}
132+
133+
// Walk lines from newest to oldest, accumulating bytes (line + '\n'),
134+
// and keep as many as fit within the trim target. Always keep at
135+
// least the last line.
136+
const kept: string[] = [];
137+
let bytes = 0;
138+
for (let i = lines.length - 1; i >= 0; i--) {
139+
const lineBytes = Buffer.byteLength(lines[i], 'utf-8') + 1; // +1 for '\n'
140+
if (kept.length > 0 && bytes + lineBytes > this.HISTORY_FILE_TRIM_TARGET_BYTES) {
141+
break;
142+
}
143+
kept.push(lines[i]);
144+
bytes += lineBytes;
145+
}
146+
kept.reverse();
147+
148+
fs.writeFileSync(this.historyFile, kept.join('\n') + '\n', 'utf-8');
149+
return true;
150+
} catch (error) {
151+
// Trim failed; do not claim the file was changed.
152+
return false;
153+
}
154+
}
155+
93156
/**
94157
* Trim history file to prevent it from growing indefinitely
95158
*/
@@ -125,12 +188,18 @@ class ToolHistory {
125188
*/
126189
private async flushToDisk(): Promise<void> {
127190
if (this.isWriting || this.writeQueue.length === 0) return;
128-
191+
129192
this.isWriting = true;
130193
const toWrite = [...this.writeQueue];
131194
this.writeQueue = [];
132-
195+
133196
try {
197+
// If the on-disk file has grown past the cap, trim it down to the
198+
// target size (keeping the most recent entries) before appending.
199+
// The in-memory cache is unaffected — it is already bounded by
200+
// MAX_ENTRIES via addCall.
201+
this.trimHistoryFileIfTooLarge();
202+
134203
// Append to file (atomic append operation)
135204
const lines = toWrite.map(entry => JSON.stringify(entry)).join('\n') + '\n';
136205
fs.appendFileSync(this.historyFile, lines, 'utf-8');

0 commit comments

Comments
 (0)