Skip to content

Skill enumeration is non-deterministic when the same skill is reachable through multiple discovery roots #29950

Closed as not planned

Description

@rikkarth

Summary

opencode auto-discovers skills from six default locations including ~/.claude/skills/ and ~/.agents/skills/. When the same skill basename is reachable through more than one root (a common Claude Code setup, where ~/.claude/skills/<name> is symlinked to ~/.agents/skills/<name>), opencode's discovery randomly assigns one path or the other per session in the emitted <skill><location>...</location></skill> entries. This injects per-session volatility into the system prompt at random offsets, defeating prefix-cache reuse on every upstream backend.

Environment

  • opencode dev branch (also reproduced on 1.15.11 release)
  • Skills available in both ~/.claude/skills/ (mostly symlinks → ~/.agents/skills/) and ~/.agents/skills/
  • 20 skills are reachable through both roots in our setup

Steps to reproduce

  1. Have ~/.agents/skills/foo/SKILL.md and ~/.claude/skills/foo -> ../../.agents/skills/foo (symlink, or independent copy)
  2. Open two fresh opencode sessions
  3. Capture the outgoing system prompt in each session (we did this via a LiteLLM async_pre_call_hook writing the full system payload to disk)
  4. Diff the two captures

Observed

Two fresh sessions in the same cwd, with identical config, produced byte-different <skill><location>...</location></skill> entries:

@@ -443,7 +443,7 @@
   <skill>
     <name>linear-reporting</name>
-    <location>file:///Users/.../.agents/skills/linear-reporting/SKILL.md</location>
+    <location>file:///Users/.../.claude/skills/linear-reporting/SKILL.md</location>
   </skill>
@@ -453,7 +453,7 @@
   <skill>
     <name>officecli</name>
-    <location>file:///Users/.../.agents/skills/officecli/SKILL.md</location>
+    <location>file:///Users/.../.claude/skills/officecli/SKILL.md</location>
   </skill>
@@ -495,7 +495,7 @@
   <skill>
     <name>pr-verify</name>
-    <location>file:///Users/.../.claude/skills/pr-verify/SKILL.md</location>
+    <location>file:///Users/.../.agents/skills/pr-verify/SKILL.md</location>
   </skill>

Note that the resolved root flips both directions across skills: linear-reporting goes .agents → .claude between sessions, while pr-verify goes .claude → .agents. So this isn't "preferred root" inconsistency — it's purely non-deterministic per-skill resolution.

Why it matters

Each one of these <location> flips changes ~30 characters of the system prompt at a deep offset. Every upstream prefix cache (Anthropic cache_control, OpenAI auto-cache, vLLM APC, oMLX paged SSD) keys on byte/block-level prefix match. Even with the prompt structurally identical, the location-string flips break cache hit rate.

Measured impact in our setup (Qwen3-Coder-Next-80B-A3B via oMLX on M3 Ultra):

  • With this non-determinism: cache.read=0 or ~4096 tokens of 30,000 (~13%) per fresh session, ~30 s TTFT every session
  • After workaround (see below): cache.read=28,672 / 30,000 (95.6%), 3–5 s TTFT on subsequent fresh sessions

Workaround

Set OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 in the launching environment. This drops ~/.claude/skills/ from external discovery entirely, leaving ~/.agents/skills/ as the only external root and eliminating the duplicate-resolution path. Tested and confirmed effective.

This workaround is not documented. We discovered it by reading packages/opencode/src/skill/index.ts (the disableClaudeCodeSkills flag) and packages/opencode/src/effect/runtime-flags.ts (the env var wire-up).

Proposed fix

In packages/opencode/src/skill/index.ts, deduplicate skills by basename at discovery time. When the same skill name appears in multiple roots, pick a canonical location (e.g., prefer .agents/skills/<name> over .claude/skills/<name>, or sort roots alphabetically and take the first match). Then sort the final set deterministically before emitting <skill> blocks.

This would:

  • Eliminate the per-session non-determinism
  • Restore prefix-cache hit rate to the structural maximum
  • Make the OPENCODE_DISABLE_CLAUDE_CODE_SKILLS workaround unnecessary for this scenario

Relationship to #18215 (NOT a duplicate)

A bot flagged #18215 ("Non-deterministic agent/skill ordering in tool descriptions breaks prompt caching", CLOSED 2026-03-19) as a possible duplicate. It is not — the two issues describe orthogonal axes of non-determinism, and #18215's fix does not fix this one.

Property #18215 (closed) This issue (#29950)
Non-determinism axis List order — which slot does skill X occupy? Entry content — what URL appears in skill X's <location> tag?
Root cause accessibleAgents / Skill.available() returned in unsorted insertion order Same skill basename reachable through .claude/skills/ and .agents/skills/; discovery picks one root randomly per session
Fix surface packages/opencode/src/tool/task.ts:39, packages/opencode/src/tool/skill.ts:10 (.sort(localeCompare)) packages/opencode/src/skill/index.ts (deduplicate by basename at discovery, canonicalize root selection)
Status CLOSED — fix in dev OPEN — reproduces on dev and 1.15.11

The reproducer in this issue's body was captured against current dev — which already includes #18215's sort fix. The byte diff sits inside the <location> tag of each <skill> entry, not in the ordering of entries. Sorting by name pins linear-reporting at index N every session, but the URL inside that entry still flips .agents ↔ .claude, so byte-level prefix caches still miss.

In short: #18215 made the order of skill entries deterministic. This issue is about making the content of each entry deterministic. Both are needed for a fully stable system prefix.

Related

Activity

  1. github-actions commented on May 30, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    If you've already verified that #18215's fix did not address your specific case, feel free to note that here to help maintainers triage.

  2. rikkarth commented on May 30, 2026

    @rikkarth
    Author

    For the maintainer triaging this — the bot flagged #18215 as a possible duplicate. It isn't:

    The reproducer in the body was captured against current dev (which already has #18215's sort applied). The byte diff is inside each <location> tag, not in the ordering of <skill> blocks. I added an explicit "Relationship to #18215" section in the body to spell this out side-by-side.

  3. lexlian commented on Jun 1, 2026

    @lexlian

    I fixed the issue with PR #30184.

    Root Cause

    discoverSkills in packages/opencode/src/skill/index.ts scans skills from five phases in fixed sequential order:

    Phase Source Root
    1 ~/.claude/skills/ global.home + .claude
    2 ~/.agents/skills/ global.home + .agents
    3 Project-level dirs up from CWD + .opencode/ dirs
    4 Config-specified paths cfg.skills?.paths
    5 Remote URLs discovery.pull

    When the same skill basename exists in multiple roots, Glob.scan returns both absolute paths. Both were added to a Set<string>. JavaScript Set iteration order is insertion-order within a process but not stable across sessions — the same two paths could iterate in either order on restart. Since state.skills is keyed by skill name (not path), whichever path was processed last would overwrite the first via state.skills[md.data.name] = {...}. This caused <skill><location> to flip between .claude and .agents per session, breaking byte-level prompt cache reuse.

    Fix

    DEDUPE_MODE Design

    Introduced a DEDUPE_MODE const object with three modes:

    • KeepFirst (default): When a new basename is already in the seen Map, skip inserting the new path. First-discovered path wins deterministically.
    • KeepLast: When a new basename is already in the Map, delete the old path from state.matches, then insert the new one. Latest-discovered path wins.
    • KeepBoth: No deduplication — both paths remain in state.matches. Set iteration order determines winner (preserves pre-fix behavior).

    With KeepFirst (default), priority across all 5 phases follows scan order:

    Priority Source Phase
    Highest ~/.claude/skills/ Phase 1
    ↓ ~/.agents/skills/ Phase 2
    ↓ .opencode/skills/ via configDirs Phase 3
    ↓ .claude/skills/ via upDirs (project) Phase 3
    ↓ .agents/skills/ via upDirs (project) Phase 3
    ↓ cfg.skills?.paths custom dirs Phase 4
    Lowest remote URL pulled dirs Phase 5

    Major Code Changes

    packages/opencode/src/skill/index.ts — scan function:

    • Added dedupeMode parameter (default: DEDUPE_MODE.KeepFirst)
    • Added seen: Map<string, string> to ScanState (basename → path)
    • Deduplication logic during scan loop:
    for (const match of matches) {
      const basename = path.basename(path.dirname(match))
      const existing = state.seen.get(basename)
    
      if (existing !== undefined) {
        if (dedupeMode === DEDUPE_MODE.KeepBoth) {
          state.matches.add(match)
        } else if (dedupeMode === DEDUPE_MODE.KeepLast) {
          state.matches.delete(existing)
          state.matches.add(match)
          state.seen.set(basename, match)
        }
        continue  // KeepFirst: skip (first wins)
      }
    
      state.seen.set(basename, match)
      state.matches.add(match)
      state.dirs.add(path.dirname(match))
    }

    packages/opencode/src/skill/index.ts — discoverSkills function:

    • state now initialized with seen: new Map() alongside matches and dirs
    • All 5 phase scan() calls pass dedupeMode parameter

    packages/opencode/test/skill/skill.test.ts — new test block:

    • KeepFirst: deduplicates same basename from .claude and .agents, .claude wins
    • KeepFirst: available() returns stable list across calls
    • KeepFirst: skills with unique basenames are all retained
  4. github-actions commented on Aug 1, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

  5. rneilson-cerby commented on Aug 4, 2026

    @rneilson-cerby

    Please reopen, issue still relevant

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions