Skip to content

[Bug]: cached plugin tool wrapper uses plugin-level declaredNames as per-factory runtime identity #78671

Description

@100yenadmin

Summary

Plugin tools can be exposed from contracts.tools/cached descriptors but fail at execution with:

plugin tool runtime missing (lossless-claw): <tool_name>

Observed with @martian-engineering/[email protected] on OpenClaw 2026.5.x. lcm_grep works, but sibling tools such as lcm_describe, lcm_search_entities, and lcm_synthesize_around are visible in the tool palette and fail at runtime.

Minimal repro shape

A plugin declares a broad manifest contract:

{
  "contracts": {
    "tools": [
      "lcm_grep",
      "lcm_semantic_recall",
      "lcm_describe",
      "lcm_expand",
      "lcm_expand_query",
      "lcm_get_entity",
      "lcm_search_entities",
      "lcm_synthesize_around"
    ]
  }
}

and registers each tool as an unnamed context factory:

api.registerTool(ctx => createLcmGrepTool(...));
api.registerTool(ctx => createLcmDescribeTool(...));
api.registerTool(ctx => createLcmSearchEntitiesTool(...));
// etc.

With cached descriptor tools enabled, the palette exposes the declared contract names. At execution time, the cached wrapper resolves a runtime registry entry by checking:

(candidate.names.length > 0 ? candidate.names : candidate.declaredNames ?? [])
  .some(name => normalizeToolName(name) === normalizeToolName(toolName))

Because every unnamed factory entry inherits the full plugin-level declaredNames, the first registered factory (lcm_grep) matches every requested LCM tool name. Executing lcm_grep succeeds because that first factory returns lcm_grep; executing lcm_describe/lcm_search_entities/etc. resolves the same first factory, gets the wrong runtime tool, then throws plugin tool runtime missing.

Evidence

Sanitized local evidence from a live install:

  • Plugin manifest declares all eight tools: ~/.openclaw/extensions/lossless-claw/openclaw.plugin.json (contracts.tools: lcm_grep, lcm_semantic_recall, lcm_describe, lcm_expand, lcm_expand_query, lcm_get_entity, lcm_search_entities, lcm_synthesize_around).
  • Plugin runtime registers separate unnamed factories: ~/.openclaw/extensions/lossless-claw/dist/index.js contains api.registerTool(ctx=>createLcmGrepTool(...)), api.registerTool(ctx=>createLcmDescribeTool(...)), api.registerTool(ctx=>createLcmSearchEntitiesTool(...)), etc.
  • OpenClaw registry stores plugin-level contracts on every unnamed tool factory: dist/loader-*.js around the registerTool implementation sets declaredNames = normalizePluginToolContractNames(record.contracts) and pushes { names: normalized, declaredNames, factory }.
  • Cached wrapper runtime lookup uses candidate.names.length > 0 ? candidate.names : candidate.declaredNames and throws plugin tool runtime missing if the selected factory returns a different tool: dist/tools-*.js in createCachedDescriptorPluginTool.

Actual runtime results:

lcm_grep({ pattern: "lossless", mode: "full_text", limit: 1 })
→ reaches the real tool and returns a normal LCM scope error when no conversation is found

lcm_search_entities({ query: "Eva", limit: 1 })
→ plugin tool runtime missing (lossless-claw): lcm_search_entities

lcm_describe({ id: "sum_nonexistent", allConversations: true })
→ plugin tool runtime missing (lossless-claw): lcm_describe

lcm_synthesize_around({ window_kind: "period", period: "today" })
→ plugin tool runtime missing (lossless-claw): lcm_synthesize_around

Expected

If a tool is exposed from manifest/cached descriptors, execution should dispatch to the matching runtime tool factory, not the first factory whose plugin-level declaredNames include the name.

Actual

The cached wrapper can select the wrong factory for plugins that register multiple unnamed context factories under a shared contracts.tools list. The first factory effectively claims every declared name.

Impact

This breaks any plugin that:

  1. declares multiple contracts.tools, and
  2. registers multiple context-dependent factories via api.registerTool(ctx => tool) without opts.name/opts.names, and
  3. is served through cached descriptor wrappers.

The failure is confusing because the tool palette is correct but runtime calls fail selectively; the first registered tool may work while the rest are runtime-missing.

Suspected fix surfaces

Two possible fixes:

  1. Runtime-wrapper fix (robust): in createCachedDescriptorPluginTool.execute, do not select a single candidate by plugin-level declaredNames. Iterate candidate entries for the plugin, invoke factories as needed, and execute the first resolved tool whose actual runtimeTool.name matches toolName. Use declaredNames only as a coarse plugin-level filter, not as per-entry identity.
  2. Registration contract guard: require function-style api.registerTool(ctx => tool) registrations to pass opts.name/opts.names when contracts.tools contains more than one name, or infer/cache per-factory names during descriptor capture and persist them to the runtime registry.

Suggested regression test: plugin with contracts.tools = ["a", "b"], two unnamed factories registered in order (a then b), cached descriptor execution of b should invoke the second factory and succeed.

Sanitization

No private chat content, customer data, API keys, or full config included. Local absolute paths are generic operator/plugin install paths only.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions