Skip to content
Merged
18 changes: 18 additions & 0 deletions .changeset/fast-coats-bathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@promptx/mcp-workspace": patch
"@promptx/mcp-office": patch
"@promptx/mcp-server": patch
"@promptx/resource": patch
"@agentxjs/runtime": patch
"@promptx/config": patch
"@promptx/logger": patch
"@promptx/core": patch
"@promptx/desktop": patch
"@promptx/cli": patch
---

fix(runtime): 修复工具执行期间空闲超时误触发导致回复被截断的问题

AI 调用工具后(`message_delta` stop_reason=tool_use),SDK 进入静默等待状态,直到工具执行完成返回 `tool_result`。这段静默期内没有任何流式事件重置空闲计时器,导致超过 10 分钟后触发 "Request timeout after 600000ms",将仍在进行中的请求强制中断。

修复方式:检测到工具执行开始时,启动心跳定时器(间隔为 timeout/2,最大 2 分钟),持续重置空闲计时器直到 tool_result 返回。工具结果到达、请求正常完成或异常清理时,心跳自动停止。
20 changes: 18 additions & 2 deletions packages/core/src/rolex/RolexBridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,12 @@ class RolexBridge {
if (this.initializing) return this.initializing

this.initializing = this._doInit()
await this.initializing
this.initializing = null
try {
await this.initializing
} finally {
// 无论成功或失败都清除,避免 rejected Promise 粘连导致后续调用永远失败
this.initializing = null
}
}

async _doInit () {
Expand Down Expand Up @@ -97,6 +101,18 @@ class RolexBridge {
this.initialized = true
logger.info('[RolexBridge] RoleX initialized successfully')
} catch (error) {
// 提供更清晰的 SQLite 错误说明
if (error && error.message && (error.message.includes('SQLite') || error.message.includes('sqlite'))) {
const nodeVer = process.versions.node
const enhanced = new Error(
`RoleX V2 需要 Node.js 22+(内置 sqlite)或 Bun 运行时。` +
`当前运行时:Node.js ${nodeVer}。` +
`如需禁用 V2,设置环境变量 PROMPTX_ENABLE_V2=0。`
)
enhanced.cause = error
logger.error('[RolexBridge] RoleX 初始化失败(SQLite 不可用):', enhanced.message)
throw enhanced
}
logger.error('[RolexBridge] RoleX initialization failed:', error)
throw error
}
Expand Down
10 changes: 9 additions & 1 deletion packages/mcp-server/src/tools/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,15 @@ organization 工具仅支持 V2 角色(RoleX)。
}
}

const result = await dispatcher.dispatch(operation, args);
let result;
try {
result = await dispatcher.dispatch(operation, args);
} catch (e: any) {
return outputAdapter.convertToMCPFormat({
type: 'error',
content: `❌ RoleX V2 操作失败: ${e?.message || String(e)}`
});
}
return outputAdapter.convertToMCPFormat(result);
}
};
Expand Down
49 changes: 39 additions & 10 deletions packages/mcp-workspace/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,45 @@
import { defineConfig } from 'tsup';

export default defineConfig({
entry: {
'index': 'src/index.ts',
'mcp-server': 'src/bin/mcp-server.ts',
},
format: ['esm'],
dts: true,
// CJS shims required because pino (bundled via @promptx/logger noExternal)
// uses require/__dirname internally, which are not defined in ESM context.
const cjsShims = [
"import { createRequire } from 'module';",
"import { fileURLToPath } from 'url';",
"import { dirname } from 'path';",
"const require = createRequire(import.meta.url);",
"const __filename = fileURLToPath(import.meta.url);",
"const __dirname = dirname(__filename);"
].join('\n');

const sharedConfig = {
format: ['esm'] as const,
splitting: false,
sourcemap: false,
clean: true,
target: 'node18',
clean: false,
target: 'node18' as const,
outDir: 'dist',
noExternal: ['@promptx/logger', '@modelcontextprotocol/sdk'],
});
};

export default defineConfig([
// Library build: no special env overrides
{
...sharedConfig,
entry: { 'index': 'src/index.ts' },
dts: true,
clean: true,
banner: { js: cjsShims },
},
// MCP server binary: disable pino worker threads.
// pino-pretty is not bundled (worker threads require filesystem resolution),
// so in a deployed/standalone environment it cannot be found.
// PROMPTX_NO_WORKERS=true switches the logger to sync/file mode (no pino-pretty).
{
...sharedConfig,
entry: { 'mcp-server': 'src/bin/mcp-server.ts' },
dts: true,
banner: {
js: cjsShims + "\nif (!process.env.PROMPTX_NO_WORKERS) process.env.PROMPTX_NO_WORKERS = 'true';"
},
},
]);
56 changes: 56 additions & 0 deletions packages/runtime/src/environment/ClaudeEffector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ export interface ClaudeEffectorConfig {
timeout?: number;
/** MCP servers configuration */
mcpServers?: Record<string, import("@agentxjs/types/runtime").McpServerConfig>;
/**
* Extra CLI flags for the Claude Code subprocess.
* See EnvironmentContext.extraArgs for details and gateway compatibility notes.
*/
extraArgs?: Record<string, string | null>;
/** Extra environment variables injected into the Claude Code subprocess. */
extraEnv?: Record<string, string>;
}

/**
Expand All @@ -65,6 +72,8 @@ export class ClaudeEffector implements Effector {
private pendingRequest$: Subject<void> | null = null;
/** Subscription for timeout handling */
private pendingSubscription: Subscription | null = null;
/** Heartbeat timer during tool execution (keeps idle timeout from firing) */
private toolHeartbeatTimer: ReturnType<typeof setInterval> | null = null;

constructor(config: ClaudeEffectorConfig, receptor: ClaudeReceptor) {
this.config = config;
Expand All @@ -80,6 +89,8 @@ export class ClaudeEffector implements Effector {
cwd: config.cwd,
resumeSessionId: config.resumeSessionId,
mcpServers: config.mcpServers,
extraArgs: config.extraArgs,
extraEnv: config.extraEnv,
},
{
onStreamEvent: (msg) => this.handleStreamEvent(msg),
Expand Down Expand Up @@ -223,15 +234,30 @@ export class ClaudeEffector implements Effector {
this.receptor.feed(msg as SDKPartialAssistantMessage, this.currentMeta);
// Reset idle timeout on each stream event
this.pendingRequest$?.next();

// Detect tool execution starting: after message_delta(stop_reason=tool_use),
// the SDK goes silent until the tool completes. Start a heartbeat to keep
// the idle timeout from firing during (potentially long) tool execution.
const sdkEvent = (msg as SDKPartialAssistantMessage).event;
if (sdkEvent?.type === "message_delta") {
const delta = (sdkEvent as { delta?: { stop_reason?: string } }).delta;
if (delta?.stop_reason === "tool_use") {
this.startToolHeartbeat();
}
}
}
}

/**
* Handle user message from SDK (contains tool_result)
*/
private handleUserMessage(msg: SDKMessage): void {
// Tool result arrived - stop heartbeat, execution is done
this.stopToolHeartbeat();
if (this.currentMeta) {
this.receptor.feedUserMessage(msg as { message?: { content?: unknown[] } }, this.currentMeta);
// Reset idle timeout: tool execution completed, activity resumed
this.pendingRequest$?.next();
}
}

Expand Down Expand Up @@ -292,6 +318,34 @@ export class ClaudeEffector implements Effector {
this.cleanupPendingRequest();
}

/**
* Start heartbeat during tool execution to prevent idle timeout from firing.
*
* The SDK emits no events between message_delta(stop_reason=tool_use) and the
* tool_result user message. Without a heartbeat, the idle timeout fires after
* timeoutMs even though the request is actively progressing.
*/
private startToolHeartbeat(): void {
this.stopToolHeartbeat();
// Fire at half the timeout interval so we always reset before the timer fires
const heartbeatMs = Math.min(Math.floor((this.config.timeout ?? DEFAULT_TIMEOUT) / 2), 120_000);
logger.debug("Tool execution started - starting heartbeat", { heartbeatMs });
this.toolHeartbeatTimer = setInterval(() => {
this.pendingRequest$?.next();
logger.debug("Tool execution heartbeat - idle timeout reset");
}, heartbeatMs);
}

/**
* Stop the tool execution heartbeat
*/
private stopToolHeartbeat(): void {
if (this.toolHeartbeatTimer !== null) {
clearInterval(this.toolHeartbeatTimer);
this.toolHeartbeatTimer = null;
}
}

/**
* Handle request timeout
*/
Expand All @@ -309,6 +363,7 @@ export class ClaudeEffector implements Effector {
* Clean up pending request subscription
*/
private cleanupPendingRequest(): void {
this.stopToolHeartbeat();
if (this.pendingSubscription) {
this.pendingSubscription.unsubscribe();
this.pendingSubscription = null;
Expand All @@ -323,6 +378,7 @@ export class ClaudeEffector implements Effector {
* Complete pending request (cancels timeout)
*/
private completePendingRequest(): void {
this.stopToolHeartbeat();
if (this.pendingRequest$) {
this.pendingRequest$.complete();
this.pendingRequest$ = null;
Expand Down
6 changes: 6 additions & 0 deletions packages/runtime/src/environment/SDKQueryLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export interface SDKQueryConfig {
cwd?: string;
resumeSessionId?: string;
mcpServers?: Record<string, import("@agentxjs/types/runtime").McpServerConfig>;
/** Extra CLI flags forwarded to the Claude Code subprocess. */
extraArgs?: Record<string, string | null>;
/** Extra env vars injected into the Claude Code subprocess. */
extraEnv?: Record<string, string>;
}

/**
Expand Down Expand Up @@ -120,6 +124,8 @@ export class SDKQueryLifecycle {
cwd: this.config.cwd,
resume: this.config.resumeSessionId,
mcpServers: this.config.mcpServers,
extraArgs: this.config.extraArgs,
extraEnv: this.config.extraEnv,
};

const sdkOptions = buildOptions(context, this.abortController);
Expand Down
35 changes: 35 additions & 0 deletions packages/runtime/src/environment/buildOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,27 @@ export interface EnvironmentContext {
maxThinkingTokens?: number;
/** MCP servers configuration */
mcpServers?: Record<string, McpServerConfig>;
/**
* Extra CLI flags to pass directly to the Claude Code subprocess.
*
* Each entry becomes `--key value` (or `--key` when value is null).
*
* Gateway compatibility example (LiteLLM nightly has broken
* fine-grained-tool-streaming support which causes "Unexpected event order"
* errors). Passing `{ betas: "claude-code-20250219" }` overrides the beta
* header list and disables the problematic fine-grained streaming beta:
*
* @example
* extraArgs: { betas: "claude-code-20250219" }
*/
extraArgs?: Record<string, string | null>;
/**
* Extra environment variables injected into the Claude Code subprocess.
*
* These are merged on top of the base env that already includes
* ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY.
*/
extraEnv?: Record<string, string>;
}

/**
Expand Down Expand Up @@ -135,6 +156,20 @@ export function buildOptions(
});
}

// Extra environment variables for the Claude Code subprocess
// (merged last so they can override anything set above)
if (context.extraEnv) {
Object.assign(env, context.extraEnv);
logger.info("Extra env vars applied", { keys: Object.keys(context.extraEnv) });
}

// Extra CLI flags forwarded directly to the Claude Code subprocess.
// Useful for gateway compatibility workarounds (e.g. overriding --betas).
if (context.extraArgs && Object.keys(context.extraArgs).length > 0) {
options.extraArgs = context.extraArgs;
logger.info("Extra CLI args configured", { args: context.extraArgs });
}

// Permission system
if (context.permissionMode) {
options.permissionMode = context.permissionMode;
Expand Down