Compare commits

..

1 Commits

Author SHA1 Message Date
Keshav's Bot
dde017027c fix(codex): gate profiler timing and startup setup 2026-05-26 19:02:13 +01:00
14 changed files with 633 additions and 244 deletions

View File

@@ -50,6 +50,50 @@ Disable all flags:
OPENCLAW_DIAGNOSTICS=0
```
`OPENCLAW_DIAGNOSTICS=0` is a process-level disable override: it disables
flags from both env and config for that process.
## Profiling flags
Profiler flags enable targeted timing spans without raising global logging
levels. They are disabled by default.
Enable all profiler-gated spans for one gateway run:
```bash
OPENCLAW_DIAGNOSTICS=profiler openclaw gateway run
```
Enable only reply-dispatch profiler spans:
```bash
OPENCLAW_DIAGNOSTICS=reply.profiler openclaw gateway run
```
Enable only Codex app-server startup/tool/thread profiler spans:
```bash
OPENCLAW_DIAGNOSTICS=codex.profiler openclaw gateway run
```
Enable profiler flags from config:
```json
{
"diagnostics": {
"flags": ["reply.profiler", "codex.profiler"]
}
}
```
Restart the gateway after changing config flags. To disable a profiler flag,
remove it from `diagnostics.flags` and restart. To temporarily disable every
diagnostics flag even when config enables profiler flags, start the process with:
```bash
OPENCLAW_DIAGNOSTICS=0 openclaw gateway run
```
## Timeline artifacts
The `timeline` flag writes structured startup and runtime timing events for

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { isCodexAppServerProfilerEnabled } from "./profiler-flag.js";
describe("isCodexAppServerProfilerEnabled", () => {
it("is disabled by default", () => {
expect(isCodexAppServerProfilerEnabled(undefined, {} as NodeJS.ProcessEnv)).toBe(false);
});
it("matches global and Codex profiler flags", () => {
expect(
isCodexAppServerProfilerEnabled(
{ diagnostics: { flags: ["codex.profiler"] } },
{} as NodeJS.ProcessEnv,
),
).toBe(true);
expect(
isCodexAppServerProfilerEnabled(undefined, {
OPENCLAW_DIAGNOSTICS: "profiler",
} as NodeJS.ProcessEnv),
).toBe(true);
});
it("uses the documented diagnostics env disable override", () => {
expect(
isCodexAppServerProfilerEnabled({ diagnostics: { flags: ["codex.profiler"] } }, {
OPENCLAW_DIAGNOSTICS: "0",
} as NodeJS.ProcessEnv),
).toBe(false);
});
});

View File

@@ -0,0 +1,11 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isDiagnosticFlagEnabled } from "openclaw/plugin-sdk/diagnostic-runtime";
const PROFILER_FLAGS = ["profiler", "codex.profiler"] as const;
export function isCodexAppServerProfilerEnabled(
config?: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): boolean {
return PROFILER_FLAGS.some((flag) => isDiagnosticFlagEnabled(flag, config, env));
}

View File

@@ -887,6 +887,7 @@ describe("runCodexAppServerAttempt", () => {
await closeCodexSandboxExecServersForTests();
resetCodexAppServerClientFactoryForTest();
testing.resetOpenClawCodingToolsFactoryForTests();
testing.resetEnsuredCodexWorkspaceDirsForTests();
testing.clearPendingCodexNativeHookRelayUnregistersForTests();
resetCodexRateLimitCacheForTests();
nativeHookRelayTesting.clearNativeHookRelaysForTests();
@@ -903,6 +904,16 @@ describe("runCodexAppServerAttempt", () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it("recreates cached Codex workspace directories after cleanup removes them", async () => {
const workspaceDir = path.join(tempDir, "cached-workspace");
await testing.ensureCodexWorkspaceDirOnceForTests(workspaceDir);
await fs.rm(workspaceDir, { recursive: true, force: true });
await testing.ensureCodexWorkspaceDirOnceForTests(workspaceDir);
expect((await fs.stat(workspaceDir)).isDirectory()).toBe(true);
});
it("filters Codex-native dynamic tools from app-server tool exposure", () => {
const tools = [
"read",

View File

@@ -134,6 +134,7 @@ import {
mergeCodexThreadConfigs,
shouldBuildCodexPluginThreadConfig,
} from "./plugin-thread-config.js";
import { isCodexAppServerProfilerEnabled } from "./profiler-flag.js";
import {
assertCodexTurnStartResponse,
readCodexDynamicToolCallParams,
@@ -278,6 +279,7 @@ type CodexWorkspaceBootstrapContext = CodexBootstrapContext & {
};
let openClawCodingToolsFactoryForTests: OpenClawCodingToolsFactory | undefined;
const ensuredCodexWorkspaceDirs = new Set<string>();
type PendingCodexNativeHookRelayUnregister = {
timeout: ReturnType<typeof setTimeout>;
@@ -340,6 +342,30 @@ function clearPendingCodexNativeHookRelayUnregistersForTests(): void {
pendingCodexNativeHookRelayUnregisters.clear();
}
async function ensureCodexWorkspaceDirOnce(workspaceDir: string): Promise<void> {
const normalized = path.resolve(workspaceDir);
if (ensuredCodexWorkspaceDirs.has(normalized)) {
try {
const stat = await fs.stat(normalized);
if (stat.isDirectory()) {
return;
}
} catch (error) {
const code =
typeof error === "object" && error ? (error as { code?: unknown }).code : undefined;
if (code !== "ENOENT") {
throw error;
}
}
ensuredCodexWorkspaceDirs.delete(normalized);
}
// Codex attempts re-enter the same workspace repeatedly; caching successful
// mkdirs avoids repeated fs work while still recovering if cleanup prunes
// the directory between attempts.
await fs.mkdir(normalized, { recursive: true });
ensuredCodexWorkspaceDirs.add(normalized);
}
function emitCodexAppServerEvent(
params: EmbeddedRunAttemptParams,
event: Parameters<NonNullable<EmbeddedRunAttemptParams["onAgentEvent"]>>[0],
@@ -1013,6 +1039,7 @@ export async function runCodexAppServerAttempt(
} = {},
): Promise<EmbeddedRunAttemptResult> {
const attemptStartedAt = Date.now();
const profilerEnabled = isCodexAppServerProfilerEnabled(params.config);
const codexModelCallTrace = freezeDiagnosticTraceContext(
createDiagnosticTraceContextFromActiveScope(),
);
@@ -1022,13 +1049,20 @@ export async function runCodexAppServerAttempt(
let codexModelCallStarted = false;
let codexModelCallTerminalEmitted = false;
let codexModelCallRequestPayloadBytes: number | undefined;
// Startup phase timings are profiler-gated because this function runs before
// every Codex turn; normal production should not do timing bookkeeping here.
const preDynamicStartupStages = createCodexDynamicToolBuildStageTracker({
enabled: profilerEnabled,
});
const attemptClientFactory = options.clientFactory ?? defaultCodexAppServerClientFactory;
const pluginConfig = readCodexPluginConfig(options.pluginConfig);
const computerUseConfig = resolveCodexComputerUseConfig({ pluginConfig });
const configuredAppServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const beforeToolCallPolicy = getBeforeToolCallPolicyDiagnosticState();
preDynamicStartupStages.mark("config");
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
await fs.mkdir(resolvedWorkspace, { recursive: true });
await ensureCodexWorkspaceDirOnce(resolvedWorkspace);
preDynamicStartupStages.mark("workspace");
const sandboxSessionKey =
params.sandboxSessionKey?.trim() || params.sessionKey?.trim() || params.sessionId;
const contextSessionKey = params.sessionKey?.trim() || sandboxSessionKey;
@@ -1037,12 +1071,14 @@ export async function runCodexAppServerAttempt(
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspace,
});
preDynamicStartupStages.mark("sandbox");
const effectiveWorkspace = sandbox?.enabled
? sandbox.workspaceAccess === "rw"
? resolvedWorkspace
: sandbox.workspaceDir
: resolvedWorkspace;
await fs.mkdir(effectiveWorkspace, { recursive: true });
await ensureCodexWorkspaceDirOnce(effectiveWorkspace);
preDynamicStartupStages.mark("effective-workspace");
const appServer = resolveCodexAppServerForOpenClawToolPolicy({
appServer: configuredAppServer,
pluginConfig,
@@ -1062,11 +1098,13 @@ export async function runCodexAppServerAttempt(
trustedToolPolicies: beforeToolCallPolicy.trustedToolPolicies,
});
}
preDynamicStartupStages.mark("app-server-policy");
let pluginAppServer: CodexAppServerRuntimeOptions = appServer;
const nativeHookRelayEvents = resolveCodexNativeHookRelayEvents({
configuredEvents: options.nativeHookRelay?.events,
appServer,
});
preDynamicStartupStages.mark("native-hook-relay");
const runAbortController = new AbortController();
const abortFromUpstream = () => {
@@ -1084,7 +1122,9 @@ export async function runCodexAppServerAttempt(
agentId: params.agentId,
});
const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, sessionAgentId);
preDynamicStartupStages.mark("session-agent");
let startupBinding = await readCodexAppServerBinding(params.sessionFile);
preDynamicStartupStages.mark("read-binding");
const startupBindingAuthProfileId = startupBinding?.authProfileId;
startupBinding = await rotateOversizedCodexAppServerStartupBinding({
binding: startupBinding,
@@ -1094,6 +1134,7 @@ export async function runCodexAppServerAttempt(
config: params.config,
contextEngineActive: isActiveHarnessContextEngine(params.contextEngine),
});
preDynamicStartupStages.mark("rotate-binding");
const startupAuthProfileCandidate =
params.runtimePlan?.auth.forwardedAuthProfileId ??
params.authProfileId ??
@@ -1110,6 +1151,7 @@ export async function runCodexAppServerAttempt(
agentDir,
config: params.config,
});
preDynamicStartupStages.mark("auth-profile");
const runtimeParams = {
...params,
sessionKey: contextSessionKey,
@@ -1133,11 +1175,13 @@ export async function runCodexAppServerAttempt(
: resolveCodexAppServerFallbackApiKeyCacheKey({
startOptions: appServer.start,
});
preDynamicStartupStages.mark("auth-cache");
const nodeExecBlocksNativeExecution = isCodexNativeExecutionBlockedByNodeExecHost(params, {
agentId: sessionAgentId,
runtimeSessionKey: sandboxSessionKey,
sandbox,
});
preDynamicStartupStages.mark("native-exec-policy");
const bundleMcpThreadConfig = await loadCodexBundleMcpThreadConfig({
workspaceDir: effectiveWorkspace,
cfg: params.config,
@@ -1145,12 +1189,14 @@ export async function runCodexAppServerAttempt(
disableTools: params.disableTools,
toolsAllow: nodeExecBlocksNativeExecution ? [] : params.toolsAllow,
});
preDynamicStartupStages.mark("bundle-mcp");
const sandboxExecServerEnabled = isCodexSandboxExecServerEnabled(pluginConfig);
const nativeToolSurfaceEnabled = shouldEnableCodexAppServerNativeToolSurface(params, sandbox, {
agentId: sessionAgentId,
runtimeSessionKey: sandboxSessionKey,
sandboxExecServerEnabled,
});
preDynamicStartupStages.mark("native-tool-surface");
for (const diagnostic of bundleMcpThreadConfig.diagnostics) {
embeddedAgentLog.warn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`);
}
@@ -1165,6 +1211,23 @@ export async function runCodexAppServerAttempt(
});
}
const hookChannelId = resolveCodexAppServerHookChannelId(params, sandboxSessionKey);
preDynamicStartupStages.mark("context-engine-support");
const preDynamicSummary = preDynamicStartupStages.snapshot();
if (shouldWarnCodexDynamicToolBuildStageSummary(preDynamicSummary)) {
embeddedAgentLog.warn(
`codex app-server pre-dynamic startup timings runId=${params.runId} sessionId=${params.sessionId} totalMs=${preDynamicSummary.totalMs} stages=${formatCodexDynamicToolBuildStageSummary(preDynamicSummary)}`,
{
runId: params.runId,
sessionId: params.sessionId,
totalMs: preDynamicSummary.totalMs,
stages: preDynamicSummary.stages,
hasStartupBinding: Boolean(startupBinding?.threadId),
startupAuthProfileId: startupAuthProfileId ?? null,
bundleMcpDiagnosticCount: bundleMcpThreadConfig.diagnostics.length,
nativeToolSurfaceEnabled,
},
);
}
let yieldDetected = false;
const tools = await buildDynamicTools({
params,
@@ -1176,6 +1239,7 @@ export async function runCodexAppServerAttempt(
runAbortController,
sessionAgentId,
pluginConfig,
profilerEnabled,
onYieldDetected: () => {
yieldDetected = true;
},
@@ -1190,6 +1254,7 @@ export async function runCodexAppServerAttempt(
runAbortController,
sessionAgentId,
pluginConfig,
profilerEnabled,
forceHeartbeatTool: true,
ignoreRuntimePlan: true,
onYieldDetected: () => {
@@ -3853,6 +3918,9 @@ function createCodexNativeHookRelay(params: {
}),
signal: params.signal,
command: {
// Hook relay subprocesses are observational for most tool events; keep
// them lower priority so they do not compete with the active reply turn.
nice: 10,
timeoutMs: params.options?.gatewayTimeoutMs,
},
});
@@ -4022,6 +4090,7 @@ type DynamicToolBuildParams = {
runAbortController: AbortController;
sessionAgentId: string;
pluginConfig: CodexPluginConfig;
profilerEnabled?: boolean;
forceHeartbeatTool?: boolean;
ignoreRuntimePlan?: boolean;
onYieldDetected: () => void;
@@ -4051,16 +4120,91 @@ function resolveCodexAppServerHookChannelId(
}).channelId;
}
type CodexDynamicToolBuildStageTiming = {
name: string;
durationMs: number;
elapsedMs: number;
};
type CodexDynamicToolBuildStageSummary = {
totalMs: number;
stages: CodexDynamicToolBuildStageTiming[];
};
const CODEX_DYNAMIC_TOOL_BUILD_WARN_TOTAL_MS = 1_000;
const CODEX_DYNAMIC_TOOL_BUILD_WARN_STAGE_MS = 500;
function createCodexDynamicToolBuildStageTracker(options: { enabled?: boolean } = {}): {
mark: (name: string) => void;
snapshot: () => CodexDynamicToolBuildStageSummary;
} {
if (!options.enabled) {
return {
mark() {},
snapshot() {
return { totalMs: 0, stages: [] };
},
};
}
const startedAt = Date.now();
let previousAt = startedAt;
const stages: CodexDynamicToolBuildStageTiming[] = [];
const toMs = (value: number) => Math.max(0, Math.round(value));
return {
mark(name) {
const currentAt = Date.now();
stages.push({
name,
durationMs: toMs(currentAt - previousAt),
elapsedMs: toMs(currentAt - startedAt),
});
previousAt = currentAt;
},
snapshot() {
return {
totalMs: toMs(Date.now() - startedAt),
stages: stages.slice(),
};
},
};
}
function shouldWarnCodexDynamicToolBuildStageSummary(
summary: CodexDynamicToolBuildStageSummary,
): boolean {
return (
summary.totalMs >= CODEX_DYNAMIC_TOOL_BUILD_WARN_TOTAL_MS ||
summary.stages.some((stage) => stage.durationMs >= CODEX_DYNAMIC_TOOL_BUILD_WARN_STAGE_MS)
);
}
function formatCodexDynamicToolBuildStageSummary(
summary: CodexDynamicToolBuildStageSummary,
): string {
return summary.stages.length > 0
? summary.stages
.map((stage) => `${stage.name}:${stage.durationMs}ms@${stage.elapsedMs}ms`)
.join(",")
: "none";
}
async function buildDynamicTools(input: DynamicToolBuildParams) {
const { params } = input;
if (params.disableTools || !supportsModelTools(params.model)) {
return [];
}
// Dynamic tool construction is on the reply hot path, so per-stage
// Date.now/span bookkeeping runs only when the Codex profiler flag is set.
const toolBuildStages = createCodexDynamicToolBuildStageTracker({
enabled: input.profilerEnabled,
});
const modelHasVision = params.model.input?.includes("image") ?? false;
const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, input.sessionAgentId);
const createOpenClawCodingTools =
openClawCodingToolsFactoryForTests ??
(await import("openclaw/plugin-sdk/agent-harness")).createOpenClawCodingTools;
toolBuildStages.mark("load-agent-harness-tools");
const sessionKeys = resolveOpenClawCodingToolsSessionKeys(params, input.sandboxSessionKey);
const allTools = createOpenClawCodingTools({
agentId: input.sessionAgentId,
@@ -4130,7 +4274,11 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
data: { name: "sessions_yield", message },
});
},
recordToolPrepStage: (name) => {
toolBuildStages.mark(name);
},
});
toolBuildStages.mark("create-openclaw-coding-tools");
const codexFilteredTools = addNodeShellDynamicToolsIfNeeded(
addSandboxShellDynamicToolsIfAvailable(
isCodexMemoryFlushRun(params)
@@ -4142,13 +4290,16 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
allTools,
input,
);
toolBuildStages.mark("codex-filtering");
const visionFilteredTools = filterToolsForVisionInputs(codexFilteredTools, {
modelHasVision,
hasInboundImages: (params.images?.length ?? 0) > 0,
});
toolBuildStages.mark("vision-filtering");
const toolsAllow = includeForcedCodexDynamicToolAllow(params.toolsAllow, params);
const filteredTools = filterCodexDynamicToolsForAllowlist(visionFilteredTools, toolsAllow);
return normalizeAgentRuntimeTools({
toolBuildStages.mark("allowlist-filter");
const normalizedTools = normalizeAgentRuntimeTools({
runtimePlan: input.ignoreRuntimePlan ? undefined : params.runtimePlan,
tools: filteredTools,
provider: params.provider,
@@ -4159,6 +4310,30 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
modelApi: params.model.api,
model: params.model,
});
toolBuildStages.mark("runtime-normalization");
const summary = toolBuildStages.snapshot();
if (shouldWarnCodexDynamicToolBuildStageSummary(summary)) {
const phase = input.forceHeartbeatTool ? "registered-tools" : "runtime-tools";
embeddedAgentLog.warn(
`codex app-server dynamic tool build timings runId=${params.runId} sessionId=${params.sessionId} phase=${phase} totalMs=${summary.totalMs} stages=${formatCodexDynamicToolBuildStageSummary(summary)}`,
{
runId: params.runId,
sessionId: params.sessionId,
phase,
totalMs: summary.totalMs,
stages: summary.stages,
allToolCount: allTools.length,
codexFilteredToolCount: codexFilteredTools.length,
visionFilteredToolCount: visionFilteredTools.length,
filteredToolCount: filteredTools.length,
normalizedToolCount: normalizedTools.length,
forceHeartbeatTool: input.forceHeartbeatTool === true,
ignoreRuntimePlan: input.ignoreRuntimePlan === true,
nativeToolSurfaceEnabled: input.nativeToolSurfaceEnabled === true,
},
);
}
return normalizedTools;
}
function includeForcedCodexDynamicToolAllow(
@@ -5994,6 +6169,12 @@ export const testing = {
resetOpenClawCodingToolsFactoryForTests(): void {
openClawCodingToolsFactoryForTests = undefined;
},
async ensureCodexWorkspaceDirOnceForTests(workspaceDir: string): Promise<void> {
await ensureCodexWorkspaceDirOnce(workspaceDir);
},
resetEnsuredCodexWorkspaceDirsForTests(): void {
ensuredCodexWorkspaceDirs.clear();
},
flushPendingCodexNativeHookRelayUnregistersForTests,
clearPendingCodexNativeHookRelayUnregistersForTests,
resolveCodexNativeHookRelayUnregisterGraceMs,

View File

@@ -19,6 +19,7 @@ import {
mergeCodexThreadConfigs,
type CodexPluginThreadConfig,
} from "./plugin-thread-config.js";
import { isCodexAppServerProfilerEnabled } from "./profiler-flag.js";
import {
assertCodexThreadResumeResponse,
assertCodexThreadStartResponse,
@@ -84,6 +85,113 @@ const CODEX_LIGHTWEIGHT_CONTEXT_THREAD_CONFIG: JsonObject = {
project_doc_max_bytes: 0,
};
type CodexThreadLifecycleTimingSpan = {
name: string;
durationMs: number;
elapsedMs: number;
};
type CodexThreadLifecycleTimingSummary = {
totalMs: number;
spans: CodexThreadLifecycleTimingSpan[];
};
const CODEX_THREAD_LIFECYCLE_TIMING_WARN_TOTAL_MS = 1_000;
const CODEX_THREAD_LIFECYCLE_TIMING_WARN_STAGE_MS = 500;
function createCodexThreadLifecycleTimingTracker(options: { enabled?: boolean } = {}): {
measure: <T>(name: string, run: () => Promise<T> | T) => Promise<T>;
measureSync: <T>(name: string, run: () => T) => T;
logIfSlow: (params: {
runId: string;
sessionId: string;
sessionKey?: string;
action: "started" | "resumed" | "rotated";
threadId?: string;
}) => void;
} {
if (!options.enabled) {
return {
async measure(_name, run) {
return await run();
},
measureSync(_name, run) {
return run();
},
logIfSlow() {},
};
}
const startedAt = Date.now();
let didLog = false;
const spans: CodexThreadLifecycleTimingSpan[] = [];
const toMs = (value: number) => Math.max(0, Math.round(value));
const record = (name: string, spanStartedAt: number) => {
spans.push({
name,
durationMs: toMs(Date.now() - spanStartedAt),
elapsedMs: toMs(Date.now() - startedAt),
});
};
const snapshot = (): CodexThreadLifecycleTimingSummary => ({
totalMs: toMs(Date.now() - startedAt),
spans: spans.slice(),
});
const shouldLog = (summary: CodexThreadLifecycleTimingSummary) =>
summary.totalMs >= CODEX_THREAD_LIFECYCLE_TIMING_WARN_TOTAL_MS ||
summary.spans.some((span) => span.durationMs >= CODEX_THREAD_LIFECYCLE_TIMING_WARN_STAGE_MS);
const formatSpans = (summary: CodexThreadLifecycleTimingSummary) =>
summary.spans.length > 0
? summary.spans
.map((span) => `${span.name}:${span.durationMs}ms@${span.elapsedMs}ms`)
.join(",")
: "none";
return {
async measure(name, run) {
const spanStartedAt = Date.now();
try {
return await run();
} finally {
record(name, spanStartedAt);
}
},
measureSync(name, run) {
const spanStartedAt = Date.now();
try {
return run();
} finally {
record(name, spanStartedAt);
}
},
logIfSlow(params) {
if (didLog) {
return;
}
const summary = snapshot();
if (!shouldLog(summary)) {
return;
}
didLog = true;
embeddedAgentLog.warn(
`codex app-server thread lifecycle timings runId=${params.runId} sessionId=${
params.sessionId
} sessionKey=${params.sessionKey ?? "unknown"} action=${params.action} totalMs=${
summary.totalMs
} stages=${formatSpans(summary)}`,
{
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
action: params.action,
threadId: params.threadId,
totalMs: summary.totalMs,
spans: summary.spans,
},
);
},
};
}
export async function startOrResumeThread(params: {
client: CodexAppServerClient;
params: EmbeddedRunAttemptParams;
@@ -103,10 +211,16 @@ export async function startOrResumeThread(params: {
pluginThreadConfig?: CodexPluginThreadConfigProvider;
contextEngineProjection?: CodexContextEngineThreadBootstrapProjection;
}): Promise<CodexAppServerThreadLifecycleBinding> {
const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools);
const contextEngineBinding = buildContextEngineBinding(
params.params,
params.contextEngineProjection,
// Thread lifecycle spans are useful when profiling startup churn, but normal
// turns should not pay Date.now/span-array overhead while resuming threads.
const lifecycleTiming = createCodexThreadLifecycleTimingTracker({
enabled: isCodexAppServerProfilerEnabled(params.params.config),
});
const dynamicToolsFingerprint = lifecycleTiming.measureSync("fingerprint_dynamic_tools", () =>
fingerprintDynamicTools(params.dynamicTools),
);
const contextEngineBinding = lifecycleTiming.measureSync("context_engine_binding", () =>
buildContextEngineBinding(params.params, params.contextEngineProjection),
);
const userMcpServersConfigPatch =
params.userMcpServersEnabled === false
@@ -118,11 +232,13 @@ export async function startOrResumeThread(params: {
const environmentSelectionFingerprint = fingerprintEnvironmentSelection(
params.environmentSelection,
);
let binding = await readCodexAppServerBinding(params.params.sessionFile, {
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
});
let binding = await lifecycleTiming.measure("read_binding", () =>
readCodexAppServerBinding(params.params.sessionFile, {
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
}),
);
let preserveExistingBinding = false;
let rotatedContextEngineBinding = false;
let prebuiltPluginThreadConfig: CodexPluginThreadConfig | undefined;
@@ -207,7 +323,9 @@ export async function startOrResumeThread(params: {
})
) {
try {
prebuiltPluginThreadConfig = await params.pluginThreadConfig?.build();
prebuiltPluginThreadConfig = await lifecycleTiming.measure("plugin_config_recovery", () =>
params.pluginThreadConfig?.build(),
);
pluginBindingStale =
prebuiltPluginThreadConfig?.fingerprint !== binding.pluginAppsFingerprint;
} catch (error) {
@@ -274,19 +392,21 @@ export async function startOrResumeThread(params: {
userMcpServersConfigPatch,
params.finalConfigPatch,
);
const resumeParams = lifecycleTiming.measureSync("thread_resume_params", () =>
buildThreadResumeParams(params.params, {
threadId: binding.threadId,
authProfileId,
appServer: params.appServer,
dynamicTools: params.dynamicTools,
developerInstructions: params.developerInstructions,
config: resumeConfig,
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled,
}),
);
const response = assertCodexThreadResumeResponse(
await params.client.request(
"thread/resume",
buildThreadResumeParams(params.params, {
threadId: binding.threadId,
authProfileId,
appServer: params.appServer,
dynamicTools: params.dynamicTools,
developerInstructions: params.developerInstructions,
config: resumeConfig,
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled,
}),
await lifecycleTiming.measure("thread_resume_request", () =>
params.client.request("thread/resume", resumeParams),
),
);
const boundAuthProfileId = authProfileId;
@@ -301,29 +421,31 @@ export async function startOrResumeThread(params: {
params.mcpServersFingerprintEvaluated === true
? params.mcpServersFingerprint
: binding.mcpServersFingerprint;
await writeCodexAppServerBinding(
params.params.sessionFile,
{
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: boundAuthProfileId,
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
pluginAppsFingerprint: binding.pluginAppsFingerprint,
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
createdAt: binding.createdAt,
},
{
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
},
await lifecycleTiming.measure("thread_resume_write_binding", () =>
writeCodexAppServerBinding(
params.params.sessionFile,
{
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: boundAuthProfileId,
model: params.params.modelId,
modelProvider: response.modelProvider ?? fallbackModelProvider,
dynamicToolsFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
pluginAppsFingerprint: binding.pluginAppsFingerprint,
pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint,
pluginAppPolicyContext: binding.pluginAppPolicyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
createdAt: binding.createdAt,
},
{
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
},
),
);
if (contextEngineBinding) {
embeddedAgentLog.info("codex app-server wrote context-engine thread binding", {
@@ -336,6 +458,13 @@ export async function startOrResumeThread(params: {
action: "resumed",
});
}
lifecycleTiming.logIfSlow({
runId: params.params.runId,
sessionId: params.params.sessionId,
sessionKey: params.params.sessionKey,
threadId: response.thread.id,
action: "resumed",
});
return {
...binding,
threadId: response.thread.id,
@@ -366,27 +495,34 @@ export async function startOrResumeThread(params: {
}
const pluginThreadConfig = params.pluginThreadConfig?.enabled
? (prebuiltPluginThreadConfig ?? (await params.pluginThreadConfig.build()))
? (prebuiltPluginThreadConfig ??
(await lifecycleTiming.measure("plugin_config_build", () =>
params.pluginThreadConfig?.build(),
)))
: undefined;
const config = mergeCodexThreadConfigs(
params.config,
userMcpServersConfigPatch,
pluginThreadConfig?.configPatch,
params.finalConfigPatch,
const config = lifecycleTiming.measureSync("merge_thread_config", () =>
mergeCodexThreadConfigs(
params.config,
userMcpServersConfigPatch,
pluginThreadConfig?.configPatch,
params.finalConfigPatch,
),
);
const startParams = lifecycleTiming.measureSync("thread_start_params", () =>
buildThreadStartParams(params.params, {
cwd: params.cwd,
dynamicTools: params.dynamicTools,
appServer: params.appServer,
developerInstructions: params.developerInstructions,
config,
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled,
environmentSelection: params.environmentSelection,
}),
);
const response = assertCodexThreadStartResponse(
await params.client.request(
"thread/start",
buildThreadStartParams(params.params, {
cwd: params.cwd,
dynamicTools: params.dynamicTools,
appServer: params.appServer,
developerInstructions: params.developerInstructions,
config,
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled,
environmentSelection: params.environmentSelection,
}),
await lifecycleTiming.measure("thread_start_request", () =>
params.client.request("thread/start", startParams),
),
);
const modelProvider = resolveCodexAppServerModelProvider({
@@ -400,29 +536,31 @@ export async function startOrResumeThread(params: {
const nextMcpServersFingerprint =
params.mcpServersFingerprintEvaluated === true ? params.mcpServersFingerprint : undefined;
if (!preserveExistingBinding) {
await writeCodexAppServerBinding(
params.params.sessionFile,
{
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: params.params.authProfileId,
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? modelProvider,
dynamicToolsFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
createdAt,
},
{
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
},
await lifecycleTiming.measure("thread_start_write_binding", () =>
writeCodexAppServerBinding(
params.params.sessionFile,
{
threadId: response.thread.id,
cwd: params.cwd,
authProfileId: params.params.authProfileId,
model: response.model ?? params.params.modelId,
modelProvider: response.modelProvider ?? modelProvider,
dynamicToolsFingerprint,
userMcpServersFingerprint,
mcpServersFingerprint: nextMcpServersFingerprint,
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
pluginAppPolicyContext: pluginThreadConfig?.policyContext,
contextEngine: contextEngineBinding,
environmentSelectionFingerprint,
createdAt,
},
{
authProfileStore: params.params.authProfileStore,
agentDir: params.params.agentDir,
config: params.params.config,
},
),
);
if (contextEngineBinding) {
embeddedAgentLog.info("codex app-server wrote context-engine thread binding", {
@@ -436,6 +574,13 @@ export async function startOrResumeThread(params: {
});
}
}
lifecycleTiming.logIfSlow({
runId: params.params.runId,
sessionId: params.params.sessionId,
sessionKey: params.params.sessionKey,
threadId: response.thread.id,
action: rotatedContextEngineBinding ? "rotated" : "started",
});
return {
schemaVersion: 1,
threadId: response.thread.id,

View File

@@ -24,12 +24,10 @@ type BuildTelegramMessageContextForTestParams = {
options?: BuildTelegramMessageContextParams["options"];
cfg?: Record<string, unknown>;
accountId?: string;
dmPolicy?: BuildTelegramMessageContextParams["dmPolicy"];
historyLimit?: number;
groupHistories?: Map<string, import("openclaw/plugin-sdk/reply-history").HistoryEntry[]>;
ackReactionScope?: BuildTelegramMessageContextParams["ackReactionScope"];
botApi?: Record<string, unknown>;
sendChatActionHandler?: BuildTelegramMessageContextParams["sendChatActionHandler"];
runtime?: BuildTelegramMessageContextParams["runtime"];
sessionRuntime?: BuildTelegramMessageContextParams["sessionRuntime"] | null;
resolveGroupActivation?: BuildTelegramMessageContextParams["resolveGroupActivation"];
@@ -129,7 +127,7 @@ export async function buildTelegramMessageContextForTest(
account: { accountId: params.accountId ?? "default" } as never,
historyLimit: params.historyLimit ?? 0,
groupHistories: params.groupHistories ?? new Map(),
dmPolicy: params.dmPolicy ?? "open",
dmPolicy: "open",
allowFrom: ["*"],
groupAllowFrom: [],
ackReactionScope: params.ackReactionScope ?? "off",
@@ -142,7 +140,7 @@ export async function buildTelegramMessageContextForTest(
groupConfig: { requireMention: false },
topicConfig: undefined,
})),
sendChatActionHandler: params.sendChatActionHandler ?? ({ sendChatAction: vi.fn() } as never),
sendChatActionHandler: { sendChatAction: vi.fn() } as never,
});
}

View File

@@ -109,7 +109,6 @@ export type TelegramMessageContext = {
sendTyping: () => Promise<void>;
sendRecordVoice: () => Promise<void>;
sendChatActionHandler: BuildTelegramMessageContextParams["sendChatActionHandler"];
initialTypingCueSent?: boolean;
ackReactionPromise: Promise<boolean> | null;
reactionApi: TelegramReactionApi | null;
removeAckAfterReply: boolean;
@@ -369,7 +368,6 @@ export const buildTelegramMessageContext = async ({
) {
return null;
}
let initialTypingCueSent = false;
const ensureConfiguredBindingReady = async (): Promise<boolean> => {
if (!configuredBinding) {
return true;
@@ -482,15 +480,6 @@ export const buildTelegramMessageContext = async ({
return null;
}
// Direct chats are now reply-eligible; send the first typing cue before
// expensive context/session construction without showing typing for dropped turns.
if (!isGroup) {
initialTypingCueSent = true;
void sendTyping().catch((err) => {
logVerbose(`telegram early direct typing cue failed for chat ${chatId}: ${String(err)}`);
});
}
const { ctxPayload, skillFilter, turn } = await buildTelegramInboundContextPayload({
cfg,
primaryCtx,
@@ -654,7 +643,6 @@ export const buildTelegramMessageContext = async ({
sendTyping,
sendRecordVoice,
sendChatActionHandler,
initialTypingCueSent,
ackReactionPromise,
reactionApi,
removeAckAfterReply,

View File

@@ -1,80 +0,0 @@
import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inbound";
import { describe, expect, it, vi } from "vitest";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
import type { TelegramSendChatActionHandler } from "./sendchataction-401-backoff.js";
function createSendChatActionHandler(
sendChatAction = vi.fn(async () => undefined),
): TelegramSendChatActionHandler & { sendChatAction: typeof sendChatAction } {
return {
sendChatAction,
isSuspended: () => false,
reset: () => undefined,
};
}
describe("buildTelegramMessageContext typing", () => {
it("sends direct typing after body resolution and before session context construction", async () => {
const buildInboundContext = vi.fn(buildChannelInboundEventContext);
const sendChatActionHandler = createSendChatActionHandler();
await expect(
buildTelegramMessageContextForTest({
message: {
chat: { id: 42, type: "private", first_name: "Pat" },
from: { id: 42, first_name: "Pat" },
text: "hello",
},
sendChatActionHandler,
sessionRuntime: {
buildChannelInboundEventContext: buildInboundContext,
},
}),
).resolves.not.toBeNull();
expect(sendChatActionHandler.sendChatAction).toHaveBeenCalledWith(42, "typing", undefined);
expect(sendChatActionHandler.sendChatAction.mock.invocationCallOrder[0]).toBeLessThan(
buildInboundContext.mock.invocationCallOrder[0],
);
});
it("does not send direct typing when there is no replyable body", async () => {
const sendChatActionHandler = createSendChatActionHandler();
await expect(
buildTelegramMessageContextForTest({
message: {
chat: { id: 42, type: "private", first_name: "Pat" },
from: { id: 42, first_name: "Pat" },
text: undefined,
},
sendChatActionHandler,
}),
).resolves.toBeNull();
expect(sendChatActionHandler.sendChatAction).not.toHaveBeenCalled();
});
it("does not send early direct typing before DM access passes", async () => {
const sendChatActionHandler = createSendChatActionHandler();
await expect(
buildTelegramMessageContextForTest({
message: {
chat: { id: 42, type: "private", first_name: "Pat" },
from: { id: 42, first_name: "Pat" },
text: "hello",
},
cfg: {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
channels: { telegram: { dmPolicy: "disabled", allowFrom: [] } },
messages: { groupChat: { mentionPatterns: [] } },
},
dmPolicy: "disabled",
sendChatActionHandler,
}),
).resolves.toBeNull();
expect(sendChatActionHandler.sendChatAction).not.toHaveBeenCalled();
});
});

View File

@@ -1981,35 +1981,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(editMessageTelegram).not.toHaveBeenCalled();
});
it("does not stream text-only tool results into progress drafts", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await dispatcherOptions.deliver(
{ text: "stdout line one\nstdout line two" },
{ kind: "tool" },
);
await replyOptions?.onItemEvent?.({ kind: "search", progressText: "docs lookup" });
return { queuedFinal: false };
},
);
await dispatchWithContext({
context: createContext(),
streamMode: "progress",
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
});
expect(answerDraftStream.update).not.toHaveBeenCalledWith(
expect.stringContaining("stdout line one"),
);
expect(answerDraftStream.update).toHaveBeenLastCalledWith(
"Shelling\n\n`🛠️ Exec`\n`🔎 Web Search: docs lookup`",
);
expect(deliverReplies).not.toHaveBeenCalled();
});
it("does not restart progress drafts after final answer delivery", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(

View File

@@ -1752,28 +1752,19 @@ export const dispatchTelegramMessage = async ({
reasoningStepState.noteReasoningHint();
}
if (segment.lane === "answer" && info.kind === "tool") {
const canRepresentAsTransientProgress = canUseNativeToolProgressDraft({
payload: effectivePayload,
reply,
buttons: telegramButtons,
});
if (nativeToolProgressDraft && canRepresentAsTransientProgress) {
if (
nativeToolProgressDraft &&
canUseNativeToolProgressDraft({
payload: effectivePayload,
reply,
buttons: telegramButtons,
})
) {
if (await pushStreamToolProgress(segment.update.text)) {
blockDelivered = true;
continue;
}
}
if (
canRepresentAsTransientProgress &&
streamMode === "progress" &&
answerLane.stream
) {
// Progress-mode streams render tool status in the
// live draft. Do not also emit text-only tool output
// as answer text, or simple commands duplicate and
// restart the progress draft.
continue;
}
await prepareAnswerLaneForToolProgress();
}
const result =

View File

@@ -160,10 +160,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep
(options?.ingressBuffer ? ` buffer=${options.ingressBuffer}` : ""),
);
}
if (
context.ctxPayload.InboundEventKind !== "room_event" &&
context.initialTypingCueSent !== true
) {
if (context.ctxPayload.InboundEventKind !== "room_event") {
void context.sendTyping().catch((err) => {
logVerbose(`telegram early typing cue failed for chat ${context.chatId}: ${String(err)}`);
});

View File

@@ -115,7 +115,28 @@ describe("native hook relay registry", () => {
);
});
it("preserves safety relays while marking hook-only events without handlers inactive", () => {
it("preserves permission relays while marking hook-only events without handlers inactive", () => {
const relay = registerNativeHookRelay({
provider: "codex",
sessionId: "session-1",
runId: "run-1",
command: {
executable: "/opt/Open Claw/openclaw.mjs",
nodeExecutable: "/usr/local/bin/node",
timeoutMs: 1234,
},
});
expect(relay.shouldRelayEvent("pre_tool_use")).toBe(false);
expect(relay.shouldRelayEvent("post_tool_use")).toBe(false);
expect(relay.shouldRelayEvent("before_agent_finalize")).toBe(false);
expect(relay.shouldRelayEvent("permission_request")).toBe(true);
});
it("builds pre-tool relay commands only when before-tool policy is active", () => {
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: vi.fn() }]),
);
const relay = registerNativeHookRelay({
provider: "codex",
sessionId: "session-1",
@@ -128,9 +149,42 @@ describe("native hook relay registry", () => {
});
expect(relay.shouldRelayEvent("pre_tool_use")).toBe(true);
expect(relay.shouldRelayEvent("post_tool_use")).toBe(false);
expect(relay.shouldRelayEvent("before_agent_finalize")).toBe(false);
expect(relay.shouldRelayEvent("permission_request")).toBe(true);
expect(relay.commandForEvent("pre_tool_use")).toBe(
"/usr/local/bin/node '/opt/Open Claw/openclaw.mjs' hooks relay --provider codex --relay-id " +
`${relay.relayId} --event pre_tool_use --timeout 1234`,
);
});
it("keeps pre-tool relays active when native loop detection is not disabled", () => {
const relay = registerNativeHookRelay({
provider: "codex",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
command: {
executable: "/opt/Open Claw/openclaw.mjs",
nodeExecutable: "/usr/local/bin/node",
timeoutMs: 1234,
},
});
expect(relay.shouldRelayEvent("pre_tool_use")).toBe(true);
expect(relay.commandForEvent("pre_tool_use")).toBe(
"/usr/local/bin/node '/opt/Open Claw/openclaw.mjs' hooks relay --provider codex --relay-id " +
`${relay.relayId} --event pre_tool_use --timeout 1234`,
);
});
it("omits pre-tool relays when native loop detection is explicitly disabled", () => {
const relay = registerNativeHookRelay({
provider: "codex",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
config: { tools: { loopDetection: { enabled: false } } } as never,
});
expect(relay.shouldRelayEvent("pre_tool_use")).toBe(false);
});
it("builds relay commands only for native events with matching local hooks", () => {
@@ -148,7 +202,7 @@ describe("native hook relay registry", () => {
},
});
expect(relay.shouldRelayEvent("pre_tool_use")).toBe(true);
expect(relay.shouldRelayEvent("pre_tool_use")).toBe(false);
expect(relay.shouldRelayEvent("post_tool_use")).toBe(true);
expect(relay.shouldRelayEvent("before_agent_finalize")).toBe(false);
expect(relay.commandForEvent("post_tool_use")).toBe(
@@ -2284,4 +2338,19 @@ describe("native hook relay command builder", () => {
"openclaw hooks relay --provider codex --relay-id relay-1 --event permission_request --timeout 5000",
);
});
it("can lower native hook relay process priority", () => {
const prefix = process.platform === "win32" ? "" : "nice -n 10 ";
expect(
buildNativeHookRelayCommand({
provider: "codex",
relayId: "relay-1",
event: "pre_tool_use",
executable: "openclaw",
nice: 10,
}),
).toBe(
`${prefix}openclaw hooks relay --provider codex --relay-id relay-1 --event pre_tool_use --timeout 5000`,
);
});
});

View File

@@ -17,8 +17,9 @@ import { hasGlobalHooks } from "../../plugins/hook-runner-global.js";
import { PluginApprovalResolutions } from "../../plugins/types.js";
import { uniqueValues } from "../../shared/string-normalization.js";
import { asBoolean } from "../../utils/boolean.js";
import { runBeforeToolCallHook } from "../pi-tools.before-tool-call.js";
import { hasBeforeToolCallPolicy, runBeforeToolCallHook } from "../pi-tools.before-tool-call.js";
import { stableStringify } from "../stable-stringify.js";
import { resolveToolLoopDetectionConfig } from "../tool-loop-detection-config.js";
import { normalizeToolName } from "../tool-policy.js";
import { callGatewayTool } from "../tools/gateway.js";
import { runAgentHarnessAfterToolCallHook } from "./hook-helpers.js";
@@ -110,6 +111,7 @@ export type RegisterNativeHookRelayParams = {
export type NativeHookRelayCommandOptions = {
executable?: string;
nice?: number | false;
nodeExecutable?: string;
timeoutMs?: number;
};
@@ -324,12 +326,13 @@ export function registerNativeHookRelay(
registerNativeHookRelayBridge(registration);
const handle: NativeHookRelayRegistrationHandle = {
...registration,
shouldRelayEvent: nativeHookRelayEventHasLocalWork,
shouldRelayEvent: (event) => nativeHookRelayEventHasLocalWork(registration, event),
commandForEvent: (event) =>
buildNativeHookRelayCommand({
provider: params.provider,
relayId,
event,
nice: params.command?.nice,
timeoutMs: params.command?.timeoutMs,
executable: params.command?.executable,
nodeExecutable: params.command?.nodeExecutable,
@@ -376,12 +379,24 @@ function normalizeRelayId(value: string | undefined): string | undefined {
return trimmed;
}
function resolveNativeHookRelayNicePrefix(value: number | false | undefined): string[] {
if (process.platform === "win32" || value === false || value === undefined) {
return [];
}
const nice = normalizePositiveInteger(value, 0);
if (nice <= 0) {
return [];
}
return ["nice", "-n", String(nice)];
}
export function buildNativeHookRelayCommand(params: {
provider: NativeHookRelayProvider;
relayId: string;
event: NativeHookRelayEvent;
timeoutMs?: number;
executable?: string;
nice?: number | false;
nodeExecutable?: string;
}): string {
const timeoutMs = normalizePositiveInteger(params.timeoutMs, DEFAULT_RELAY_TIMEOUT_MS);
@@ -390,7 +405,9 @@ export function buildNativeHookRelayCommand(params: {
executable === "openclaw"
? ["openclaw"]
: [params.nodeExecutable ?? process.execPath, executable];
const nicePrefix = resolveNativeHookRelayNicePrefix(params.nice);
return shellQuoteArgs([
...nicePrefix,
...argv,
"hooks",
"relay",
@@ -405,9 +422,25 @@ export function buildNativeHookRelayCommand(params: {
]);
}
function nativeHookRelayEventHasLocalWork(event: NativeHookRelayEvent): boolean {
function nativePreToolUseMayRunLoopDetection(registration: NativeHookRelayRegistration): boolean {
if (!registration.sessionKey) {
return false;
}
const loopDetection = resolveToolLoopDetectionConfig({
cfg: registration.config,
agentId: registration.agentId,
});
return loopDetection?.enabled !== false;
}
function nativeHookRelayEventHasLocalWork(
registration: NativeHookRelayRegistration,
event: NativeHookRelayEvent,
): boolean {
if (event === "pre_tool_use") {
return true;
// Avoid spawning a native hook relay for every Codex tool call when there
// is no before_tool_call hook, trusted-tool policy, or loop detector work.
return hasBeforeToolCallPolicy() || nativePreToolUseMayRunLoopDetection(registration);
}
if (event === "post_tool_use") {
return hasGlobalHooks("after_tool_call");