|
| 1 | +.. _adr-175: |
| 2 | + |
| 3 | +================================================================== |
| 4 | +ADR-175: A forced skill binds by the same rule as a forced snippet |
| 5 | +================================================================== |
| 6 | + |
| 7 | +:Status: Accepted |
| 8 | +:Date: 2026-08-18 |
| 9 | +:Amends: :ref:`ADR-166 <adr-166>` (whose "the skill half was already correct" |
| 10 | + held for the resume path it was judging and not for the two composition |
| 11 | + paths it did not look at) |
| 12 | +:Authors: Netresearch DTT GmbH |
| 13 | + |
| 14 | +Context |
| 15 | +======= |
| 16 | + |
| 17 | +:ref:`ADR-166 <adr-166>` split the snippet lookup in two. |
| 18 | +:php:`PromptSnippetRepository::findByUids()` stayed active-only — "the lookup |
| 19 | +for a prompt being assembled *now*, and a snippet an operator switched off must |
| 20 | +not enter one" — and :php:`findExistingByUids()` was added for text that is |
| 21 | +already in a transcript, so deactivating a snippet mid-run cannot quietly lower |
| 22 | +the :ref:`ADR-164 <adr-164>` ceiling of content still going out. |
| 23 | + |
| 24 | +That record then said the skill half needed no counterpart, because |
| 25 | +:php:`ToolLoopService::augmentationFrom()` filtered :php:`findAll()` by uid |
| 26 | +alone and so ignored ``enabled`` on both sides. **That was true, and it was |
| 27 | +about one path.** Three places rebuild a forced skill set from persisted uids, |
| 28 | +and only that one is a resume: |
| 29 | + |
| 30 | +- :php:`ToolPlaygroundController::resolveForcedSkills()` — a synchronous send, |
| 31 | + composing now; |
| 32 | +- :php:`AgentRunRequestCodec::skillsByUids()` — a queued run being dequeued, |
| 33 | + composing now; |
| 34 | +- :php:`ToolLoopService::augmentationFrom()` — a resume, re-gating text already |
| 35 | + sent. |
| 36 | + |
| 37 | +On the first two, snippets went through the active-only lookup and skills did |
| 38 | +not. So a forced **snippet** switched off between enqueue and start was gone |
| 39 | +when the run began, while a forced **skill** disabled in the identical |
| 40 | +situation survived (issue `#781`). The run then did not do what the person who |
| 41 | +queued it asked for. |
| 42 | + |
| 43 | +The three copies also disagreed about order. The two composition copies iterate |
| 44 | +the persisted uid list. The resume copy iterated :php:`findAll()` and therefore |
| 45 | +returned :php:`SkillRepository::$defaultOrderings`, ``name ASC``. Order is not |
| 46 | +cosmetic: |
| 47 | +:php:`InputContextClassification::withStricter()` keeps the *later* source on an |
| 48 | +equal data class, and it is that source's name the refusal message and the |
| 49 | +governance row carry. A run started with two equally-classified skills |
| 50 | +therefore blamed one before it suspended and the other after it resumed — |
| 51 | +same ceiling, same outcome, different name in the audit (issue `#777`). |
| 52 | + |
| 53 | +Decision |
| 54 | +======== |
| 55 | + |
| 56 | +**Skills get the pair snippets already have.** |
| 57 | +:php:`SkillRepository::findByUids()` resolves enabled skills only; |
| 58 | +:php:`findExistingByUids()` drops the ``enabled`` clause and nothing else. Both |
| 59 | +preserve the caller's order and both keep the deleted restriction, so a deleted |
| 60 | +record still resolves to nothing either way. |
| 61 | + |
| 62 | +**The two composition paths use the enabled-only lookup.** ADR-166's own words |
| 63 | +decide this rather than a new principle: a source an operator switched off must |
| 64 | +not enter a prompt being assembled now, and "a fresh run that forces it gets it |
| 65 | +through the active-only lookup like any other". A queued run has composed |
| 66 | +nothing at enqueue time; dequeuing it *is* that assembly. |
| 67 | + |
| 68 | +**The resume path uses the existence lookup**, which keeps ADR-166's resume |
| 69 | +semantics exactly as written. |
| 70 | + |
| 71 | +**One ordering rule: the caller's uid order, on all three paths.** The order a |
| 72 | +run was started with is the order every later lookup reproduces, so the fold |
| 73 | +names the same source at every point in that run's life. |
| 74 | + |
| 75 | +What this corrects |
| 76 | +================== |
| 77 | + |
| 78 | +:php:`AgentRunRequestCodec::skillsByUids()` carried a docblock stating that |
| 79 | +forcing a skill overrides its global toggle, "the same semantics the |
| 80 | +playground's force-inject control has". No record decided that, and the |
| 81 | +playground does not offer it: :php:`availableSkills()` lists enabled skills |
| 82 | +only, so a disabled skill can reach the forced set only from a stale form or a |
| 83 | +hand-built request body. The sentence described neither an intended rule nor |
| 84 | +the behaviour it claimed to copy, and it is replaced rather than kept. |
| 85 | + |
| 86 | +What this does not do |
| 87 | +===================== |
| 88 | + |
| 89 | +**It does not change what the ceiling reads.** The forced set is the set |
| 90 | +:ref:`ADR-164 <adr-164>` defined; this changes which rows resolve on which |
| 91 | +path, not which rows are asked for. |
| 92 | + |
| 93 | +**It does not make a disabled skill usable.** Nothing composes it, and the |
| 94 | +picker does not offer it. |
| 95 | + |
| 96 | +**It does not surface the drop.** A forced source that disappears before a run |
| 97 | +starts is silent — now uniformly, where before it was silent for snippets and |
| 98 | +absent for skills. That is issue `#809`, split out deliberately: making it |
| 99 | +visible needs a place to show it and a decision about whether a queued run |
| 100 | +should refuse instead, and neither follows from this one. |
| 101 | + |
| 102 | +**It is not an API change.** :php:`SkillRepository` is ``@internal`` |
| 103 | +(:ref:`ADR-127 <adr-127>`), so the two new methods change no frozen surface. |
| 104 | + |
| 105 | +Consequences |
| 106 | +============ |
| 107 | + |
| 108 | +A run queued with a skill that is disabled before it starts runs without that |
| 109 | +skill, as it already did for a snippet. |
| 110 | + |
| 111 | +The resume path returns uid order where it returned name order. On a |
| 112 | +classification tie the source named by a refusal can differ from what the old |
| 113 | +code would have named — which is the defect this closes, not a new one: the |
| 114 | +name is now the same before and after the suspension. |
| 115 | + |
| 116 | +An installation that forces nothing, or that has classified nothing, is |
| 117 | +unaffected. |
0 commit comments