@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.
@@ -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
+ }
@@ -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
  };
@@ -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
- const attemptCompletionMatch = cleanedXmlString.match(/<attempt_completion>([\s\S]*?)(?:<\/attempt_completion>|$)/);
63
- if (attemptCompletionMatch) {
64
- const content = attemptCompletionMatch[1].trim();
65
- const hasClosingTag = cleanedXmlString.includes('</attempt_completion>');
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
@@ -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
- let closeIndex = xmlString.indexOf(closeTag, openIndex + openTag.length);
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