Skip to content

feat(skill): skills management manager, cache and client#861

Open
Robin1511 wants to merge 2 commits into
langfuse:mainfrom
Robin1511:feat/skills-management
Open

feat(skill): skills management manager, cache and client#861
Robin1511 wants to merge 2 commits into
langfuse:mainfrom
Robin1511:feat/skills-management

Conversation

@Robin1511

@Robin1511 Robin1511 commented Jul 7, 2026

Copy link
Copy Markdown

Skills management (JS SDK)

Hand-written high-level skills API mirroring the prompt manager:

  • SkillManagerget (caching + stale-while-revalidate + fallback + retry), create, update (labels), list, delete
  • LangfuseSkillCache (TTL + prefix invalidation)
  • SkillClient with {{variable}} compilation of the instructions body (single client — skills have no chat/text split)
  • wired onto LangfuseClient as .skill + getSkill/createSkill/updateSkill
  • unit tests for cache TTL and client compilation (10 passing)

Depends on the auto-generated @langfuse/core skills client from the api-spec-bot regeneration of the Fern contract in langfuse/langfuse (langfuse/langfuse#14838). Typechecks/builds once that client lands.

Greptile Summary

This PR adds a hand-written SkillManager / SkillClient / LangfuseSkillCache layer for skills management, closely mirroring the existing PromptManager implementation. It wires the manager onto LangfuseClient as .skill with convenience aliases (getSkill, createSkill, updateSkill), and ships unit tests for cache TTL behaviour and Mustache template compilation.

  • SkillManager implements get (TTL cache + stale-while-revalidate + fallback + retry), create, update (labels), list, and delete, all following the same patterns as PromptManager.
  • SkillClient wraps the API response, exposes skill fields as readonly properties, and provides a compile() helper for {{variable}} substitution via Mustache (HTML-escaping disabled).
  • The cache invalidate() method uses key.startsWith(skillName) without a - separator, which is a logic error copied from LangfusePromptCache: skill names that are a prefix of another skill name (e.g., "foo" vs "foobar") will cause false cache evictions.

Confidence Score: 3/5

The skill manager logic is sound and closely tracks the existing prompt manager. One real defect in the cache invalidation logic — using a bare prefix match without a separator — can silently evict unrelated skills from cache in applications where skill names share a common prefix.

The invalidation method deletes every cache entry whose key starts with the supplied skill name, but the key format always places a '-' immediately after the name. Without anchoring the check to skillName + '-', a skill named 'foo' incorrectly evicts all cached entries for 'foobar' when either is deleted or its labels are updated. Any application with skills following a naming convention (e.g., 'agent', 'agent-v2', 'agent-v3') would experience unexpected cache misses and extra API calls. The same defect exists verbatim in LangfusePromptCache and was carried forward unchanged here.

packages/client/src/skill/skillCache.ts — the invalidate method's prefix match needs the '-' separator fix. The same one-line change is also worth applying to packages/client/src/prompt/promptCache.ts.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant LangfuseClient
    participant SkillManager
    participant LangfuseSkillCache
    participant LangfuseAPIClient

    Caller->>LangfuseClient: getSkill("my-skill", options)
    LangfuseClient->>SkillManager: get("my-skill", options)
    SkillManager->>LangfuseSkillCache: "createKey({name, version, label})"
    LangfuseSkillCache-->>SkillManager: cacheKey
    SkillManager->>LangfuseSkillCache: getIncludingExpired(cacheKey)

    alt "Cache miss or cacheTtlSeconds === 0"
        LangfuseSkillCache-->>SkillManager: null
        SkillManager->>LangfuseAPIClient: "skills.get(name, {version, label}, opts)"
        LangfuseAPIClient-->>SkillManager: Skill data
        SkillManager->>LangfuseSkillCache: set(cacheKey, SkillClient, ttl)
        SkillManager-->>LangfuseClient: SkillClient
    else Cache hit (fresh)
        LangfuseSkillCache-->>SkillManager: CacheItem (not expired)
        SkillManager-->>LangfuseClient: SkillClient (from cache)
    else Cache hit (stale)
        LangfuseSkillCache-->>SkillManager: CacheItem (expired)
        SkillManager->>LangfuseSkillCache: isRefreshing(cacheKey)?
        LangfuseSkillCache-->>SkillManager: false
        SkillManager->>LangfuseAPIClient: skills.get(...) [background]
        SkillManager->>LangfuseSkillCache: addRefreshingPromise(cacheKey, promise)
        SkillManager-->>LangfuseClient: SkillClient (stale, refreshing in background)
    end

    LangfuseClient-->>Caller: SkillClient
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant LangfuseClient
    participant SkillManager
    participant LangfuseSkillCache
    participant LangfuseAPIClient

    Caller->>LangfuseClient: getSkill("my-skill", options)
    LangfuseClient->>SkillManager: get("my-skill", options)
    SkillManager->>LangfuseSkillCache: "createKey({name, version, label})"
    LangfuseSkillCache-->>SkillManager: cacheKey
    SkillManager->>LangfuseSkillCache: getIncludingExpired(cacheKey)

    alt "Cache miss or cacheTtlSeconds === 0"
        LangfuseSkillCache-->>SkillManager: null
        SkillManager->>LangfuseAPIClient: "skills.get(name, {version, label}, opts)"
        LangfuseAPIClient-->>SkillManager: Skill data
        SkillManager->>LangfuseSkillCache: set(cacheKey, SkillClient, ttl)
        SkillManager-->>LangfuseClient: SkillClient
    else Cache hit (fresh)
        LangfuseSkillCache-->>SkillManager: CacheItem (not expired)
        SkillManager-->>LangfuseClient: SkillClient (from cache)
    else Cache hit (stale)
        LangfuseSkillCache-->>SkillManager: CacheItem (expired)
        SkillManager->>LangfuseSkillCache: isRefreshing(cacheKey)?
        LangfuseSkillCache-->>SkillManager: false
        SkillManager->>LangfuseAPIClient: skills.get(...) [background]
        SkillManager->>LangfuseSkillCache: addRefreshingPromise(cacheKey, promise)
        SkillManager-->>LangfuseClient: SkillClient (stale, refreshing in background)
    end

    LangfuseClient-->>Caller: SkillClient
Loading
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
packages/client/src/skill/skillCache.ts:85-89
Cache invalidation prefix collision: `startsWith(skillName)` without a separator will incorrectly evict entries for any skill whose name begins with the same characters. For example, calling `invalidate("foo")` will also delete the cached entries for a skill named `"foobar"` because `"foobar-label:production".startsWith("foo")` is `true`. The key format always appends `-` before the version/label segment, so anchoring the prefix check to `skillName + "-"` prevents these false invalidations. Note: the identical issue is present in `LangfusePromptCache.invalidate`.

```suggestion
    for (const key of this._cache.keys()) {
      if (key.startsWith(skillName + "-")) {
        this._cache.delete(key);
      }
    }
```

### Issue 2 of 3
packages/client/src/skill/skillCache.ts:63
The `promise` parameter is typed as `Promise<any>`, but the internal `_refreshingKeys` map is typed `Map<string, Promise<void>>`. Typing the parameter as `Promise<void>` (or at minimum `Promise<unknown>`) makes the contract more precise and aligns with the map's declared type.

```suggestion
  public addRefreshingPromise(key: string, promise: Promise<void>): void {
```

### Issue 3 of 3
packages/client/src/skill/skillManager.ts:228
**`cacheTtlSeconds: 0` still writes to cache**: When `cacheTtlSeconds` is explicitly `0` (caching disabled), `fetchSkillAndUpdateCache` is still called, which stores the result with TTL 0. This creates an immediately-expired entry. On the very next `get()` call that does *not* pass `cacheTtlSeconds: 0`, `getIncludingExpired` will find this stale entry and trigger the stale-while-revalidate path, returning stale data rather than fetching synchronously. The same behaviour exists in `PromptManager`, but it may be surprising to callers who used `cacheTtlSeconds: 0` expecting a complete bypass.

Reviews (1): Last reviewed commit: "feat(skill): add skills management manag..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

Hand-written high-level skills API mirroring the prompt manager:

- SkillManager (get with caching + stale-while-revalidate + fallback +
  retry, create, update labels, list, delete)
- LangfuseSkillCache with TTL and prefix invalidation
- SkillClient wrapper with {{variable}} compilation of the instructions
  body (single client; skills have no chat/text split)
- wired onto LangfuseClient as .skill plus getSkill/createSkill/updateSkill
- unit tests for cache TTL and client compilation

Depends on the auto-generated @langfuse/core skills client
(api-spec-bot regeneration from the Fern contract in langfuse/langfuse).

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the langfuse Team on Vercel.

A member of the Team first needs to authorize it.

@CLAassistant

CLAassistant commented Jul 7, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Comment on lines +85 to +89
for (const key of this._cache.keys()) {
if (key.startsWith(skillName)) {
this._cache.delete(key);
}
}

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.

P1 Cache invalidation prefix collision: startsWith(skillName) without a separator will incorrectly evict entries for any skill whose name begins with the same characters. For example, calling invalidate("foo") will also delete the cached entries for a skill named "foobar" because "foobar-label:production".startsWith("foo") is true. The key format always appends - before the version/label segment, so anchoring the prefix check to skillName + "-" prevents these false invalidations. Note: the identical issue is present in LangfusePromptCache.invalidate.

Suggested change
for (const key of this._cache.keys()) {
if (key.startsWith(skillName)) {
this._cache.delete(key);
}
}
for (const key of this._cache.keys()) {
if (key.startsWith(skillName + "-")) {
this._cache.delete(key);
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/client/src/skill/skillCache.ts
Line: 85-89

Comment:
Cache invalidation prefix collision: `startsWith(skillName)` without a separator will incorrectly evict entries for any skill whose name begins with the same characters. For example, calling `invalidate("foo")` will also delete the cached entries for a skill named `"foobar"` because `"foobar-label:production".startsWith("foo")` is `true`. The key format always appends `-` before the version/label segment, so anchoring the prefix check to `skillName + "-"` prevents these false invalidations. Note: the identical issue is present in `LangfusePromptCache.invalidate`.

```suggestion
    for (const key of this._cache.keys()) {
      if (key.startsWith(skillName + "-")) {
        this._cache.delete(key);
      }
    }
```

How can I resolve this? If you propose a fix, please make it concise.

version: options?.version,
});
const cachedSkill = this.cache.getIncludingExpired(cacheKey);
if (!cachedSkill || options?.cacheTtlSeconds === 0) {

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 cacheTtlSeconds: 0 still writes to cache: When cacheTtlSeconds is explicitly 0 (caching disabled), fetchSkillAndUpdateCache is still called, which stores the result with TTL 0. This creates an immediately-expired entry. On the very next get() call that does not pass cacheTtlSeconds: 0, getIncludingExpired will find this stale entry and trigger the stale-while-revalidate path, returning stale data rather than fetching synchronously. The same behaviour exists in PromptManager, but it may be surprising to callers who used cacheTtlSeconds: 0 expecting a complete bypass.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/client/src/skill/skillManager.ts
Line: 228

Comment:
**`cacheTtlSeconds: 0` still writes to cache**: When `cacheTtlSeconds` is explicitly `0` (caching disabled), `fetchSkillAndUpdateCache` is still called, which stores the result with TTL 0. This creates an immediately-expired entry. On the very next `get()` call that does *not* pass `cacheTtlSeconds: 0`, `getIncludingExpired` will find this stale entry and trigger the stale-while-revalidate path, returning stale data rather than fetching synchronously. The same behaviour exists in `PromptManager`, but it may be surprising to callers who used `cacheTtlSeconds: 0` expecting a complete bypass.

How can I resolve this? If you propose a fix, please make it concise.

LangfuseSkillCache.invalidate matched on startsWith(skillName), which could
evict entries for a different skill whose name shares the prefix (e.g.
invalidate('foo') dropping 'foobar-*'). Anchor to skillName + '-'.
@Robin1511 Robin1511 force-pushed the feat/skills-management branch from abd49e7 to 602b604 Compare July 7, 2026 13:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants