Skip to content

fix(plugins): deduplicate tool registration to prevent double-register#52592

Open
dingtao416 wants to merge 1 commit intoopenclaw:mainfrom
dingtao416:fix/feishu-duplicate-tool-registration
Open

fix(plugins): deduplicate tool registration to prevent double-register#52592
dingtao416 wants to merge 1 commit intoopenclaw:mainfrom
dingtao416:fix/feishu-duplicate-tool-registration

Conversation

@dingtao416
Copy link
Copy Markdown

Summary

  • Problem: Feishu plugin registers all tools twice during gateway startup. When loadGatewayPlugins() is called twice with different preferSetupRuntimeForChannelPlugins values (producing different cache keys), registerFull() executes twice for plugins that are already fully loaded on the first pass.
  • Why it matters: Duplicate tools cause confusion in the agent tool selection and waste memory/startup time.
  • What changed: Added deduplication logic in 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 a warn diagnostic.
  • What did NOT change (scope boundary): No changes to plugin loading order, cache keying, or the deferred-channel-plugin promotion flow. registerChannel already had dedup logic; this aligns registerTool with that pattern.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

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)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No
  • Command/tool execution surface changed? No
  • Data access scope changed? No

Repro + Verification

Environment

  • OS: Windows 11
  • Runtime/container: Node 22
  • Integration/channel (if any): Feishu

Steps

  1. Configure a Feishu account with doc/chat/wiki tools enabled
  2. Start the gateway
  3. Observe tool registration logs

Expected

Each tool (feishu_doc, feishu_chat, feishu_wiki, etc.) is registered exactly once.

Actual (before fix)

Each tool registration log appears twice.

Evidence

  • Failing test/log before + passing after
  • Trace/log snippets

All related tests pass:

  • extensions/feishu/index.test.ts — 1 test passed
  • src/plugins/contracts/registry.contract.test.ts — 19 tests passed
  • src/plugins/loader.test.ts — 75 tests passed
  • pnpm tsgo — no new errors introduced
  • pnpm format — passed

Human Verification (required)

  • Verified scenarios: Ran feishu plugin test, loader test suite, registry contract tests
  • Edge cases checked: Partial name overlap (warn diagnostic path), unnamed tools (no-op dedup)
  • What you did not verify: Live Feishu gateway with real account (no credentials available)

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? Yes
  • Config/env changes? No
  • Migration needed? No

Failure Recovery (if this breaks)

  • How to disable/revert this change quickly: Revert the single commit on src/plugins/registry.ts
  • Files/config to restore: src/plugins/registry.ts
  • Known bad symptoms reviewers should watch for: Tools not appearing if dedup check is too aggressive (unlikely — check is scoped to same pluginId + same tool name)

Risks and Mitigations

  • Risk: A plugin intentionally registers the same tool name twice with different factories.
    • Mitigation: The dedup only skips when the same pluginId re-registers the same name. Cross-plugin duplicates are unaffected. This matches the existing registerChannel dedup pattern.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 23, 2026

Greptile Summary

This PR adds deduplication logic to registerTool() in src/plugins/registry.ts to fix a double-registration bug when loadGatewayPlugins() is called twice with different cache keys, causing registerFull() to run more than once for the same plugin.

Key observations:

  • The primary fix — silently returning when every name in the new registration is already registered by the same plugin — is correct and handles the reported Feishu double-registration scenario.
  • The partial-overlap branch has a logic bug: after emitting the warn diagnostic the code falls through and pushes the unmodified normalized array (which still contains the duplicate names) to both record.toolNames and registry.tools. The comment says "register the novel names" but the implementation registers all names, including the duplicates. normalized must be filtered to exclude duplicateNames before the push sites.
  • Consistency note: registerChannel uses level: "error" and hard-stops for all duplicates, while this new code uses level: "warn" and continues for partial overlaps. This is a deliberate difference (the partial-overlap case is considered unexpected rather than fatal), which is reasonable, but worth being aware of.
  • The dedup guard is correctly scoped to the same pluginId, so cross-plugin name collisions are unaffected.

Confidence Score: 4/5

  • Safe to merge with one targeted fix in the partial-overlap branch before the push calls.
  • The primary fix (full-duplicate early return) is correct and resolves the reported bug. The partial-overlap branch has a logic error — duplicate names are not filtered out before being pushed — but the author explicitly calls this path "unexpected," so it won't be exercised by the Feishu scenario or any normal double-registration flow. One concrete fix remains before the code is fully correct.
  • src/plugins/registry.ts — partial-overlap fall-through at lines 283–295
Prompt To Fix All 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.

Reviews (1): Last reviewed commit: "plugins: deduplicate tool registration t..." | Re-trigger Greptile

Comment on lines +283 to 295
// 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);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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:

Suggested change
// 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Fixed in 783a3b1normalized is now let and filtered to exclude duplicateNames before the push calls. The partial-overlap path now correctly only registers novel names.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +271 to +281
// 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@dingtao416 dingtao416 force-pushed the fix/feishu-duplicate-tool-registration branch from 406e350 to 783a3b1 Compare March 23, 2026 06:27
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +269 to +272
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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: **Feishu plugin registers all tools twice**

1 participant