perf: trace reply tool prep stages

This commit is contained in:
Shakker
2026-05-01 18:23:06 +01:00
parent f968c30e94
commit a36a3ab0de
5 changed files with 98 additions and 5 deletions

View File

@@ -100,6 +100,8 @@ export function createOpenClawTools(
enableHeartbeatTool?: boolean;
/** If true, skip plugin tool resolution and return only shipped core tools. */
disablePluginTools?: boolean;
/** Records hot-path tool-prep stages for reply startup diagnostics. */
recordToolPrepStage?: (name: string) => void;
/** Trusted sender id from inbound context (not tool args). */
requesterSenderId?: string | null;
/** Whether the requesting sender is an owner. */
@@ -135,6 +137,7 @@ export function createOpenClawTools(
const spawnWorkspaceDir = resolveWorkspaceRoot(
options?.spawnWorkspaceDir ?? options?.workspaceDir ?? inferredWorkspaceDir,
);
options?.recordToolPrepStage?.("openclaw-tools:session-workspace");
const deliveryContext = normalizeDeliveryContext({
channel: options?.agentChannel,
to: options?.agentTo,
@@ -156,6 +159,7 @@ export function createOpenClawTools(
modelHasVision: options?.modelHasVision,
})
: null;
options?.recordToolPrepStage?.("openclaw-tools:image-tool");
const imageGenerateTool = createImageGenerateTool({
config: options?.config,
agentDir: options?.agentDir,
@@ -163,6 +167,7 @@ export function createOpenClawTools(
sandbox,
fsPolicy: options?.fsPolicy,
});
options?.recordToolPrepStage?.("openclaw-tools:image-generate-tool");
const videoGenerateTool = createVideoGenerateTool({
config: options?.config,
agentDir: options?.agentDir,
@@ -172,6 +177,7 @@ export function createOpenClawTools(
sandbox,
fsPolicy: options?.fsPolicy,
});
options?.recordToolPrepStage?.("openclaw-tools:video-generate-tool");
const musicGenerateTool = createMusicGenerateTool({
config: options?.config,
agentDir: options?.agentDir,
@@ -181,6 +187,7 @@ export function createOpenClawTools(
sandbox,
fsPolicy: options?.fsPolicy,
});
options?.recordToolPrepStage?.("openclaw-tools:music-generate-tool");
const pdfTool = options?.agentDir?.trim()
? createPdfTool({
config: options?.config,
@@ -190,17 +197,20 @@ export function createOpenClawTools(
fsPolicy: options?.fsPolicy,
})
: null;
options?.recordToolPrepStage?.("openclaw-tools:pdf-tool");
const webSearchTool = createWebSearchTool({
config: options?.config,
sandboxed: options?.sandboxed,
runtimeWebSearch: runtimeWebTools?.search,
lateBindRuntimeConfig: true,
});
options?.recordToolPrepStage?.("openclaw-tools:web-search-tool");
const webFetchTool = createWebFetchTool({
config: options?.config,
sandboxed: options?.sandboxed,
runtimeWebFetch: runtimeWebTools?.fetch,
});
options?.recordToolPrepStage?.("openclaw-tools:web-fetch-tool");
const messageTool = options?.disableMessageTool
? null
: createMessageTool({
@@ -220,6 +230,7 @@ export function createOpenClawTools(
senderIsOwner: options?.senderIsOwner,
});
const heartbeatTool = options?.enableHeartbeatTool ? createHeartbeatResponseTool() : null;
options?.recordToolPrepStage?.("openclaw-tools:message-tool");
const nodesToolBase = createNodesTool({
agentSessionKey: options?.agentSessionKey,
agentChannel: options?.agentChannel,
@@ -236,6 +247,7 @@ export function createOpenClawTools(
sandboxRoot: options?.sandboxRoot,
workspaceDir,
});
options?.recordToolPrepStage?.("openclaw-tools:nodes-tool");
const embedded = isEmbeddedMode();
const effectiveCallGateway = embedded
? createEmbeddedCallGateway()
@@ -341,6 +353,7 @@ export function createOpenClawTools(
}),
...collectPresentOpenClawTools([webSearchTool, webFetchTool, imageTool, pdfTool]),
];
options?.recordToolPrepStage?.("openclaw-tools:core-tool-list");
if (options?.disablePluginTools) {
return tools;
@@ -351,6 +364,7 @@ export function createOpenClawTools(
resolvedConfig,
existingToolNames: new Set(tools.map((tool) => tool.name)),
});
options?.recordToolPrepStage?.("openclaw-tools:plugin-tools");
return [...tools, ...wrappedPluginTools];
}

View File

@@ -723,6 +723,30 @@ export async function runEmbeddedAttempt(
log.trace(message);
}
};
const emitCorePluginToolStageSummary = (
phase: string,
summary: ReturnType<typeof prepStages.snapshot>,
) => {
if (summary.stages.length === 0) {
return;
}
const shouldWarn = shouldWarnEmbeddedRunStageSummary(summary, {
totalThresholdMs: 5_000,
stageThresholdMs: 2_000,
});
if (!shouldWarn && !log.isEnabled("trace")) {
return;
}
const message = formatEmbeddedRunStageSummary(
`[trace:embedded-run] core-plugin-tool stages: runId=${params.runId} sessionId=${params.sessionId} phase=${phase}`,
summary,
);
if (shouldWarn) {
log.warn(message);
} else {
log.trace(message);
}
};
await fs.mkdir(resolvedWorkspace, { recursive: true });
@@ -833,6 +857,7 @@ export async function runEmbeddedAttempt(
...(err ? { errorCategory: diagnosticErrorCategory(err) } : {}),
});
};
const corePluginToolStages = createEmbeddedRunStageTracker();
const toolsRaw =
params.disableTools || isRawModelRun
? []
@@ -893,6 +918,7 @@ export async function runEmbeddedAttempt(
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
disableMessageTool: params.disableMessageTool,
forceMessageTool: params.forceMessageTool,
recordToolPrepStage: (name) => corePluginToolStages.mark(name),
onYield: (message) => {
yieldDetected = true;
yieldMessage = message;
@@ -901,9 +927,13 @@ export async function runEmbeddedAttempt(
abortSessionForYield?.();
},
});
return applyEmbeddedAttemptToolsAllow(allTools, params.toolsAllow);
corePluginToolStages.mark("attempt:create-openclaw-coding-tools");
const filteredTools = applyEmbeddedAttemptToolsAllow(allTools, params.toolsAllow);
corePluginToolStages.mark("attempt:tools-allow");
return filteredTools;
})();
prepStages.mark("core-plugin-tools");
emitCorePluginToolStageSummary("core-plugin-tools", corePluginToolStages.snapshot());
const toolsEnabled = supportsModelTools(params.model);
const bootstrapHasFileAccess = toolsEnabled && toolsRaw.some((tool) => tool.name === "read");
const bootstrapRouting = await resolveAttemptWorkspaceBootstrapRouting({

View File

@@ -180,6 +180,40 @@ describe("createOpenClawCodingTools", () => {
);
});
it("records core tool-prep stages for hot-path diagnostics", () => {
const stages: string[] = [];
createOpenClawCodingTools({
config: testConfig,
recordToolPrepStage: (name) => stages.push(name),
senderIsOwner: true,
});
expect(stages).toEqual(
expect.arrayContaining([
"tool-policy",
"workspace-policy",
"base-coding-tools",
"shell-tools",
"openclaw-tools:test-helper",
"openclaw-tools",
"message-provider-policy",
"model-provider-policy",
"authorization-policy",
"schema-normalization",
"tool-hooks",
"abort-wrappers",
"deferred-followup-descriptions",
]),
);
expect(stages.indexOf("tool-policy")).toBeLessThan(stages.indexOf("workspace-policy"));
expect(stages.indexOf("workspace-policy")).toBeLessThan(stages.indexOf("base-coding-tools"));
expect(stages.indexOf("openclaw-tools:test-helper")).toBeLessThan(
stages.indexOf("openclaw-tools"),
);
expect(stages.indexOf("schema-normalization")).toBeLessThan(stages.indexOf("tool-hooks"));
});
it("preserves action enums in normalized schemas", () => {
const defaultTools = createOpenClawCodingTools({ config: testConfig, senderIsOwner: true });
const toolNames = ["canvas", "nodes", "cron", "gateway", "message"];

View File

@@ -489,6 +489,7 @@ export function createOpenClawCodingTools(options?: {
throw new Error("Sandbox filesystem bridge is unavailable.");
}
const imageSanitization = resolveImageSanitizationLimits(options?.config);
options?.recordToolPrepStage?.("workspace-policy");
const base = includeCoreTools
? (createCodingTools(workspaceRoot) as unknown as AnyAgentTool[]).flatMap((tool) => {
@@ -535,6 +536,7 @@ export function createOpenClawCodingTools(options?: {
return [tool];
})
: [];
options?.recordToolPrepStage?.("base-coding-tools");
const { cleanupMs: cleanupMsOverride, ...execDefaults } = options?.exec ?? {};
const execTool = includeCoreTools
? createLazyExecTool({
@@ -594,6 +596,7 @@ export function createOpenClawCodingTools(options?: {
: undefined,
workspaceOnly: applyPatchWorkspaceOnly,
});
options?.recordToolPrepStage?.("shell-tools");
const pluginToolAllowlist = collectExplicitAllowlist([
profilePolicy,
providerProfilePolicy,
@@ -712,9 +715,11 @@ export function createOpenClawCodingTools(options?: {
sessionId: options?.sessionId,
onYield: options?.onYield,
allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding,
recordToolPrepStage: options?.recordToolPrepStage,
})
: pluginToolsOnly),
];
options?.recordToolPrepStage?.("openclaw-tools");
const toolsForMemoryFlush =
isMemoryFlushRun && memoryFlushWritePath
? tools.flatMap((tool) => {
@@ -741,6 +746,7 @@ export function createOpenClawCodingTools(options?: {
toolsForMemoryFlush,
options?.messageProvider,
);
options?.recordToolPrepStage?.("message-provider-policy");
const toolsForModelProvider = applyModelProviderToolPolicy(toolsForMessageProvider, {
config: options?.config,
modelProvider: options?.modelProvider,
@@ -750,6 +756,7 @@ export function createOpenClawCodingTools(options?: {
modelCompat: options?.modelCompat,
suppressManagedWebSearch: options?.suppressManagedWebSearch,
});
options?.recordToolPrepStage?.("model-provider-policy");
// Security: treat unknown/undefined as unauthorized (opt-in, not opt-out)
const senderIsOwner = options?.senderIsOwner === true;
const toolsByAuthorization = applyOwnerOnlyToolPolicy(
@@ -780,6 +787,7 @@ export function createOpenClawCodingTools(options?: {
{ policy: subagentPolicy, label: "subagent tools.allow" },
],
});
options?.recordToolPrepStage?.("authorization-policy");
// Always normalize tool JSON Schemas before handing them to pi-agent/pi-ai.
// Without this, some providers (notably OpenAI) will reject root-level union schemas.
// Provider-specific cleaning: Gemini needs constraint keywords stripped, but Anthropic expects them.
@@ -790,6 +798,7 @@ export function createOpenClawCodingTools(options?: {
modelCompat: options?.modelCompat,
}),
);
options?.recordToolPrepStage?.("schema-normalization");
const withHooks = normalized.map((tool) =>
wrapToolWithBeforeToolCallHook(tool, {
agentId,
@@ -800,12 +809,15 @@ export function createOpenClawCodingTools(options?: {
loopDetection: resolveToolLoopDetectionConfig({ cfg: options?.config, agentId }),
}),
);
options?.recordToolPrepStage?.("tool-hooks");
const withAbort = options?.abortSignal
? withHooks.map((tool) => wrapToolWithAbortSignal(tool, options.abortSignal))
: withHooks;
options?.recordToolPrepStage?.("abort-wrappers");
const withDeferredFollowupDescriptions = applyDeferredFollowupToolDescriptions(withAbort, {
agentId,
});
options?.recordToolPrepStage?.("deferred-followup-descriptions");
// NOTE: Keep canonical (lowercase) tool names here.
// pi-ai's Anthropic OAuth transport remaps tool names to Claude Code-style names

View File

@@ -47,10 +47,13 @@ const coreTools = [
stubTool("pdf"),
];
const createOpenClawToolsMock = vi.fn((options?: { enableHeartbeatTool?: boolean }) =>
coreTools
.filter((tool) => tool.name !== "heartbeat_respond" || options?.enableHeartbeatTool === true)
.map((tool) => Object.assign({}, tool)),
const createOpenClawToolsMock = vi.fn(
(options?: { enableHeartbeatTool?: boolean; recordToolPrepStage?: (name: string) => void }) => {
options?.recordToolPrepStage?.("openclaw-tools:test-helper");
return coreTools
.filter((tool) => tool.name !== "heartbeat_respond" || options?.enableHeartbeatTool === true)
.map((tool) => Object.assign({}, tool));
},
);
vi.mock("../openclaw-tools.js", () => ({