@compilr-dev/agents 0.3.11 → 0.3.13

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.
Files changed (42) hide show
  1. package/dist/agent.d.ts +42 -1
  2. package/dist/agent.js +116 -17
  3. package/dist/anchors/manager.js +3 -2
  4. package/dist/context/delegated-result-store.d.ts +67 -0
  5. package/dist/context/delegated-result-store.js +99 -0
  6. package/dist/context/delegation-types.d.ts +82 -0
  7. package/dist/context/delegation-types.js +18 -0
  8. package/dist/context/file-tracker.d.ts +59 -1
  9. package/dist/context/file-tracker.js +96 -1
  10. package/dist/context/file-tracking-hook.js +9 -4
  11. package/dist/context/index.d.ts +7 -1
  12. package/dist/context/index.js +4 -0
  13. package/dist/context/manager.js +12 -32
  14. package/dist/context/tool-result-delegator.d.ts +63 -0
  15. package/dist/context/tool-result-delegator.js +314 -0
  16. package/dist/index.d.ts +5 -5
  17. package/dist/index.js +9 -3
  18. package/dist/memory/loader.js +2 -1
  19. package/dist/memory/types.d.ts +1 -1
  20. package/dist/providers/claude.d.ts +1 -5
  21. package/dist/providers/claude.js +6 -29
  22. package/dist/providers/gemini-native.d.ts +1 -1
  23. package/dist/providers/gemini-native.js +10 -24
  24. package/dist/providers/mock.d.ts +1 -1
  25. package/dist/providers/mock.js +3 -24
  26. package/dist/providers/openai-compatible.d.ts +1 -5
  27. package/dist/providers/openai-compatible.js +14 -28
  28. package/dist/providers/types.d.ts +5 -0
  29. package/dist/rate-limit/provider-wrapper.d.ts +1 -1
  30. package/dist/rate-limit/provider-wrapper.js +3 -27
  31. package/dist/tools/builtin/index.d.ts +2 -0
  32. package/dist/tools/builtin/index.js +2 -0
  33. package/dist/tools/builtin/recall-result.d.ts +29 -0
  34. package/dist/tools/builtin/recall-result.js +48 -0
  35. package/dist/tools/builtin/task.js +1 -0
  36. package/dist/tools/index.d.ts +2 -2
  37. package/dist/tools/index.js +2 -0
  38. package/dist/utils/index.d.ts +1 -0
  39. package/dist/utils/index.js +2 -0
  40. package/dist/utils/tokenizer.d.ts +19 -0
  41. package/dist/utils/tokenizer.js +93 -0
  42. package/package.json +3 -2
@@ -38,9 +38,13 @@ export class FileAccessTracker {
38
38
  accesses = new Map();
39
39
  maxEntries;
40
40
  deduplicateReferences;
41
+ inlineThreshold;
42
+ maxContentEntries;
41
43
  constructor(options = {}) {
42
44
  this.maxEntries = options.maxEntries ?? 100;
43
45
  this.deduplicateReferences = options.deduplicateReferences ?? true;
46
+ this.inlineThreshold = options.inlineThreshold ?? 200;
47
+ this.maxContentEntries = options.maxContentEntries ?? 10;
44
48
  }
45
49
  // ==========================================================================
46
50
  // Track Methods
@@ -48,13 +52,15 @@ export class FileAccessTracker {
48
52
  /**
49
53
  * Track a file that was fully read
50
54
  */
51
- trackRead(filePath, lineCount, summary) {
55
+ trackRead(filePath, lineCount, summary, content) {
52
56
  const normalizedPath = this.normalizePath(filePath);
53
57
  // Read supersedes reference - remove any existing reference
54
58
  const existing = this.accesses.get(normalizedPath);
55
59
  if (existing && existing.type === 'referenced') {
56
60
  this.accesses.delete(normalizedPath);
57
61
  }
62
+ // Store content only for small files
63
+ const shouldStoreContent = content !== undefined && lineCount <= this.inlineThreshold;
58
64
  // Update or create read entry
59
65
  this.accesses.set(normalizedPath, {
60
66
  path: normalizedPath,
@@ -62,7 +68,12 @@ export class FileAccessTracker {
62
68
  timestamp: Date.now(),
63
69
  lineCount,
64
70
  summary,
71
+ content: shouldStoreContent ? content : undefined,
72
+ tokenCount: shouldStoreContent ? this.estimateTokens(content) : undefined,
65
73
  });
74
+ if (shouldStoreContent) {
75
+ this.enforceMaxContentEntries();
76
+ }
66
77
  this.enforceMaxEntries();
67
78
  }
68
79
  /**
@@ -184,6 +195,65 @@ export class FileAccessTracker {
184
195
  includeTimestamp,
185
196
  });
186
197
  }
198
+ /**
199
+ * Format restoration hints with inline file content (Claude Code style).
200
+ *
201
+ * Small files with stored content are inlined up to a token budget.
202
+ * Large files or files exceeding the budget get reference-only hints.
203
+ * Each file produces a separate hint message for individual injection.
204
+ *
205
+ * Priority order for inlining:
206
+ * 1. Modified files (most critical context)
207
+ * 2. Read files, most recent first
208
+ * 3. Once budget exhausted → remaining become reference-only
209
+ * 4. Referenced-only files → always reference-only
210
+ */
211
+ formatRestorationHintsWithContent(options) {
212
+ if (this.accesses.size === 0) {
213
+ return [];
214
+ }
215
+ const maxInlineTokens = options?.maxInlineTokens ?? 4000;
216
+ const allAccesses = this.getAccesses();
217
+ const hints = [];
218
+ let usedTokens = 0;
219
+ // Sort by priority: modified first, then read (most recent first), then referenced
220
+ const prioritized = [...allAccesses].sort((a, b) => {
221
+ const typePriority = { modified: 0, read: 1, referenced: 2 };
222
+ const aPriority = typePriority[a.type];
223
+ const bPriority = typePriority[b.type];
224
+ if (aPriority !== bPriority)
225
+ return aPriority - bPriority;
226
+ return b.timestamp - a.timestamp; // most recent first within same type
227
+ });
228
+ for (const access of prioritized) {
229
+ // Referenced files are always reference-only (no content was read)
230
+ if (access.type === 'referenced') {
231
+ continue; // Skip referenced files — they add noise without value
232
+ }
233
+ const canInline = access.content !== undefined &&
234
+ access.tokenCount !== undefined &&
235
+ usedTokens + access.tokenCount <= maxInlineTokens;
236
+ if (canInline) {
237
+ // Inline: simulate a read_file tool call result
238
+ // canInline guarantees content and tokenCount are defined
239
+ const fileContent = access.content;
240
+ const tokens = access.tokenCount;
241
+ const text = `Note: ${access.path} was read before the last conversation was compacted.\n` +
242
+ `<file_content>\n${fileContent}\n</file_content>`;
243
+ usedTokens += tokens;
244
+ hints.push({ path: access.path, type: 'inline', text, tokens });
245
+ }
246
+ else {
247
+ // Reference-only
248
+ const lineInfo = access.lineCount !== undefined ? ` (${String(access.lineCount)} lines)` : '';
249
+ const text = `Note: ${access.path}${lineInfo} was read before the last conversation was compacted, ` +
250
+ `but the contents are too large to include. Use read_file if you need to access it.`;
251
+ const tokens = this.estimateTokens(text);
252
+ hints.push({ path: access.path, type: 'reference', text, tokens });
253
+ }
254
+ }
255
+ return hints;
256
+ }
187
257
  // ==========================================================================
188
258
  // Lifecycle Methods
189
259
  // ==========================================================================
@@ -202,6 +272,31 @@ export class FileAccessTracker {
202
272
  // ==========================================================================
203
273
  // Private Helpers
204
274
  // ==========================================================================
275
+ /**
276
+ * Rough token estimate (4 chars ≈ 1 token). Avoids heavy tiktoken dependency
277
+ * in the tracker — exact counts aren't critical for budget enforcement.
278
+ */
279
+ estimateTokens(text) {
280
+ if (!text)
281
+ return 0;
282
+ return Math.ceil(text.length / 4);
283
+ }
284
+ /**
285
+ * Evict oldest stored content when maxContentEntries is exceeded.
286
+ * Only drops the `content`/`tokenCount` fields — the access entry itself is preserved.
287
+ */
288
+ enforceMaxContentEntries() {
289
+ const entriesWithContent = Array.from(this.accesses.values())
290
+ .filter((a) => a.content !== undefined)
291
+ .sort((a, b) => a.timestamp - b.timestamp); // oldest first
292
+ while (entriesWithContent.length > this.maxContentEntries) {
293
+ const oldest = entriesWithContent.shift();
294
+ if (oldest) {
295
+ oldest.content = undefined;
296
+ oldest.tokenCount = undefined;
297
+ }
298
+ }
299
+ }
205
300
  normalizePath(filePath) {
206
301
  // Resolve to absolute path
207
302
  return path.resolve(filePath);
@@ -32,18 +32,23 @@ export function createFileTrackingHook(tracker) {
32
32
  case TOOL_NAMES.READ_FILE: {
33
33
  const readInput = input;
34
34
  const readResult = result;
35
- const lineCount = readResult.result?.lineCount ?? 0;
36
- tracker.trackRead(readInput.file_path, lineCount);
35
+ const content = readResult.result?.content;
36
+ // totalLines is set when maxLines/startLine used or content truncated;
37
+ // linesReturned is the actual slice size; fall back to counting content lines
38
+ const lineCount = readResult.result?.totalLines ??
39
+ readResult.result?.linesReturned ??
40
+ (content ? content.split('\n').length : 0);
41
+ tracker.trackRead(readInput.path, lineCount, undefined, content);
37
42
  break;
38
43
  }
39
44
  case TOOL_NAMES.WRITE_FILE: {
40
45
  const writeInput = input;
41
- tracker.trackModification(writeInput.file_path, 'File written');
46
+ tracker.trackModification(writeInput.path, 'File written');
42
47
  break;
43
48
  }
44
49
  case TOOL_NAMES.EDIT: {
45
50
  const editInput = input;
46
- tracker.trackModification(editInput.file_path, 'File edited');
51
+ tracker.trackModification(editInput.filePath, 'File edited');
47
52
  break;
48
53
  }
49
54
  case TOOL_NAMES.GREP: {
@@ -11,6 +11,12 @@
11
11
  export { ContextManager, DEFAULT_CONTEXT_CONFIG } from './manager.js';
12
12
  export type { ContextManagerOptions } from './manager.js';
13
13
  export { FileAccessTracker } from './file-tracker.js';
14
- export type { FileAccessType, FileAccess, FileAccessTrackerOptions, FormatHintsOptions, FileAccessStats, } from './file-tracker.js';
14
+ export type { FileAccessType, FileAccess, FileAccessTrackerOptions, FormatHintsOptions, FileAccessStats, RestorationHintMessage, } from './file-tracker.js';
15
15
  export { createFileTrackingHook, TRACKED_TOOLS } from './file-tracking-hook.js';
16
16
  export type { ContextCategory, BudgetAllocation, CategoryBudgetInfo, PreflightResult, VerbosityLevel, VerbosityConfig, ContextConfig, FilteringConfig, CompactionConfig, SummarizationConfig, CompactionResult, SummarizationResult, FilteringResult, ContextEvent, ContextEventHandler, ContextStats, CategorizedMessages, SmartCompactOptions, SmartCompactionResult, } from './types.js';
17
+ export { DelegatedResultStore } from './delegated-result-store.js';
18
+ export type { DelegatedResultStoreStats } from './delegated-result-store.js';
19
+ export { ToolResultDelegator, DELEGATION_SYSTEM_PROMPT } from './tool-result-delegator.js';
20
+ export type { ToolResultDelegatorOptions } from './tool-result-delegator.js';
21
+ export { DEFAULT_DELEGATION_CONFIG } from './delegation-types.js';
22
+ export type { DelegationConfig, StoredResult, DelegationEvent } from './delegation-types.js';
@@ -11,3 +11,7 @@
11
11
  export { ContextManager, DEFAULT_CONTEXT_CONFIG } from './manager.js';
12
12
  export { FileAccessTracker } from './file-tracker.js';
13
13
  export { createFileTrackingHook, TRACKED_TOOLS } from './file-tracking-hook.js';
14
+ // Tool Result Delegation
15
+ export { DelegatedResultStore } from './delegated-result-store.js';
16
+ export { ToolResultDelegator, DELEGATION_SYSTEM_PROMPT } from './tool-result-delegator.js';
17
+ export { DEFAULT_DELEGATION_CONFIG } from './delegation-types.js';
@@ -18,6 +18,7 @@
18
18
  * ```
19
19
  */
20
20
  import { repairToolPairing } from '../messages/index.js';
21
+ import { countTokens, countMessageTokens } from '../utils/tokenizer.js';
21
22
  /**
22
23
  * Default budget allocation
23
24
  */
@@ -122,29 +123,8 @@ export class ContextManager {
122
123
  if (this.provider.countTokens) {
123
124
  return this.provider.countTokens(messages);
124
125
  }
125
- // Fallback: rough estimate based on character count
126
- let charCount = 0;
127
- for (const msg of messages) {
128
- if (typeof msg.content === 'string') {
129
- charCount += msg.content.length;
130
- }
131
- else {
132
- for (const block of msg.content) {
133
- switch (block.type) {
134
- case 'text':
135
- charCount += block.text.length;
136
- break;
137
- case 'tool_use':
138
- charCount += JSON.stringify(block.input).length;
139
- break;
140
- case 'tool_result':
141
- charCount += block.content.length;
142
- break;
143
- }
144
- }
145
- }
146
- }
147
- return Math.ceil(charCount / 4);
126
+ // Fallback: count tokens using tiktoken
127
+ return countMessageTokens(messages);
148
128
  }
149
129
  /**
150
130
  * Update token count for messages
@@ -351,7 +331,7 @@ export class ContextManager {
351
331
  }
352
332
  // Compact this message
353
333
  if (typeof msg.content === 'string') {
354
- const tokens = Math.ceil(msg.content.length / 4);
334
+ const tokens = countTokens(msg.content);
355
335
  if (tokens >= this.config.compaction.minTokensToCompact) {
356
336
  const filePath = await saveToFile(msg.content, compactedIndex++);
357
337
  filesCreated.push(filePath);
@@ -369,7 +349,7 @@ export class ContextManager {
369
349
  const compactedBlocks = [];
370
350
  for (const block of msg.content) {
371
351
  if (block.type === 'tool_result' && category === 'toolResults') {
372
- const tokens = Math.ceil(block.content.length / 4);
352
+ const tokens = countTokens(block.content);
373
353
  if (tokens >= this.config.compaction.minTokensToCompact) {
374
354
  const filePath = await saveToFile(block.content, compactedIndex++);
375
355
  filesCreated.push(filePath);
@@ -702,7 +682,7 @@ export class ContextManager {
702
682
  * Estimate tokens for a string content
703
683
  */
704
684
  estimateTokens(content) {
705
- return Math.ceil(content.length / 4);
685
+ return countTokens(content);
706
686
  }
707
687
  /**
708
688
  * Check if content can be added to a category
@@ -822,13 +802,13 @@ export class ContextManager {
822
802
  maxLines = this.config.filtering.maxErrorLines;
823
803
  break;
824
804
  default: {
825
- // For tool results, check token count estimate
826
- const estimatedTokens = Math.ceil(content.length / 4);
805
+ // For tool results, check token count
806
+ const estimatedTokens = countTokens(content);
827
807
  if (estimatedTokens <= this.config.filtering.maxToolResultTokens) {
828
808
  return { content, filtered: false, originalLength };
829
809
  }
830
- // Truncate to roughly maxToolResultTokens
831
- const maxChars = this.config.filtering.maxToolResultTokens * 4;
810
+ // Truncate to roughly maxToolResultTokens (~3 chars per token)
811
+ const maxChars = this.config.filtering.maxToolResultTokens * 3;
832
812
  const truncated = content.slice(0, maxChars);
833
813
  return {
834
814
  content: truncated + '\n\n[Content truncated - see file for full output]',
@@ -877,7 +857,7 @@ export class ContextManager {
877
857
  for (const msg of oldMessages) {
878
858
  if (typeof msg.content === 'string') {
879
859
  // Check if string content is large enough to compact
880
- const tokens = Math.ceil(msg.content.length / 4);
860
+ const tokens = countTokens(msg.content);
881
861
  if (tokens >= this.config.compaction.minTokensToCompact) {
882
862
  const filePath = await saveToFile(msg.content, compactedIndex++);
883
863
  filesCreated.push(filePath);
@@ -895,7 +875,7 @@ export class ContextManager {
895
875
  const compactedBlocks = [];
896
876
  for (const block of msg.content) {
897
877
  if (block.type === 'tool_result') {
898
- const tokens = Math.ceil(block.content.length / 4);
878
+ const tokens = countTokens(block.content);
899
879
  if (tokens >= this.config.compaction.minTokensToCompact) {
900
880
  const filePath = await saveToFile(block.content, compactedIndex++);
901
881
  filesCreated.push(filePath);
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Tool Result Delegator
3
+ *
4
+ * Intercepts large tool results via the AfterToolHook mechanism,
5
+ * stores the full result for optional recall, and replaces it
6
+ * with a compact summary to conserve context tokens.
7
+ */
8
+ import type { LLMProvider } from '../providers/types.js';
9
+ import type { AfterToolHook } from '../hooks/types.js';
10
+ import type { DelegationConfig, DelegationEvent } from './delegation-types.js';
11
+ import { DelegatedResultStore } from './delegated-result-store.js';
12
+ /**
13
+ * Options for creating a ToolResultDelegator.
14
+ */
15
+ export interface ToolResultDelegatorOptions {
16
+ /** LLM provider for summarization (small/medium tier preferred) */
17
+ provider: LLMProvider;
18
+ /** Delegation configuration (merged with defaults) */
19
+ config?: Partial<DelegationConfig>;
20
+ /** Event callback for delegation lifecycle events */
21
+ onEvent?: (event: DelegationEvent) => void;
22
+ }
23
+ /**
24
+ * Core class that creates an AfterToolHook for auto-delegating large tool results.
25
+ */
26
+ export declare class ToolResultDelegator {
27
+ private readonly store;
28
+ private readonly config;
29
+ private readonly provider;
30
+ private readonly onEvent?;
31
+ constructor(options: ToolResultDelegatorOptions);
32
+ /**
33
+ * Returns an AfterToolHook to register with HooksManager.
34
+ */
35
+ createHook(): AfterToolHook;
36
+ /**
37
+ * Perform the actual delegation (async path).
38
+ */
39
+ private delegateResult;
40
+ /**
41
+ * Access the store (needed by recall_full_result tool).
42
+ */
43
+ getStore(): DelegatedResultStore;
44
+ /**
45
+ * Get the resolved config for a specific tool.
46
+ */
47
+ private getToolConfig;
48
+ /**
49
+ * Extractive summarization: first/last lines + prioritized structural markers.
50
+ * No LLM cost, fast, preserves actual code signatures.
51
+ */
52
+ private summarizeExtractive;
53
+ /**
54
+ * LLM-based summarization using the provider.
55
+ * Returns null if the LLM call fails.
56
+ */
57
+ private summarizeLLM;
58
+ }
59
+ /**
60
+ * System prompt addition when delegation is enabled.
61
+ * Append to the agent's system prompt.
62
+ */
63
+ export declare const DELEGATION_SYSTEM_PROMPT: string;
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Tool Result Delegator
3
+ *
4
+ * Intercepts large tool results via the AfterToolHook mechanism,
5
+ * stores the full result for optional recall, and replaces it
6
+ * with a compact summary to conserve context tokens.
7
+ */
8
+ import { DEFAULT_DELEGATION_CONFIG } from './delegation-types.js';
9
+ import { DelegatedResultStore } from './delegated-result-store.js';
10
+ import { countTokens } from '../utils/tokenizer.js';
11
+ /**
12
+ * System prompt for LLM-based summarization.
13
+ */
14
+ const SUMMARIZATION_SYSTEM_PROMPT = 'You are a tool result summarizer. Compress this tool output preserving:\n' +
15
+ '- ALL errors/warnings verbatim\n' +
16
+ '- File paths, function names, line numbers, counts\n' +
17
+ '- Overall structure (sections, item counts)\n' +
18
+ '- First few items of lists verbatim\n' +
19
+ 'Drop: verbose repetition, successful routine output, function bodies.\n' +
20
+ 'Never add information not in the original.\n' +
21
+ 'Respond with ONLY the summary, no preamble.';
22
+ /**
23
+ * Core class that creates an AfterToolHook for auto-delegating large tool results.
24
+ */
25
+ export class ToolResultDelegator {
26
+ store;
27
+ config;
28
+ provider;
29
+ onEvent;
30
+ constructor(options) {
31
+ this.provider = options.provider;
32
+ this.config = { ...DEFAULT_DELEGATION_CONFIG, ...options.config };
33
+ this.onEvent = options.onEvent;
34
+ this.store = new DelegatedResultStore({
35
+ maxSize: this.config.maxStoredResults,
36
+ defaultTTL: this.config.resultTTL,
37
+ });
38
+ }
39
+ /**
40
+ * Returns an AfterToolHook to register with HooksManager.
41
+ */
42
+ createHook() {
43
+ const getToolConfig = this.getToolConfig.bind(this);
44
+ const delegateResult = this.delegateResult.bind(this);
45
+ return (context) => {
46
+ // Skip failed results — errors should always pass through
47
+ if (!context.result.success)
48
+ return undefined;
49
+ // Never re-delegate recall_full_result — that would create a loop
50
+ if (context.toolName === 'recall_full_result')
51
+ return undefined;
52
+ // Check if delegation is enabled for this tool
53
+ const toolConfig = getToolConfig(context.toolName);
54
+ if (!toolConfig.enabled)
55
+ return undefined;
56
+ // Serialize the result content
57
+ const content = typeof context.result.result === 'string'
58
+ ? context.result.result
59
+ : JSON.stringify(context.result.result);
60
+ // Count tokens
61
+ const tokens = countTokens(content);
62
+ // Below threshold — pass through unchanged
63
+ if (tokens <= toolConfig.threshold)
64
+ return undefined;
65
+ // Delegate asynchronously
66
+ return delegateResult(context, content, tokens, toolConfig);
67
+ };
68
+ }
69
+ /**
70
+ * Perform the actual delegation (async path).
71
+ */
72
+ async delegateResult(context, content, tokens, toolConfig) {
73
+ // If already aborted, skip delegation entirely — return original result
74
+ if (context.signal?.aborted) {
75
+ return { result: context.result };
76
+ }
77
+ // Generate delegation ID
78
+ const id = this.store.generateId();
79
+ this.onEvent?.({
80
+ type: 'delegation:started',
81
+ toolName: context.toolName,
82
+ originalTokens: tokens,
83
+ delegationId: id,
84
+ });
85
+ try {
86
+ // Determine strategy
87
+ const strategy = toolConfig.strategy === 'auto' ? 'extractive' : toolConfig.strategy;
88
+ let summary;
89
+ let usedStrategy = strategy;
90
+ if (strategy === 'llm') {
91
+ const llmSummary = await this.summarizeLLM(content, context.toolName, context.signal);
92
+ if (llmSummary !== null) {
93
+ summary = llmSummary;
94
+ usedStrategy = 'llm';
95
+ }
96
+ else {
97
+ // LLM failed, fall back to extractive
98
+ summary = this.summarizeExtractive(content);
99
+ usedStrategy = 'extractive';
100
+ }
101
+ }
102
+ else if (toolConfig.strategy === 'auto') {
103
+ // Auto: try LLM first, fall back to extractive
104
+ const llmSummary = await this.summarizeLLM(content, context.toolName, context.signal);
105
+ if (llmSummary !== null) {
106
+ summary = llmSummary;
107
+ usedStrategy = 'llm';
108
+ }
109
+ else {
110
+ summary = this.summarizeExtractive(content);
111
+ usedStrategy = 'extractive';
112
+ }
113
+ }
114
+ else {
115
+ summary = this.summarizeExtractive(content);
116
+ usedStrategy = 'extractive';
117
+ }
118
+ const summaryTokens = countTokens(summary);
119
+ // Store the full result
120
+ const now = Date.now();
121
+ const stored = {
122
+ id,
123
+ toolName: context.toolName,
124
+ toolInput: context.input,
125
+ fullContent: content,
126
+ fullTokens: tokens,
127
+ summary,
128
+ summaryTokens,
129
+ storedAt: now,
130
+ expiresAt: now + this.config.resultTTL,
131
+ };
132
+ this.store.add(stored);
133
+ this.onEvent?.({
134
+ type: 'delegation:completed',
135
+ toolName: context.toolName,
136
+ originalTokens: tokens,
137
+ summaryTokens,
138
+ delegationId: id,
139
+ strategy: usedStrategy,
140
+ });
141
+ // Return modified result with summary
142
+ // The format must be unmistakable so the LLM never confuses
143
+ // a delegated summary with the actual tool output.
144
+ return {
145
+ result: {
146
+ success: true,
147
+ result: {
148
+ _delegated: true,
149
+ _delegationId: id,
150
+ _originalTokens: tokens,
151
+ _summaryTokens: summaryTokens,
152
+ _warning: 'THIS IS A SUMMARY, NOT THE FULL RESULT. You did not see the full output.',
153
+ summary,
154
+ recall: `To get the full output, call recall_full_result with id="${id}"`,
155
+ },
156
+ },
157
+ };
158
+ }
159
+ catch (error) {
160
+ this.onEvent?.({
161
+ type: 'delegation:failed',
162
+ toolName: context.toolName,
163
+ error: error instanceof Error ? error.message : String(error),
164
+ });
165
+ // On failure, return original result unchanged
166
+ return { result: context.result };
167
+ }
168
+ }
169
+ /**
170
+ * Access the store (needed by recall_full_result tool).
171
+ */
172
+ getStore() {
173
+ return this.store;
174
+ }
175
+ /**
176
+ * Get the resolved config for a specific tool.
177
+ */
178
+ getToolConfig(toolName) {
179
+ const override = this.config.toolOverrides?.[toolName];
180
+ return {
181
+ enabled: override?.enabled ?? this.config.enabled,
182
+ threshold: override?.threshold ?? this.config.delegationThreshold,
183
+ strategy: override?.strategy ?? this.config.strategy,
184
+ };
185
+ }
186
+ /**
187
+ * Extractive summarization: first/last lines + prioritized structural markers.
188
+ * No LLM cost, fast, preserves actual code signatures.
189
+ */
190
+ summarizeExtractive(content) {
191
+ const lines = content.split('\n');
192
+ const totalLines = lines.length;
193
+ if (totalLines <= 40) {
194
+ // Small enough to include mostly as-is
195
+ return content;
196
+ }
197
+ const parts = [];
198
+ // Header: makes it impossible to confuse with full content
199
+ parts.push(`[SUMMARIZED — showing first 20 + last 10 of ${String(totalLines)} lines, plus key signatures. Use recall_full_result for complete output.]`);
200
+ parts.push('');
201
+ // First 20 lines
202
+ parts.push(...lines.slice(0, 20));
203
+ // Extract structural markers from the middle, prioritized by importance
204
+ const middleLines = lines.slice(20, -10);
205
+ // High priority: exports, class/interface/function declarations with signatures
206
+ const highPriority = /^\s*export\s+(default\s+)?(async\s+)?(function|class|interface|type|const|enum)\s/;
207
+ // Medium priority: non-exported declarations, method signatures
208
+ const medPriority = /^\s*(async\s+)?(function|class|interface|type|const|enum)\s|^\s+(async\s+)?(\w+)\s*\([^)]*\)\s*[:{]/;
209
+ // Low priority: errors, headings, section markers
210
+ const lowPriority = /^\s*(ERROR|WARN|FAIL|error|warning|#{1,6}\s|---{3,}|\*{3,}|={3,}|\/\/\s*={3,})/;
211
+ const high = [];
212
+ const med = [];
213
+ const low = [];
214
+ for (const line of middleLines) {
215
+ if (highPriority.test(line)) {
216
+ high.push(line);
217
+ }
218
+ else if (medPriority.test(line)) {
219
+ med.push(line);
220
+ }
221
+ else if (lowPriority.test(line)) {
222
+ low.push(line);
223
+ }
224
+ }
225
+ // Budget: scale markers with file size, cap at 40
226
+ const totalMarkers = high.length + med.length + low.length;
227
+ const budget = Math.min(40, Math.max(15, Math.floor(totalLines / 80)));
228
+ // Fill budget: all high, then medium, then low
229
+ const selected = [];
230
+ for (const line of high) {
231
+ if (selected.length >= budget)
232
+ break;
233
+ selected.push(line);
234
+ }
235
+ for (const line of med) {
236
+ if (selected.length >= budget)
237
+ break;
238
+ selected.push(line);
239
+ }
240
+ for (const line of low) {
241
+ if (selected.length >= budget)
242
+ break;
243
+ selected.push(line);
244
+ }
245
+ if (selected.length > 0) {
246
+ parts.push('');
247
+ parts.push(`... (${String(middleLines.length)} lines omitted, ${String(totalMarkers)} structural markers found, showing ${String(selected.length)} key signatures) ...`);
248
+ parts.push(...selected);
249
+ if (totalMarkers > selected.length) {
250
+ parts.push(`... (${String(totalMarkers - selected.length)} more markers omitted) ...`);
251
+ }
252
+ }
253
+ else {
254
+ parts.push('');
255
+ parts.push(`... (${String(middleLines.length)} lines omitted) ...`);
256
+ }
257
+ // Last 10 lines
258
+ parts.push('');
259
+ parts.push(...lines.slice(-10));
260
+ parts.push('');
261
+ parts.push(`[Total: ${String(totalLines)} lines]`);
262
+ return parts.join('\n');
263
+ }
264
+ /**
265
+ * LLM-based summarization using the provider.
266
+ * Returns null if the LLM call fails.
267
+ */
268
+ async summarizeLLM(content, toolName, signal) {
269
+ try {
270
+ if (signal?.aborted)
271
+ return null;
272
+ const messages = [
273
+ {
274
+ role: 'system',
275
+ content: SUMMARIZATION_SYSTEM_PROMPT,
276
+ },
277
+ {
278
+ role: 'user',
279
+ content: `Summarize this ${toolName} tool result (keep under ${String(this.config.summaryMaxTokens)} tokens):\n\n${content}`,
280
+ },
281
+ ];
282
+ let text = '';
283
+ for await (const chunk of this.provider.chat(messages, {
284
+ maxTokens: this.config.summaryMaxTokens,
285
+ temperature: 0,
286
+ model: undefined, // Use the provider's default model
287
+ signal,
288
+ })) {
289
+ if (signal?.aborted)
290
+ break;
291
+ if (chunk.type === 'text' && chunk.text) {
292
+ text += chunk.text;
293
+ }
294
+ }
295
+ return text.trim() || null;
296
+ }
297
+ catch {
298
+ return null;
299
+ }
300
+ }
301
+ }
302
+ /**
303
+ * System prompt addition when delegation is enabled.
304
+ * Append to the agent's system prompt.
305
+ */
306
+ export const DELEGATION_SYSTEM_PROMPT = '\n\n## Tool Result Delegation\n' +
307
+ 'Some tool results are automatically summarized to conserve context. When a result has `_delegated: true`, ' +
308
+ 'you received a SUMMARY, not the full output. You MUST acknowledge this — never claim you read the full content.\n' +
309
+ 'Delegated results include:\n' +
310
+ '- `_warning`: confirms this is a summary\n' +
311
+ '- `summary`: extracted key information\n' +
312
+ '- `recall`: how to get the full output\n' +
313
+ 'Use `recall_full_result` with the delegation ID to get the full output when the summary is insufficient.\n' +
314
+ 'Only recall when you need specific code, exact values, or the summary indicates omitted content.\n';