@compilr-dev/agents 0.3.17 → 0.3.19

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/dist/agent.d.ts CHANGED
@@ -15,6 +15,8 @@ import type { DelegationConfig } from './context/delegation-types.js';
15
15
  import { PermissionManager } from './permissions/manager.js';
16
16
  import { ContextManager } from './context/manager.js';
17
17
  import { FileAccessTracker } from './context/file-tracker.js';
18
+ import type { ObservationMaskConfig } from './context/observation-masker.js';
19
+ import type { PruneConfig } from './context/dead-message-pruner.js';
18
20
  import { AnchorManager } from './anchors/manager.js';
19
21
  import { GuardrailManager } from './guardrails/manager.js';
20
22
  import { type RetryConfig } from './utils/index.js';
@@ -278,6 +280,22 @@ export interface AgentConfig {
278
280
  * Requires contextManager to be set. Default: true when contextManager is provided.
279
281
  */
280
282
  autoContextManagement?: boolean;
283
+ /**
284
+ * Observation masking configuration. Masks old tool results in history to reduce tokens.
285
+ * Enabled by default when contextManager is provided. Set to `false` to disable.
286
+ */
287
+ observationMask?: Partial<ObservationMaskConfig> | false;
288
+ /**
289
+ * Use compact text format for tool results in LLM messages.
290
+ * Strips JSON wrappers and metadata, reducing token usage.
291
+ * Enabled by default when contextManager is provided. Set to `false` to disable.
292
+ */
293
+ compactToolResults?: boolean;
294
+ /**
295
+ * Dead message pruning configuration. Prunes superseded errors and permission exchanges.
296
+ * Enabled by default when contextManager is provided. Set to `false` to disable.
297
+ */
298
+ deadMessagePruning?: Partial<PruneConfig> | false;
281
299
  /**
282
300
  * Event handler for monitoring agent execution
283
301
  */
@@ -878,6 +896,18 @@ export declare class Agent {
878
896
  * File restoration options for post-compaction content injection
879
897
  */
880
898
  private readonly fileRestorationConfig?;
899
+ /**
900
+ * Observation masker for reducing token usage by masking old tool results
901
+ */
902
+ private readonly observationMasker?;
903
+ /**
904
+ * Whether to use compact text format for tool results in LLM messages
905
+ */
906
+ private readonly compactToolResults;
907
+ /**
908
+ * Dead message pruner for removing superseded errors and permission exchanges
909
+ */
910
+ private readonly deadMessagePruner?;
881
911
  constructor(config: AgentConfig);
882
912
  /**
883
913
  * Create an agent with project memory loaded from files.
@@ -1280,6 +1310,23 @@ export declare class Agent {
1280
1310
  * Get context statistics
1281
1311
  */
1282
1312
  getContextStats(): ContextStats | undefined;
1313
+ /**
1314
+ * Get observation masking statistics (tokens saved, observations masked).
1315
+ */
1316
+ getObservationMaskStats(): {
1317
+ maskedCount: number;
1318
+ tokensSaved: number;
1319
+ activeStamps: number;
1320
+ } | undefined;
1321
+ /**
1322
+ * Get dead message pruning statistics (errors pruned, permissions pruned, tokens saved).
1323
+ */
1324
+ getDeadMessagePruneStats(): {
1325
+ prunedCount: number;
1326
+ tokensSaved: number;
1327
+ errorsPruned: number;
1328
+ permissionsPruned: number;
1329
+ } | undefined;
1283
1330
  /**
1284
1331
  * Get current verbosity level based on context pressure
1285
1332
  */
package/dist/agent.js CHANGED
@@ -10,6 +10,9 @@ import { ContextManager } from './context/manager.js';
10
10
  import { FileAccessTracker } from './context/file-tracker.js';
11
11
  import { createFileTrackingHook } from './context/file-tracking-hook.js';
12
12
  import { ToolResultDelegator, DELEGATION_SYSTEM_PROMPT } from './context/tool-result-delegator.js';
13
+ import { ObservationMasker } from './context/observation-masker.js';
14
+ import { compactToolResult } from './context/result-compactor.js';
15
+ import { DeadMessagePruner } from './context/dead-message-pruner.js';
13
16
  import { createRecallResultTool } from './tools/builtin/recall-result.js';
14
17
  import { AnchorManager } from './anchors/manager.js';
15
18
  import { GuardrailManager } from './guardrails/manager.js';
@@ -97,6 +100,18 @@ export class Agent {
97
100
  * File restoration options for post-compaction content injection
98
101
  */
99
102
  fileRestorationConfig;
103
+ /**
104
+ * Observation masker for reducing token usage by masking old tool results
105
+ */
106
+ observationMasker;
107
+ /**
108
+ * Whether to use compact text format for tool results in LLM messages
109
+ */
110
+ compactToolResults;
111
+ /**
112
+ * Dead message pruner for removing superseded errors and permission exchanges
113
+ */
114
+ deadMessagePruner;
100
115
  constructor(config) {
101
116
  this.provider = config.provider;
102
117
  this.systemPrompt = config.systemPrompt ?? '';
@@ -112,6 +127,16 @@ export class Agent {
112
127
  this.contextManager = config.contextManager;
113
128
  this.autoContextManagement =
114
129
  config.autoContextManagement ?? config.contextManager !== undefined;
130
+ // Observation masking: enabled by default when contextManager is provided, unless explicitly false
131
+ if (config.observationMask !== false && this.contextManager) {
132
+ this.observationMasker = new ObservationMasker(config.observationMask === undefined ? undefined : config.observationMask);
133
+ }
134
+ // Compact tool results: enabled by default when contextManager is provided
135
+ this.compactToolResults = config.compactToolResults ?? this.contextManager !== undefined;
136
+ // Dead message pruning: enabled by default when contextManager is provided, unless explicitly false
137
+ if (config.deadMessagePruning !== false && this.contextManager) {
138
+ this.deadMessagePruner = new DeadMessagePruner(config.deadMessagePruning === undefined ? undefined : config.deadMessagePruning);
139
+ }
115
140
  this.onEvent = config.onEvent;
116
141
  this.onIterationLimitReached = config.onIterationLimitReached;
117
142
  // State management
@@ -752,6 +777,8 @@ export class Agent {
752
777
  clearHistory() {
753
778
  this.conversationHistory = [];
754
779
  this.contextManager?.reset();
780
+ this.observationMasker?.reset();
781
+ this.deadMessagePruner?.reset();
755
782
  return this;
756
783
  }
757
784
  /**
@@ -807,6 +834,18 @@ export class Agent {
807
834
  getContextStats() {
808
835
  return this.contextManager?.getStats(this.conversationHistory.length);
809
836
  }
837
+ /**
838
+ * Get observation masking statistics (tokens saved, observations masked).
839
+ */
840
+ getObservationMaskStats() {
841
+ return this.observationMasker?.getStats();
842
+ }
843
+ /**
844
+ * Get dead message pruning statistics (errors pruned, permissions pruned, tokens saved).
845
+ */
846
+ getDeadMessagePruneStats() {
847
+ return this.deadMessagePruner?.getStats();
848
+ }
810
849
  /**
811
850
  * Get current verbosity level based on context pressure
812
851
  */
@@ -1915,7 +1954,9 @@ export class Agent {
1915
1954
  type: 'tool_result',
1916
1955
  toolUseId: toolUse.id,
1917
1956
  content: result.success
1918
- ? JSON.stringify(result.result)
1957
+ ? this.compactToolResults
1958
+ ? compactToolResult(toolUse.name, result.result, toolUse.input)
1959
+ : JSON.stringify(result.result)
1919
1960
  : `Error: ${result.error ?? 'Unknown error'}`,
1920
1961
  isError: !result.success,
1921
1962
  },
@@ -1998,7 +2039,9 @@ export class Agent {
1998
2039
  emit({ type: 'tool_end', name: toolUse.name, result, toolUseId: toolUse.id });
1999
2040
  // Build tool result content
2000
2041
  let toolResultContent = result.success
2001
- ? JSON.stringify(result.result)
2042
+ ? this.compactToolResults
2043
+ ? compactToolResult(toolUse.name, result.result, toolUse.input)
2044
+ : JSON.stringify(result.result)
2002
2045
  : `Error: ${result.error ?? 'Unknown error'}`;
2003
2046
  // Context management (only for sequential - parallel handles this after)
2004
2047
  if (!inParallelGroup && this.contextManager && this.autoContextManagement) {
@@ -2071,6 +2114,15 @@ export class Agent {
2071
2114
  iterationToolCalls.push(toolCallEntry);
2072
2115
  messages.push(toolResultMsg);
2073
2116
  newMessages.push(toolResultMsg);
2117
+ // Stamp for observation masking
2118
+ if (this.observationMasker) {
2119
+ const block = toolResultMsg.content[0];
2120
+ this.observationMasker.stamp(toolUse.id, toolUse.name, toolUse.input, block.content.length, this.contextManager?.getTurnCount() ?? 0);
2121
+ }
2122
+ // Stamp for dead message pruning
2123
+ if (this.deadMessagePruner) {
2124
+ this.deadMessagePruner.stamp(toolUse.id, toolUse.name, toolUse.input, !result.success, this.contextManager?.getTurnCount() ?? 0);
2125
+ }
2074
2126
  }
2075
2127
  }
2076
2128
  else {
@@ -2105,6 +2157,15 @@ export class Agent {
2105
2157
  iterationToolCalls.push(toolCallEntry);
2106
2158
  messages.push(toolResultMsg);
2107
2159
  newMessages.push(toolResultMsg);
2160
+ // Stamp for observation masking
2161
+ if (this.observationMasker) {
2162
+ const block = toolResultMsg.content[0];
2163
+ this.observationMasker.stamp(toolUse.id, toolUse.name, toolUse.input, block.content.length, this.contextManager?.getTurnCount() ?? 0);
2164
+ }
2165
+ // Stamp for dead message pruning
2166
+ if (this.deadMessagePruner) {
2167
+ this.deadMessagePruner.stamp(toolUse.id, toolUse.name, toolUse.input, !result.success, this.contextManager?.getTurnCount() ?? 0);
2168
+ }
2108
2169
  if (skipped) {
2109
2170
  continue;
2110
2171
  }
@@ -2223,6 +2284,14 @@ export class Agent {
2223
2284
  // Context management: increment turn count and update token count
2224
2285
  if (this.contextManager) {
2225
2286
  this.contextManager.incrementTurn();
2287
+ // Observation masking: mask old tool results in-place before token update
2288
+ if (this.observationMasker) {
2289
+ this.observationMasker.maskHistory(messages, this.contextManager.getTurnCount());
2290
+ }
2291
+ // Dead message pruning: prune superseded errors and permission exchanges
2292
+ if (this.deadMessagePruner) {
2293
+ this.deadMessagePruner.prune(messages, this.contextManager.getTurnCount());
2294
+ }
2226
2295
  await this.contextManager.updateTokenCount(messages);
2227
2296
  }
2228
2297
  // Update internal state tracking
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Dead Message Pruner — Phase 4 of Advanced Token Optimization
3
+ *
4
+ * Prunes provably obsolete messages in conversation history:
5
+ * 1. Superseded errors — tool error followed by same tool succeeding on same input
6
+ * 2. Permission exchanges — ask_user/ask_user_simple calls (purely procedural)
7
+ *
8
+ * Uses in-place content replacement (not message removal) to preserve
9
+ * the tool_use/tool_result pairing required by the Anthropic API.
10
+ */
11
+ import type { Message } from '../providers/types.js';
12
+ export interface PruneConfig {
13
+ /** Enable superseded error pruning (default: true) */
14
+ supersededErrors: boolean;
15
+ /** Enable permission exchange pruning (default: true) */
16
+ permissionExchanges: boolean;
17
+ /** Tool names considered permission exchanges */
18
+ permissionTools: string[];
19
+ /** Never prune messages newer than this many turns (default: 4) */
20
+ protectedTurns: number;
21
+ }
22
+ export declare const DEFAULT_PRUNE_CONFIG: PruneConfig;
23
+ export interface PruneResult {
24
+ prunedCount: number;
25
+ tokensSaved: number;
26
+ }
27
+ export interface PruneStats {
28
+ prunedCount: number;
29
+ tokensSaved: number;
30
+ errorsPruned: number;
31
+ permissionsPruned: number;
32
+ }
33
+ /**
34
+ * Check if a tool result content string has been pruned.
35
+ */
36
+ export declare function isPruned(content: string): boolean;
37
+ export declare class DeadMessagePruner {
38
+ private readonly config;
39
+ private readonly stamps;
40
+ private stats;
41
+ constructor(config?: Partial<PruneConfig>);
42
+ /**
43
+ * Register a tool result for pruning analysis.
44
+ * Called at the same point as ObservationMasker.stamp().
45
+ */
46
+ stamp(toolUseId: string, toolName: string, input: Record<string, unknown>, isError: boolean, turn: number): void;
47
+ /**
48
+ * Prune dead messages in-place. Replaces content of dead tool_result
49
+ * blocks with short placeholders and clears corresponding tool_use input.
50
+ */
51
+ prune(messages: Message[], currentTurn: number): PruneResult;
52
+ getStats(): PruneStats;
53
+ /**
54
+ * Reset all state (stamps and stats). Used when clearing history.
55
+ */
56
+ reset(): void;
57
+ /**
58
+ * Get current configuration (for testing/inspection).
59
+ */
60
+ getConfig(): PruneConfig;
61
+ /**
62
+ * Identify tool_use IDs that should be pruned.
63
+ */
64
+ private identifyDeadMessages;
65
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Dead Message Pruner — Phase 4 of Advanced Token Optimization
3
+ *
4
+ * Prunes provably obsolete messages in conversation history:
5
+ * 1. Superseded errors — tool error followed by same tool succeeding on same input
6
+ * 2. Permission exchanges — ask_user/ask_user_simple calls (purely procedural)
7
+ *
8
+ * Uses in-place content replacement (not message removal) to preserve
9
+ * the tool_use/tool_result pairing required by the Anthropic API.
10
+ */
11
+ import { extractInputSummary } from './observation-masker.js';
12
+ import { isMasked } from './observation-masker.js';
13
+ export const DEFAULT_PRUNE_CONFIG = {
14
+ supersededErrors: true,
15
+ permissionExchanges: true,
16
+ permissionTools: ['ask_user', 'ask_user_simple'],
17
+ protectedTurns: 4,
18
+ };
19
+ // ============================================================
20
+ // Pruned content detection
21
+ // ============================================================
22
+ const PRUNED_ERROR = '[pruned:error superseded]';
23
+ const PRUNED_PERMISSION = '[pruned:permission]';
24
+ /**
25
+ * Check if a tool result content string has been pruned.
26
+ */
27
+ export function isPruned(content) {
28
+ return content.startsWith('[pruned:');
29
+ }
30
+ // ============================================================
31
+ // DeadMessagePruner
32
+ // ============================================================
33
+ export class DeadMessagePruner {
34
+ config;
35
+ stamps = new Map();
36
+ stats = {
37
+ prunedCount: 0,
38
+ tokensSaved: 0,
39
+ errorsPruned: 0,
40
+ permissionsPruned: 0,
41
+ };
42
+ constructor(config) {
43
+ this.config = { ...DEFAULT_PRUNE_CONFIG, ...config };
44
+ }
45
+ // ----------------------------------------------------------
46
+ // Stamping — called when tool results are added to history
47
+ // ----------------------------------------------------------
48
+ /**
49
+ * Register a tool result for pruning analysis.
50
+ * Called at the same point as ObservationMasker.stamp().
51
+ */
52
+ stamp(toolUseId, toolName, input, isError, turn) {
53
+ this.stamps.set(toolUseId, {
54
+ turn,
55
+ toolName,
56
+ inputKey: extractInputSummary(toolName, input),
57
+ isError,
58
+ });
59
+ }
60
+ // ----------------------------------------------------------
61
+ // Pruning — called after ObservationMasker.maskHistory()
62
+ // ----------------------------------------------------------
63
+ /**
64
+ * Prune dead messages in-place. Replaces content of dead tool_result
65
+ * blocks with short placeholders and clears corresponding tool_use input.
66
+ */
67
+ prune(messages, currentTurn) {
68
+ // 1. Build set of tool_use IDs to prune
69
+ const toPrune = this.identifyDeadMessages(currentTurn);
70
+ if (toPrune.size === 0)
71
+ return { prunedCount: 0, tokensSaved: 0 };
72
+ // 2. Walk messages and replace content
73
+ let prunedCount = 0;
74
+ let tokensSaved = 0;
75
+ let errorsPruned = 0;
76
+ let permissionsPruned = 0;
77
+ for (const msg of messages) {
78
+ if (typeof msg.content === 'string')
79
+ continue;
80
+ for (const block of msg.content) {
81
+ if (block.type === 'tool_result' && toPrune.has(block.toolUseId)) {
82
+ // Skip if already masked by Phase 1 or already pruned
83
+ if (isMasked(block.content) || isPruned(block.content)) {
84
+ toPrune.delete(block.toolUseId); // don't clear tool_use input either
85
+ continue;
86
+ }
87
+ const stamp = this.stamps.get(block.toolUseId);
88
+ if (!stamp)
89
+ continue;
90
+ const pruneLabel = stamp.isError ? PRUNED_ERROR : PRUNED_PERMISSION;
91
+ const savedChars = block.content.length - pruneLabel.length;
92
+ const savedTokens = Math.max(0, Math.ceil(savedChars / 4));
93
+ block.content = pruneLabel;
94
+ tokensSaved += savedTokens;
95
+ prunedCount++;
96
+ if (stamp.isError) {
97
+ errorsPruned++;
98
+ }
99
+ else {
100
+ permissionsPruned++;
101
+ }
102
+ // Clean up stamp
103
+ this.stamps.delete(block.toolUseId);
104
+ }
105
+ // Clear tool_use input for pruned blocks
106
+ if (block.type === 'tool_use' && toPrune.has(block.id)) {
107
+ const inputStr = JSON.stringify(block.input);
108
+ if (inputStr.length > 2) {
109
+ // Estimate savings from clearing input (subtract '{}')
110
+ tokensSaved += Math.max(0, Math.ceil((inputStr.length - 2) / 4));
111
+ }
112
+ block.input = {};
113
+ }
114
+ }
115
+ }
116
+ this.stats.prunedCount += prunedCount;
117
+ this.stats.tokensSaved += tokensSaved;
118
+ this.stats.errorsPruned += errorsPruned;
119
+ this.stats.permissionsPruned += permissionsPruned;
120
+ return { prunedCount, tokensSaved };
121
+ }
122
+ // ----------------------------------------------------------
123
+ // Stats
124
+ // ----------------------------------------------------------
125
+ getStats() {
126
+ return { ...this.stats };
127
+ }
128
+ /**
129
+ * Reset all state (stamps and stats). Used when clearing history.
130
+ */
131
+ reset() {
132
+ this.stamps.clear();
133
+ this.stats = { prunedCount: 0, tokensSaved: 0, errorsPruned: 0, permissionsPruned: 0 };
134
+ }
135
+ /**
136
+ * Get current configuration (for testing/inspection).
137
+ */
138
+ getConfig() {
139
+ return { ...this.config };
140
+ }
141
+ // ----------------------------------------------------------
142
+ // Internal
143
+ // ----------------------------------------------------------
144
+ /**
145
+ * Identify tool_use IDs that should be pruned.
146
+ */
147
+ identifyDeadMessages(currentTurn) {
148
+ const toPrune = new Set();
149
+ // Build set of successful {toolName:inputKey} pairs
150
+ const successKeys = new Set();
151
+ for (const stamp of this.stamps.values()) {
152
+ if (!stamp.isError) {
153
+ successKeys.add(`${stamp.toolName}:${stamp.inputKey}`);
154
+ }
155
+ }
156
+ for (const [toolUseId, stamp] of this.stamps) {
157
+ const age = currentTurn - stamp.turn;
158
+ if (age < this.config.protectedTurns)
159
+ continue;
160
+ // Rule 1: Superseded errors
161
+ if (this.config.supersededErrors && stamp.isError) {
162
+ const key = `${stamp.toolName}:${stamp.inputKey}`;
163
+ if (successKeys.has(key)) {
164
+ toPrune.add(toolUseId);
165
+ }
166
+ }
167
+ // Rule 2: Permission exchanges
168
+ if (this.config.permissionExchanges && this.config.permissionTools.includes(stamp.toolName)) {
169
+ toPrune.add(toolUseId);
170
+ }
171
+ }
172
+ return toPrune;
173
+ }
174
+ }
@@ -20,3 +20,8 @@ export { ToolResultDelegator, DELEGATION_SYSTEM_PROMPT } from './tool-result-del
20
20
  export type { ToolResultDelegatorOptions } from './tool-result-delegator.js';
21
21
  export { DEFAULT_DELEGATION_CONFIG } from './delegation-types.js';
22
22
  export type { DelegationConfig, StoredResult, DelegationEvent } from './delegation-types.js';
23
+ export { compactToolResult } from './result-compactor.js';
24
+ export { ObservationMasker, DEFAULT_MASK_CONFIG, extractInputSummary, buildMaskText, isMasked, } from './observation-masker.js';
25
+ export type { ObservationMaskConfig, MaskResult, ObservationMaskStats, } from './observation-masker.js';
26
+ export { DeadMessagePruner, DEFAULT_PRUNE_CONFIG, isPruned } from './dead-message-pruner.js';
27
+ export type { PruneConfig, PruneResult, PruneStats } from './dead-message-pruner.js';
@@ -15,3 +15,9 @@ export { createFileTrackingHook, TRACKED_TOOLS } from './file-tracking-hook.js';
15
15
  export { DelegatedResultStore } from './delegated-result-store.js';
16
16
  export { ToolResultDelegator, DELEGATION_SYSTEM_PROMPT } from './tool-result-delegator.js';
17
17
  export { DEFAULT_DELEGATION_CONFIG } from './delegation-types.js';
18
+ // Compact Tool Result Formatting (Phase 2 Token Optimization)
19
+ export { compactToolResult } from './result-compactor.js';
20
+ // Observation Masking (Phase 1 Token Optimization)
21
+ export { ObservationMasker, DEFAULT_MASK_CONFIG, extractInputSummary, buildMaskText, isMasked, } from './observation-masker.js';
22
+ // Dead Message Pruning (Phase 4 Token Optimization)
23
+ export { DeadMessagePruner, DEFAULT_PRUNE_CONFIG, isPruned } from './dead-message-pruner.js';
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Observation Masker — Phase 1 of Advanced Token Optimization
3
+ *
4
+ * Masks old tool results (observations) in conversation history to reduce token usage.
5
+ * Based on JetBrains Research (Dec 2025): observation masking outperforms LLM summarization,
6
+ * achieving 52% cost reduction with +2.6% higher solve rates.
7
+ *
8
+ * Strategy: In-place masking of conversationHistory after N turns.
9
+ * The agent can re-read from the environment if needed (files, git, etc.).
10
+ */
11
+ import type { Message } from '../providers/types.js';
12
+ export interface ObservationMaskConfig {
13
+ /** Turns after which tool results are masked (default: 6) */
14
+ maskAfterTurns: number;
15
+ /** Minimum content length (chars) to mask — skip tiny results (default: 400 ≈ 100 tokens) */
16
+ minCharsToMask: number;
17
+ /** Tool names to NEVER mask (e.g., recall tools, state queries) */
18
+ neverMask: string[];
19
+ /** Tool names to mask after just 1 turn (large reads, bash output) */
20
+ alwaysMaskEarly: string[];
21
+ }
22
+ export declare const DEFAULT_MASK_CONFIG: ObservationMaskConfig;
23
+ interface TurnStamp {
24
+ turn: number;
25
+ toolName: string;
26
+ /** Short summary of input for mask text (e.g., file path, command) */
27
+ inputSummary: string;
28
+ /** Original content length in chars */
29
+ contentLength: number;
30
+ }
31
+ export interface MaskResult {
32
+ maskedCount: number;
33
+ tokensSaved: number;
34
+ }
35
+ export interface ObservationMaskStats {
36
+ /** Total observations masked this session */
37
+ maskedCount: number;
38
+ /** Estimated tokens saved this session */
39
+ tokensSaved: number;
40
+ /** Active stamps (pending masking) */
41
+ activeStamps: number;
42
+ }
43
+ export declare class ObservationMasker {
44
+ private readonly stamps;
45
+ private readonly config;
46
+ private stats;
47
+ constructor(config?: Partial<ObservationMaskConfig>);
48
+ /**
49
+ * Register a tool result with its turn number and input context.
50
+ * Called immediately after a tool result is added to the messages array.
51
+ */
52
+ stamp(toolUseId: string, toolName: string, input: Record<string, unknown>, contentLength: number, turn: number): void;
53
+ /**
54
+ * Mask old tool results in-place in the messages array.
55
+ * Modifies ToolResultBlock.content directly.
56
+ */
57
+ maskHistory(messages: Message[], currentTurn: number): MaskResult;
58
+ getStats(): ObservationMaskStats;
59
+ /**
60
+ * Reset all state (stamps and stats). Used when clearing history.
61
+ */
62
+ reset(): void;
63
+ /**
64
+ * Get current configuration (for testing/inspection).
65
+ */
66
+ getConfig(): ObservationMaskConfig;
67
+ }
68
+ /**
69
+ * Extract a short summary from tool input for the mask text.
70
+ */
71
+ export declare function extractInputSummary(toolName: string, input: Record<string, unknown>): string;
72
+ /**
73
+ * Build the compact mask text that replaces the original content.
74
+ */
75
+ export declare function buildMaskText(stamp: TurnStamp): string;
76
+ /**
77
+ * Check if a tool result content string is already masked.
78
+ */
79
+ export declare function isMasked(content: string): boolean;
80
+ export {};
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Observation Masker — Phase 1 of Advanced Token Optimization
3
+ *
4
+ * Masks old tool results (observations) in conversation history to reduce token usage.
5
+ * Based on JetBrains Research (Dec 2025): observation masking outperforms LLM summarization,
6
+ * achieving 52% cost reduction with +2.6% higher solve rates.
7
+ *
8
+ * Strategy: In-place masking of conversationHistory after N turns.
9
+ * The agent can re-read from the environment if needed (files, git, etc.).
10
+ */
11
+ export const DEFAULT_MASK_CONFIG = {
12
+ maskAfterTurns: 6,
13
+ minCharsToMask: 400,
14
+ neverMask: ['recall_full_result', 'recall_work'],
15
+ alwaysMaskEarly: ['read_file', 'bash', 'bash_output', 'grep', 'glob'],
16
+ };
17
+ // ============================================================
18
+ // ObservationMasker
19
+ // ============================================================
20
+ export class ObservationMasker {
21
+ stamps = new Map();
22
+ config;
23
+ stats = { maskedCount: 0, tokensSaved: 0 };
24
+ constructor(config) {
25
+ this.config = { ...DEFAULT_MASK_CONFIG, ...config };
26
+ }
27
+ // ----------------------------------------------------------
28
+ // Stamping — called when tool results are added to history
29
+ // ----------------------------------------------------------
30
+ /**
31
+ * Register a tool result with its turn number and input context.
32
+ * Called immediately after a tool result is added to the messages array.
33
+ */
34
+ stamp(toolUseId, toolName, input, contentLength, turn) {
35
+ this.stamps.set(toolUseId, {
36
+ turn,
37
+ toolName,
38
+ inputSummary: extractInputSummary(toolName, input),
39
+ contentLength,
40
+ });
41
+ }
42
+ // ----------------------------------------------------------
43
+ // Masking — called after incrementTurn()
44
+ // ----------------------------------------------------------
45
+ /**
46
+ * Mask old tool results in-place in the messages array.
47
+ * Modifies ToolResultBlock.content directly.
48
+ */
49
+ maskHistory(messages, currentTurn) {
50
+ let tokensSaved = 0;
51
+ let maskedCount = 0;
52
+ for (const msg of messages) {
53
+ if (msg.role !== 'user' || typeof msg.content === 'string')
54
+ continue;
55
+ for (const block of msg.content) {
56
+ if (block.type !== 'tool_result')
57
+ continue;
58
+ if (isMasked(block.content))
59
+ continue;
60
+ const stamp = this.stamps.get(block.toolUseId);
61
+ if (!stamp)
62
+ continue;
63
+ if (this.config.neverMask.includes(stamp.toolName))
64
+ continue;
65
+ const age = currentTurn - stamp.turn;
66
+ const threshold = this.config.alwaysMaskEarly.includes(stamp.toolName)
67
+ ? 1
68
+ : this.config.maskAfterTurns;
69
+ if (age < threshold)
70
+ continue;
71
+ if (stamp.contentLength < this.config.minCharsToMask)
72
+ continue;
73
+ // Build mask and calculate savings
74
+ const maskText = buildMaskText(stamp);
75
+ const savedChars = stamp.contentLength - maskText.length;
76
+ const savedTokens = Math.max(0, Math.ceil(savedChars / 4));
77
+ // Mask in-place
78
+ block.content = maskText;
79
+ tokensSaved += savedTokens;
80
+ maskedCount++;
81
+ // Clean up stamp
82
+ this.stamps.delete(block.toolUseId);
83
+ }
84
+ }
85
+ this.stats.maskedCount += maskedCount;
86
+ this.stats.tokensSaved += tokensSaved;
87
+ return { maskedCount, tokensSaved };
88
+ }
89
+ // ----------------------------------------------------------
90
+ // Stats
91
+ // ----------------------------------------------------------
92
+ getStats() {
93
+ return {
94
+ ...this.stats,
95
+ activeStamps: this.stamps.size,
96
+ };
97
+ }
98
+ /**
99
+ * Reset all state (stamps and stats). Used when clearing history.
100
+ */
101
+ reset() {
102
+ this.stamps.clear();
103
+ this.stats = { maskedCount: 0, tokensSaved: 0 };
104
+ }
105
+ /**
106
+ * Get current configuration (for testing/inspection).
107
+ */
108
+ getConfig() {
109
+ return { ...this.config };
110
+ }
111
+ }
112
+ // ============================================================
113
+ // Pure functions (exported for testing)
114
+ // ============================================================
115
+ /**
116
+ * Extract a short summary from tool input for the mask text.
117
+ */
118
+ export function extractInputSummary(toolName, input) {
119
+ // File operations — use path
120
+ if (toolName === 'read_file' || toolName === 'edit' || toolName === 'write_file') {
121
+ const path = input.path ?? input.file_path;
122
+ if (typeof path === 'string')
123
+ return path;
124
+ }
125
+ // Shell commands — use command (truncated)
126
+ if (toolName === 'bash' || toolName === 'bash_output') {
127
+ const cmd = input.command;
128
+ if (typeof cmd === 'string') {
129
+ return cmd.length > 40 ? cmd.slice(0, 40) + '...' : cmd;
130
+ }
131
+ }
132
+ // Search — use pattern
133
+ if (toolName === 'grep') {
134
+ const pattern = input.pattern;
135
+ if (typeof pattern === 'string')
136
+ return `"${pattern}"`;
137
+ }
138
+ // Glob — use pattern
139
+ if (toolName === 'glob') {
140
+ const pattern = input.pattern;
141
+ if (typeof pattern === 'string')
142
+ return pattern;
143
+ }
144
+ // Git tools — use subcommand or operation
145
+ if (toolName.startsWith('git_')) {
146
+ return toolName.slice(4); // "git_diff" → "diff"
147
+ }
148
+ // Web fetch — use URL
149
+ if (toolName === 'web_fetch') {
150
+ const url = input.url;
151
+ if (typeof url === 'string') {
152
+ return url.length > 60 ? url.slice(0, 60) + '...' : url;
153
+ }
154
+ }
155
+ // Default — just the tool name
156
+ return toolName;
157
+ }
158
+ /**
159
+ * Build the compact mask text that replaces the original content.
160
+ */
161
+ export function buildMaskText(stamp) {
162
+ const { toolName, inputSummary, turn, contentLength } = stamp;
163
+ const lines = String(Math.ceil(contentLength / 80));
164
+ const t = String(turn);
165
+ if (toolName === 'read_file') {
166
+ return `[file:${inputSummary} ~${lines}L read@turn:${t}]`;
167
+ }
168
+ if (toolName === 'bash' || toolName === 'bash_output') {
169
+ return `[cmd:${inputSummary} ~${lines}L@turn:${t}]`;
170
+ }
171
+ if (toolName === 'grep') {
172
+ return `[search:${inputSummary}@turn:${t}]`;
173
+ }
174
+ if (toolName === 'glob') {
175
+ return `[glob:${inputSummary}@turn:${t}]`;
176
+ }
177
+ if (toolName.startsWith('git_')) {
178
+ return `[git:${inputSummary}@turn:${t}]`;
179
+ }
180
+ if (toolName === 'edit' || toolName === 'write_file') {
181
+ return `[write:${inputSummary}@turn:${t}]`;
182
+ }
183
+ // Generic
184
+ const tokens = String(Math.ceil(contentLength / 4));
185
+ return `[tool:${toolName} ${tokens}tok@turn:${t}]`;
186
+ }
187
+ /**
188
+ * Check if a tool result content string is already masked.
189
+ */
190
+ export function isMasked(content) {
191
+ return content.startsWith('[') && content.endsWith(']') && content.includes('@turn:');
192
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Compact Tool Result Formatting — Phase 2 of Advanced Token Optimization
3
+ *
4
+ * Replaces JSON.stringify for tool results in the LLM messages array.
5
+ * Per-tool formatters strip JSON overhead (escaped newlines, metadata fields)
6
+ * and produce a text format the LLM can parse just as well.
7
+ *
8
+ * Only affects what the LLM reads in its message history.
9
+ * CLI formatters, events, and hooks still receive the raw ToolExecutionResult.
10
+ */
11
+ /**
12
+ * Format a tool result as compact text for LLM context.
13
+ * Falls back to JSON.stringify for unknown tools or non-object results.
14
+ */
15
+ export declare function compactToolResult(toolName: string, result: unknown, input?: Record<string, unknown>): string;
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Compact Tool Result Formatting — Phase 2 of Advanced Token Optimization
3
+ *
4
+ * Replaces JSON.stringify for tool results in the LLM messages array.
5
+ * Per-tool formatters strip JSON overhead (escaped newlines, metadata fields)
6
+ * and produce a text format the LLM can parse just as well.
7
+ *
8
+ * Only affects what the LLM reads in its message history.
9
+ * CLI formatters, events, and hooks still receive the raw ToolExecutionResult.
10
+ */
11
+ // ============================================================
12
+ // Helpers
13
+ // ============================================================
14
+ /** Safely extract a string field from an unknown record. */
15
+ function str(value, fallback = '') {
16
+ return typeof value === 'string' ? value : fallback;
17
+ }
18
+ /** Safely extract a number field from an unknown record. */
19
+ function num(value, fallback = 0) {
20
+ return typeof value === 'number' ? value : fallback;
21
+ }
22
+ // ============================================================
23
+ // Public API
24
+ // ============================================================
25
+ /**
26
+ * Format a tool result as compact text for LLM context.
27
+ * Falls back to JSON.stringify for unknown tools or non-object results.
28
+ */
29
+ export function compactToolResult(toolName, result, input) {
30
+ // String results — already compact
31
+ if (typeof result === 'string')
32
+ return result;
33
+ if (result === null || result === undefined)
34
+ return '';
35
+ // Per-tool formatters
36
+ const formatter = TOOL_FORMATTERS.get(toolName);
37
+ if (formatter)
38
+ return formatter(result, input);
39
+ // Fallback: JSON.stringify (current behavior)
40
+ return JSON.stringify(result);
41
+ }
42
+ const TOOL_FORMATTERS = new Map([
43
+ ['read_file', formatReadFile],
44
+ ['bash', formatBash],
45
+ ['bash_output', formatBash],
46
+ ['grep', formatGrep],
47
+ ['glob', formatGlob],
48
+ ['edit', formatEdit],
49
+ ['write_file', formatWriteFile],
50
+ ]);
51
+ /**
52
+ * read_file → [path 200L] + content
53
+ */
54
+ function formatReadFile(r) {
55
+ const path = str(r.path);
56
+ const content = str(r.content);
57
+ const totalLines = typeof r.totalLines === 'number' ? r.totalLines : undefined;
58
+ const linesReturned = typeof r.linesReturned === 'number' ? r.linesReturned : undefined;
59
+ const truncated = r.truncated === true;
60
+ // Header: [path NL] or [path M/NL] if truncated
61
+ let header;
62
+ if (truncated && linesReturned !== undefined && totalLines !== undefined) {
63
+ header = `[${path} ${String(linesReturned)}/${String(totalLines)}L]`;
64
+ }
65
+ else if (totalLines !== undefined) {
66
+ header = `[${path} ${String(totalLines)}L]`;
67
+ }
68
+ else {
69
+ header = `[${path}]`;
70
+ }
71
+ return `${header}\n${content}`;
72
+ }
73
+ /**
74
+ * bash → $ command\nstdout\n[stderr]\nstderr
75
+ * Non-zero exit: [exit:N] $ command\n...
76
+ * Background: shell_id message
77
+ */
78
+ function formatBash(r, input) {
79
+ // Background execution has different shape
80
+ if (r.shell_id) {
81
+ return `[bg:${str(r.shell_id)}] ${str(r.message)}`;
82
+ }
83
+ const stdout = str(r.stdout);
84
+ const stderr = str(r.stderr);
85
+ const exitCode = num(r.exitCode);
86
+ const command = input ? str(input.command) : '';
87
+ const parts = [];
88
+ // Command line
89
+ if (command) {
90
+ const prefix = exitCode !== 0 ? `[exit:${String(exitCode)}] ` : '';
91
+ parts.push(`${prefix}$ ${command}`);
92
+ }
93
+ else if (exitCode !== 0) {
94
+ parts.push(`[exit:${String(exitCode)}]`);
95
+ }
96
+ // stdout
97
+ if (stdout) {
98
+ parts.push(stdout);
99
+ }
100
+ // stderr (only if non-empty)
101
+ if (stderr) {
102
+ parts.push('[stderr]');
103
+ parts.push(stderr);
104
+ }
105
+ return parts.join('\n');
106
+ }
107
+ /**
108
+ * grep → N matches in M files:\n...matches
109
+ * Files-only mode → N files matching:\n...files
110
+ */
111
+ function formatGrep(r) {
112
+ const count = num(r.count);
113
+ // Files-only mode (has `files` array)
114
+ if (Array.isArray(r.files)) {
115
+ const files = r.files;
116
+ if (files.length === 0)
117
+ return '0 files matching';
118
+ return `${String(count)} files matching:\n${files.join('\n')}`;
119
+ }
120
+ // Matches mode
121
+ const matches = Array.isArray(r.matches) ? r.matches : undefined;
122
+ const filesSearched = typeof r.filesSearched === 'number' ? r.filesSearched : undefined;
123
+ if (!matches || matches.length === 0) {
124
+ return filesSearched ? `0 matches in ${String(filesSearched)} files` : '0 matches';
125
+ }
126
+ const summary = filesSearched
127
+ ? `${String(count)} matches in ${String(filesSearched)} files:`
128
+ : `${String(count)} matches:`;
129
+ return `${summary}\n${matches.join('\n')}`;
130
+ }
131
+ /**
132
+ * glob → N files:\n...files
133
+ */
134
+ function formatGlob(r) {
135
+ const files = Array.isArray(r.files) ? r.files : undefined;
136
+ const count = num(r.count);
137
+ if (!files || files.length === 0)
138
+ return '0 files';
139
+ return `${String(count)} files:\n${files.join('\n')}`;
140
+ }
141
+ /**
142
+ * edit → OK path (N replacements)
143
+ */
144
+ function formatEdit(r) {
145
+ const filePath = str(r.filePath);
146
+ const replacements = num(r.replacements);
147
+ const created = r.created === true;
148
+ if (created) {
149
+ return `OK created ${filePath}`;
150
+ }
151
+ return `OK ${filePath} (${String(replacements)} replacement${replacements !== 1 ? 's' : ''})`;
152
+ }
153
+ /**
154
+ * write_file → OK wrote path (NB)
155
+ */
156
+ function formatWriteFile(r) {
157
+ const path = str(r.path);
158
+ const bytesWritten = typeof r.bytesWritten === 'number' ? r.bytesWritten : undefined;
159
+ const mode = str(r.mode);
160
+ const action = mode === 'appended' ? 'appended' : 'wrote';
161
+ if (bytesWritten !== undefined) {
162
+ return `OK ${action} ${path} (${String(bytesWritten)}B)`;
163
+ }
164
+ return `OK ${action} ${path}`;
165
+ }
package/dist/index.d.ts CHANGED
@@ -39,8 +39,8 @@ export type { ToolPairingValidation } from './messages/index.js';
39
39
  export { generateId, sleep, retry, truncate, withRetryGenerator, calculateBackoffDelay, DEFAULT_RETRY_CONFIG, countTokens, countMessageTokens, } from './utils/index.js';
40
40
  export type { RetryConfig as LLMRetryConfig, WithRetryOptions } from './utils/index.js';
41
41
  export { AgentError, ProviderError, ToolError, ToolTimeoutError, ToolLoopError, ValidationError, MaxIterationsError, AbortError, ContextOverflowError, isAgentError, isProviderError, isToolError, isToolTimeoutError, isToolLoopError, isContextOverflowError, wrapError, } from './errors.js';
42
- export { ContextManager, DEFAULT_CONTEXT_CONFIG, FileAccessTracker, createFileTrackingHook, TRACKED_TOOLS, DelegatedResultStore, ToolResultDelegator, DELEGATION_SYSTEM_PROMPT, DEFAULT_DELEGATION_CONFIG, } from './context/index.js';
43
- export type { ContextManagerOptions, ContextCategory, BudgetAllocation, CategoryBudgetInfo, PreflightResult, VerbosityLevel, VerbosityConfig, ContextConfig, FilteringConfig, CompactionConfig, SummarizationConfig, CompactionResult, SummarizationResult, FilteringResult, ContextEvent, ContextEventHandler, ContextStats, FileAccessType, FileAccess, FileAccessTrackerOptions, FormatHintsOptions, FileAccessStats, RestorationHintMessage, DelegatedResultStoreStats, ToolResultDelegatorOptions, DelegationConfig, StoredResult, DelegationEvent, } from './context/index.js';
42
+ export { ContextManager, DEFAULT_CONTEXT_CONFIG, FileAccessTracker, createFileTrackingHook, TRACKED_TOOLS, DelegatedResultStore, ToolResultDelegator, DELEGATION_SYSTEM_PROMPT, DEFAULT_DELEGATION_CONFIG, compactToolResult, ObservationMasker, DEFAULT_MASK_CONFIG, extractInputSummary, buildMaskText, isMasked, DeadMessagePruner, DEFAULT_PRUNE_CONFIG, isPruned, } from './context/index.js';
43
+ export type { ContextManagerOptions, ContextCategory, BudgetAllocation, CategoryBudgetInfo, PreflightResult, VerbosityLevel, VerbosityConfig, ContextConfig, FilteringConfig, CompactionConfig, SummarizationConfig, CompactionResult, SummarizationResult, FilteringResult, ContextEvent, ContextEventHandler, ContextStats, FileAccessType, FileAccess, FileAccessTrackerOptions, FormatHintsOptions, FileAccessStats, RestorationHintMessage, DelegatedResultStoreStats, ToolResultDelegatorOptions, DelegationConfig, StoredResult, DelegationEvent, ObservationMaskConfig, MaskResult, ObservationMaskStats, PruneConfig, PruneResult, PruneStats, } from './context/index.js';
44
44
  export { SkillRegistry, defineSkill, createSkillRegistry, builtinSkills, getDefaultSkillRegistry, resetDefaultSkillRegistry, } from './skills/index.js';
45
45
  export type { Skill, SkillInvocationResult, SkillInvokeOptions } from './skills/index.js';
46
46
  export { JsonSerializer, CompactJsonSerializer, defaultSerializer, MemoryCheckpointer, FileCheckpointer, StateError, StateErrorCode, CURRENT_STATE_VERSION, } from './state/index.js';
package/dist/index.js CHANGED
@@ -47,7 +47,13 @@ export { AgentError, ProviderError, ToolError, ToolTimeoutError, ToolLoopError,
47
47
  // Context management
48
48
  export { ContextManager, DEFAULT_CONTEXT_CONFIG, FileAccessTracker, createFileTrackingHook, TRACKED_TOOLS,
49
49
  // Tool result delegation
50
- DelegatedResultStore, ToolResultDelegator, DELEGATION_SYSTEM_PROMPT, DEFAULT_DELEGATION_CONFIG, } from './context/index.js';
50
+ DelegatedResultStore, ToolResultDelegator, DELEGATION_SYSTEM_PROMPT, DEFAULT_DELEGATION_CONFIG,
51
+ // Compact tool result formatting (Phase 2 Token Optimization)
52
+ compactToolResult,
53
+ // Observation masking (Phase 1 Token Optimization)
54
+ ObservationMasker, DEFAULT_MASK_CONFIG, extractInputSummary, buildMaskText, isMasked,
55
+ // Dead message pruning (Phase 4 Token Optimization)
56
+ DeadMessagePruner, DEFAULT_PRUNE_CONFIG, isPruned, } from './context/index.js';
51
57
  // Skills system
52
58
  export { SkillRegistry, defineSkill, createSkillRegistry, builtinSkills, getDefaultSkillRegistry, resetDefaultSkillRegistry, } from './skills/index.js';
53
59
  // State management
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compilr-dev/agents",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
4
4
  "description": "Lightweight multi-LLM agent library for building CLI AI assistants",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",