From fe9893d41c2bedd71fd4eb0bb9f0c9c9f79b1be7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Jul 2026 09:16:35 -0400 Subject: [PATCH] refactor(agents): resolve code-mode vs tool-search once for every harness (#115189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(agents): share one tool-surface resolver across runner and harness The code-mode vs tool-search gate decision and the three-way catalog application were duplicated across the embedded runner and the native harness bridge, and had already drifted: the harness copy was missing the skillWorkshopProposalOnly condition and its params object had no field to express it. Both surfaces now consume resolveAgentToolSurfacePlan and applyAgentToolSurfaceCatalog, so the gates and the catalog branch exist once. Both gate booleans derive from a single shared toolsAvailable intermediate. Behavior change: the harness path now honors skillWorkshopProposalOnly. Proposal-only skill-workshop runs are deliberately narrow single-tool runs, so code-mode indirection and tool-search catalogs are pure overhead — a harness-independent reason. Both callers currently pin agentHarnessRuntimeOverride "openclaw", so this closes a latent fail-open rather than fixing a live bug. * fix(agents): let the shared catalog params carry a config-less run resolveAgentToolSearchRuntimeConfig returns OpenClawConfig | undefined, so requiring a non-null toolSearchRuntimeConfig broke tsgo:core at both call sites. Keep the key required so it cannot be silently omitted, but let the value be undefined as the pre-refactor code already allowed. * fix(agents): keep the tool-surface module free of dead exports Importing isCodeModeEngagedForModel and applyToolSchemaDirectoryCatalog from their defining modules left the code-mode.ts and tool-search.ts re-exports with no production consumer, which knip rejects. Go back through the barrels, which is also what both call sites did before this refactor. The two params types were exported only for the test, so knip's production scan saw them as dead. Keep them module-local and derive the type in the test. --- docs/plugins/sdk-agent-harness.md | 5 + extensions/copilot/src/tool-bridge.ts | 1 + .../run/attempt-tool-base-prepare.ts | 38 ++--- .../run/attempt-tool-catalog.ts | 60 +++----- .../harness/tool-surface-bridge.test.ts | 21 +++ src/agents/harness/tool-surface-bridge.ts | 89 ++++------- src/agents/tool-surface-plan.test.ts | 141 ++++++++++++++++++ src/agents/tool-surface-plan.ts | 92 ++++++++++++ 8 files changed, 321 insertions(+), 126 deletions(-) create mode 100644 src/agents/tool-surface-plan.test.ts create mode 100644 src/agents/tool-surface-plan.ts diff --git a/docs/plugins/sdk-agent-harness.md b/docs/plugins/sdk-agent-harness.md index b6056bef4f4f..0a030da11e81 100644 --- a/docs/plugins/sdk-agent-harness.md +++ b/docs/plugins/sdk-agent-harness.md @@ -294,6 +294,11 @@ runtime-compatible schema filtering, hidden catalog execution, directory hydration, and catalog cleanup. Harnesses still own their SDK-specific tool conversion and native execution callback. +Harnesses that forward embedded attempt params should pass +`skillWorkshopProposalOnly` through. Proposal-only skill-workshop runs are +deliberately narrow single-tool runs, and the runtime keeps them on the raw +tool surface instead of engaging code mode or a tool-search catalog. + ### Native Codex harness mode The bundled `codex` harness is the native Codex mode for embedded OpenClaw diff --git a/extensions/copilot/src/tool-bridge.ts b/extensions/copilot/src/tool-bridge.ts index 6ceda16457f6..2b5fb808801b 100644 --- a/extensions/copilot/src/tool-bridge.ts +++ b/extensions/copilot/src/tool-bridge.ts @@ -212,6 +212,7 @@ export async function createCopilotToolBridge( sessionId: input.sessionId, sessionKey: attemptParams.sandboxSessionKey ?? attemptParams.sessionKey ?? input.sessionKey, scheduledToolPolicy: attemptParams.scheduledToolPolicy, + skillWorkshopProposalOnly: attemptParams.skillWorkshopProposalOnly, sourceReplyDeliveryMode: attemptParams.sourceReplyDeliveryMode, toolsAllow: attemptParams.toolsAllow, }); diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts index 8b4677e6765b..18efbde39e48 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts @@ -4,10 +4,8 @@ import { extractModelCompat } from "../../../plugins/provider-model-compat.js"; import { getPluginToolMeta } from "../../../plugins/tools.js"; import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { createOpenClawCodingTools } from "../../agent-tools.js"; -import { getActiveAgentRingZeroTools } from "../../agent-tools.ring-zero-context.js"; import { getChannelAgentToolMeta } from "../../channel-tools.js"; import type { CodeModeSkill } from "../../code-mode-skills.js"; -import { isCodeModeEngagedForModel, resolveCodeModeConfig } from "../../code-mode.js"; import { resolveConversationCapabilityProfile } from "../../conversation-capability-profile.js"; import { isLocalModelLeanEnabled, @@ -17,13 +15,12 @@ import { resolveModelAuthMode } from "../../model-auth.js"; import { supportsModelTools } from "../../model-tool-support.js"; import type { SandboxContext } from "../../sandbox/types.js"; import { isAgentToolRestartSafe } from "../../tool-replay-safety.js"; -import { resolveAgentToolSearchRuntimeConfig } from "../../tool-search-runtime-config.js"; import { createToolSearchCatalogRef, - resolveToolSearchConfig, type ToolSearchCatalogToolExecutor, type ToolSearchTargetTranscriptProjection, } from "../../tool-search.js"; +import { resolveAgentToolSurfacePlan } from "../../tool-surface-plan.js"; import type { ComputerContextEpoch } from "../../tools/computer-tool.js"; import type { CronCreatorToolAllowlistEntry } from "../../tools/cron-tool.js"; import { log } from "../logger.js"; @@ -70,7 +67,6 @@ export function prepareEmbeddedAttemptToolBase(params: { }, ); const toolsEnabled = supportsModelTools(attempt.model); - const ringZeroToolRun = getActiveAgentRingZeroTools().length > 0; const isRawModelRun = attempt.modelRun === true || attempt.promptMode === "none"; const toolConstructionPlan = resolveEmbeddedAttemptToolConstructionPlan({ disableTools: attempt.disableTools, @@ -78,31 +74,23 @@ export function prepareEmbeddedAttemptToolBase(params: { toolsEnabled, toolsAllow: toolsAllowWithForcedRuntimeTools, }); - const codeModeConfig = resolveCodeModeConfig(attempt.config, params.sessionAgentId); - const toolSearchRuntimeConfig = resolveAgentToolSearchRuntimeConfig({ + const { + codeModeControlsEnabled: codeModeControlsEnabledForRun, + toolSearchConfig, + toolSearchControlsEnabled: toolSearchControlsEnabledForRun, + toolSearchRuntimeConfig, + } = resolveAgentToolSurfacePlan({ config: attempt.config, agentId: params.sessionAgentId, sessionKey: params.sandboxSessionKey, forceDirectMessageTool, + model: attempt.model, + toolsEnabled, + disableTools: attempt.disableTools, + isRawModelRun, + skillWorkshopProposalOnly: attempt.skillWorkshopProposalOnly, + toolsAllow: attempt.toolsAllow, }); - const toolSearchConfig = resolveToolSearchConfig(toolSearchRuntimeConfig); - const codeModeControlsEnabledForRun = - toolsEnabled && - !ringZeroToolRun && - attempt.disableTools !== true && - !isRawModelRun && - attempt.skillWorkshopProposalOnly !== true && - attempt.toolsAllow?.length !== 0 && - isCodeModeEngagedForModel(codeModeConfig, attempt.model); - const toolSearchControlsEnabledForRun = - toolsEnabled && - !ringZeroToolRun && - attempt.disableTools !== true && - !isRawModelRun && - attempt.skillWorkshopProposalOnly !== true && - attempt.toolsAllow?.length !== 0 && - !codeModeControlsEnabledForRun && - toolSearchConfig.enabled; const effectiveToolsAllow = toolSearchControlsEnabledForRun && toolsAllowWithForcedRuntimeTools ? [...new Set([...toolsAllowWithForcedRuntimeTools, ...TOOL_SEARCH_CONTROL_ALLOWLIST_NAMES])] diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-catalog.ts b/src/agents/embedded-agent-runner/run/attempt-tool-catalog.ts index e4dd2cc29e5c..cd886c36411e 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-catalog.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-catalog.ts @@ -4,7 +4,6 @@ import type { DiagnosticTraceContext } from "../../../infra/diagnostic-trace-context.js"; import { resolveToolLoopDetectionConfig } from "../../agent-tools.js"; import { - applyCodeModeCatalog, CODE_MODE_EXEC_TOOL_NAME, CODE_MODE_WAIT_TOOL_NAME, createCodeModeTools, @@ -15,13 +14,12 @@ import { buildEmptyExplicitToolAllowlistError } from "../../tool-allowlist-guard import { filterRuntimeCompatibleTools } from "../../tool-schema-projection.js"; import { logRuntimeToolSchemaQuarantine } from "../../tool-schema-quarantine.js"; import { - applyToolSchemaDirectoryCatalog, - applyToolSearchCatalog, TOOL_CALL_RAW_TOOL_NAME, TOOL_DESCRIBE_RAW_TOOL_NAME, TOOL_SEARCH_RAW_TOOL_NAME, type ToolSearchCatalogToolExecutor, } from "../../tool-search.js"; +import { applyAgentToolSurfaceCatalog } from "../../tool-surface-plan.js"; import { log } from "../logger.js"; import type { prepareEmbeddedAttemptBundleTools } from "./attempt-bundle-tools.js"; import { collectAttemptExplicitToolAllowlistSources } from "./attempt-tool-allowlist.js"; @@ -93,45 +91,23 @@ export function prepareEmbeddedAttemptToolCatalog(input: { codeModeSkills, }) : []; - // When the message tool is the only reply path it must stay directly visible - // in every search mode; a hidden delivery tool can leave the run mute. - const requiredDirectToolNames = preparedToolBase.forceDirectMessageTool ? ["message"] : []; - const toolSearch = codeModeControlsEnabledForRun - ? applyCodeModeCatalog({ - tools: [...codeModeTools, ...effectiveTools], - config: attempt.config, - sessionId: attempt.sessionId, - sessionKey: input.sandboxSessionKey, - agentId: input.sessionAgentId, - runId: attempt.runId, - catalogRef: preparedToolBase.toolSearchCatalogRef, - toolHookContext: catalogToolHookContext, - directToolNames: requiredDirectToolNames, - codeModeSkills, - }) - : toolSearchConfig.mode === "directory" - ? applyToolSchemaDirectoryCatalog({ - tools: effectiveTools, - config: toolSearchRuntimeConfig, - sessionId: attempt.sessionId, - sessionKey: input.sandboxSessionKey, - agentId: input.sessionAgentId, - runId: attempt.runId, - catalogRef: preparedToolBase.toolSearchCatalogRef, - toolHookContext: catalogToolHookContext, - directToolNames: requiredDirectToolNames, - }) - : applyToolSearchCatalog({ - tools: effectiveTools, - config: toolSearchRuntimeConfig, - sessionId: attempt.sessionId, - sessionKey: input.sandboxSessionKey, - agentId: input.sessionAgentId, - runId: attempt.runId, - catalogRef: preparedToolBase.toolSearchCatalogRef, - toolHookContext: catalogToolHookContext, - directToolNames: requiredDirectToolNames, - }); + const toolSearch = applyAgentToolSurfaceCatalog({ + // `codeModeTools` is empty unless code-mode controls are on, so this stays + // exactly `effectiveTools` for the tool-search branches. + tools: [...codeModeTools, ...effectiveTools], + config: attempt.config, + toolSearchRuntimeConfig, + codeModeControlsEnabled: codeModeControlsEnabledForRun, + toolSearchConfig, + forceDirectMessageTool: preparedToolBase.forceDirectMessageTool, + sessionId: attempt.sessionId, + sessionKey: input.sandboxSessionKey, + agentId: input.sessionAgentId, + runId: attempt.runId, + catalogRef: preparedToolBase.toolSearchCatalogRef, + toolHookContext: catalogToolHookContext, + codeModeSkills, + }); const projectedToolSearchTools = filterLocalModelLeanTools({ tools: toolSearch.tools, config: attempt.config, diff --git a/src/agents/harness/tool-surface-bridge.test.ts b/src/agents/harness/tool-surface-bridge.test.ts index 192a71cbd18f..5ae8c6dc62d6 100644 --- a/src/agents/harness/tool-surface-bridge.test.ts +++ b/src/agents/harness/tool-surface-bridge.test.ts @@ -58,6 +58,27 @@ describe("createAgentHarnessToolSurfaceRuntime", () => { }); }); + it("keeps proposal-only skill workshop runs on the raw harness tool surface", () => { + const rawTools = tools(["skill_workshop"]); + const runtime = createAgentHarnessToolSurfaceRuntime({ + config: { tools: { codeMode: true, toolSearch: true } }, + executeTool: async () => ({ content: [], details: {} }), + modelToolsEnabled: true, + skillWorkshopProposalOnly: true, + toolsAllow: ["skill_workshop"], + }); + + try { + expect(runtime.codeModeControlsEnabled).toBe(false); + expect(runtime.toolSearchControlsEnabled).toBe(false); + expect(runtime.compactTools(rawTools).tools).toEqual(rawTools); + expect(runtime.compactTools(rawTools).tools.map((tool) => tool.name)).not.toContain("exec"); + expect(runtime.compactTools(rawTools).tools.map((tool) => tool.name)).not.toContain("wait"); + } finally { + runtime.cleanup(); + } + }); + it("filters raw SDK tools but does not refilter prepared constructor output", () => { const config: OpenClawConfig = { agents: { defaults: { experimental: { localModelLean: true } } }, diff --git a/src/agents/harness/tool-surface-bridge.ts b/src/agents/harness/tool-surface-bridge.ts index fc326c8f1d1b..4665396cb26b 100644 --- a/src/agents/harness/tool-surface-bridge.ts +++ b/src/agents/harness/tool-surface-bridge.ts @@ -1,14 +1,10 @@ import { messageToolOwnsVisibleReply } from "../../auto-reply/source-reply-delivery-mode.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { HookContext } from "../agent-tools.before-tool-call.js"; -import { getActiveAgentRingZeroTools } from "../agent-tools.ring-zero-context.js"; import { CODE_MODE_EXEC_TOOL_NAME, CODE_MODE_WAIT_TOOL_NAME, - applyCodeModeCatalog, createCodeModeTools, - isCodeModeEngagedForModel, - resolveCodeModeConfig, } from "../code-mode.js"; import { resolveConversationCapabilityProfile } from "../conversation-capability-profile.js"; import { @@ -17,13 +13,9 @@ import { } from "../local-model-lean.js"; import type { ScheduledToolPolicyContext } from "../scheduled-tool-policy.js"; import { filterRuntimeCompatibleTools } from "../tool-schema-projection.js"; -import { resolveAgentToolSearchRuntimeConfig } from "../tool-search-runtime-config.js"; import { - applyToolSchemaDirectoryCatalog, - applyToolSearchCatalog, clearToolSearchCatalog, createToolSearchCatalogRef, - resolveToolSearchConfig, TOOL_CALL_RAW_TOOL_NAME, TOOL_DESCRIBE_RAW_TOOL_NAME, TOOL_SEARCH_CODE_MODE_TOOL_NAME, @@ -31,6 +23,7 @@ import { type ToolSearchCatalogRef, type ToolSearchCatalogToolExecutor, } from "../tool-search.js"; +import { applyAgentToolSurfaceCatalog, resolveAgentToolSurfacePlan } from "../tool-surface-plan.js"; import type { AnyAgentTool } from "../tools/common.js"; const TOOL_SEARCH_CONTROL_ALLOWLIST_NAMES = [ @@ -78,27 +71,27 @@ export function createAgentHarnessToolSurfaceRuntime(params: { sessionKey?: string; scheduledToolPolicy?: ScheduledToolPolicyContext; sourceReplyDeliveryMode?: string; + skillWorkshopProposalOnly?: boolean; toolsAllow?: readonly string[]; }): AgentHarnessToolSurfaceRuntime { const forceDirectMessageTool = messageToolOwnsVisibleReply(params); - const codeModeConfig = resolveCodeModeConfig(params.config, params.agentId); - const toolSearchRuntimeConfig = resolveAgentToolSearchRuntimeConfig({ + const { + codeModeControlsEnabled, + toolSearchControlsEnabled, + toolSearchConfig, + toolSearchRuntimeConfig, + } = resolveAgentToolSurfacePlan({ config: params.config, agentId: params.agentId, sessionKey: params.sessionKey, forceDirectMessageTool, + model: params.model, + toolsEnabled: params.modelToolsEnabled, + disableTools: params.disableTools, + isRawModelRun: params.isRawModelRun === true, + skillWorkshopProposalOnly: params.skillWorkshopProposalOnly, + toolsAllow: params.toolsAllow, }); - const toolSearchConfig = resolveToolSearchConfig(toolSearchRuntimeConfig); - const toolsAvailable = - params.modelToolsEnabled && - params.disableTools !== true && - params.isRawModelRun !== true && - params.toolsAllow?.length !== 0; - const ringZeroToolRun = getActiveAgentRingZeroTools().length > 0; - const codeModeControlsEnabled = - toolsAvailable && !ringZeroToolRun && isCodeModeEngagedForModel(codeModeConfig, params.model); - const toolSearchControlsEnabled = - toolsAvailable && !ringZeroToolRun && !codeModeControlsEnabled && toolSearchConfig.enabled; const toolSearchCatalogRef = toolSearchControlsEnabled || codeModeControlsEnabled ? createToolSearchCatalogRef() : undefined; const runtimeToolAllowlist = @@ -159,44 +152,22 @@ export function createAgentHarnessToolSurfaceRuntime(params: { executeTool: params.executeTool, }) : []; - // When the message tool is the only reply path it must stay directly visible - // in every search mode; a hidden delivery tool can leave the run mute. - const requiredDirectToolNames = forceDirectMessageTool ? ["message"] : []; - const compacted = codeModeControlsEnabled - ? applyCodeModeCatalog({ - tools: [...codeModeTools, ...effectiveTools], - config: params.config, - sessionId: params.sessionId, - sessionKey: params.sessionKey, - agentId: params.agentId, - runId: params.runId, - catalogRef: toolSearchCatalogRef, - toolHookContext: options.hookContext, - directToolNames: requiredDirectToolNames, - }) - : toolSearchConfig.mode === "directory" - ? applyToolSchemaDirectoryCatalog({ - tools: effectiveTools, - config: toolSearchRuntimeConfig, - sessionId: params.sessionId, - sessionKey: params.sessionKey, - agentId: params.agentId, - runId: params.runId, - catalogRef: toolSearchCatalogRef, - toolHookContext: options.hookContext, - directToolNames: requiredDirectToolNames, - }) - : applyToolSearchCatalog({ - tools: effectiveTools, - config: toolSearchRuntimeConfig, - sessionId: params.sessionId, - sessionKey: params.sessionKey, - agentId: params.agentId, - runId: params.runId, - catalogRef: toolSearchCatalogRef, - toolHookContext: options.hookContext, - directToolNames: requiredDirectToolNames, - }); + const compacted = applyAgentToolSurfaceCatalog({ + // `codeModeTools` is empty unless code-mode controls are on, so this stays + // exactly `effectiveTools` for the tool-search branches. + tools: [...codeModeTools, ...effectiveTools], + config: params.config, + toolSearchRuntimeConfig, + codeModeControlsEnabled, + toolSearchConfig, + forceDirectMessageTool, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + agentId: params.agentId, + runId: params.runId, + catalogRef: toolSearchCatalogRef, + toolHookContext: options.hookContext, + }); const projectedCompactedTools = options.localModelLeanApplied ? compacted.tools : filterLocalModelLeanTools({ diff --git a/src/agents/tool-surface-plan.test.ts b/src/agents/tool-surface-plan.test.ts new file mode 100644 index 000000000000..ee0e234582c9 --- /dev/null +++ b/src/agents/tool-surface-plan.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { runWithAgentRingZeroTools } from "./agent-tools.ring-zero-context.js"; +import { createCodeModeTools } from "./code-mode.js"; +import { createStubTool } from "./test-helpers/agent-tool-stubs.js"; +import { + createToolSearchCatalogRef, + TOOL_SEARCH_RAW_TOOL_NAME, + type ToolSearchCatalogToolExecutor, +} from "./tool-search.js"; +import { applyAgentToolSurfaceCatalog, resolveAgentToolSurfacePlan } from "./tool-surface-plan.js"; + +// Params type stays module-local in production; derive it so the test cannot +// keep a public export alive that no production caller needs. +type AgentToolSurfacePlanParams = Parameters[0]; + +const controlsEnabledConfig: OpenClawConfig = { + tools: { codeMode: true, toolSearch: true }, +}; +const basePlanParams: AgentToolSurfacePlanParams = { + config: controlsEnabledConfig, + forceDirectMessageTool: false, + toolsEnabled: true, + isRawModelRun: false, +}; + +describe("resolveAgentToolSurfacePlan", () => { + it.each([ + { name: "model tools disabled", overrides: { toolsEnabled: false } }, + { name: "tools disabled for the run", overrides: { disableTools: true } }, + { name: "raw model run", overrides: { isRawModelRun: true } }, + { name: "host-scoped ring-zero run", overrides: {}, ringZero: true }, + { name: "empty explicit allowlist", overrides: { toolsAllow: [] } }, + { + name: "proposal-only skill workshop run", + overrides: { skillWorkshopProposalOnly: true }, + }, + ] satisfies Array<{ + name: string; + overrides: Partial; + ringZero?: boolean; + }>)("suppresses both controls for $name", ({ overrides, ringZero }) => { + const resolve = () => resolveAgentToolSurfacePlan({ ...basePlanParams, ...overrides }); + const plan = ringZero + ? runWithAgentRingZeroTools([createStubTool("openclaw")], resolve) + : resolve(); + + expect(plan.codeModeControlsEnabled).toBe(false); + expect(plan.toolSearchControlsEnabled).toBe(false); + }); + + it.each([ + { + name: "code mode wins when engaged", + config: { tools: { codeMode: true, toolSearch: true } }, + expected: { codeMode: true, toolSearch: false }, + }, + { + name: "tool search engages when code mode does not", + config: { tools: { codeMode: false, toolSearch: true } }, + expected: { codeMode: false, toolSearch: true }, + }, + ] satisfies Array<{ + name: string; + config: OpenClawConfig; + expected: { codeMode: boolean; toolSearch: boolean }; + }>)("keeps controls mutually exclusive: $name", ({ config, expected }) => { + const plan = resolveAgentToolSurfacePlan({ ...basePlanParams, config }); + + expect(plan.codeModeControlsEnabled).toBe(expected.codeMode); + expect(plan.toolSearchControlsEnabled).toBe(expected.toolSearch); + expect(plan.codeModeControlsEnabled && plan.toolSearchControlsEnabled).toBe(false); + }); +}); + +describe("applyAgentToolSurfaceCatalog", () => { + const executeTool: ToolSearchCatalogToolExecutor = async () => ({ content: [], details: {} }); + + it("uses the code-mode catalog when code-mode controls are enabled", () => { + const config: OpenClawConfig = { + tools: { codeMode: true, toolSearch: { enabled: true, mode: "directory" } }, + }; + const plan = resolveAgentToolSurfacePlan({ ...basePlanParams, config }); + const catalogRef = createToolSearchCatalogRef(); + const result = applyAgentToolSurfaceCatalog({ + tools: [ + ...createCodeModeTools({ config, catalogRef, executeTool }), + createStubTool("hidden_target"), + ], + config, + toolSearchRuntimeConfig: plan.toolSearchRuntimeConfig, + codeModeControlsEnabled: plan.codeModeControlsEnabled, + toolSearchConfig: plan.toolSearchConfig, + forceDirectMessageTool: false, + catalogRef, + }); + + expect(result.tools.map((tool) => tool.name)).toEqual(["exec", "wait"]); + expect(result.catalogToolCount).toBe(1); + }); + + it("uses the schema-directory catalog in directory mode", () => { + const config: OpenClawConfig = { + tools: { codeMode: false, toolSearch: { enabled: true, mode: "directory" } }, + }; + const plan = resolveAgentToolSurfacePlan({ ...basePlanParams, config }); + const result = applyAgentToolSurfaceCatalog({ + tools: [createStubTool("uncataloged_without_directory_controls")], + config, + toolSearchRuntimeConfig: plan.toolSearchRuntimeConfig, + codeModeControlsEnabled: plan.codeModeControlsEnabled, + toolSearchConfig: plan.toolSearchConfig, + forceDirectMessageTool: false, + catalogRef: createToolSearchCatalogRef(), + }); + + expect(result.tools.map((tool) => tool.name)).toEqual([ + "uncataloged_without_directory_controls", + ]); + expect(result.compacted).toBe(false); + }); + + it("uses the tool-search catalog outside directory mode", () => { + const config: OpenClawConfig = { + tools: { codeMode: false, toolSearch: { enabled: true, mode: "tools" } }, + }; + const plan = resolveAgentToolSurfacePlan({ ...basePlanParams, config }); + const result = applyAgentToolSurfaceCatalog({ + tools: [createStubTool(TOOL_SEARCH_RAW_TOOL_NAME), createStubTool("hidden_target")], + config, + toolSearchRuntimeConfig: plan.toolSearchRuntimeConfig, + codeModeControlsEnabled: plan.codeModeControlsEnabled, + toolSearchConfig: plan.toolSearchConfig, + forceDirectMessageTool: false, + catalogRef: createToolSearchCatalogRef(), + }); + + expect(result.tools.map((tool) => tool.name)).toEqual([TOOL_SEARCH_RAW_TOOL_NAME]); + expect(result.catalogToolCount).toBe(1); + }); +}); diff --git a/src/agents/tool-surface-plan.ts b/src/agents/tool-surface-plan.ts new file mode 100644 index 000000000000..5669b3c6cd3b --- /dev/null +++ b/src/agents/tool-surface-plan.ts @@ -0,0 +1,92 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { getActiveAgentRingZeroTools } from "./agent-tools.ring-zero-context.js"; +import { + applyCodeModeCatalog, + isCodeModeEngagedForModel, + resolveCodeModeConfig, +} from "./code-mode.js"; +import { resolveAgentToolSearchRuntimeConfig } from "./tool-search-runtime-config.js"; +import type { ToolSearchConfig } from "./tool-search-types.js"; +import { + applyToolSchemaDirectoryCatalog, + applyToolSearchCatalog, + resolveToolSearchConfig, +} from "./tool-search.js"; + +type AgentToolSurfacePlanParams = { + config?: OpenClawConfig; + agentId?: string; + sessionKey?: string; + forceDirectMessageTool: boolean; + model?: { compat?: unknown }; + toolsEnabled: boolean; + disableTools?: boolean; + isRawModelRun: boolean; + skillWorkshopProposalOnly?: boolean; + toolsAllow?: readonly string[]; +}; + +export function resolveAgentToolSurfacePlan(params: AgentToolSurfacePlanParams) { + const codeModeConfig = resolveCodeModeConfig(params.config, params.agentId); + const toolSearchRuntimeConfig = resolveAgentToolSearchRuntimeConfig({ + config: params.config, + agentId: params.agentId, + sessionKey: params.sessionKey, + forceDirectMessageTool: params.forceDirectMessageTool, + }); + const toolSearchConfig = resolveToolSearchConfig(toolSearchRuntimeConfig); + const toolsAvailable = + params.toolsEnabled && + getActiveAgentRingZeroTools().length === 0 && + params.disableTools !== true && + !params.isRawModelRun && + // Proposal-only workshop runs are deliberately narrow single-tool runs; + // code-mode indirection and tool-search catalogs are pure overhead. + params.skillWorkshopProposalOnly !== true && + params.toolsAllow?.length !== 0; + const codeModeControlsEnabled = + toolsAvailable && isCodeModeEngagedForModel(codeModeConfig, params.model); + const toolSearchControlsEnabled = + toolsAvailable && !codeModeControlsEnabled && toolSearchConfig.enabled; + return { + codeModeControlsEnabled, + toolSearchControlsEnabled, + toolSearchConfig, + toolSearchRuntimeConfig, + }; +} + +type CodeModeCatalogParams = Parameters[0]; +type ApplyAgentToolSurfaceCatalogParams = Omit & { + /** Required key (may be undefined for a config-less run): the tool-search + * branches resolve their mode from this, so omitting it would silently + * downgrade the run to schema defaults. */ + toolSearchRuntimeConfig: OpenClawConfig | undefined; + codeModeControlsEnabled: boolean; + toolSearchConfig: ToolSearchConfig; + forceDirectMessageTool: boolean; +}; + +export function applyAgentToolSurfaceCatalog({ + codeModeControlsEnabled, + toolSearchConfig, + toolSearchRuntimeConfig, + forceDirectMessageTool, + ...catalogParams +}: ApplyAgentToolSurfaceCatalogParams) { + // When the message tool is the only reply path it must stay directly visible + // in every search mode; a hidden delivery tool can leave the run mute. + const directToolNames = forceDirectMessageTool ? ["message"] : []; + const applyCatalog = codeModeControlsEnabled + ? applyCodeModeCatalog + : toolSearchConfig.mode === "directory" + ? applyToolSchemaDirectoryCatalog + : applyToolSearchCatalog; + return applyCatalog({ + ...catalogParams, + // Code mode reads the base config; tool-search modes read the run's + // resolved tool-search runtime config. + config: codeModeControlsEnabled ? catalogParams.config : toolSearchRuntimeConfig, + directToolNames, + }); +}