Evaluate if skills based progressive disclosure is actually working #968

Open
opened 2026-08-18 17:13:54 +00:00 by coilysiren · 6 comments
Owner
No description provided.
Member

Measured it. Short answer: the reference tier works and is carrying real weight, the entrypoint tier does not exist as disclosure at all, and #859 decided that on purpose. What changed since is that the engineer role broke #859's sizing assumption.

What the code does

loadSkills (internal/community/skillpack.go:60) partitions every file under a skill root with one condition:

if isReferencePath(path) && !inlineAlways(text) { ... defer to read_skill ... }
fmt.Fprintf(&output, "\n## Source: %s\n\n%s\n", slashed, body)
  • A reference (*/references/*.md, one level) is deferred unless its frontmatter carries inline: true, and the prompt gets an index naming what read_skill can serve.
  • An entrypoint (SKILL.md / COMPOSED.md) has no condition on it. It falls through and is inlined unconditionally, every turn, whatever the turn is about.

LoadBundle runs the composed bundle through the same LoadSkillpack, so the catalogue is governed by the identical rule. read_skill is real and registered (skilltool.go:15).

Measured on the engineer bundle, 51 catalogue skills

  • entrypoints, always inlined - 128,334 bytes
  • references, deferred to read_skill - 168,519 bytes
  • references forced back inline with inline: true - 0 bytes, nothing in the catalogue uses the flag

So the reference tier is deferring 57% of the catalogue material. Without it the composed prompt would be roughly 2.3x its current size. That half is working.

The tier that is not disclosure

Tokenized with cl100k_base, the 51 entrypoints are 28,714 tokens. Their names plus frontmatter descriptions are 2,354 tokens. So a Claude-Code-shaped split, description in the prompt and body on trigger, would free 26,360 tokens, about 74% of the composed prompt.

Why this is a new question rather than a re-run of #859

#859 chose this. Its own proposal reads "a read_skill-style tool, the SKILL.md bodies staying inline as the index, and references/*.md loaded only when called for." Entrypoints staying inline was the design, and it was sound for what existed then: Echo carried three local roots and about 20 KB of skillpack, so the entrypoints genuinely were an index.

The composed catalogue at 51 skills is not an index. The engineer entry took it to 128 KB of entrypoint bodies, averaging 2.5 KB each. #859's assumption held at three roots and does not hold at fifty-one. That is the thing worth re-deciding, and it is a consequence of #956 rather than a defect in #859's implementation.

The argument I would not make

Do not sell this as a cost fix. Those 26K tokens sit in the byte-stable prefix, and BuildSystemPrompt carries no clock and no per-turn variance, so DeepSeek caches them automatically. Measured over seven days of agent-proxy spans, sirens-echo/deepseek ran 99,292,714 input tokens against 42,894,848 cache reads, a 43.2% hit rate, priced at 2.8e-09 against 1.4e-07 fresh. The cutover should push that up, because the growth landed almost entirely in the cacheable region. Optimizing tokens billed at a fiftieth is the wrong lever.

Two arguments do survive:

  • Attention. 51 full skill bodies competing for relevance on every turn is a different problem from 51 descriptions, and caching does nothing for it. This is the real case and it should be argued on recall and precision, not on bytes.
  • Cold prefill. Every image publish invalidates the prefix, so the first turn after each deploy pays it fresh. That lands on a lane whose p95 community.turn was already 182.2s against what was then a 180s budget.

If it gets built

#859's caveats still bind and are worth restating, because they are what makes this a design question rather than a mechanical change:

  • a turn needing a body costs an extra round trip, competing with real work inside tool_rounds
  • some content must not be optional. #859's reasoning was "a model that has to choose to read its own boundaries may not," which is exactly why inline: true exists. Nothing uses it yet, and a body-level split is when that flag starts earning its keep
  • ValidateSystemPrompt anchors on prompt content, so anything moved out stops being checkable that way

Refs #859, #956, #964, coilyco-bridge/deploy#932

Measured it. Short answer: the reference tier works and is carrying real weight, the entrypoint tier does not exist as disclosure at all, and #859 decided that on purpose. What changed since is that the engineer role broke #859's sizing assumption. ## What the code does `loadSkills` (`internal/community/skillpack.go:60`) partitions every file under a skill root with one condition: ```go if isReferencePath(path) && !inlineAlways(text) { ... defer to read_skill ... } fmt.Fprintf(&output, "\n## Source: %s\n\n%s\n", slashed, body) ``` * A **reference** (`*/references/*.md`, one level) is deferred unless its frontmatter carries `inline: true`, and the prompt gets an index naming what `read_skill` can serve. * An **entrypoint** (`SKILL.md` / `COMPOSED.md`) has no condition on it. It falls through and is inlined unconditionally, every turn, whatever the turn is about. `LoadBundle` runs the composed bundle through the same `LoadSkillpack`, so the catalogue is governed by the identical rule. `read_skill` is real and registered (`skilltool.go:15`). ## Measured on the engineer bundle, 51 catalogue skills * entrypoints, always inlined - **128,334 bytes** * references, deferred to `read_skill` - **168,519 bytes** * references forced back inline with `inline: true` - **0 bytes**, nothing in the catalogue uses the flag So the reference tier is deferring **57%** of the catalogue material. Without it the composed prompt would be roughly 2.3x its current size. That half is working. ## The tier that is not disclosure Tokenized with `cl100k_base`, the 51 entrypoints are **28,714 tokens**. Their names plus frontmatter descriptions are **2,354 tokens**. So a Claude-Code-shaped split, description in the prompt and body on trigger, would free **26,360 tokens**, about 74% of the composed prompt. ## Why this is a new question rather than a re-run of #859 #859 chose this. Its own proposal reads "a `read_skill`-style tool, the `SKILL.md` bodies staying inline **as the index**, and `references/*.md` loaded only when called for." Entrypoints staying inline was the design, and it was sound for what existed then: Echo carried three local roots and about 20 KB of skillpack, so the entrypoints genuinely were an index. The composed catalogue at 51 skills is not an index. The engineer entry took it to 128 KB of entrypoint bodies, averaging 2.5 KB each. #859's assumption held at three roots and does not hold at fifty-one. That is the thing worth re-deciding, and it is a consequence of #956 rather than a defect in #859's implementation. ## The argument I would not make Do not sell this as a cost fix. Those 26K tokens sit in the byte-stable prefix, and `BuildSystemPrompt` carries no clock and no per-turn variance, so DeepSeek caches them automatically. Measured over seven days of agent-proxy spans, `sirens-echo/deepseek` ran 99,292,714 input tokens against 42,894,848 cache reads, a **43.2% hit rate**, priced at 2.8e-09 against 1.4e-07 fresh. The cutover should push that up, because the growth landed almost entirely in the cacheable region. Optimizing tokens billed at a fiftieth is the wrong lever. Two arguments do survive: * **Attention.** 51 full skill bodies competing for relevance on every turn is a different problem from 51 descriptions, and caching does nothing for it. This is the real case and it should be argued on recall and precision, not on bytes. * **Cold prefill.** Every image publish invalidates the prefix, so the first turn after each deploy pays it fresh. That lands on a lane whose p95 `community.turn` was already 182.2s against what was then a 180s budget. ## If it gets built #859's caveats still bind and are worth restating, because they are what makes this a design question rather than a mechanical change: * a turn needing a body costs an extra round trip, competing with real work inside `tool_rounds` * some content must not be optional. #859's reasoning was "a model that has to choose to read its own boundaries may not," which is exactly why `inline: true` exists. Nothing uses it yet, and a body-level split is when that flag starts earning its keep * `ValidateSystemPrompt` anchors on prompt content, so anything moved out stops being checkable that way Refs #859, #956, #964, coilyco-bridge/deploy#932
Member

Correction and sharpening of my previous comment, from Kai's read of it. I framed this as a sizing decision that #956 invalidated. That is true but it is downstream of something simpler: the loader discards the field that exists to be the index.

The inversion, exactly

stripFrontmatter (skillpack.go:265) returns remainder[end+5:], which is everything after the closing ---. The frontmatter block is dropped on the floor. loadSkills then writes that body into the prompt.

So for every entrypoint the harness:

  • throws away name and description, the fields whose whole purpose is to be a cheap always-on index
  • inlines the body, the part designed to be fetched when relevant

Grepped to be sure: description is read nowhere in skillpack.go or prompt.go. The only hits in the package are Discord command descriptions and the guardfile skill, unrelated.

The catalogue is authored correctly. All 70 of 70 composed skills carry a description, and check-skills (agentic_os/pre_commit/check_skill.py:358) fails the build on an empty one and caps it at 500 bytes. So agent-compose hands the harness a properly shaped, contract-conforming skill tree, and the loader inverts it.

One more tell: skillIndex labels each deferred reference with firstHeading(body), an H1 scraped out of the text. The one place a description is exactly the right string, the code reaches for a fallback instead.

Why "move the bodies into references/" is the wrong fix

That proposal works mechanically, since isReferencePath would then defer them. It should still not be done:

  • .agents/composed/ is a shared catalogue. agent-compose serves every harness from it, not just this one. Gutting COMPOSED.md to a stub so that one Go loader behaves would degrade every other consumer, including native Claude Code skill install.
  • It inverts authoring and rollout. The defect is in loadSkills. Fixing it by editing the upstream catalogue is the downward fetch AGENTS.md rules out, and it puts a sirens-echo bug's workaround in a repo that does not know sirens-echo exists.
  • The index would get worse, not better. Deferred entries are labelled by H1, so coding-python would advertise itself as "Python" instead of the authored 200-character description already sitting in its frontmatter unused.
  • check-skills stands in the way and is right to. A COMPOSED.md reduced to a pointer is not a skill.

Where the fix belongs

Inside loadSkills, and it is small in the middle and awkward at the edges:

  • emit name plus description for an entrypoint instead of its body, which is 2,354 tokens across the 51 engineer skills against 28,714 today
  • add entrypoint paths to the read_skill index so a body is reachable, which the tool already supports since it takes a repo-relative path
  • keep inline: true as the escape hatch and start using it, on boundaries and capability, for the reason #859 gave: a model that has to choose to read its own boundaries may not
  • ValidateSystemPrompt anchors on prompt content, so whatever moves out needs its check moved with it. This is the part that is real work rather than a loader edit.

Everything above is a harness change. The catalogue needs no edit at all, which is the tell that it was never the catalogue's problem.

The cost framing in my earlier comment stands unchanged: this is an attention and cold-prefill argument, not a token-bill argument, because DeepSeek is already caching those bytes at a fiftieth.

Correction and sharpening of my previous comment, from Kai's read of it. I framed this as a sizing decision that #956 invalidated. That is true but it is downstream of something simpler: **the loader discards the field that exists to be the index.** ## The inversion, exactly `stripFrontmatter` (`skillpack.go:265`) returns `remainder[end+5:]`, which is everything *after* the closing `---`. The frontmatter block is dropped on the floor. `loadSkills` then writes that body into the prompt. So for every entrypoint the harness: * **throws away** `name` and `description`, the fields whose whole purpose is to be a cheap always-on index * **inlines** the body, the part designed to be fetched when relevant Grepped to be sure: `description` is read nowhere in `skillpack.go` or `prompt.go`. The only hits in the package are Discord command descriptions and the guardfile skill, unrelated. The catalogue is authored correctly. All **70 of 70** composed skills carry a `description`, and `check-skills` (`agentic_os/pre_commit/check_skill.py:358`) fails the build on an empty one and caps it at 500 bytes. So agent-compose hands the harness a properly shaped, contract-conforming skill tree, and the loader inverts it. One more tell: `skillIndex` labels each deferred reference with `firstHeading(body)`, an H1 scraped out of the text. The one place a description is exactly the right string, the code reaches for a fallback instead. ## Why "move the bodies into references/" is the wrong fix That proposal works mechanically, since `isReferencePath` would then defer them. It should still not be done: * **`.agents/composed/` is a shared catalogue.** agent-compose serves every harness from it, not just this one. Gutting `COMPOSED.md` to a stub so that one Go loader behaves would degrade every other consumer, including native Claude Code skill install. * **It inverts authoring and rollout.** The defect is in `loadSkills`. Fixing it by editing the upstream catalogue is the downward fetch AGENTS.md rules out, and it puts a sirens-echo bug's workaround in a repo that does not know sirens-echo exists. * **The index would get worse, not better.** Deferred entries are labelled by H1, so `coding-python` would advertise itself as "Python" instead of the authored 200-character description already sitting in its frontmatter unused. * **`check-skills` stands in the way** and is right to. A `COMPOSED.md` reduced to a pointer is not a skill. ## Where the fix belongs Inside `loadSkills`, and it is small in the middle and awkward at the edges: * emit `name` plus `description` for an entrypoint instead of its body, which is 2,354 tokens across the 51 engineer skills against 28,714 today * add entrypoint paths to the `read_skill` index so a body is reachable, which the tool already supports since it takes a repo-relative path * keep `inline: true` as the escape hatch and **start using it**, on boundaries and capability, for the reason #859 gave: a model that has to choose to read its own boundaries may not * `ValidateSystemPrompt` anchors on prompt content, so whatever moves out needs its check moved with it. This is the part that is real work rather than a loader edit. Everything above is a harness change. The catalogue needs no edit at all, which is the tell that it was never the catalogue's problem. The cost framing in my earlier comment stands unchanged: this is an attention and cold-prefill argument, not a token-bill argument, because DeepSeek is already caching those bytes at a fiftieth.
Member

Filed the implementation side as #971, so this issue's question is answered and the work has somewhere to live.

The evaluation, short form. Progressive disclosure half works. The reference tier from #859 is real and carrying 57% of the catalogue material, 168,519 bytes deferred against 128,334 inlined on the engineer bundle. The entrypoint tier is not disclosure at all: stripFrontmatter discards name and description and the loader inlines the body unconditionally, which is the skill contract backwards.

Headroom if the entrypoint tier is fixed: 26,360 tokens, 28,714 down to 2,354 across the 51 engineer skills.

Not urgent before the 2026-08-19 stream, and specifically not a cost fix, since DeepSeek is already caching those bytes at a fiftieth. The live arguments are attention and cold prefill, both in #971.

This one can close as evaluated whenever you like.

Filed the implementation side as #971, so this issue's question is answered and the work has somewhere to live. **The evaluation, short form.** Progressive disclosure half works. The reference tier from #859 is real and carrying 57% of the catalogue material, 168,519 bytes deferred against 128,334 inlined on the engineer bundle. The entrypoint tier is not disclosure at all: `stripFrontmatter` discards `name` and `description` and the loader inlines the body unconditionally, which is the skill contract backwards. Headroom if the entrypoint tier is fixed: **26,360 tokens**, 28,714 down to 2,354 across the 51 engineer skills. Not urgent before the 2026-08-19 stream, and specifically not a cost fix, since DeepSeek is already caching those bytes at a fiftieth. The live arguments are attention and cold prefill, both in #971. This one can close as evaluated whenever you like.
Member

One concrete data point for this, measured rather than argued. Filed the specific case as #993.

On the shared roots it is working. Both roots the sirens definitions load use the split #927 built:

coilyco-general    entry 2,504    deferred 5,717
coilyco-org        entry   673    deferred 1,380

Roughly two thirds of that material is out of the prompt and reachable through read_skill.

On sirens-dowel it was opted out of. All six references carry inline: always, so the root is 24,097 bytes in the prompt every turn and nothing is deferred. That is the lane with the largest prompt and the worst median turn (#932).

Two of the six earn it on #927's own reasoning, since a model that has to choose to read its own boundaries may not. Three read as long tail: a conditional (site-work.md, whose own pointer says "before touching a page") and two subject-matter files.

So the honest answer to the title is partly. The mechanism works and is used where it was designed in. What has no pressure behind it is the decision to defer: inline: always is a per-file opt-out with no review step, each one looks reasonable alone, and nothing measures the total. That is the gap worth closing if this issue wants a structural answer rather than a per-root audit.

Two related findings from today, both about the mechanism rather than its adoption:

  • #969, merged - the composed bundle's references were advertised in the prompt and refused by the tool, because NewAgent built the provider from the definition's local roots alone. Roughly 96KB of coding-* references were deferred and unreachable. So on composed bundles progressive disclosure was not working at all until today.
  • #943's mcp.tools.cached is derived rather than recorded, which is a different instance of the same shape: a fact nobody verifies because it looks self-evident.
One concrete data point for this, measured rather than argued. Filed the specific case as #993. **On the shared roots it is working.** Both roots the sirens definitions load use the split #927 built: ``` coilyco-general entry 2,504 deferred 5,717 coilyco-org entry 673 deferred 1,380 ``` Roughly two thirds of that material is out of the prompt and reachable through `read_skill`. **On `sirens-dowel` it was opted out of.** All six references carry `inline: always`, so the root is **24,097 bytes in the prompt every turn** and nothing is deferred. That is the lane with the largest prompt and the worst median turn (#932). Two of the six earn it on #927's own reasoning, since a model that has to choose to read its own boundaries may not. Three read as long tail: a conditional (`site-work.md`, whose own pointer says "before touching a page") and two subject-matter files. So the honest answer to the title is **partly**. The mechanism works and is used where it was designed in. What has no pressure behind it is the *decision* to defer: `inline: always` is a per-file opt-out with no review step, each one looks reasonable alone, and nothing measures the total. That is the gap worth closing if this issue wants a structural answer rather than a per-root audit. Two related findings from today, both about the mechanism rather than its adoption: * #969, merged - the composed bundle's references were **advertised in the prompt and refused by the tool**, because `NewAgent` built the provider from the definition's local roots alone. Roughly 96KB of `coding-*` references were deferred and unreachable. So on composed bundles progressive disclosure was not working at all until today. * #943's `mcp.tools.cached` is derived rather than recorded, which is a different instance of the same shape: a fact nobody verifies because it looks self-evident.
Member

Measured rather than argued. Counted inline: always against fetchable across every reference in every skill root on main at 2026-08-19T04:45Z.

root references inline fetchable
coilyco-general 2 1 1
coilyco-org 2 2 0
sirens-dowel 7 6 1
sirens-echo-community 0 0 0
sirens-echo-knowledge 7 2 5

The answer differs sharply by lane

On Echo it is working. sirens-echo-knowledge has 5 of 7 references fetchable, which is the mechanism doing its job: the bulk of the knowledge sits outside the prompt and arrives when the model asks.

On Dowel it is effectively off. 6 of 7 inline in its own root, 2 of 2 in coilyco-org, 1 of 2 in coilyco-general. Loading the lane's three roots produces exactly one fetchable reference, so read_skill has a catalogue of one and the prompt carries everything else. #993 measures the cost at 24KB every turn, and my own load of the pack came to 33,536 bytes.

So the feature works and this lane opted out of it, one file at a time, each with a reason that was locally good.

Why each of those inline: always was chosen, and why that is the actual finding

The stated justification is real and load-bearing: a model that has to choose to read its own boundaries may not. skillpack.go says exactly that, and it is why the refusal-shaping files are inline.

But that argument has no natural stopping point. Every author of a rule believes their rule is the one that must not be missed, and I wrote five of the six inline files in sirens-dowel myself over the last day. Nobody decided to disable progressive disclosure on this lane. It happened by each decision being individually defensible.

That is the thing worth evaluating, and it is a governance question rather than a mechanism one. The mechanism is fine.

What would settle it properly

  • A budget rather than a per-file judgement. If a root may hold N inline bytes, the next author has to argue against a named alternative rather than only for their own file.
  • Test whether the fear is real. The claim that a model will skip its own boundaries if they are fetchable is asserted, not measured. One board run with a refusal-shaping reference moved to fetchable would answer it, and if the model reliably reads it, most of these could move.
  • Distinguish boundary prose from knowledge prose. dowel-provenance.md and the refusal files have a genuine case for inline. tool-surfaces.md and coilyco-suite.md are reference material the model could fetch when the subject comes up, and both are mine.

Caveat

This counts declaration, not behaviour. It shows what the prompt carries, not whether the model would have fetched a reference it needed. The second question needs a run, and #1019 section C is where that sits.

Refs #993, #1019, #1011

**Measured rather than argued.** Counted `inline: always` against fetchable across every reference in every skill root on `main` at 2026-08-19T04:45Z. | root | references | inline | fetchable | | --- | --- | --- | --- | | `coilyco-general` | 2 | 1 | 1 | | `coilyco-org` | 2 | **2** | **0** | | `sirens-dowel` | 7 | **6** | **1** | | `sirens-echo-community` | 0 | 0 | 0 | | `sirens-echo-knowledge` | 7 | 2 | **5** | ## The answer differs sharply by lane **On Echo it is working.** `sirens-echo-knowledge` has 5 of 7 references fetchable, which is the mechanism doing its job: the bulk of the knowledge sits outside the prompt and arrives when the model asks. **On Dowel it is effectively off.** 6 of 7 inline in its own root, 2 of 2 in `coilyco-org`, 1 of 2 in `coilyco-general`. Loading the lane's three roots produces exactly **one** fetchable reference, so `read_skill` has a catalogue of one and the prompt carries everything else. #993 measures the cost at 24KB every turn, and my own load of the pack came to 33,536 bytes. So the feature works and this lane opted out of it, one file at a time, each with a reason that was locally good. ## Why each of those `inline: always` was chosen, and why that is the actual finding The stated justification is real and load-bearing: **a model that has to choose to read its own boundaries may not.** `skillpack.go` says exactly that, and it is why the refusal-shaping files are inline. But that argument has no natural stopping point. Every author of a rule believes their rule is the one that must not be missed, and I wrote five of the six inline files in `sirens-dowel` myself over the last day. **Nobody decided to disable progressive disclosure on this lane. It happened by each decision being individually defensible.** That is the thing worth evaluating, and it is a governance question rather than a mechanism one. The mechanism is fine. ## What would settle it properly * **A budget rather than a per-file judgement.** If a root may hold N inline bytes, the next author has to argue against a named alternative rather than only for their own file. * **Test whether the fear is real.** The claim that a model will skip its own boundaries if they are fetchable is asserted, not measured. One board run with a refusal-shaping reference moved to fetchable would answer it, and if the model reliably reads it, most of these could move. * **Distinguish boundary prose from knowledge prose.** `dowel-provenance.md` and the refusal files have a genuine case for inline. `tool-surfaces.md` and `coilyco-suite.md` are reference material the model could fetch when the subject comes up, and both are mine. ## Caveat This counts declaration, not behaviour. It shows what the prompt carries, not whether the model would have fetched a reference it needed. The second question needs a run, and #1019 section C is where that sits. Refs #993, #1019, #1011
Member

The instrument this issue wants is now on main: 36 deliberately thin science drawers behind read_skill (each one index line plus a two-sentence body, #1074), and an mcp.tool.skill span attribute carrying the validated reference name on every delivered skill read. Once the lanes roll onto the new image (coilyco-bridge/deploy#732), the evaluation is one SigNoz query: tool-call spans filtered to server=skills, grouped by mcp.tool.skill, over a week of traffic. Reads per drawer answers whether disclosure triggers at all, the zero-read drawers are cut candidates, the hottest are split candidates (astronomy into stellar/planetary/observational and so on), and reads-per-turn against the worklog receipts answers whether the model batches or dribbles. Kai's stated target is growing the catalogue from 36 toward ~100 as the trigger data justifies it, so this issue's question gets answered with real member traffic rather than a synthetic probe. I will run the first read-rate report after the lanes have a few days on the new image.

The instrument this issue wants is now on main: 36 deliberately thin science drawers behind read_skill (each one index line plus a two-sentence body, #1074), and an mcp.tool.skill span attribute carrying the validated reference name on every delivered skill read. Once the lanes roll onto the new image (coilyco-bridge/deploy#732), the evaluation is one SigNoz query: tool-call spans filtered to server=skills, grouped by mcp.tool.skill, over a week of traffic. Reads per drawer answers whether disclosure triggers at all, the zero-read drawers are cut candidates, the hottest are split candidates (astronomy into stellar/planetary/observational and so on), and reads-per-turn against the worklog receipts answers whether the model batches or dribbles. Kai's stated target is growing the catalogue from 36 toward ~100 as the trigger data justifies it, so this issue's question gets answered with real member traffic rather than a synthetic probe. I will run the first read-rate report after the lanes have a few days on the new image.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo#968
No description provided.