fix(plugins): deduplicate tool registration to prevent double-register#52592
fix(plugins): deduplicate tool registration to prevent double-register#52592dingtao416 wants to merge 1 commit intoopenclaw:mainfrom
Conversation
Greptile SummaryThis PR adds deduplication logic to Key observations:
Confidence Score: 4/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/plugins/registry.ts
Line: 283-295
Comment:
**Partial overlap path still registers duplicate names**
The comment says "warn but still register the novel names", but the code falls through and pushes the unmodified `normalized` array — which still contains the `duplicateNames` — to both `record.toolNames` and `registry.tools`. As a result the duplicate names end up double-registered in exactly the scenario the warning is meant to describe.
`normalized` needs to be filtered to only the novel names before the push calls:
```suggestion
// Partial overlap is unexpected; warn but still register the novel names.
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: `tool names already registered by same plugin, skipping duplicates: ${duplicateNames.join(", ")}`,
});
// Remove already-registered names so only novel ones are pushed below.
normalized = normalized.filter((name) => !duplicateNames.includes(name));
}
}
if (normalized.length > 0) {
record.toolNames.push(...normalized);
}
```
Note that `normalized` must also be changed from `const` to `let` for this mutation to compile. Alternatively, introduce a separate `const novelNames = ...` variable and use it in the two push sites below.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "plugins: deduplicate tool registration t..." | Re-trigger Greptile |
| // Partial overlap is unexpected; warn but still register the novel names. | ||
| pushDiagnostic({ | ||
| level: "warn", | ||
| pluginId: record.id, | ||
| source: record.source, | ||
| message: `tool names already registered by same plugin, skipping duplicates: ${duplicateNames.join(", ")}`, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| if (normalized.length > 0) { | ||
| record.toolNames.push(...normalized); | ||
| } |
There was a problem hiding this comment.
Partial overlap path still registers duplicate names
The comment says "warn but still register the novel names", but the code falls through and pushes the unmodified normalized array — which still contains the duplicateNames — to both record.toolNames and registry.tools. As a result the duplicate names end up double-registered in exactly the scenario the warning is meant to describe.
normalized needs to be filtered to only the novel names before the push calls:
| // Partial overlap is unexpected; warn but still register the novel names. | |
| pushDiagnostic({ | |
| level: "warn", | |
| pluginId: record.id, | |
| source: record.source, | |
| message: `tool names already registered by same plugin, skipping duplicates: ${duplicateNames.join(", ")}`, | |
| }); | |
| } | |
| } | |
| if (normalized.length > 0) { | |
| record.toolNames.push(...normalized); | |
| } | |
| // Partial overlap is unexpected; warn but still register the novel names. | |
| pushDiagnostic({ | |
| level: "warn", | |
| pluginId: record.id, | |
| source: record.source, | |
| message: `tool names already registered by same plugin, skipping duplicates: ${duplicateNames.join(", ")}`, | |
| }); | |
| // Remove already-registered names so only novel ones are pushed below. | |
| normalized = normalized.filter((name) => !duplicateNames.includes(name)); | |
| } | |
| } | |
| if (normalized.length > 0) { | |
| record.toolNames.push(...normalized); | |
| } |
Note that normalized must also be changed from const to let for this mutation to compile. Alternatively, introduce a separate const novelNames = ... variable and use it in the two push sites below.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/registry.ts
Line: 283-295
Comment:
**Partial overlap path still registers duplicate names**
The comment says "warn but still register the novel names", but the code falls through and pushes the unmodified `normalized` array — which still contains the `duplicateNames` — to both `record.toolNames` and `registry.tools`. As a result the duplicate names end up double-registered in exactly the scenario the warning is meant to describe.
`normalized` needs to be filtered to only the novel names before the push calls:
```suggestion
// Partial overlap is unexpected; warn but still register the novel names.
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: `tool names already registered by same plugin, skipping duplicates: ${duplicateNames.join(", ")}`,
});
// Remove already-registered names so only novel ones are pushed below.
normalized = normalized.filter((name) => !duplicateNames.includes(name));
}
}
if (normalized.length > 0) {
record.toolNames.push(...normalized);
}
```
Note that `normalized` must also be changed from `const` to `let` for this mutation to compile. Alternatively, introduce a separate `const novelNames = ...` variable and use it in the two push sites below.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Good catch! Fixed in 783a3b1 — normalized is now let and filtered to exclude duplicateNames before the push calls. The partial-overlap path now correctly only registers novel names.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 406e35015a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // Deduplicate: skip if every resolved name from this plugin is already registered. | ||
| if (normalized.length > 0) { | ||
| const duplicateNames = normalized.filter((name) => | ||
| registry.tools.some( | ||
| (existing) => existing.pluginId === record.id && existing.names.includes(name), | ||
| ), | ||
| ); | ||
| if (duplicateNames.length > 0) { | ||
| // All names already registered by the same plugin — silently skip the duplicate. | ||
| if (duplicateNames.length === normalized.length) { | ||
| return; |
There was a problem hiding this comment.
Deduplicate tool registrations across plugin loader passes
This guard only checks registry.tools inside the current PluginRegistry, so it cannot catch the startup case described in the commit message. In the deferred-channel flow, src/gateway/server.impl.ts:564-571 calls loadGatewayPlugins(..., preferSetupRuntimeForChannelPlugins: true) and then src/gateway/server.impl.ts:1189-1197 calls it again without that flag; src/plugins/loader.ts:689-712 treats those as different cache keys and builds/returns a fresh registry for the second pass. That means the first pass’s tool registrations are never present in registry.tools here, so Feishu-style registerFull() handlers still run twice and the duplicate tool registration/logging problem remains unfixed.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid observation. You're correct that registry.tools is per-registry-instance, so the dedup guard cannot catch cross-registry duplicates from the deferred-channel two-pass flow.
However, after deeper investigation I found that for Feishu specifically, shouldLoadChannelPluginInSetupRuntime() returns false (Feishu has no startupDeferConfiguredChannelFullLoadUntilAfterListen), so it loads in full mode on the first pass. The second loadGatewayPlugins() call at line 1189 only fires when deferredConfiguredChannelPluginIds.length > 0 — which requires another plugin to have startupDeferConfiguredChannelFullLoadUntilAfterListen: true. If no such plugin exists, the second pass never runs.
When it does run, the second pass creates a fresh registry that replaces the first via activatePluginRegistry(), so the final global registry won't contain duplicates from both passes — the first registry is discarded. The observable symptom is duplicate log lines (from registerFull() executing twice), not duplicate tools in the active registry.
This intra-registry dedup still has value as a defensive guard (e.g., a plugin calling api.registerTool() twice in the same registerFull()). A deeper fix at the loader/cache-key level to prevent redundant registerFull() calls across passes would be a separate, higher-risk change. Left unresolved for maintainer judgment.
406e350 to
783a3b1
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 783a3b1657
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| let normalized = names.map((name) => name.trim()).filter(Boolean); | ||
|
|
||
| // Deduplicate: skip if every resolved name from this plugin is already registered. | ||
| if (normalized.length > 0) { |
There was a problem hiding this comment.
Handle factory tools that omit explicit registration names
When the same plugin is registered twice, this guard only runs if normalized.length > 0, but for function-based registrations normalized is populated solely from opts.name(s). Several real plugins still register factories without those options — for example extensions/feishu/src/wiki.ts, extensions/feishu/src/drive.ts, extensions/feishu/src/perm.ts, and extensions/lobster/index.ts — so they continue to append duplicate registry.tools entries. resolvePluginTools() then instantiates both entries and drops/logs the second one as a name conflict, which means the double-registration this patch is trying to remove still happens for those tools.
Useful? React with 👍 / 👎.
Summary
loadGatewayPlugins()is called twice with differentpreferSetupRuntimeForChannelPluginsvalues (producing different cache keys),registerFull()executes twice for plugins that are already fully loaded on the first pass.registerTool()(src/plugins/registry.ts) that silently skips a tool registration when the same plugin has already registered a tool with the same name. Partial overlaps emit awarndiagnostic.registerChannelalready had dedup logic; this alignsregisterToolwith that pattern.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
User-visible / Behavior Changes
Plugin tools are no longer registered twice. Users with the Feishu plugin (or any plugin whose
registerFull()runs more than once due to cache-key divergence) will see each tool appear exactly once.Security Impact (required)
NoNoNoNoNoRepro + Verification
Environment
Steps
Expected
Each tool (
feishu_doc,feishu_chat,feishu_wiki, etc.) is registered exactly once.Actual (before fix)
Each tool registration log appears twice.
Evidence
All related tests pass:
extensions/feishu/index.test.ts— 1 test passedsrc/plugins/contracts/registry.contract.test.ts— 19 tests passedsrc/plugins/loader.test.ts— 75 tests passedpnpm tsgo— no new errors introducedpnpm format— passedHuman Verification (required)
Review Conversations
Compatibility / Migration
YesNoNoFailure Recovery (if this breaks)
src/plugins/registry.tssrc/plugins/registry.tsRisks and Mitigations
pluginIdre-registers the same name. Cross-plugin duplicates are unaffected. This matches the existingregisterChanneldedup pattern.