@gajae-code/agent-core 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.4] - 2026-06-27
6
+
7
+ ### Added
8
+
9
+ - Added `pruneAssistantToolArguments`: an isolated pre-compaction pruning pass that redacts stale `edit`/`write`/`apply_patch`/`ast_edit` tool-call argument payloads only when every touched path group has a later successful mutation, preserving tool-call identity (id/name/customWireName/signatures/intent/path hints), protecting latest/failed/ambiguous calls, and reporting separate stats from tool-result pruning. Reduces pre-compaction context pressure and the resident footprint of superseded large edit arguments.
10
+
5
11
  ## [0.7.3] - 2026-06-25
6
12
  ### Added
7
13
 
@@ -34,4 +34,14 @@ export interface PruneResult {
34
34
  */
35
35
  prunedEntries: SessionMessageEntry[];
36
36
  }
37
+ export interface AssistantArgumentPruneResult {
38
+ argumentPrunedCount: number;
39
+ argumentTokensSaved: number;
40
+ /**
41
+ * The mutated assistant message entries. Callers whose entry source returns
42
+ * materialized copies must write these back into their canonical store by id.
43
+ */
44
+ prunedEntries: SessionMessageEntry[];
45
+ }
46
+ export declare function pruneAssistantToolArguments(entries: SessionEntry[], config?: PruneConfig): AssistantArgumentPruneResult;
37
47
  export declare function pruneToolOutputs(entries: SessionEntry[], config?: PruneConfig): PruneResult;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/agent-core",
4
- "version": "0.7.3",
4
+ "version": "0.7.4",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://gaebal-gajae.dev",
7
7
  "author": "Yeachan-Heo",
@@ -35,9 +35,9 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@gajae-code/ai": "0.7.3",
39
- "@gajae-code/natives": "0.7.3",
40
- "@gajae-code/utils": "0.7.3",
38
+ "@gajae-code/ai": "0.7.4",
39
+ "@gajae-code/natives": "0.7.4",
40
+ "@gajae-code/utils": "0.7.4",
41
41
  "@opentelemetry/api": "^1.9.0"
42
42
  },
43
43
  "devDependencies": {
@@ -126,6 +126,24 @@ function estimatePrunedSavings(tokens: number, notice: string): number {
126
126
  return Math.max(0, tokens - noticeTokens);
127
127
  }
128
128
 
129
+ export interface AssistantArgumentPruneResult {
130
+ argumentPrunedCount: number;
131
+ argumentTokensSaved: number;
132
+ /**
133
+ * The mutated assistant message entries. Callers whose entry source returns
134
+ * materialized copies must write these back into their canonical store by id.
135
+ */
136
+ prunedEntries: SessionMessageEntry[];
137
+ }
138
+
139
+ interface PrunedToolArgumentsSentinel {
140
+ pruned: true;
141
+ reason: "stale_tool_arguments";
142
+ pathHints: string[];
143
+ originalChars: number;
144
+ prunedAt: number;
145
+ }
146
+
129
147
  const EDIT_TOOL_NAMES = new Set(["edit", "write", "apply_patch", "ast_edit"]);
130
148
 
131
149
  /** Extract the file-path argument from a tool call, when the tool has one. */
@@ -170,6 +188,68 @@ function editToolPathGroups(call: ToolCall): string[][] {
170
188
  }
171
189
  return groups;
172
190
  }
191
+ function pathGroupKey(group: string[]): string {
192
+ return JSON.stringify([...group].sort());
193
+ }
194
+
195
+ function pathHintsForGroups(groups: string[][]): string[] {
196
+ return [...new Set(groups.flat())].sort();
197
+ }
198
+
199
+ function isPrunedToolArgumentsSentinel(value: unknown): value is PrunedToolArgumentsSentinel {
200
+ return (
201
+ typeof value === "object" &&
202
+ value !== null &&
203
+ (value as { pruned?: unknown; reason?: unknown }).pruned === true &&
204
+ (value as { pruned?: unknown; reason?: unknown }).reason === "stale_tool_arguments"
205
+ );
206
+ }
207
+
208
+ function isEditToolCall(call: ToolCall): boolean {
209
+ return EDIT_TOOL_NAMES.has(call.name) || call.customWireName === "apply_patch";
210
+ }
211
+
212
+ interface AssistantArgumentStalenessIndex {
213
+ latestSuccessfulMutationByPathGroup: Map<string, { index: number; callId: string }>;
214
+ failedCallIds: Set<string>;
215
+ }
216
+
217
+ function buildAssistantArgumentStalenessIndex(entries: SessionEntry[]): AssistantArgumentStalenessIndex {
218
+ const callsById = new Map<string, ToolCall>();
219
+ for (const entry of entries) {
220
+ if (entry.type !== "message") continue;
221
+ const message = entry.message as AgentMessage;
222
+ if (message.role !== "assistant") continue;
223
+ for (const content of message.content) {
224
+ if (content.type === "toolCall") callsById.set(content.id, content);
225
+ }
226
+ }
227
+
228
+ const latestSuccessfulMutationByPathGroup = new Map<string, { index: number; callId: string }>();
229
+ const failedCallIds = new Set<string>();
230
+ for (let i = 0; i < entries.length; i++) {
231
+ const message = getToolResultMessage(entries[i]);
232
+ if (!message) continue;
233
+ const call = callsById.get(message.toolCallId);
234
+ if (!call || !isEditToolCall(call)) continue;
235
+ const detailFiles = call.name === "ast_edit" ? resultDetailFiles(message) : [];
236
+ const groups = detailFiles.length > 0 ? detailFiles.map(file => [file]) : editToolPathGroups(call);
237
+ if (groups.length === 0) continue;
238
+ if (message.isError) {
239
+ failedCallIds.add(call.id);
240
+ continue;
241
+ }
242
+ const failed = failedEditPaths(message);
243
+ let mutated = false;
244
+ for (const group of groups) {
245
+ if (group.some(groupPath => failed.has(groupPath))) continue;
246
+ latestSuccessfulMutationByPathGroup.set(pathGroupKey(group), { index: i, callId: call.id });
247
+ mutated = true;
248
+ }
249
+ if (!mutated) failedCallIds.add(call.id);
250
+ }
251
+ return { latestSuccessfulMutationByPathGroup, failedCallIds };
252
+ }
173
253
 
174
254
  /**
175
255
  * Trailing read selectors (`:50`, `:50-200`, `:50+150`, `:5-16,960-973`,
@@ -361,6 +441,91 @@ function buildStalenessIndex(entries: SessionEntry[]): StalenessIndex {
361
441
 
362
442
  return { staleResultIndices };
363
443
  }
444
+ export function pruneAssistantToolArguments(
445
+ entries: SessionEntry[],
446
+ config: PruneConfig = DEFAULT_PRUNE_CONFIG,
447
+ ): AssistantArgumentPruneResult {
448
+ let accumulatedTokens = 0;
449
+ let argumentTokensSaved = 0;
450
+ const { latestSuccessfulMutationByPathGroup, failedCallIds } = buildAssistantArgumentStalenessIndex(entries);
451
+ const candidates: Array<{
452
+ entry: SessionMessageEntry;
453
+ call: ToolCall;
454
+ pathHints: string[];
455
+ originalChars: number;
456
+ savings: number;
457
+ }> = [];
458
+
459
+ for (let i = entries.length - 1; i >= 0; i--) {
460
+ const entry = entries[i];
461
+ if (entry.type !== "message") continue;
462
+ const message = entry.message as AgentMessage;
463
+ if (message.role !== "assistant") continue;
464
+ const entryTokens = estimateEntryTokens(entry);
465
+ const insideProtectWindow = accumulatedTokens < config.protectTokens;
466
+ accumulatedTokens += entryTokens;
467
+ for (const content of message.content) {
468
+ if (content.type !== "toolCall" || !isEditToolCall(content)) continue;
469
+ const argumentJson = JSON.stringify(content.arguments);
470
+ if (argumentJson === undefined) continue;
471
+ const originalChars = argumentJson.length;
472
+ if (isPrunedToolArgumentsSentinel(content.arguments)) continue;
473
+ if (insideProtectWindow || failedCallIds.has(content.id)) continue;
474
+ const groups = editToolPathGroups(content);
475
+ if (groups.length === 0) continue;
476
+ // Arguments are pruned as one indivisible payload, so require EVERY
477
+ // concrete path group to be stale from a later successful mutation.
478
+ // A group with no later success (failed/unknown/ambiguous) protects the
479
+ // whole call rather than dropping non-stale multi-file patch evidence.
480
+ const isStale =
481
+ groups.length > 0 &&
482
+ groups.every(group => {
483
+ const latest = latestSuccessfulMutationByPathGroup.get(pathGroupKey(group));
484
+ return latest !== undefined && latest.index > i && latest.callId !== content.id;
485
+ });
486
+ if (!isStale) continue;
487
+ const sentinelChars = JSON.stringify({
488
+ pruned: true,
489
+ reason: "stale_tool_arguments",
490
+ pathHints: pathHintsForGroups(groups),
491
+ originalChars,
492
+ prunedAt: 0,
493
+ } satisfies PrunedToolArgumentsSentinel).length;
494
+ candidates.push({
495
+ entry: entry as SessionMessageEntry,
496
+ call: content,
497
+ pathHints: pathHintsForGroups(groups),
498
+ originalChars,
499
+ savings: Math.max(0, Math.ceil((originalChars - sentinelChars) / 4)),
500
+ });
501
+ }
502
+ }
503
+
504
+ for (const candidate of candidates) {
505
+ argumentTokensSaved += candidate.savings;
506
+ }
507
+ if (argumentTokensSaved < config.minimumSavings || candidates.length === 0) {
508
+ return { argumentPrunedCount: 0, argumentTokensSaved: 0, prunedEntries: [] };
509
+ }
510
+
511
+ const prunedAt = Date.now();
512
+ const prunedEntries: SessionMessageEntry[] = [];
513
+ const prunedEntryIds = new Set<string>();
514
+ for (const candidate of candidates) {
515
+ candidate.call.arguments = {
516
+ pruned: true,
517
+ reason: "stale_tool_arguments",
518
+ pathHints: candidate.pathHints,
519
+ originalChars: candidate.originalChars,
520
+ prunedAt,
521
+ };
522
+ if (!prunedEntryIds.has(candidate.entry.id)) {
523
+ prunedEntries.push(candidate.entry);
524
+ prunedEntryIds.add(candidate.entry.id);
525
+ }
526
+ }
527
+ return { argumentPrunedCount: candidates.length, argumentTokensSaved, prunedEntries };
528
+ }
364
529
 
365
530
  export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = DEFAULT_PRUNE_CONFIG): PruneResult {
366
531
  let accumulatedTokens = 0;