diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 078aeb1389e..76448003a04 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -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]; } diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index f5cd0bb52c5..79a77320507 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -723,6 +723,30 @@ export async function runEmbeddedAttempt( log.trace(message); } }; + const emitCorePluginToolStageSummary = ( + phase: string, + summary: ReturnType, + ) => { + 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({ diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts index a00a707a6de..cd255c3b7df 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts @@ -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"]; diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 48622dd0642..c77f86ae2f6 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -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 diff --git a/src/agents/test-helpers/fast-openclaw-tools.ts b/src/agents/test-helpers/fast-openclaw-tools.ts index 30bd14aef87..9e7454c6972 100644 --- a/src/agents/test-helpers/fast-openclaw-tools.ts +++ b/src/agents/test-helpers/fast-openclaw-tools.ts @@ -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", () => ({