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:
- declares multiple
contracts.tools, and
- registers multiple context-dependent factories via
api.registerTool(ctx => tool) without opts.name/opts.names, and
- 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:
- 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.
- 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.
Summary
Plugin tools can be exposed from
contracts.tools/cached descriptors but fail at execution with:Observed with
@martian-engineering/[email protected]on OpenClaw 2026.5.x.lcm_grepworks, but sibling tools such aslcm_describe,lcm_search_entities, andlcm_synthesize_aroundare 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:
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:
Because every unnamed factory entry inherits the full plugin-level
declaredNames, the first registered factory (lcm_grep) matches every requested LCM tool name. Executinglcm_grepsucceeds because that first factory returnslcm_grep; executinglcm_describe/lcm_search_entities/etc. resolves the same first factory, gets the wrong runtime tool, then throwsplugin tool runtime missing.Evidence
Sanitized local evidence from a live install:
~/.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).~/.openclaw/extensions/lossless-claw/dist/index.jscontainsapi.registerTool(ctx=>createLcmGrepTool(...)),api.registerTool(ctx=>createLcmDescribeTool(...)),api.registerTool(ctx=>createLcmSearchEntitiesTool(...)), etc.dist/loader-*.jsaround theregisterToolimplementation setsdeclaredNames = normalizePluginToolContractNames(record.contracts)and pushes{ names: normalized, declaredNames, factory }.candidate.names.length > 0 ? candidate.names : candidate.declaredNamesand throwsplugin tool runtime missingif the selected factory returns a different tool:dist/tools-*.jsincreateCachedDescriptorPluginTool.Actual runtime results:
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
declaredNamesinclude the name.Actual
The cached wrapper can select the wrong factory for plugins that register multiple unnamed context factories under a shared
contracts.toolslist. The first factory effectively claims every declared name.Impact
This breaks any plugin that:
contracts.tools, andapi.registerTool(ctx => tool)withoutopts.name/opts.names, andThe 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:
createCachedDescriptorPluginTool.execute, do not select a single candidate by plugin-leveldeclaredNames. Iterate candidate entries for the plugin, invoke factories as needed, and execute the first resolved tool whose actualruntimeTool.namematchestoolName. UsedeclaredNamesonly as a coarse plugin-level filter, not as per-entry identity.api.registerTool(ctx => tool)registrations to passopts.name/opts.nameswhencontracts.toolscontains 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 (athenb), cached descriptor execution ofbshould 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.