openclaw - ✅(Solved) Fix Capability-provider lookups bypass cache when plugins.entries is non-empty (~25-30s latency per turn) [2 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#73793Fetched 2026-04-29 06:15:08
View on GitHub
Comments
1
Participants
2
Timeline
6
Reactions
0
Timeline (top)
cross-referenced ×5commented ×1

resolvePluginCapabilityProviders bypasses its active-registry cache on every call when cfg.plugins.entries is non-empty, causing 4–5 full loadOpenClawPlugins cycles per agent turn (~5–6s each), adding ~25–30s of wall-clock latency to every turn even when the underlying LLM call completes in <100ms.

Root Cause

resolvePluginCapabilityProviders bypasses its active-registry cache on every call when cfg.plugins.entries is non-empty, causing 4–5 full loadOpenClawPlugins cycles per agent turn (~5–6s each), adding ~25–30s of wall-clock latency to every turn even when the underlying LLM call completes in <100ms.

Fix Action

Fixed

PR fix notes

PR #73794: perf(capability-provider): reuse active registry on non-memory/non-speech lookups

Description (problem / solution / changelog)

Summary

  • Problem: resolvePluginCapabilityProviders's active-registry early-return is gated on !hasExplicitPluginConfig(params.cfg?.plugins). hasExplicitPluginConfig returns true for any non-empty plugins.entries — i.e., every gateway with at least one installed plugin. The gate fails for effectively every production user, every capability lookup falls through to loadOpenClawPlugins, and the full plugin registry is rebuilt (Jiti loader + manifest validation + per-plugin register()) on each call.
  • Why it matters: A single agent turn triggers ~4 capability lookups (imageGenerationProviders, videoGenerationProviders, musicGenerationProviders, mediaUnderstandingProviders, realtimeVoiceProviders, realtimeTranscriptionProviders, etc.). At ~5–6s per re-load, this stacks into ~25–30s of wall-clock latency per turn — even when the LLM call itself completes in <100ms. Repro and instrumented logs in #73793.
  • What changed: Dropped the !hasExplicitPluginConfig clause from the early-return. memoryEmbeddingProviders and speechProviders continue to fall through (they reconcile against cfg-level provider preferences). For every other capability key, activeProviders.length > 0 means the active registry is authoritative — the runtime already rebuilds it on config-affecting changes, so reusing it here is safe regardless of cfg.plugins.entries. The unused hasExplicitPluginConfig import was removed from this file.
  • What did NOT change (scope boundary): Behavior for memoryEmbeddingProviders and speechProviders. Behavior when the active registry has zero providers for the requested key (still falls through to load). Behavior of hasExplicitPluginConfig itself (still used in bundle-commands.ts, bundled-compat.ts, etc.). Cache-key construction in loader.ts. The capability-provider load path's compat config resolution.

Change Type (select all)

  • Bug fix
  • Refactor required for the fix

Scope (select all touched areas)

  • Gateway / orchestration

Linked Issue/PR

  • Closes #73793
  • This PR fixes a bug or regression

Root Cause

hasExplicitPluginConfig was added to defensively bypass the active-registry cache when callers passed an explicit cfg, on the assumption that an explicit cfg might request a different plugin set than the active registry was built with. In practice, however, hasExplicitPluginConfig returns true for any non-empty plugins.entries — and every installed plugin populates plugins.entries — so the gate fires universally and the cache is effectively never used in production. The runtime already invalidates the active registry on real config changes (the cache key in resolvePluginLoadCacheContext includes plugins, installs, activationMetadataKey, etc.), so the defensive bypass is redundant. Removing it lets the cache do its job for capability lookups.

Tests

  • Added uses active non-speech capability providers even when cfg has explicit plugin entries in src/plugins/capability-provider-runtime.test.ts. Asserts that mediaUnderstandingProviders lookup with cfg.plugins.entries populated returns the active provider directly without invoking loadPluginManifestRegistry or resolveRuntimePluginRegistry({…, activate: false}).
  • All 19 existing tests in capability-provider-runtime.test.ts pass unchanged.

Verification

$ pnpm vitest run src/plugins/capability-provider-runtime.test.ts
 Test Files  1 passed (1)
      Tests  19 passed (19)
   Duration  2.87s

Production validation pending — once this lands, scope-openclaw's instrumented timing log will confirm the per-turn register() count drops from 4–5 to 1.

Changed files

  • src/plugins/capability-provider-runtime.test.ts (modified, +38/-0)
  • src/plugins/capability-provider-runtime.ts (modified, +9/-3)

PR #74097: perf(agents/tools): lazily list capability providers in tool model-config resolution

Description (problem / solution / changelog)

Summary

  • Problem: resolveCapabilityModelConfigForTool (src/agents/tools/media-tool-shared.ts:246) takes providers: CapabilityProvider[] as a required eagerly-evaluated parameter, and short-circuits via hasToolModelConfig(explicit) after the caller has already computed the provider list. The 3 callers (image-generate-tool.ts:202, video-generate-tool.ts:232, music-generate-tool.ts:138) call listRuntime{Image,Video,Music}GenerationProviders({ config: cfg }) eagerly. On hosted gateways with plugins.entries populated, each provider listing triggers a full plugin-registry reload through resolvePluginCapabilityProviders → loadOpenClawPlugins (~5–6s per call).
  • Why it matters: For agents with an explicit, complete modelConfig for image / video / music generation, the provider list is pure waste — resolveCapabilityModelConfigForTool returns at the hasToolModelConfig(explicit) check without using it. With 3 generation tools registered on every agent, that's ~15–18s of wasted wall-clock per turn on hosted gateways. Repro and instrumented stack traces in #74096.
  • What changed: providers is now () => CapabilityProvider[] (a thunk). It's only invoked when the function falls through to candidate resolution (i.e. when modelConfig is incomplete). The 3 internal callers pass thunks. Two regression tests added in media-tool-shared.test.ts.
  • What did NOT change (scope boundary): Behavior when modelConfig is incomplete (still lists providers and resolves candidates). The provider listing logic itself (listRuntime{Image,Video,Music}GenerationProviders). The capability-provider runtime's caching behavior (separate concern, see #73793). All existing image/video/music tool tests pass unchanged.

Change Type (select all)

  • Bug fix
  • Refactor required for the fix

Scope (select all touched areas)

  • Skills / tool execution

Linked Issue/PR

  • Closes #74096
  • Related #73793 (independent capability-provider cache fix; this PR helps even without that one)
  • This PR fixes a bug or regression

Root Cause

resolveCapabilityModelConfigForTool short-circuits via hasToolModelConfig(explicit) before using the provider list, but the provider list is computed by callers before calling the function. The eager evaluation was the natural choice when the helper was first written, but on hosted gateways it has a hidden cost of ~5–6s per call because listRuntime*GenerationProviders triggers a plugin-registry reload (see #73793 for the deeper cache-miss issue on the registry side). Making the parameter lazy lets the helper choose to evaluate it only when needed.

Tests

  • Added 2 tests in src/agents/tools/media-tool-shared.test.ts:
    • does not invoke the providers thunk when an explicit modelConfig resolves
    • invokes the providers thunk when modelConfig is incomplete
  • All 116 existing tests in image-generate-tool.test.ts / video-generate-tool.test.ts / music-generate-tool.test.ts pass.

Verification

$ pnpm vitest run src/agents/tools/media-tool-shared.test.ts \
                  src/agents/tools/image-generate-tool.test.ts \
                  src/agents/tools/video-generate-tool.test.ts \
                  src/agents/tools/music-generate-tool.test.ts
 Test Files  6 passed (6)
      Tests  116 passed (116)

Changed files

  • src/agents/tools/image-generate-tool.ts (modified, +1/-1)
  • src/agents/tools/media-tool-shared.test.ts (modified, +37/-1)
  • src/agents/tools/media-tool-shared.ts (modified, +16/-3)
  • src/agents/tools/music-generate-tool.ts (modified, +1/-1)
  • src/agents/tools/video-generate-tool.ts (modified, +1/-1)

Code Example

register #2ensureRuntimePluginsLoaded            (initial)
register #3resolvePluginCapabilityProviders      (+~6s)
register #4resolvePluginCapabilityProviders      (+~6s)
register #5resolvePluginCapabilityProviders      (+~6s)

---

if (
  activeProviders.length > 0 &&
  params.key !== "memoryEmbeddingProviders" &&
  params.key !== "speechProviders" &&
  !hasExplicitPluginConfig(params.cfg?.plugins)   // ← always false when plugins.entries non-empty
) {
  return activeProviders.map(...);
}

---

if (plugins.entries && Object.keys(plugins.entries).length > 0) {
  return true;
}

---

17:28:21.299  scope-openclaw register #2 timing: module-load=83955ms log=2ms channel=0ms provider=0ms hooks=0ms tools+on=0ms total=2ms
17:28:21.300  scope-openclaw register #2 caller: runPluginRegisterSync ← loadOpenClawPlugins ← resolveRuntimePluginRegistry ← ensureRuntimePluginsLoaded
17:28:36.472  scope-openclaw register #3 total=2ms
17:28:36.473  scope-openclaw register #3 caller: runPluginRegisterSync ← loadOpenClawPlugins ← resolveRuntimePluginRegistry ← resolvePluginCapabilityProviders
17:28:42.711  scope-openclaw register #4 total=2ms
17:28:42.713  scope-openclaw register #4 caller: runPluginRegisterSync ← loadOpenClawPlugins ← resolveRuntimePluginRegistry ← resolvePluginCapabilityProviders
17:28:48.597  scope-openclaw register #5 total=2ms
17:28:48.598  scope-openclaw register #5 caller: runPluginRegisterSync ← loadOpenClawPlugins ← resolveRuntimePluginRegistry ← resolvePluginCapabilityProviders
RAW_BUFFERClick to expand / collapse

Bug type

Behavior bug (incorrect output/state without crash) — performance regression-class issue (latency stacking).

Beta release blocker

No

Summary

resolvePluginCapabilityProviders bypasses its active-registry cache on every call when cfg.plugins.entries is non-empty, causing 4–5 full loadOpenClawPlugins cycles per agent turn (~5–6s each), adding ~25–30s of wall-clock latency to every turn even when the underlying LLM call completes in <100ms.

Steps to reproduce

  1. Run any gateway with openclaw.json containing at least one plugin in plugins.entries (i.e., any user who has installed any plugin via openclaw plugins install …). Confirmed against 2026.4.26-f53b52ad6d21.
  2. Connect a third-party channel plugin that exports a default register(api) function and logs on entry. (Reproduced with scope-openclaw 0.35.4, which logs register #N timing per call with the trimmed openclaw runtime call stack.)
  3. Send a single inbound message to the gateway.
  4. Observe register() is called 4× in a single turn, ~5–6s apart, all with stack runPluginRegisterSync ← loadOpenClawPlugins ← resolveRuntimePluginRegistry ← resolvePluginCapabilityProviders (capability-provider-runtime.ts:316 in source).

Observed log (top of stack on each repeat):

register #2  ← ensureRuntimePluginsLoaded            (initial)
register #3  ← resolvePluginCapabilityProviders      (+~6s)
register #4  ← resolvePluginCapabilityProviders      (+~6s)
register #5  ← resolvePluginCapabilityProviders      (+~6s)

Expected behavior

After the initial loadOpenClawPlugins cycle has populated the active plugin registry, subsequent calls to resolvePluginCapabilityProviders for capability keys whose providers already exist in the active registry should reuse it without re-running the full plugin load + register cycle. The runtime invalidates the active registry on real config changes already, so the early-return should be safe.

Actual behavior

The early-return at src/plugins/capability-provider-runtime.ts:326-333 is gated on:

if (
  activeProviders.length > 0 &&
  params.key !== "memoryEmbeddingProviders" &&
  params.key !== "speechProviders" &&
  !hasExplicitPluginConfig(params.cfg?.plugins)   // ← always false when plugins.entries non-empty
) {
  return activeProviders.map(...);
}

hasExplicitPluginConfig (src/plugins/config-normalization-shared.ts:162) returns true for any non-empty plugins.entries:

if (plugins.entries && Object.keys(plugins.entries).length > 0) {
  return true;
}

Since installing a plugin populates plugins.entries, this gate fails for effectively every production user. Each capability lookup falls through to resolveRuntimePluginRegistry(loadOptions)loadOpenClawPlugins(loadOptions) → re-import + manifest validation + register() across all loaded plugins.

The hot path on a single turn invokes lookups for imageGenerationProviders, videoGenerationProviders, musicGenerationProviders, mediaUnderstandingProviders, realtimeVoiceProviders, realtimeTranscriptionProviders, etc. (each with its own caller in src/{image,video,music}-generation/provider-registry.ts, src/media-understanding/provider-{capability-,}registry.ts, etc.). Each one cache-misses through this gate.

Environment

  • OpenClaw 2026.4.26-f53b52ad6d21 (production gateway)
  • Node v24.14.0
  • Linux x64 (Elestio-hosted Docker container)
  • Active plugins: memory-core, scope (channel)

Logs / evidence

Per-turn timing from instrumented plugin (truncated):

17:28:21.299  scope-openclaw register #2 timing: module-load=83955ms log=2ms channel=0ms provider=0ms hooks=0ms tools+on=0ms total=2ms
17:28:21.300  scope-openclaw register #2 caller: runPluginRegisterSync ← loadOpenClawPlugins ← resolveRuntimePluginRegistry ← ensureRuntimePluginsLoaded
17:28:36.472  scope-openclaw register #3 total=2ms
17:28:36.473  scope-openclaw register #3 caller: runPluginRegisterSync ← loadOpenClawPlugins ← resolveRuntimePluginRegistry ← resolvePluginCapabilityProviders
17:28:42.711  scope-openclaw register #4 total=2ms
17:28:42.713  scope-openclaw register #4 caller: runPluginRegisterSync ← loadOpenClawPlugins ← resolveRuntimePluginRegistry ← resolvePluginCapabilityProviders
17:28:48.597  scope-openclaw register #5 total=2ms
17:28:48.598  scope-openclaw register #5 caller: runPluginRegisterSync ← loadOpenClawPlugins ← resolveRuntimePluginRegistry ← resolvePluginCapabilityProviders

Module-load value is monotonically growing (84s → 99s → 105s → 111s) confirming the JS module is the same instance across calls — it's not a chokidar / dynamic-import issue. Plugin-side register() body itself runs in 2ms; the 5–6s/call is entirely inside the runtime's loadOpenClawPlugins cycle.

Proposed fix

Drop the !hasExplicitPluginConfig gate on this early-return. memoryEmbeddingProviders and speechProviders continue to fall through (their lookups must reconcile against cfg-level provider preferences). For every other capability key, when activeProviders.length > 0 the active registry is already authoritative — the runtime rebuilds it on config-affecting changes, so reusing it here is safe regardless of cfg.plugins.entries.

PR with fix + regression test: poolside-ventures/openclaw#fix/capability-provider-cache-bypass (will link separately once filed).

extent analysis

TL;DR

The most likely fix is to drop the !hasExplicitPluginConfig gate in the resolvePluginCapabilityProviders function to allow reusing the active plugin registry.

Guidance

  • Review the hasExplicitPluginConfig function to understand why it returns true for non-empty plugins.entries, causing the early-return gate to fail.
  • Consider the proposed fix of dropping the !hasExplicitPluginConfig gate, as the active registry is already rebuilt on config changes.
  • Verify the fix by checking the log output for repeated register() calls and monitoring the module-load time to ensure it's no longer growing monotonically.
  • Test the fix with different capability keys, such as imageGenerationProviders and videoGenerationProviders, to ensure the cache is being reused correctly.

Example

// Proposed fix: drop the !hasExplicitPluginConfig gate
if (
  activeProviders.length > 0 &&
  params.key !== "memoryEmbeddingProviders" &&
  params.key !== "speechProviders"
) {
  return activeProviders.map(...);
}

Notes

The fix assumes that the active registry is authoritative when activeProviders.length > 0, and reusing it is safe regardless of cfg.plugins.entries. However, further testing is needed to ensure this fix does not introduce any regressions.

Recommendation

Apply the proposed workaround by dropping the !hasExplicitPluginConfig gate, as it is a targeted fix that addresses the specific issue of cache bypassing. This change should reduce the latency caused by repeated loadOpenClawPlugins cycles.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

Expected behavior

After the initial loadOpenClawPlugins cycle has populated the active plugin registry, subsequent calls to resolvePluginCapabilityProviders for capability keys whose providers already exist in the active registry should reuse it without re-running the full plugin load + register cycle. The runtime invalidates the active registry on real config changes already, so the early-return should be safe.

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - ✅(Solved) Fix Capability-provider lookups bypass cache when plugins.entries is non-empty (~25-30s latency per turn) [2 pull requests, 1 comments, 2 participants]