Skip to content

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

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

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