@probelabs/probe 0.6.0-rc295 → 0.6.0-rc296
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 +7 -0
- package/bin/binaries/{probe-v0.6.0-rc295-aarch64-apple-darwin.tar.gz → probe-v0.6.0-rc296-aarch64-apple-darwin.tar.gz} +0 -0
- package/bin/binaries/{probe-v0.6.0-rc295-aarch64-unknown-linux-musl.tar.gz → probe-v0.6.0-rc296-aarch64-unknown-linux-musl.tar.gz} +0 -0
- package/bin/binaries/{probe-v0.6.0-rc295-x86_64-apple-darwin.tar.gz → probe-v0.6.0-rc296-x86_64-apple-darwin.tar.gz} +0 -0
- package/bin/binaries/{probe-v0.6.0-rc295-x86_64-pc-windows-msvc.zip → probe-v0.6.0-rc296-x86_64-pc-windows-msvc.zip} +0 -0
- package/bin/binaries/{probe-v0.6.0-rc295-x86_64-unknown-linux-musl.tar.gz → probe-v0.6.0-rc296-x86_64-unknown-linux-musl.tar.gz} +0 -0
- package/build/agent/ProbeAgent.d.ts +8 -2
- package/build/agent/ProbeAgent.js +683 -10
- package/build/agent/mcp/client.js +81 -4
- package/build/agent/mcp/xmlBridge.js +11 -0
- package/build/agent/otelLogBridge.js +184 -0
- package/build/agent/simpleTelemetry.js +8 -0
- package/build/delegate.js +75 -6
- package/build/index.js +6 -2
- package/build/tools/common.js +84 -11
- package/build/tools/vercel.js +78 -18
- package/cjs/agent/ProbeAgent.cjs +858 -32
- package/cjs/agent/simpleTelemetry.cjs +112 -0
- package/cjs/index.cjs +970 -32
- package/index.d.ts +26 -0
- package/package.json +1 -1
- package/src/agent/ProbeAgent.d.ts +8 -2
- package/src/agent/ProbeAgent.js +683 -10
- package/src/agent/mcp/client.js +81 -4
- package/src/agent/mcp/xmlBridge.js +11 -0
- package/src/agent/otelLogBridge.js +184 -0
- package/src/agent/simpleTelemetry.js +8 -0
- package/src/delegate.js +75 -6
- package/src/index.js +6 -2
- package/src/tools/common.js +84 -11
- package/src/tools/vercel.js +78 -18
|
@@ -58,6 +58,11 @@ export function isMethodAllowed(methodName, allowedMethods, blockedMethods) {
|
|
|
58
58
|
export function createTransport(serverConfig) {
|
|
59
59
|
const { transport, command, args, url, env } = serverConfig;
|
|
60
60
|
|
|
61
|
+
// Allow pre-created transport instances (e.g., InMemoryTransport for testing)
|
|
62
|
+
if (serverConfig.transportInstance) {
|
|
63
|
+
return serverConfig.transportInstance;
|
|
64
|
+
}
|
|
65
|
+
|
|
61
66
|
switch (transport) {
|
|
62
67
|
case 'stdio':
|
|
63
68
|
return new StdioClientTransport({
|
|
@@ -520,6 +525,57 @@ export class MCPClientManager {
|
|
|
520
525
|
}
|
|
521
526
|
}
|
|
522
527
|
|
|
528
|
+
/**
|
|
529
|
+
* Call graceful_stop on all MCP servers that expose it.
|
|
530
|
+
* This signals agent-type MCP servers to wrap up their work.
|
|
531
|
+
* @returns {Promise<Array<{server: string, success: boolean, error?: string}>>}
|
|
532
|
+
*/
|
|
533
|
+
async callGracefulStopAll() {
|
|
534
|
+
const results = [];
|
|
535
|
+
for (const [serverName, clientInfo] of this.clients) {
|
|
536
|
+
// Look for a graceful_stop tool on this server (qualified name: serverName_graceful_stop)
|
|
537
|
+
const qualifiedName = `${serverName}_graceful_stop`;
|
|
538
|
+
if (this.tools.has(qualifiedName)) {
|
|
539
|
+
if (this.debug) {
|
|
540
|
+
console.log(`[DEBUG] MCP callGracefulStopAll: calling graceful_stop on server "${serverName}"`);
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
// Short timeout — this is a signal, not a long operation
|
|
544
|
+
const timeoutMs = 5000;
|
|
545
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
546
|
+
setTimeout(() => reject(new Error('graceful_stop timeout')), timeoutMs)
|
|
547
|
+
);
|
|
548
|
+
await Promise.race([
|
|
549
|
+
clientInfo.client.callTool({ name: 'graceful_stop', arguments: {} }, undefined, { timeout: timeoutMs }),
|
|
550
|
+
timeoutPromise
|
|
551
|
+
]);
|
|
552
|
+
results.push({ server: serverName, success: true });
|
|
553
|
+
if (this.debug) {
|
|
554
|
+
console.log(`[DEBUG] MCP callGracefulStopAll: server "${serverName}" acknowledged graceful_stop`);
|
|
555
|
+
}
|
|
556
|
+
} catch (e) {
|
|
557
|
+
results.push({ server: serverName, success: false, error: e.message });
|
|
558
|
+
if (this.debug) {
|
|
559
|
+
console.log(`[DEBUG] MCP callGracefulStopAll: server "${serverName}" graceful_stop failed: ${e.message}`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (this.debug) {
|
|
565
|
+
const withStop = results.length;
|
|
566
|
+
const total = this.clients.size;
|
|
567
|
+
console.log(`[DEBUG] MCP callGracefulStopAll: ${withStop}/${total} servers had graceful_stop tool`);
|
|
568
|
+
}
|
|
569
|
+
// Record telemetry event for the graceful_stop sweep
|
|
570
|
+
this.recordMcpEvent('graceful_stop.sweep_completed', {
|
|
571
|
+
servers_total: this.clients.size,
|
|
572
|
+
servers_with_graceful_stop: results.length,
|
|
573
|
+
servers_acknowledged: results.filter(r => r.success).length,
|
|
574
|
+
servers_failed: results.filter(r => !r.success).length,
|
|
575
|
+
});
|
|
576
|
+
return results;
|
|
577
|
+
}
|
|
578
|
+
|
|
523
579
|
/**
|
|
524
580
|
* Get all available tools with their schemas
|
|
525
581
|
* @returns {Object} Map of tool name to tool definition
|
|
@@ -550,11 +606,32 @@ export class MCPClientManager {
|
|
|
550
606
|
inputSchema: tool.inputSchema,
|
|
551
607
|
execute: async (args) => {
|
|
552
608
|
const result = await this.callTool(name, args);
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
609
|
+
if (!result.content || !result.content[0]) {
|
|
610
|
+
return JSON.stringify(result);
|
|
611
|
+
}
|
|
612
|
+
// Check if response contains image content blocks
|
|
613
|
+
const hasImage = result.content.some(block => block.type === 'image');
|
|
614
|
+
if (hasImage) {
|
|
615
|
+
// Return the full content array so toModelOutput can convert it
|
|
616
|
+
return { _mcpContent: result.content };
|
|
617
|
+
}
|
|
618
|
+
// Text-only: return just the text
|
|
619
|
+
return result.content[0].text;
|
|
620
|
+
},
|
|
621
|
+
// Convert MCP content blocks (including images) to Vercel AI SDK format
|
|
622
|
+
toModelOutput: ({ output }) => {
|
|
623
|
+
if (output && typeof output === 'object' && output._mcpContent) {
|
|
624
|
+
const parts = [];
|
|
625
|
+
for (const block of output._mcpContent) {
|
|
626
|
+
if (block.type === 'text') {
|
|
627
|
+
parts.push({ type: 'text', text: block.text });
|
|
628
|
+
} else if (block.type === 'image') {
|
|
629
|
+
parts.push({ type: 'image-data', data: block.data, mediaType: block.mimeType });
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return { type: 'content', value: parts };
|
|
556
633
|
}
|
|
557
|
-
return JSON.stringify(
|
|
634
|
+
return { type: 'text', value: typeof output === 'string' ? output : JSON.stringify(output) };
|
|
558
635
|
}
|
|
559
636
|
};
|
|
560
637
|
}
|
|
@@ -145,6 +145,17 @@ export class MCPXmlBridge {
|
|
|
145
145
|
return toolName in this.mcpTools;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Call graceful_stop on all MCP servers that expose it.
|
|
150
|
+
* @returns {Promise<Array>}
|
|
151
|
+
*/
|
|
152
|
+
async callGracefulStopAll() {
|
|
153
|
+
if (this.mcpManager) {
|
|
154
|
+
return this.mcpManager.callGracefulStopAll();
|
|
155
|
+
}
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
|
|
148
159
|
/**
|
|
149
160
|
* Clean up MCP connections
|
|
150
161
|
*/
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OTEL Log Bridge — patches console.log/info/warn/error to:
|
|
3
|
+
* 1. Append trace context [trace_id=... span_id=...] to output (like visor2)
|
|
4
|
+
* 2. Emit each log as an OTEL Log Record via @opentelemetry/api-logs
|
|
5
|
+
*
|
|
6
|
+
* Lazy-loads @opentelemetry/api and @opentelemetry/api-logs.
|
|
7
|
+
* If packages are not installed, patching is a no-op.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* import { patchConsole, unpatchConsole } from './otelLogBridge.js';
|
|
11
|
+
* patchConsole(); // Call once at startup
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createRequire } from 'module';
|
|
15
|
+
|
|
16
|
+
// createRequire for loading optional @opentelemetry packages in ESM context
|
|
17
|
+
const _require = createRequire(import.meta.url);
|
|
18
|
+
|
|
19
|
+
// OTel severity mapping (OpenTelemetry SeverityNumber values)
|
|
20
|
+
const OTEL_SEVERITY = {
|
|
21
|
+
log: 9, // INFO
|
|
22
|
+
info: 9, // INFO
|
|
23
|
+
warn: 13, // WARN
|
|
24
|
+
error: 17, // ERROR
|
|
25
|
+
debug: 5, // DEBUG
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Track patch state
|
|
29
|
+
let patched = false;
|
|
30
|
+
const originals = {};
|
|
31
|
+
|
|
32
|
+
// Lazy-loaded OTel references
|
|
33
|
+
let otelApi = null;
|
|
34
|
+
let otelApiAttempted = false;
|
|
35
|
+
let otelLogger = null;
|
|
36
|
+
let otelLoggerAttempted = false;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Try to load @opentelemetry/api lazily.
|
|
40
|
+
* Returns { trace, context } or null if not available.
|
|
41
|
+
*/
|
|
42
|
+
function getOtelApi() {
|
|
43
|
+
if (otelApiAttempted) return otelApi;
|
|
44
|
+
otelApiAttempted = true;
|
|
45
|
+
try {
|
|
46
|
+
// Dynamic require wrapped in IIFE to prevent bundler from resolving
|
|
47
|
+
otelApi = (function(name) { return _require(name); })('@opentelemetry/api');
|
|
48
|
+
} catch {
|
|
49
|
+
// @opentelemetry/api not installed
|
|
50
|
+
}
|
|
51
|
+
return otelApi;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Try to get an OTEL Logger from @opentelemetry/api-logs lazily.
|
|
56
|
+
* Returns a logger instance or null if not available.
|
|
57
|
+
*/
|
|
58
|
+
function getOtelLogger() {
|
|
59
|
+
if (otelLoggerAttempted) return otelLogger;
|
|
60
|
+
otelLoggerAttempted = true;
|
|
61
|
+
try {
|
|
62
|
+
const { logs } = (function(name) { return _require(name); })('@opentelemetry/api-logs');
|
|
63
|
+
otelLogger = logs.getLogger('probe-agent');
|
|
64
|
+
} catch {
|
|
65
|
+
// @opentelemetry/api-logs not installed
|
|
66
|
+
}
|
|
67
|
+
return otelLogger;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Extract trace context suffix from the active OTel span.
|
|
72
|
+
* Returns '' if no active span or OTel is not available.
|
|
73
|
+
*/
|
|
74
|
+
function getTraceSuffix() {
|
|
75
|
+
try {
|
|
76
|
+
const api = getOtelApi();
|
|
77
|
+
if (!api) return '';
|
|
78
|
+
const span = api.trace.getSpan(api.context.active());
|
|
79
|
+
const ctx = span?.spanContext?.();
|
|
80
|
+
if (!ctx?.traceId) return '';
|
|
81
|
+
return ` [trace_id=${ctx.traceId} span_id=${ctx.spanId}]`;
|
|
82
|
+
} catch {
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Emit a log record to the OTEL Logs pipeline.
|
|
89
|
+
* Non-blocking, best-effort — errors are silently ignored.
|
|
90
|
+
*/
|
|
91
|
+
function emitOtelLog(msg, level) {
|
|
92
|
+
try {
|
|
93
|
+
const logger = getOtelLogger();
|
|
94
|
+
if (!logger) return;
|
|
95
|
+
|
|
96
|
+
const api = getOtelApi();
|
|
97
|
+
let traceId, spanId;
|
|
98
|
+
if (api) {
|
|
99
|
+
const span = api.trace.getSpan(api.context.active());
|
|
100
|
+
const ctx = span?.spanContext?.();
|
|
101
|
+
if (ctx?.traceId) {
|
|
102
|
+
traceId = ctx.traceId;
|
|
103
|
+
spanId = ctx.spanId;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
logger.emit({
|
|
108
|
+
severityNumber: OTEL_SEVERITY[level] || 9,
|
|
109
|
+
severityText: level.toUpperCase(),
|
|
110
|
+
body: msg,
|
|
111
|
+
attributes: {
|
|
112
|
+
'probe.logger': true,
|
|
113
|
+
...(traceId ? { trace_id: traceId, span_id: spanId } : {}),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
} catch {
|
|
117
|
+
// OTel logs not available; ignore
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Patch console.log/info/warn/error to:
|
|
123
|
+
* - Append trace context to output
|
|
124
|
+
* - Emit OTEL log records
|
|
125
|
+
*
|
|
126
|
+
* Safe to call multiple times — only patches once.
|
|
127
|
+
*/
|
|
128
|
+
export function patchConsole() {
|
|
129
|
+
if (patched) return;
|
|
130
|
+
|
|
131
|
+
const methods = ['log', 'info', 'warn', 'error'];
|
|
132
|
+
const c = globalThis.console;
|
|
133
|
+
|
|
134
|
+
for (const m of methods) {
|
|
135
|
+
const orig = c[m].bind(c);
|
|
136
|
+
originals[m] = orig;
|
|
137
|
+
|
|
138
|
+
c[m] = (...args) => {
|
|
139
|
+
// Build the message string for OTEL log emission
|
|
140
|
+
const msgParts = args.map(a =>
|
|
141
|
+
typeof a === 'string' ? a : (a instanceof Error ? a.message : JSON.stringify(a))
|
|
142
|
+
);
|
|
143
|
+
const msg = msgParts.join(' ');
|
|
144
|
+
|
|
145
|
+
// Emit to OTEL Logs pipeline (non-blocking, best-effort)
|
|
146
|
+
emitOtelLog(msg, m === 'log' ? 'log' : m);
|
|
147
|
+
|
|
148
|
+
// Append trace context suffix to console output
|
|
149
|
+
const suffix = getTraceSuffix();
|
|
150
|
+
if (suffix) {
|
|
151
|
+
if (typeof args[0] === 'string') {
|
|
152
|
+
args[0] = args[0] + suffix;
|
|
153
|
+
} else {
|
|
154
|
+
args.push(suffix);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return orig(...args);
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
patched = true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Restore original console methods.
|
|
167
|
+
* Useful for testing or cleanup.
|
|
168
|
+
*/
|
|
169
|
+
export function unpatchConsole() {
|
|
170
|
+
if (!patched) return;
|
|
171
|
+
|
|
172
|
+
const c = globalThis.console;
|
|
173
|
+
for (const [m, orig] of Object.entries(originals)) {
|
|
174
|
+
c[m] = orig;
|
|
175
|
+
}
|
|
176
|
+
patched = false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Check if console is currently patched.
|
|
181
|
+
*/
|
|
182
|
+
export function isConsolePatched() {
|
|
183
|
+
return patched;
|
|
184
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, createWriteStream } from 'fs';
|
|
2
2
|
import { dirname } from 'path';
|
|
3
|
+
import { patchConsole } from './otelLogBridge.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Simple telemetry implementation for probe-agent
|
|
@@ -512,5 +513,12 @@ export function initializeSimpleTelemetryFromOptions(options) {
|
|
|
512
513
|
filePath: options.traceFile || './traces.jsonl'
|
|
513
514
|
});
|
|
514
515
|
|
|
516
|
+
// Patch console methods to append trace context and emit OTEL log records.
|
|
517
|
+
// This bridges ALL console.log/info/warn/error calls to the OTEL Logs pipeline
|
|
518
|
+
// automatically — no code changes needed at existing call sites.
|
|
519
|
+
// Safe to call multiple times (idempotent). No-op if @opentelemetry/api-logs
|
|
520
|
+
// is not installed.
|
|
521
|
+
patchConsole();
|
|
522
|
+
|
|
515
523
|
return telemetry;
|
|
516
524
|
}
|
package/build/delegate.js
CHANGED
|
@@ -397,7 +397,14 @@ export async function delegate({
|
|
|
397
397
|
mcpConfigPath = null,
|
|
398
398
|
delegationManager = null, // Optional per-instance manager, falls back to default singleton
|
|
399
399
|
concurrencyLimiter = null, // Optional global AI concurrency limiter
|
|
400
|
-
parentAbortSignal = null
|
|
400
|
+
parentAbortSignal = null, // Optional AbortSignal from parent to cancel this delegation
|
|
401
|
+
// Timeout settings inherited from parent agent
|
|
402
|
+
timeoutBehavior = undefined,
|
|
403
|
+
requestTimeout = undefined,
|
|
404
|
+
gracefulTimeoutBonusSteps = undefined,
|
|
405
|
+
// Subagent lifecycle callbacks for graceful stop coordination
|
|
406
|
+
onSubagentCreated = null,
|
|
407
|
+
onSubagentCompleted = null,
|
|
401
408
|
}) {
|
|
402
409
|
if (!task || typeof task !== 'string') {
|
|
403
410
|
throw new Error('Task parameter is required and must be a string');
|
|
@@ -489,12 +496,38 @@ export async function delegate({
|
|
|
489
496
|
enableMcp, // Inherit from parent (subagent creates own MCPXmlBridge)
|
|
490
497
|
mcpConfig, // Inherit from parent
|
|
491
498
|
mcpConfigPath, // Inherit from parent
|
|
492
|
-
concurrencyLimiter // Inherit global AI concurrency limiter
|
|
499
|
+
concurrencyLimiter, // Inherit global AI concurrency limiter
|
|
500
|
+
// Inherit timeout behavior from parent — subagent gets its own graceful wind-down
|
|
501
|
+
// so it can produce partial results instead of being hard-killed by the external timer.
|
|
502
|
+
// The external delegate timeout (capped to parent's remaining budget) is the hard limit;
|
|
503
|
+
// maxOperationTimeout on the subagent is set slightly shorter so its own wind-down
|
|
504
|
+
// fires before the external kill.
|
|
505
|
+
maxOperationTimeout: Math.max(10000, (timeout * 1000) - 15000), // 15s before external kill
|
|
506
|
+
timeoutBehavior: timeoutBehavior || 'graceful',
|
|
507
|
+
requestTimeout,
|
|
508
|
+
gracefulTimeoutBonusSteps: gracefulTimeoutBonusSteps ?? 2, // fewer steps for subagents
|
|
493
509
|
});
|
|
494
510
|
|
|
511
|
+
// Register subagent with parent for graceful stop coordination
|
|
512
|
+
if (onSubagentCreated) {
|
|
513
|
+
onSubagentCreated(sessionId, subagent);
|
|
514
|
+
}
|
|
515
|
+
|
|
495
516
|
if (debug) {
|
|
496
517
|
console.error(`[DELEGATE] Created subagent with session ${sessionId}`);
|
|
497
518
|
console.error(`[DELEGATE] Subagent config: promptType=${promptType}, enableDelegate=false, maxIterations=${remainingIterations}`);
|
|
519
|
+
console.error(`[DELEGATE] Timeout inheritance: externalTimeout=${timeout}s, maxOperationTimeout=${Math.max(10000, (timeout * 1000) - 15000)}ms, behavior=${timeoutBehavior || 'graceful'}, bonusSteps=${gracefulTimeoutBonusSteps ?? 2}`);
|
|
520
|
+
}
|
|
521
|
+
if (tracer) {
|
|
522
|
+
tracer.addEvent('delegation.subagent_created', {
|
|
523
|
+
'delegation.session_id': sessionId,
|
|
524
|
+
'delegation.parent_session_id': parentSessionId,
|
|
525
|
+
'delegation.external_timeout_s': timeout,
|
|
526
|
+
'delegation.internal_timeout_ms': Math.max(10000, (timeout * 1000) - 15000),
|
|
527
|
+
'delegation.timeout_behavior': timeoutBehavior || 'graceful',
|
|
528
|
+
'delegation.bonus_steps': gracefulTimeoutBonusSteps ?? 2,
|
|
529
|
+
'delegation.max_iterations': remainingIterations,
|
|
530
|
+
});
|
|
498
531
|
}
|
|
499
532
|
|
|
500
533
|
// Set up timeout and parent abort handling.
|
|
@@ -507,8 +540,11 @@ export async function delegate({
|
|
|
507
540
|
}, timeout * 1000);
|
|
508
541
|
});
|
|
509
542
|
|
|
510
|
-
// Listen for parent abort signal
|
|
543
|
+
// Listen for parent abort signal — use two-phase shutdown:
|
|
544
|
+
// Phase 1: Trigger graceful wind-down so subagent can summarize its work
|
|
545
|
+
// Phase 2: Hard cancel after deadline if subagent hasn't finished
|
|
511
546
|
let parentAbortHandler;
|
|
547
|
+
let parentAbortHardCancelId = null;
|
|
512
548
|
const parentAbortPromise = new Promise((_, reject) => {
|
|
513
549
|
if (parentAbortSignal) {
|
|
514
550
|
if (parentAbortSignal.aborted) {
|
|
@@ -517,8 +553,33 @@ export async function delegate({
|
|
|
517
553
|
return;
|
|
518
554
|
}
|
|
519
555
|
parentAbortHandler = () => {
|
|
520
|
-
subagent
|
|
521
|
-
|
|
556
|
+
// Phase 1: graceful wind-down — let subagent finish its current step
|
|
557
|
+
subagent.triggerGracefulWindDown();
|
|
558
|
+
if (debug) {
|
|
559
|
+
console.error(`[DELEGATE] Parent abort signal received — triggered graceful wind-down on subagent ${sessionId}`);
|
|
560
|
+
}
|
|
561
|
+
if (tracer) {
|
|
562
|
+
tracer.addEvent('delegation.parent_abort_phase1', {
|
|
563
|
+
'delegation.session_id': sessionId,
|
|
564
|
+
'delegation.parent_session_id': parentSessionId,
|
|
565
|
+
'delegation.action': 'graceful_wind_down',
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
// Phase 2: hard cancel after 30s if subagent hasn't finished
|
|
569
|
+
parentAbortHardCancelId = setTimeout(() => {
|
|
570
|
+
if (debug) {
|
|
571
|
+
console.error(`[DELEGATE] Graceful wind-down deadline expired — hard cancelling subagent ${sessionId}`);
|
|
572
|
+
}
|
|
573
|
+
if (tracer) {
|
|
574
|
+
tracer.addEvent('delegation.parent_abort_phase2', {
|
|
575
|
+
'delegation.session_id': sessionId,
|
|
576
|
+
'delegation.parent_session_id': parentSessionId,
|
|
577
|
+
'delegation.action': 'hard_cancel',
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
subagent.cancel();
|
|
581
|
+
reject(new Error('Delegation cancelled: parent operation was aborted (graceful wind-down deadline expired)'));
|
|
582
|
+
}, 30000);
|
|
522
583
|
};
|
|
523
584
|
parentAbortSignal.addEventListener('abort', parentAbortHandler, { once: true });
|
|
524
585
|
}
|
|
@@ -533,10 +594,18 @@ export async function delegate({
|
|
|
533
594
|
try {
|
|
534
595
|
response = await Promise.race(racers);
|
|
535
596
|
} finally {
|
|
536
|
-
// Clean up parent abort listener to prevent memory leaks
|
|
597
|
+
// Clean up parent abort listener and hard cancel timer to prevent memory leaks
|
|
537
598
|
if (parentAbortHandler && parentAbortSignal) {
|
|
538
599
|
parentAbortSignal.removeEventListener('abort', parentAbortHandler);
|
|
539
600
|
}
|
|
601
|
+
if (parentAbortHardCancelId) {
|
|
602
|
+
clearTimeout(parentAbortHardCancelId);
|
|
603
|
+
parentAbortHardCancelId = null;
|
|
604
|
+
}
|
|
605
|
+
// Unregister subagent from parent
|
|
606
|
+
if (onSubagentCompleted) {
|
|
607
|
+
onSubagentCompleted(sessionId);
|
|
608
|
+
}
|
|
540
609
|
}
|
|
541
610
|
|
|
542
611
|
// Clear timeout immediately after race completes to prevent memory leak
|
package/build/index.js
CHANGED
|
@@ -45,7 +45,7 @@ import { createExecutePlanTool, createCleanupExecutePlanTool } from './tools/exe
|
|
|
45
45
|
import { bashTool } from './tools/bash.js';
|
|
46
46
|
import { editTool, createTool, multiEditTool } from './tools/edit.js';
|
|
47
47
|
import { FileTracker } from './tools/fileTracker.js';
|
|
48
|
-
import { ProbeAgent } from './agent/ProbeAgent.js';
|
|
48
|
+
import { ProbeAgent, ENGINE_ACTIVITY_TIMEOUT_DEFAULT, ENGINE_ACTIVITY_TIMEOUT_MIN, ENGINE_ACTIVITY_TIMEOUT_MAX } from './agent/ProbeAgent.js';
|
|
49
49
|
import { SimpleTelemetry, SimpleAppTracer, initializeSimpleTelemetryFromOptions } from './agent/simpleTelemetry.js';
|
|
50
50
|
import { listFilesToolInstance, searchFilesToolInstance } from './agent/probeTool.js';
|
|
51
51
|
import { StorageAdapter, InMemoryStorageAdapter } from './agent/storage/index.js';
|
|
@@ -68,8 +68,12 @@ export {
|
|
|
68
68
|
listFilesByLevel,
|
|
69
69
|
tools,
|
|
70
70
|
DEFAULT_SYSTEM_MESSAGE,
|
|
71
|
-
// Export AI Agent
|
|
71
|
+
// Export AI Agent
|
|
72
72
|
ProbeAgent,
|
|
73
|
+
// Export timeout constants
|
|
74
|
+
ENGINE_ACTIVITY_TIMEOUT_DEFAULT,
|
|
75
|
+
ENGINE_ACTIVITY_TIMEOUT_MIN,
|
|
76
|
+
ENGINE_ACTIVITY_TIMEOUT_MAX,
|
|
73
77
|
// Export storage adapters
|
|
74
78
|
StorageAdapter,
|
|
75
79
|
InMemoryStorageAdapter,
|
package/build/tools/common.js
CHANGED
|
@@ -10,7 +10,7 @@ import { resolve, isAbsolute } from 'path';
|
|
|
10
10
|
export const searchSchema = z.object({
|
|
11
11
|
query: z.string().describe('Search query — natural language questions or Elasticsearch-style keywords both work. For keywords: use quotes for exact phrases, AND/OR for boolean logic, - for negation. Probe handles stemming and camelCase/snake_case splitting automatically, so do NOT try case or style variations of the same keyword.'),
|
|
12
12
|
path: z.string().optional().default('.').describe('Path to search in. For dependencies use "go:github.com/owner/repo", "js:package_name", or "rust:cargo_name" etc.'),
|
|
13
|
-
exact: z.boolean().optional().default(false).describe('Default (false) enables stemming and keyword splitting for exploratory search - "getUserData" matches "get", "user", "data", etc. Set true for precise symbol lookup
|
|
13
|
+
exact: z.boolean().optional().default(false).describe('Default (false) enables stemming and keyword splitting for exploratory search - "getUserData" matches "get", "user", "data", etc. Set true for precise symbol lookup OR when searching for strings with punctuation/quotes/empty values (e.g. \'description: ""\' — BM25 strips punctuation so exact=true is required for literal matching). Use true when you know the exact symbol name or need literal string matching.'),
|
|
14
14
|
maxTokens: z.number().nullable().optional().describe('Maximum tokens to return. Default is 20000. Set to null for unlimited results.'),
|
|
15
15
|
session: z.string().optional().describe('Session ID for result caching and pagination. Pass the session ID from a previous search to get additional results (next page). Results already shown in a session are automatically excluded. Omit for a fresh search.'),
|
|
16
16
|
nextPage: z.boolean().optional().default(false).describe('Set to true when requesting the next page of results. Requires passing the same session ID from the previous search output.')
|
|
@@ -188,9 +188,74 @@ export function areBothStuckResponses(response1, response2) {
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Parse a shell-like string into tokens, respecting quoted substrings.
|
|
193
|
+
* Supports double quotes, single quotes, and escaped characters within quotes.
|
|
194
|
+
* Splits on commas and/or whitespace outside of quotes.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} input - The string to tokenize
|
|
197
|
+
* @returns {string[]} Array of tokens with quotes stripped
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* splitQuotedString('"path with spaces/file.md" other.rs')
|
|
201
|
+
* // Returns: ["path with spaces/file.md", "other.rs"]
|
|
202
|
+
*/
|
|
203
|
+
export function splitQuotedString(input) {
|
|
204
|
+
const tokens = [];
|
|
205
|
+
let current = '';
|
|
206
|
+
let inQuote = null; // null, '"', or "'"
|
|
207
|
+
let i = 0;
|
|
208
|
+
|
|
209
|
+
while (i < input.length) {
|
|
210
|
+
const ch = input[i];
|
|
211
|
+
|
|
212
|
+
if (inQuote) {
|
|
213
|
+
if (ch === '\\' && i + 1 < input.length) {
|
|
214
|
+
// Escaped character inside quotes — keep the literal character
|
|
215
|
+
current += input[i + 1];
|
|
216
|
+
i += 2;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (ch === inQuote) {
|
|
220
|
+
// Closing quote
|
|
221
|
+
inQuote = null;
|
|
222
|
+
i++;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
current += ch;
|
|
226
|
+
i++;
|
|
227
|
+
} else {
|
|
228
|
+
if (ch === '"' || ch === "'") {
|
|
229
|
+
inQuote = ch;
|
|
230
|
+
i++;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (/[\s,]/.test(ch)) {
|
|
234
|
+
// Delimiter outside quotes
|
|
235
|
+
if (current.length > 0) {
|
|
236
|
+
tokens.push(current);
|
|
237
|
+
current = '';
|
|
238
|
+
}
|
|
239
|
+
i++;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
current += ch;
|
|
243
|
+
i++;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (current.length > 0) {
|
|
248
|
+
tokens.push(current);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return tokens;
|
|
252
|
+
}
|
|
253
|
+
|
|
191
254
|
/**
|
|
192
255
|
* Parse targets string into array of file specifications
|
|
193
|
-
* Handles both space-separated and comma-separated targets for extract tool
|
|
256
|
+
* Handles both space-separated and comma-separated targets for extract tool.
|
|
257
|
+
* Quoted strings (single or double) are preserved as single targets,
|
|
258
|
+
* allowing file paths with spaces.
|
|
194
259
|
*
|
|
195
260
|
* @param {string} targets - Space or comma-separated file targets (e.g., "file1.rs:10-20, file2.rs#symbol")
|
|
196
261
|
* @returns {string[]} Array of individual file specifications
|
|
@@ -204,16 +269,15 @@ export function areBothStuckResponses(response1, response2) {
|
|
|
204
269
|
* // Returns: ["file1.rs:10-20", "file2.rs:30-40"]
|
|
205
270
|
*
|
|
206
271
|
* @example
|
|
207
|
-
* parseTargets("
|
|
208
|
-
* // Returns: ["
|
|
272
|
+
* parseTargets('"Customers/First American/Meeting Notes.md" other.rs')
|
|
273
|
+
* // Returns: ["Customers/First American/Meeting Notes.md", "other.rs"]
|
|
209
274
|
*/
|
|
210
275
|
export function parseTargets(targets) {
|
|
211
276
|
if (!targets || typeof targets !== 'string') {
|
|
212
277
|
return [];
|
|
213
278
|
}
|
|
214
279
|
|
|
215
|
-
|
|
216
|
-
return targets.split(/[\s,]+/).filter(f => f.length > 0);
|
|
280
|
+
return splitQuotedString(targets);
|
|
217
281
|
}
|
|
218
282
|
|
|
219
283
|
/**
|
|
@@ -227,7 +291,19 @@ export function parseTargets(targets) {
|
|
|
227
291
|
export function parseAndResolvePaths(pathStr, cwd) {
|
|
228
292
|
if (!pathStr) return [];
|
|
229
293
|
|
|
230
|
-
//
|
|
294
|
+
// If the input contains quotes, use the quote-aware tokenizer which
|
|
295
|
+
// preserves quoted strings with spaces as single tokens.
|
|
296
|
+
if (/["']/.test(pathStr)) {
|
|
297
|
+
const paths = splitQuotedString(pathStr);
|
|
298
|
+
return paths.map(p => {
|
|
299
|
+
if (isAbsolute(p)) return p;
|
|
300
|
+
return cwd ? resolve(cwd, p) : p;
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// No quotes: use comma-split + space-split heuristic (original behavior).
|
|
305
|
+
// Split on comma first, then auto-fix space-separated paths if each part
|
|
306
|
+
// looks like a file path.
|
|
231
307
|
let paths = pathStr.split(',').map(p => p.trim()).filter(p => p.length > 0);
|
|
232
308
|
|
|
233
309
|
// Auto-fix: model sometimes passes space-separated file paths as one string
|
|
@@ -242,10 +318,7 @@ export function parseAndResolvePaths(pathStr, cwd) {
|
|
|
242
318
|
|
|
243
319
|
// Resolve relative paths against cwd
|
|
244
320
|
return paths.map(p => {
|
|
245
|
-
if (isAbsolute(p))
|
|
246
|
-
return p;
|
|
247
|
-
}
|
|
248
|
-
// Resolve relative path against cwd
|
|
321
|
+
if (isAbsolute(p)) return p;
|
|
249
322
|
return cwd ? resolve(cwd, p) : p;
|
|
250
323
|
});
|
|
251
324
|
}
|