Skip to content

Commit 9bc5449

Browse files
authored
fix(agent): resolve a forced skill by the same rule as a forced snippet (#810)
Two issues in the same three functions. #781 is a forced skill and a forced snippet answering the same question differently; #777 is the same forced skill set being ordered two ways. ## What the issues got wrong, and what the code says #781 described the disagreement as living inside `AgentRunRequestCodec`. It is wider: **three** places rebuild a forced skill set from persisted uids, and the playground has the same disagreement as the codec. `AgentRunRequestCodec::skillsByUids()` also carried a docblock saying the missing `enabled` filter was deliberate — "forcing a skill overrides its global toggle, the same semantics the playground's force-inject control has". That reads like an intended rule, so it was checked rather than taken: no record decides it, and the playground does not behave that way — `availableSkills()` lists enabled skills only, so a disabled skill can reach the forced set only from a stale form or a hand-built body. The sentence described neither an intent nor the thing it claimed to copy. ## The decision comes from ADR-166, not from me ADR-166 already wrote the rule down twice. `findByUids()` is "the lookup for a prompt being assembled *now*, and a snippet an operator switched off must not enter one", and "a fresh run that forces it gets it through the active-only lookup like any other". Its "**the skill half was already correct**" is true of the path it was judging — the resume — and says nothing about the two that compose. So the fix direction is not a coin toss between #781's two options; the record picks one, and this change applies it to the path ADR-166 never looked at. ADR-175 records that and ADR-166 gains the matching `:Amended:`. | Path | Composing or re-gating | Lookup | |---|---|---| | `ToolPlaygroundController::resolveForcedSkills()` | composing now | `findByUids()` — enabled only | | `AgentRunRequestCodec::skillsByUids()` | composing now (dequeue) | `findByUids()` — enabled only | | `ToolLoopService::augmentationFrom()` | re-gating sent text | `findExistingByUids()` — existence | ## The ordering half (#777) The resume path iterated `findAll()` and so returned `$defaultOrderings`, `name ASC`; the two composition paths returned the persisted uid order. `InputContextClassification::withStricter()` keeps the **later** source on an equal data class, and that source's name is what the refusal message and the governance row carry — so one run blamed one skill before it suspended and another after it resumed. Same ceiling, same outcome, different name in the audit. All three now use the caller's order. ## Verification Ten functional cases against real rows. The fixture is its own and its names sort **against** its uids — `21` is "Zulu", `22` is "Alpha" — so an order assertion distinguishes the caller's order from `name ASC` instead of passing under both. Each assertion was seen to fail before being trusted: | Control | Result | |---|---| | drop the `enabled` clause from `orderedByUid()` | `findByUidsSkipsDisabledSkills` fails | | return query order instead of the caller's | 3 tests fail, including both `findExisting…` order cases | Restored, green, `git status` clean. **One existing unit test broke, and that was a real signal rather than noise.** `ToolLoopServiceTest::resumeRebuildsTheForcedSkillsToo` stubbed `findAll()`. Its assertion — a disabled skill still resolves on resume — is exactly what must not regress, so the double was moved to the new lookup rather than the test being relaxed. It now also asserts *which* uids reach that lookup, so the seam is covered here while the filtering is proven against the database, where a stub cannot flatter it. Gates: `phpstan` level 10 clean, `unit` 7137 pass, `fuzzy` pass, `functional -d sqlite` on the touched classes 38 pass, `cgl` and `rector -n` both stable at PHP 8.2 after applying. ## Deliberately not in this PR A forced source that disappears before a run starts is **silent** — now uniformly, where before it was silent for snippets and absent for skills. #781 asked for it to be visible. That needs somewhere to show it and a decision on whether a queued run should refuse instead, so it is filed as #809 rather than guessed at here. The ADR is in this PR rather than ahead of it because it amends a decision instead of taking one. Closes #781, closes #777
2 parents 4d360cd + c3f3aac commit 9bc5449

11 files changed

Lines changed: 414 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,24 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
203203
A row suspended before this change has neither key and resumes exactly as it
204204
did. A uid that no longer resolves — the snippet was deleted meanwhile —
205205
contributes nothing rather than stranding the run.
206+
- A forced skill now resolves enabled-only where it is being composed, and by
207+
existence only where it is being re-gated — the rule ADR-166 already gave
208+
snippets, applied to skills (ADR-175).
209+
210+
A run queued with a forced skill that an operator disabled before the run
211+
started used to keep that skill, while a forced snippet deactivated in the
212+
identical situation was dropped. The two source kinds now answer the same
213+
question the same way on the playground's synchronous send and on a queued
214+
run's dequeue: a source switched off does not enter a prompt being assembled
215+
now.
216+
217+
A resume is unchanged and deliberately so — its text is already in the
218+
transcript, so dropping the source there would lower the ceiling of content
219+
still going out.
220+
221+
`SkillRepository` gains `findByUids()` and `findExistingByUids()`. It is
222+
`@internal`, so no frozen surface moves.
223+
206224

207225
### Fixed
208226

@@ -256,6 +274,16 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
256274
so the profile had to be found again by hand, one screen after reading its
257275
name. The readout has always honoured a `profile` query parameter; only the
258276
link never sent one.
277+
- The same forced skill set is no longer ordered two different ways.
278+
279+
Three places rebuilt it from persisted uids; two kept the order the run was
280+
started with and the resume path returned `name ASC`. On an equal data class
281+
the later source in the fold wins, so one run named one skill in a refusal
282+
before it suspended and a different one after it resumed — same ceiling, same
283+
outcome, different name in the message and in the governance row. All three
284+
now use the caller's order (ADR-175).
285+
286+
259287

260288

261289
## [0.29.1] - 2026-08-13

Classes/Controller/Backend/ToolPlaygroundController.php

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -983,25 +983,7 @@ private function uidListFromBody(mixed $body, string $key): array
983983
*/
984984
private function resolveForcedSkills(array $uids): array
985985
{
986-
if ($uids === []) {
987-
return [];
988-
}
989-
990-
$byUid = [];
991-
foreach ($this->skillRepository->findAll() as $skill) {
992-
if ($skill instanceof Skill && $skill->getUid() !== null) {
993-
$byUid[$skill->getUid()] = $skill;
994-
}
995-
}
996-
997-
$skills = [];
998-
foreach ($uids as $uid) {
999-
if (isset($byUid[$uid])) {
1000-
$skills[] = $byUid[$uid];
1001-
}
1002-
}
1003-
1004-
return $skills;
986+
return $this->skillRepository->findByUids($uids);
1005987
}
1006988

1007989
/**

Classes/Domain/Repository/SkillRepository.php

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,97 @@ public function countEnabled(): int
7676

7777
return $query->count();
7878
}
79+
80+
/**
81+
* Find enabled skills by uid, preserving the input order.
82+
*
83+
* Unknown and disabled uids are silently skipped.
84+
*
85+
* This is the lookup for NEW composition — a skill an operator switched
86+
* off must not enter a prompt that is being assembled now. Do not merge it
87+
* with {@see self::findExistingByUids()}: the two answer different
88+
* questions and the difference is deliberate (ADR-175, mirroring the
89+
* snippet pair ADR-166 introduced).
90+
*
91+
* @param list<int> $uids
92+
*
93+
* @return list<Skill>
94+
*/
95+
public function findByUids(array $uids): array
96+
{
97+
return $this->orderedByUid($uids, true);
98+
}
99+
100+
/**
101+
* Find skills by uid regardless of their enabled flag, preserving the
102+
* input order.
103+
*
104+
* Same contract as {@see self::findByUids()} — unknown uids are silently
105+
* skipped, a deleted record still resolves to nothing — except that a
106+
* disabled skill still resolves.
107+
*
108+
* This is the lookup for text that is ALREADY in a transcript: a resumed
109+
* agent run re-loads its forced sources to re-gate them (ADR-165), and
110+
* disabling a skill mid-run must not silently drop the classification of
111+
* text that is still on the wire (ADR-166). "Disabled" means "not for new
112+
* composition", not "already-injected text loses its class".
113+
*
114+
* @param list<int> $uids
115+
*
116+
* @return list<Skill>
117+
*/
118+
public function findExistingByUids(array $uids): array
119+
{
120+
return $this->orderedByUid($uids, false);
121+
}
122+
123+
/**
124+
* Resolve uids to skills in the caller's order, optionally restricted to
125+
* enabled ones.
126+
*
127+
* The repository's default query settings ignore enable fields, so TYPO3's
128+
* own `hidden` plays no part here either way; `enabled` is the extension's
129+
* own field and $enabledOnly is what decides whether it applies. The
130+
* deleted restriction is untouched in both modes — a deleted record never
131+
* resolves. The caller's order replaces $defaultOrderings, which is the
132+
* point: the fold in {@see \Netresearch\NrLlm\Domain\ValueObject\InputContextClassification::withStricter()}
133+
* lets the later source win a tie, so the order a run was started with has
134+
* to be the order every later lookup reproduces.
135+
*
136+
* @param list<int> $uids
137+
*
138+
* @return list<Skill>
139+
*/
140+
private function orderedByUid(array $uids, bool $enabledOnly): array
141+
{
142+
if ($uids === []) {
143+
return [];
144+
}
145+
146+
$query = $this->createQuery();
147+
$uidMatches = $query->in('uid', $uids);
148+
$query->matching(
149+
$enabledOnly
150+
? $query->logicalAnd($query->equals('enabled', true), $uidMatches)
151+
: $uidMatches,
152+
);
153+
154+
/** @var array<int, Skill> $skillsByUid */
155+
$skillsByUid = [];
156+
foreach ($query->execute() as $skill) {
157+
$uid = $skill->getUid();
158+
if ($uid !== null) {
159+
$skillsByUid[$uid] = $skill;
160+
}
161+
}
162+
163+
$ordered = [];
164+
foreach ($uids as $uid) {
165+
if (isset($skillsByUid[$uid])) {
166+
$ordered[] = $skillsByUid[$uid];
167+
}
168+
}
169+
170+
return $ordered;
171+
}
79172
}

Classes/Service/Agent/AgentRunRequestCodec.php

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,15 @@ private function uidList(mixed $value): array
205205
}
206206

207207
/**
208-
* Forced skills by uid, preserving order. Resolved without the enabled
209-
* filter — forcing a skill overrides its global toggle, the same semantics
210-
* the playground's force-inject control has.
208+
* Forced skills by uid, preserving order, enabled only.
209+
*
210+
* A queued run has composed nothing yet, so dequeuing it is new
211+
* composition: a skill switched off between enqueue and start must not
212+
* enter it, exactly as {@see self::snippetsByUids()} has always held for
213+
* snippets (ADR-166, ADR-175). This docblock previously claimed the
214+
* opposite — that forcing overrides the global toggle — which no record
215+
* decided and which the force-inject picker does not offer, since it lists
216+
* enabled skills only.
211217
*
212218
* @param list<int> $uids
213219
*
@@ -219,21 +225,7 @@ private function skillsByUids(array $uids): array
219225
return [];
220226
}
221227

222-
$byUid = [];
223-
foreach ($this->skillRepository->findAll() as $skill) {
224-
if ($skill instanceof Skill && $skill->getUid() !== null) {
225-
$byUid[$skill->getUid()] = $skill;
226-
}
227-
}
228-
229-
$skills = [];
230-
foreach ($uids as $uid) {
231-
if (isset($byUid[$uid])) {
232-
$skills[] = $byUid[$uid];
233-
}
234-
}
235-
236-
return $skills;
228+
return $this->skillRepository->findByUids($uids);
237229
}
238230

239231
/**

Classes/Service/Tool/ToolLoopService.php

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,16 @@ private function uidsOf(array $records): array
171171
* already in the transcript and
172172
* still goes on the wire, so dropping it here would lower the ADR-164
173173
* ceiling for content that is still being sent. "Inactive" bars a snippet
174-
* from new composition; it does not un-classify text already injected. The
175-
* skill half needs no counterpart: {@see SkillRepository::findAll()} ignores
176-
* enable fields and the filter below is by uid only.
174+
* from new composition; it does not un-classify text already injected.
175+
*
176+
* The skill half is the same shape and uses
177+
* {@see SkillRepository::findExistingByUids()} for the same reason. It also
178+
* fixes an ordering divergence: this path used to iterate
179+
* {@see SkillRepository::findAll()} and so returned name order, while the
180+
* two composition paths returned the order the run was started with. On an
181+
* equal data class the later source in the fold wins, so the same run
182+
* blamed one skill before it suspended and another after it resumed
183+
* (ADR-175).
177184
*/
178185
private function augmentationFrom(SuspendedRunState $state): ?RunAugmentation
179186
{
@@ -185,15 +192,9 @@ private function augmentationFrom(SuspendedRunState $state): ?RunAugmentation
185192
? $this->promptSnippetRepository->findExistingByUids($state->forcedSnippetUids)
186193
: [];
187194

188-
$skills = [];
189-
if ($state->forcedSkillUids !== [] && $this->skillRepository instanceof SkillRepository) {
190-
$wanted = array_flip($state->forcedSkillUids);
191-
foreach ($this->skillRepository->findAll() as $skill) {
192-
if ($skill instanceof Skill && $skill->getUid() !== null && isset($wanted[$skill->getUid()])) {
193-
$skills[] = $skill;
194-
}
195-
}
196-
}
195+
$skills = $state->forcedSkillUids !== [] && $this->skillRepository instanceof SkillRepository
196+
? $this->skillRepository->findExistingByUids($state->forcedSkillUids)
197+
: [];
197198

198199
if ($snippets === [] && $skills === []) {
199200
return null;

Documentation/Adr/Adr166DeactivationDoesNotLowerACeiling.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ ADR-166: Deactivating a source does not lower a ceiling
77
:Status: Accepted
88
:Date: 2026-08-13
99
:Amends: :ref:`ADR-165 <adr-165>` (which lookup the resume re-gate uses)
10+
:Amended: 2026-08-18 by :ref:`ADR-175 <adr-175>` (its "the skill half was
11+
already correct" held for the resume path and not for the two composition
12+
paths)
1013
:Authors: Netresearch DTT GmbH
1114

1215
Context
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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.

Documentation/Adr/Index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,3 +526,4 @@ Tools
526526
Adr172FourEyesApprovalPerConfiguration
527527
Adr173SelfApprovalIsVisibleInTheRecord
528528
Adr174PerCallCostAndPreRoutingFacts
529+
Adr175ForcedSkillsBindByTheSnippetRule
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"tx_nrllm_skill"
2+
,"uid","pid","source","identifier","name","description","body","body_checksum","source_sha","support_status","unsupported_notes","allowed_tools","orphaned","enabled","tstamp","crdate","deleted","hidden"
3+
,21,0,2,"2:zulu/SKILL.md","Zulu","Enabled, sorts last by name","Body Z.","z1","sha2","full","","",0,1,1703347200,1703347200,0,0
4+
,22,0,2,"2:alpha/SKILL.md","Alpha","Enabled, sorts first by name","Body A.","a1","sha2","full","","",0,1,1703347200,1703347200,0,0
5+
,23,0,2,"2:bravo/SKILL.md","Bravo","Disabled","Body B.","b1","sha2","full","","",0,0,1703347200,1703347200,0,0
6+
,24,0,2,"2:delta/SKILL.md","Delta","Deleted","Body D.","d1","sha2","full","","",0,1,1703347200,1703347200,1,0
7+
,25,0,2,"2:echo/SKILL.md","Echo","Hidden but enabled","Body E.","e1","sha2","full","","",0,1,1703347200,1703347200,0,1

0 commit comments

Comments
 (0)