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
- Have
~/.agents/skills/foo/SKILL.md and ~/.claude/skills/foo -> ../../.agents/skills/foo (symlink, or independent copy)
- Open two fresh opencode sessions
- 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)
- 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
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
~/.claude/skills/(mostly symlinks →~/.agents/skills/) and~/.agents/skills/Steps to reproduce
~/.agents/skills/foo/SKILL.mdand~/.claude/skills/foo -> ../../.agents/skills/foo(symlink, or independent copy)async_pre_call_hookwriting the full system payload to disk)Observed
Two fresh sessions in the same cwd, with identical config, produced byte-different
<skill><location>...</location></skill>entries:Note that the resolved root flips both directions across skills:
linear-reportinggoes.agents → .claudebetween sessions, whilepr-verifygoes.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 (Anthropiccache_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):
Workaround
Set
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1in 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(thedisableClaudeCodeSkillsflag) andpackages/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:
OPENCODE_DISABLE_CLAUDE_CODE_SKILLSworkaround unnecessary for this scenarioRelationship 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.
<location>tag?accessibleAgents/Skill.available()returned in unsorted insertion order.claude/skills/and.agents/skills/; discovery picks one root randomly per sessionpackages/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)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 pinslinear-reportingat 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