Skip to content

Commit 33842ca

Browse files
bugerclaude
andauthored
fix: cap negotiated timeout extensions to external hard timeout ceiling (#522) (#523)
- Add `externalHardTimeout` option (constructor + env var) that lets callers specify their hard ceiling (e.g., visor's Promise.race timeout) - Observer caps `grantedMs` to external headroom so extensions never push the effective deadline past the external kill point - Observer declines extensions when headroom < 60s (minimum useful time) - MCP tool calls now emit `toolCall` events via `agentEvents` so the observer's `activeTools` map tracks in-flight MCP tools - Plumbed `agentEvents` through MCPXmlBridge → MCPClientManager Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent afe6eee commit 33842ca

File tree

5 files changed

+383
-3
lines changed

5 files changed

+383
-3
lines changed

npm/src/agent/ProbeAgent.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ export interface ProbeAgentOptions {
116116
negotiatedTimeoutMaxRequests?: number;
117117
/** Max ms per extension request for negotiated timeout (default: 600000 = 10 min). Env var: NEGOTIATED_TIMEOUT_MAX_PER_REQUEST */
118118
negotiatedTimeoutMaxPerRequest?: number;
119+
/** External hard timeout ceiling in ms (e.g., visor's Promise.race timeout). When set, the observer caps extensions so granted time never exceeds this ceiling. Env var: EXTERNAL_HARD_TIMEOUT */
120+
externalHardTimeout?: number | null;
119121
}
120122

121123
/**

npm/src/agent/ProbeAgent.js

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,14 @@ export class ProbeAgent {
425425
return (isNaN(parsed) || parsed < 60000 || parsed > 3600000) ? 600000 : parsed;
426426
})();
427427

428+
// External hard timeout: the caller's hard ceiling (e.g., visor's Promise.race timeout).
429+
// When set, the observer caps extensions so granted time never exceeds this ceiling,
430+
// preventing the agent from being killed mid-work with no partial results. (#522)
431+
this.externalHardTimeout = options.externalHardTimeout ?? (() => {
432+
const parsed = parseInt(process.env.EXTERNAL_HARD_TIMEOUT, 10);
433+
return (isNaN(parsed) || parsed < 0) ? null : parsed;
434+
})();
435+
428436
// Graceful stop deadline: how long to wait for subagents/MCP after observer declines (default 45s)
429437
this.gracefulStopDeadline = options.gracefulStopDeadline ?? (() => {
430438
const parsed = parseInt(process.env.GRACEFUL_STOP_DEADLINE, 10);
@@ -2759,7 +2767,7 @@ export class ProbeAgent {
27592767
}
27602768

27612769
// Initialize the MCP XML bridge
2762-
this.mcpBridge = new MCPXmlBridge({ debug: this.debug });
2770+
this.mcpBridge = new MCPXmlBridge({ debug: this.debug, agentEvents: this.events });
27632771
await this.mcpBridge.initialize(mcpConfig);
27642772

27652773
const mcpToolNames = this.mcpBridge.getToolNames();
@@ -3704,6 +3712,31 @@ Follow these instructions carefully:
37043712
return;
37053713
}
37063714

3715+
// Check external hard timeout headroom — if the caller has a hard ceiling
3716+
// (e.g., visor's Promise.race), decline extensions when headroom is insufficient (#522)
3717+
const MINIMUM_USEFUL_HEADROOM_MS = 60000; // 60s — less than this isn't useful for an extension
3718+
if (this.externalHardTimeout) {
3719+
const elapsed = Date.now() - negotiatedTimeoutState.startTime;
3720+
const externalHeadroom = Math.max(0, this.externalHardTimeout - elapsed);
3721+
if (externalHeadroom < MINIMUM_USEFUL_HEADROOM_MS) {
3722+
if (this.debug) {
3723+
console.log(`[DEBUG] Timeout observer: external hard timeout headroom exhausted (${Math.round(externalHeadroom / 1000)}s < ${MINIMUM_USEFUL_HEADROOM_MS / 1000}s minimum) — triggering graceful wind-down`);
3724+
}
3725+
if (this.tracer) {
3726+
this.tracer.addEvent('negotiated_timeout.external_headroom_exhausted', {
3727+
external_hard_timeout_ms: this.externalHardTimeout,
3728+
elapsed_ms: elapsed,
3729+
headroom_ms: externalHeadroom,
3730+
minimum_useful_ms: MINIMUM_USEFUL_HEADROOM_MS,
3731+
extensions_used: negotiatedTimeoutState.extensionsUsed,
3732+
});
3733+
}
3734+
await this._initiateGracefulStop(gracefulTimeoutState, 'external hard timeout headroom exhausted');
3735+
negotiatedTimeoutState.observerRunning = false;
3736+
return;
3737+
}
3738+
}
3739+
37073740
// Build context for the observer
37083741
const activeToolsList = Array.from(activeTools.values());
37093742
const now = Date.now();
@@ -3810,7 +3843,22 @@ or
38103843

38113844
if (decision.extend && decision.minutes > 0) {
38123845
const requestedMs = Math.min(decision.minutes, maxPerReqMin) * 60000;
3813-
const grantedMs = Math.min(requestedMs, remainingBudgetMs, negotiatedTimeoutState.maxPerRequestMs);
3846+
// Cap to external hard timeout headroom if set (#522)
3847+
const externalCap = this.externalHardTimeout
3848+
? Math.max(0, this.externalHardTimeout - (Date.now() - negotiatedTimeoutState.startTime))
3849+
: Infinity;
3850+
const grantedMs = Math.min(requestedMs, remainingBudgetMs, negotiatedTimeoutState.maxPerRequestMs, externalCap);
3851+
3852+
// If capped below minimum useful time, decline instead of granting a useless extension
3853+
if (grantedMs < MINIMUM_USEFUL_HEADROOM_MS) {
3854+
if (this.debug) {
3855+
console.log(`[DEBUG] Timeout observer: extension capped to ${Math.round(grantedMs / 1000)}s (below ${MINIMUM_USEFUL_HEADROOM_MS / 1000}s minimum) — declining and triggering graceful wind-down`);
3856+
}
3857+
await this._initiateGracefulStop(gracefulTimeoutState, `extension capped below minimum useful time (${Math.round(grantedMs / 1000)}s)`);
3858+
negotiatedTimeoutState.observerRunning = false;
3859+
return;
3860+
}
3861+
38143862
const grantedMin = Math.round(grantedMs / 60000 * 10) / 10;
38153863

38163864
// Update state

npm/src/agent/mcp/client.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ export class MCPClientManager {
164164
this.debug = options.debug || process.env.DEBUG_MCP === '1';
165165
this.config = null;
166166
this.tracer = options.tracer || null;
167+
// Optional event emitter for broadcasting tool call lifecycle to the agent (#522)
168+
this.agentEvents = options.agentEvents || null;
167169
}
168170

169171
/**
@@ -452,6 +454,7 @@ export class MCPClientManager {
452454
}
453455

454456
const startTime = Date.now();
457+
const toolCallId = `mcp-${toolName}-${startTime}`;
455458

456459
// Record tool call start
457460
this.recordMcpEvent('tool.call_started', {
@@ -460,6 +463,17 @@ export class MCPClientManager {
460463
originalToolName: tool.originalName
461464
});
462465

466+
// Emit toolCall event so the agent's activeTools map tracks MCP tool calls (#522)
467+
if (this.agentEvents) {
468+
this.agentEvents.emit('toolCall', {
469+
toolCallId,
470+
name: toolName,
471+
args,
472+
status: 'started',
473+
timestamp: new Date().toISOString(),
474+
});
475+
}
476+
463477
try {
464478
if (this.debug) {
465479
console.error(`[MCP DEBUG] Calling ${toolName} with args:`, JSON.stringify(args, null, 2));
@@ -502,6 +516,16 @@ export class MCPClientManager {
502516
durationMs
503517
});
504518

519+
// Emit toolCall completion so agent's activeTools removes this entry (#522)
520+
if (this.agentEvents) {
521+
this.agentEvents.emit('toolCall', {
522+
toolCallId,
523+
name: toolName,
524+
status: 'completed',
525+
timestamp: new Date().toISOString(),
526+
});
527+
}
528+
505529
return result;
506530
} catch (error) {
507531
const durationMs = Date.now() - startTime;
@@ -521,6 +545,16 @@ export class MCPClientManager {
521545
isTimeout: error.message.includes('timeout')
522546
});
523547

548+
// Emit toolCall error so agent's activeTools removes this entry (#522)
549+
if (this.agentEvents) {
550+
this.agentEvents.emit('toolCall', {
551+
toolCallId,
552+
name: toolName,
553+
status: 'error',
554+
timestamp: new Date().toISOString(),
555+
});
556+
}
557+
524558
throw error;
525559
}
526560
}

npm/src/agent/mcp/xmlBridge.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export class MCPXmlBridge {
3636
constructor(options = {}) {
3737
this.debug = options.debug || false;
3838
this.tracer = options.tracer || null;
39+
this.agentEvents = options.agentEvents || null;
3940
this.mcpTools = {};
4041
this.mcpManager = null;
4142
this.toolDescriptions = {};
@@ -84,7 +85,7 @@ export class MCPXmlBridge {
8485
console.error('[MCP DEBUG] Initializing MCP client manager...');
8586
}
8687

87-
this.mcpManager = new MCPClientManager({ debug: this.debug, tracer: this.tracer });
88+
this.mcpManager = new MCPClientManager({ debug: this.debug, tracer: this.tracer, agentEvents: this.agentEvents });
8889
const result = await this.mcpManager.initialize(mcpConfigs);
8990

9091
// Get tools from the manager (already in Vercel format)

0 commit comments

Comments
 (0)