@probelabs/probe 0.6.0-rc161 → 0.6.0-rc162
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/README.md +89 -0
- package/build/agent/ProbeAgent.js +349 -85
- package/build/agent/contextCompactor.js +271 -0
- package/build/agent/index.js +788 -84
- package/build/agent/schemaUtils.js +7 -0
- package/build/agent/xmlParsingUtils.js +24 -4
- package/build/tools/common.js +16 -1
- package/cjs/agent/ProbeAgent.cjs +823 -134
- package/cjs/index.cjs +823 -134
- package/package.json +1 -1
- package/src/agent/ProbeAgent.js +349 -85
- package/src/agent/contextCompactor.js +271 -0
- package/src/agent/index.js +30 -1
- package/src/agent/schemaUtils.js +7 -0
- package/src/agent/xmlParsingUtils.js +24 -4
- package/src/tools/common.js +16 -1
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Window Compactor
|
|
3
|
+
*
|
|
4
|
+
* Handles context window overflow by intelligently removing intermediate agentic
|
|
5
|
+
* monologue sections while preserving user messages, final answers, and the most
|
|
6
|
+
* recent monologue.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Context limit error patterns to detect from various AI providers
|
|
11
|
+
* Simple substring matching similar to RetryManager's approach
|
|
12
|
+
*/
|
|
13
|
+
const CONTEXT_LIMIT_ERROR_PATTERNS = [
|
|
14
|
+
// Anthropic
|
|
15
|
+
'context_length_exceeded',
|
|
16
|
+
'prompt is too long',
|
|
17
|
+
|
|
18
|
+
// OpenAI
|
|
19
|
+
'maximum context length',
|
|
20
|
+
'context length is',
|
|
21
|
+
|
|
22
|
+
// Google/Gemini
|
|
23
|
+
'input token count exceeds',
|
|
24
|
+
'token limit exceeded',
|
|
25
|
+
|
|
26
|
+
// Generic patterns
|
|
27
|
+
'context window',
|
|
28
|
+
'too many tokens',
|
|
29
|
+
'token limit',
|
|
30
|
+
'context limit',
|
|
31
|
+
'exceed', // Catches "exceeds", "exceed maximum", etc.
|
|
32
|
+
'over the limit',
|
|
33
|
+
'maximum tokens'
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if an error message indicates a context window limit was exceeded
|
|
38
|
+
* Uses simple substring matching for reliability and maintainability
|
|
39
|
+
*
|
|
40
|
+
* @param {Error|string} error - The error object or error message
|
|
41
|
+
* @returns {boolean} - True if the error indicates context limit exceeded
|
|
42
|
+
*/
|
|
43
|
+
export function isContextLimitError(error) {
|
|
44
|
+
if (!error) return false;
|
|
45
|
+
|
|
46
|
+
// Get error message in various forms
|
|
47
|
+
const errorMessage = (typeof error === 'string' ? error : (error?.message || '')).toLowerCase();
|
|
48
|
+
const errorString = error.toString().toLowerCase();
|
|
49
|
+
|
|
50
|
+
// Check if any pattern matches
|
|
51
|
+
for (const pattern of CONTEXT_LIMIT_ERROR_PATTERNS) {
|
|
52
|
+
const lowerPattern = pattern.toLowerCase();
|
|
53
|
+
if (errorMessage.includes(lowerPattern) || errorString.includes(lowerPattern)) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Identify message boundaries in conversation history
|
|
63
|
+
* Structure: <user> -> <internal agentic monologue> -> <final-agent-answer>
|
|
64
|
+
*
|
|
65
|
+
* A "segment" is:
|
|
66
|
+
* - user message (role: 'user')
|
|
67
|
+
* - followed by 0+ assistant messages (internal monologue)
|
|
68
|
+
* - ending with tool_result or attempt_completion (final answer)
|
|
69
|
+
*
|
|
70
|
+
* @param {Array} messages - Array of message objects with {role, content}
|
|
71
|
+
* @returns {Array} - Array of segments, each containing {userIndex, monologueIndices, finalIndex}
|
|
72
|
+
*/
|
|
73
|
+
export function identifyMessageSegments(messages) {
|
|
74
|
+
const segments = [];
|
|
75
|
+
let currentSegment = null;
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < messages.length; i++) {
|
|
78
|
+
const msg = messages[i];
|
|
79
|
+
|
|
80
|
+
// Skip system messages
|
|
81
|
+
if (msg.role === 'system') {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// User message starts a new segment
|
|
86
|
+
if (msg.role === 'user') {
|
|
87
|
+
// Check if this is a tool_result (final answer from previous segment)
|
|
88
|
+
const content = typeof msg.content === 'string' ? msg.content : '';
|
|
89
|
+
const isToolResult = content.includes('<tool_result>');
|
|
90
|
+
|
|
91
|
+
if (isToolResult && currentSegment) {
|
|
92
|
+
// This is the final answer for the current segment
|
|
93
|
+
currentSegment.finalIndex = i;
|
|
94
|
+
segments.push(currentSegment);
|
|
95
|
+
currentSegment = null;
|
|
96
|
+
} else {
|
|
97
|
+
// Save previous segment if it exists
|
|
98
|
+
if (currentSegment) {
|
|
99
|
+
segments.push(currentSegment);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Start new segment
|
|
103
|
+
currentSegment = {
|
|
104
|
+
userIndex: i,
|
|
105
|
+
monologueIndices: [],
|
|
106
|
+
finalIndex: null
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Assistant message is part of monologue
|
|
112
|
+
if (msg.role === 'assistant' && currentSegment) {
|
|
113
|
+
const content = typeof msg.content === 'string' ? msg.content : '';
|
|
114
|
+
|
|
115
|
+
// Check if this contains attempt_completion (marks end of segment)
|
|
116
|
+
if (content.includes('<attempt_completion>') || content.includes('attempt_completion')) {
|
|
117
|
+
currentSegment.monologueIndices.push(i);
|
|
118
|
+
currentSegment.finalIndex = i;
|
|
119
|
+
segments.push(currentSegment);
|
|
120
|
+
currentSegment = null;
|
|
121
|
+
} else {
|
|
122
|
+
// Regular monologue message
|
|
123
|
+
currentSegment.monologueIndices.push(i);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Save any remaining segment
|
|
129
|
+
if (currentSegment) {
|
|
130
|
+
segments.push(currentSegment);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return segments;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Compact messages by removing intermediate monologues
|
|
138
|
+
*
|
|
139
|
+
* Strategy:
|
|
140
|
+
* 1. Keep all user messages
|
|
141
|
+
* 2. Keep all final answers (tool_results, attempt_completion)
|
|
142
|
+
* 3. Remove intermediate monologue messages from completed segments
|
|
143
|
+
* 4. Keep the most recent (active) segment intact
|
|
144
|
+
*
|
|
145
|
+
* @param {Array} messages - Array of message objects
|
|
146
|
+
* @param {Object} options - Compaction options
|
|
147
|
+
* @param {boolean} [options.keepLastSegment=true] - Keep the most recent segment intact
|
|
148
|
+
* @param {number} [options.minSegmentsToKeep=1] - Minimum number of recent segments to preserve fully
|
|
149
|
+
* @returns {Array} - Compacted message array
|
|
150
|
+
*/
|
|
151
|
+
export function compactMessages(messages, options = {}) {
|
|
152
|
+
const {
|
|
153
|
+
keepLastSegment = true,
|
|
154
|
+
minSegmentsToKeep = 1
|
|
155
|
+
} = options;
|
|
156
|
+
|
|
157
|
+
if (!messages || messages.length === 0) {
|
|
158
|
+
return messages;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Identify segments
|
|
162
|
+
const segments = identifyMessageSegments(messages);
|
|
163
|
+
|
|
164
|
+
if (segments.length === 0) {
|
|
165
|
+
return messages;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Determine which segments to keep fully vs compact
|
|
169
|
+
const segmentsToPreserve = keepLastSegment
|
|
170
|
+
? Math.max(minSegmentsToKeep, 1)
|
|
171
|
+
: minSegmentsToKeep;
|
|
172
|
+
|
|
173
|
+
const compactableSegments = segments.slice(0, -segmentsToPreserve);
|
|
174
|
+
const preservedSegments = segments.slice(-segmentsToPreserve);
|
|
175
|
+
|
|
176
|
+
// Build set of indices to keep
|
|
177
|
+
const indicesToKeep = new Set();
|
|
178
|
+
|
|
179
|
+
// Keep system messages
|
|
180
|
+
messages.forEach((msg, idx) => {
|
|
181
|
+
if (msg.role === 'system') {
|
|
182
|
+
indicesToKeep.add(idx);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// For compactable segments: keep user message and final answer only
|
|
187
|
+
compactableSegments.forEach(segment => {
|
|
188
|
+
indicesToKeep.add(segment.userIndex);
|
|
189
|
+
if (segment.finalIndex !== null) {
|
|
190
|
+
indicesToKeep.add(segment.finalIndex);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// For preserved segments: keep everything
|
|
195
|
+
preservedSegments.forEach(segment => {
|
|
196
|
+
indicesToKeep.add(segment.userIndex);
|
|
197
|
+
segment.monologueIndices.forEach(idx => indicesToKeep.add(idx));
|
|
198
|
+
if (segment.finalIndex !== null) {
|
|
199
|
+
indicesToKeep.add(segment.finalIndex);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Filter messages
|
|
204
|
+
const compactedMessages = messages.filter((_, idx) => indicesToKeep.has(idx));
|
|
205
|
+
|
|
206
|
+
return compactedMessages;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Calculate reduction statistics
|
|
211
|
+
* @param {Array} originalMessages - Original message array
|
|
212
|
+
* @param {Array} compactedMessages - Compacted message array
|
|
213
|
+
* @returns {Object} - Statistics about the compaction
|
|
214
|
+
*/
|
|
215
|
+
export function calculateCompactionStats(originalMessages, compactedMessages) {
|
|
216
|
+
const originalCount = originalMessages.length;
|
|
217
|
+
const compactedCount = compactedMessages.length;
|
|
218
|
+
const removed = originalCount - compactedCount;
|
|
219
|
+
const reductionPercent = originalCount > 0
|
|
220
|
+
? ((removed / originalCount) * 100).toFixed(1)
|
|
221
|
+
: 0;
|
|
222
|
+
|
|
223
|
+
// Estimate token savings (rough approximation)
|
|
224
|
+
const estimateTokens = (msgs) => {
|
|
225
|
+
return msgs.reduce((sum, msg) => {
|
|
226
|
+
const content = typeof msg.content === 'string'
|
|
227
|
+
? msg.content
|
|
228
|
+
: JSON.stringify(msg.content);
|
|
229
|
+
// Rough estimate: 1 token ≈ 4 characters
|
|
230
|
+
return sum + Math.ceil(content.length / 4);
|
|
231
|
+
}, 0);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const originalTokens = estimateTokens(originalMessages);
|
|
235
|
+
const compactedTokens = estimateTokens(compactedMessages);
|
|
236
|
+
const tokensSaved = originalTokens - compactedTokens;
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
originalCount,
|
|
240
|
+
compactedCount,
|
|
241
|
+
removed,
|
|
242
|
+
reductionPercent: parseFloat(reductionPercent),
|
|
243
|
+
originalTokens,
|
|
244
|
+
compactedTokens,
|
|
245
|
+
tokensSaved
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Main compaction handler for ProbeAgent
|
|
251
|
+
* Detects context limit errors and performs intelligent compaction
|
|
252
|
+
*
|
|
253
|
+
* @param {Error} error - The error from the AI provider
|
|
254
|
+
* @param {Array} messages - Current message array
|
|
255
|
+
* @param {Object} options - Compaction options
|
|
256
|
+
* @returns {Object|null} - { compacted: true, messages, stats } or null if not applicable
|
|
257
|
+
*/
|
|
258
|
+
export function handleContextLimitError(error, messages, options = {}) {
|
|
259
|
+
if (!isContextLimitError(error)) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const compactedMessages = compactMessages(messages, options);
|
|
264
|
+
const stats = calculateCompactionStats(messages, compactedMessages);
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
compacted: true,
|
|
268
|
+
messages: compactedMessages,
|
|
269
|
+
stats
|
|
270
|
+
};
|
|
271
|
+
}
|
package/src/agent/index.js
CHANGED
|
@@ -134,6 +134,8 @@ function parseArgs() {
|
|
|
134
134
|
useStdin: false, // New flag to indicate stdin should be used
|
|
135
135
|
outline: false, // New flag to enable outline format
|
|
136
136
|
noMermaidValidation: false, // New flag to disable mermaid validation
|
|
137
|
+
allowedTools: null, // Tool filtering: ['*'] = all, [] = none, ['tool1', 'tool2'] = specific
|
|
138
|
+
disableTools: false, // Convenience flag to disable all tools
|
|
137
139
|
// Bash tool configuration
|
|
138
140
|
enableBash: false,
|
|
139
141
|
bashAllow: null,
|
|
@@ -189,6 +191,19 @@ function parseArgs() {
|
|
|
189
191
|
config.outline = true;
|
|
190
192
|
} else if (arg === '--no-mermaid-validation') {
|
|
191
193
|
config.noMermaidValidation = true;
|
|
194
|
+
} else if (arg === '--allowed-tools' && i + 1 < args.length) {
|
|
195
|
+
// Parse allowed tools: comma-separated list or special values
|
|
196
|
+
const toolsArg = args[++i];
|
|
197
|
+
if (toolsArg === '*' || toolsArg === 'all') {
|
|
198
|
+
config.allowedTools = ['*'];
|
|
199
|
+
} else if (toolsArg === 'none' || toolsArg === '') {
|
|
200
|
+
config.allowedTools = [];
|
|
201
|
+
} else {
|
|
202
|
+
config.allowedTools = toolsArg.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
|
203
|
+
}
|
|
204
|
+
} else if (arg === '--disable-tools') {
|
|
205
|
+
// Convenience flag to disable all tools (raw AI mode)
|
|
206
|
+
config.disableTools = true;
|
|
192
207
|
} else if (arg === '--enable-bash') {
|
|
193
208
|
config.enableBash = true;
|
|
194
209
|
} else if (arg === '--bash-allow' && i + 1 < args.length) {
|
|
@@ -243,6 +258,13 @@ Options:
|
|
|
243
258
|
--model <name> Override model name
|
|
244
259
|
--allow-edit Enable code modification capabilities
|
|
245
260
|
--enable-delegate Enable delegate tool for task distribution to subagents
|
|
261
|
+
--allowed-tools <tools> Filter available tools (comma-separated list)
|
|
262
|
+
Use '*' or 'all' for all tools (default)
|
|
263
|
+
Use 'none' or '' for no tools (raw AI mode)
|
|
264
|
+
Specific tools: search,query,extract,listFiles,searchFiles
|
|
265
|
+
Supports exclusion: '*,!bash' (all except bash)
|
|
266
|
+
--disable-tools Disable all tools (raw AI mode, no code analysis)
|
|
267
|
+
Convenience flag equivalent to --allowed-tools none
|
|
246
268
|
--verbose Enable verbose output
|
|
247
269
|
--outline Use outline-xml format for code search results
|
|
248
270
|
--mcp Run as MCP server
|
|
@@ -284,6 +306,9 @@ Examples:
|
|
|
284
306
|
probe agent "Analyze codebase" --schema schema.json # Schema from file
|
|
285
307
|
probe agent "Debug issue" --trace-file ./debug.jsonl --verbose
|
|
286
308
|
probe agent "Analyze code" --trace-remote http://localhost:4318/v1/traces
|
|
309
|
+
probe agent "Explain this code" --allowed-tools search,extract # Only search and extract
|
|
310
|
+
probe agent "What is this project about?" --allowed-tools none # Raw AI mode (no tools)
|
|
311
|
+
probe agent "Tell me about this project" --disable-tools # Raw AI mode (convenience flag)
|
|
287
312
|
probe agent --mcp # Start MCP server mode
|
|
288
313
|
probe agent --acp # Start ACP server mode
|
|
289
314
|
|
|
@@ -434,7 +459,9 @@ class ProbeAgentMcpServer {
|
|
|
434
459
|
allowEdit: !!args.allow_edit,
|
|
435
460
|
debug: process.env.DEBUG === '1',
|
|
436
461
|
maxResponseTokens: args.max_response_tokens,
|
|
437
|
-
disableMermaidValidation: !!args.no_mermaid_validation
|
|
462
|
+
disableMermaidValidation: !!args.no_mermaid_validation,
|
|
463
|
+
allowedTools: args.allowed_tools,
|
|
464
|
+
disableTools: args.disable_tools
|
|
438
465
|
};
|
|
439
466
|
|
|
440
467
|
this.agent = new ProbeAgent(agentConfig);
|
|
@@ -726,6 +753,8 @@ async function main() {
|
|
|
726
753
|
outline: config.outline,
|
|
727
754
|
maxResponseTokens: config.maxResponseTokens,
|
|
728
755
|
disableMermaidValidation: config.noMermaidValidation,
|
|
756
|
+
allowedTools: config.allowedTools,
|
|
757
|
+
disableTools: config.disableTools,
|
|
729
758
|
enableBash: config.enableBash,
|
|
730
759
|
bashConfig: bashConfig
|
|
731
760
|
};
|
package/src/agent/schemaUtils.js
CHANGED
|
@@ -100,6 +100,13 @@ export function decodeHtmlEntities(text) {
|
|
|
100
100
|
/**
|
|
101
101
|
* Clean AI response by extracting JSON content when response contains JSON
|
|
102
102
|
* Only processes responses that contain JSON structures { or [
|
|
103
|
+
*
|
|
104
|
+
* NOTE: This function handles both JSON extraction AND content stripping.
|
|
105
|
+
* Future improvement: Consider splitting into separate functions for better separation of concerns:
|
|
106
|
+
* - extractJsonContent() - Find and extract JSON from response
|
|
107
|
+
* - stripNonJsonContent() - Remove explanatory text while preserving validation-relevant content
|
|
108
|
+
* This would allow validation to run on full responses before content is discarded.
|
|
109
|
+
*
|
|
103
110
|
* @param {string} response - Raw AI response
|
|
104
111
|
* @returns {string} - Cleaned response with JSON boundaries extracted if applicable
|
|
105
112
|
*/
|
|
@@ -59,10 +59,30 @@ export function extractThinkingContent(xmlString) {
|
|
|
59
59
|
export function checkAttemptCompleteRecovery(cleanedXmlString, validTools = []) {
|
|
60
60
|
// Check for <attempt_completion> with content (with or without closing tag)
|
|
61
61
|
// This handles: "<attempt_completion>content" or "<attempt_completion>content</attempt_completion>"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
|
|
63
|
+
// IMPORTANT: Use greedy match ([\s\S]*) instead of non-greedy ([\s\S]*?) to handle cases
|
|
64
|
+
// where the content contains the string "</attempt_completion>" (e.g., in regex patterns or code examples).
|
|
65
|
+
// We want to find the LAST occurrence of </attempt_completion>, not the first one.
|
|
66
|
+
const openTagIndex = cleanedXmlString.indexOf('<attempt_completion>');
|
|
67
|
+
if (openTagIndex !== -1) {
|
|
68
|
+
const afterOpenTag = cleanedXmlString.substring(openTagIndex + '<attempt_completion>'.length);
|
|
69
|
+
const closeTagIndex = cleanedXmlString.lastIndexOf('</attempt_completion>');
|
|
70
|
+
|
|
71
|
+
let content;
|
|
72
|
+
let hasClosingTag = false;
|
|
73
|
+
|
|
74
|
+
if (closeTagIndex !== -1 && closeTagIndex >= openTagIndex + '<attempt_completion>'.length) {
|
|
75
|
+
// Found a closing tag at or after the opening tag - extract content between them
|
|
76
|
+
content = cleanedXmlString.substring(
|
|
77
|
+
openTagIndex + '<attempt_completion>'.length,
|
|
78
|
+
closeTagIndex
|
|
79
|
+
).trim();
|
|
80
|
+
hasClosingTag = true;
|
|
81
|
+
} else {
|
|
82
|
+
// No closing tag - use content from opening tag to end of string
|
|
83
|
+
content = afterOpenTag.trim();
|
|
84
|
+
hasClosingTag = false;
|
|
85
|
+
}
|
|
66
86
|
|
|
67
87
|
if (content) {
|
|
68
88
|
// If there's content after the tag, use it as the result
|
package/src/tools/common.js
CHANGED
|
@@ -384,7 +384,22 @@ export function parseXmlToolCall(xmlString, validTools = DEFAULT_VALID_TOOLS) {
|
|
|
384
384
|
continue; // Tool not found, try next tool
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
-
|
|
387
|
+
// For attempt_completion, use lastIndexOf to find the LAST occurrence of closing tag
|
|
388
|
+
// This prevents issues where the content contains the closing tag string (e.g., in regex patterns)
|
|
389
|
+
// For other tools, use indexOf from the opening tag position
|
|
390
|
+
let closeIndex;
|
|
391
|
+
if (toolName === 'attempt_completion') {
|
|
392
|
+
// Find the last occurrence of the closing tag in the entire string
|
|
393
|
+
// This assumes attempt_completion doesn't have nested tags of the same name
|
|
394
|
+
closeIndex = xmlString.lastIndexOf(closeTag);
|
|
395
|
+
// Make sure the closing tag is after the opening tag
|
|
396
|
+
if (closeIndex !== -1 && closeIndex <= openIndex + openTag.length) {
|
|
397
|
+
closeIndex = -1; // Invalid, treat as no closing tag
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
closeIndex = xmlString.indexOf(closeTag, openIndex + openTag.length);
|
|
401
|
+
}
|
|
402
|
+
|
|
388
403
|
let hasClosingTag = closeIndex !== -1;
|
|
389
404
|
|
|
390
405
|
// If no closing tag found, use content until end of string
|