switchroom 0.19.24 → 0.19.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +20 -7
- package/dist/auth-broker/index.js +93 -28
- package/dist/cli/autoaccept-poll.js +0 -1
- package/dist/cli/drive-write-pretool.mjs +5 -0
- package/dist/cli/ms-365-write-pretool.mjs +5 -0
- package/dist/cli/notion-write-pretool.mjs +20 -6
- package/dist/cli/switchroom.js +3091 -1435
- package/dist/host-control/main.js +92 -29
- package/dist/vault/approvals/kernel-server.js +92 -28
- package/dist/vault/broker/server.js +258 -71
- package/examples/switchroom.yaml +1 -1
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +6 -0
- package/profiles/_base/start.sh.hbs +92 -17
- package/skills/switchroom-health/SKILL.md +19 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +9 -2
- package/telegram-plugin/dist/gateway/gateway.js +6981 -6747
- package/telegram-plugin/gateway/gateway.ts +53 -52
- package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
- package/telegram-plugin/gateway/status-pin-retarget.ts +144 -0
- package/telegram-plugin/quota-bar-format.ts +4 -1
- package/telegram-plugin/status-no-truncate.ts +49 -0
- package/telegram-plugin/status-pin-driver.ts +28 -0
- package/telegram-plugin/status-pin.ts +33 -4
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +42 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +268 -0
- package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +29 -18
- package/telegram-plugin/tests/quota-bar-format.test.ts +50 -0
- package/telegram-plugin/tests/secret-detect-false-positives.test.ts +1 -1
- package/telegram-plugin/tests/status-pin-retarget.test.ts +216 -0
- package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +87 -21
- package/telegram-plugin/tests/status-pin.test.ts +128 -2
- package/telegram-plugin/tests/worker-activity-feed.test.ts +10 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +37 -21
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tier-downgrade.ts +3 -2
- package/telegram-plugin/tool-activity-summary.ts +61 -18
- package/telegram-plugin/uat/assertions.ts +21 -2
- package/telegram-plugin/uat/feed-matcher.test.ts +29 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
- package/telegram-plugin/worker-activity-feed.ts +38 -17
- package/vendor/hindsight-memory/scripts/lib/config.py +61 -19
- package/vendor/hindsight-memory/scripts/lib/content.py +376 -1
- package/vendor/hindsight-memory/scripts/lib/english_words.txt +10799 -0
- package/vendor/hindsight-memory/scripts/recall.py +503 -252
- package/vendor/hindsight-memory/scripts/tests/test_recall_bank_slots.py +509 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +22 -5
- package/vendor/hindsight-memory/scripts/tests/test_recall_error_text.py +147 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_hook_budget.py +266 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +0 -401
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +261 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +473 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +25 -8
- package/vendor/hindsight-memory/tests/test_content.py +218 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
"""Switchroom — per-bank slot reservation inside the recall count cap.
|
|
2
|
+
|
|
3
|
+
The bug these tests guard is compositional, not volumetric. Recall fans out to
|
|
4
|
+
the agent's OWN bank and the sender's profile bank, merges the results, sorts
|
|
5
|
+
them globally by ``scores.final`` and head-slices at ``recallMaxMemories``. That
|
|
6
|
+
head-slice is winner-take-all across banks: on a turn where both banks return
|
|
7
|
+
more candidates than the cap, one bank's score distribution can fill every slot,
|
|
8
|
+
and the agent is handed a dossier about its operator with none of its own
|
|
9
|
+
session memory.
|
|
10
|
+
|
|
11
|
+
Scope, stated the way the code states it: this is score-based crowd-out among
|
|
12
|
+
results that DID return. A timed-out bank contributes zero candidates, so
|
|
13
|
+
reservation is a strict no-op there — the own-bank timeout is a separate defect,
|
|
14
|
+
and the composition telemetry asserted below (``injected_own_bank_count``) is
|
|
15
|
+
what makes THAT one legible per-turn, since a fully timed-out own bank still
|
|
16
|
+
logs a full ``result_count``.
|
|
17
|
+
|
|
18
|
+
Two properties are load-bearing and each has a test that fails without it:
|
|
19
|
+
|
|
20
|
+
* the floors bind — neither bank can be zeroed out when it returned results;
|
|
21
|
+
* the floors are FLOORS — they may claim at most half the cap between them,
|
|
22
|
+
so ``scores.final`` still decides the rest and composition still moves when
|
|
23
|
+
the score regime flips. Floors summing to the cap (4+2 at the fleet's
|
|
24
|
+
deployed cap of 6) would be a fixed quota with no score influence at all.
|
|
25
|
+
|
|
26
|
+
So every test here asserts COMPOSITION (which bank the injected memories came
|
|
27
|
+
from), never just a count. A test that passes when all six injected memories
|
|
28
|
+
come from the profile bank is not a test for this bug.
|
|
29
|
+
|
|
30
|
+
Stdlib-only (unittest + mock); runs under ``python3 -m unittest discover
|
|
31
|
+
tests/``. Harness mirrors ``test_recall_integration.py``.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import io
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import shutil
|
|
38
|
+
import sys
|
|
39
|
+
import tempfile
|
|
40
|
+
import unittest
|
|
41
|
+
from unittest.mock import patch
|
|
42
|
+
|
|
43
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
44
|
+
if SCRIPTS_DIR not in sys.path:
|
|
45
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
46
|
+
|
|
47
|
+
import recall # noqa: E402
|
|
48
|
+
|
|
49
|
+
OWN = "test-bank"
|
|
50
|
+
PROFILE = "ken-profile"
|
|
51
|
+
|
|
52
|
+
# Switchroom's shipped floors, sized against the cap the fleet actually deploys
|
|
53
|
+
# (`defaults.memory.recall.max_memories: 6`, which cascades to
|
|
54
|
+
# HINDSIGHT_RECALL_MAX_MEMORIES and wins over the 8 in settings.json). Kept as
|
|
55
|
+
# named constants so a change to the shipped pair has to come here too.
|
|
56
|
+
SHIPPED_CAP = 6
|
|
57
|
+
SHIPPED_OWN_FLOOR = 2
|
|
58
|
+
SHIPPED_ADDITIONAL_FLOOR = 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _memory(text, score, mem_id=None):
|
|
62
|
+
"""A recall result carrying the engine's combined relevance score."""
|
|
63
|
+
return {
|
|
64
|
+
"text": text,
|
|
65
|
+
"type": "fact",
|
|
66
|
+
"mentioned_at": "2026-01-01",
|
|
67
|
+
"id": mem_id or text,
|
|
68
|
+
"scores": {"final": score},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _PerBankClient:
|
|
73
|
+
"""Fake HindsightClient returning a different result set per bank."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, per_bank, directives=None, bank_exc=None):
|
|
76
|
+
self._per_bank = per_bank
|
|
77
|
+
self._directives = directives or []
|
|
78
|
+
self._bank_exc = bank_exc or {}
|
|
79
|
+
|
|
80
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
81
|
+
return {"items": list(self._directives)}
|
|
82
|
+
|
|
83
|
+
def recall(self, bank_id, query, **kwargs):
|
|
84
|
+
exc = self._bank_exc.get(bank_id)
|
|
85
|
+
if exc is not None:
|
|
86
|
+
raise exc
|
|
87
|
+
# Deep-ish copy: main() stamps a private source-bank key onto results,
|
|
88
|
+
# and a shared list would leak that between calls.
|
|
89
|
+
return {"results": [dict(m) for m in self._per_bank.get(bank_id, [])]}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _run_main_with(client, prompt="what did we decide", config_extra=None):
|
|
93
|
+
hook_input = {
|
|
94
|
+
"prompt": prompt,
|
|
95
|
+
"session_id": "test-session",
|
|
96
|
+
"transcript_path": "",
|
|
97
|
+
"cwd": "/tmp",
|
|
98
|
+
}
|
|
99
|
+
config = {
|
|
100
|
+
"autoRecall": True,
|
|
101
|
+
"bankId": OWN,
|
|
102
|
+
"recallMaxTokens": 1024,
|
|
103
|
+
"recallBudget": "mid",
|
|
104
|
+
"recallContextTurns": 1,
|
|
105
|
+
"recallMaxQueryChars": 800,
|
|
106
|
+
"recallPromptPreamble": "",
|
|
107
|
+
"directivesCacheTtlSeconds": 0,
|
|
108
|
+
"recallAdditionalBanks": [PROFILE],
|
|
109
|
+
}
|
|
110
|
+
if config_extra:
|
|
111
|
+
config.update(config_extra)
|
|
112
|
+
|
|
113
|
+
stdout = io.StringIO()
|
|
114
|
+
stderr = io.StringIO()
|
|
115
|
+
with patch.object(recall, "load_config", return_value=config), patch.object(
|
|
116
|
+
recall, "get_api_url", return_value="http://localhost:18888"
|
|
117
|
+
), patch.object(recall, "HindsightClient", return_value=client), patch.object(
|
|
118
|
+
recall, "ensure_bank_mission", return_value=None
|
|
119
|
+
), patch.object(recall, "write_state", return_value=None), patch(
|
|
120
|
+
"sys.stdin", new=io.StringIO(json.dumps(hook_input))
|
|
121
|
+
), patch("sys.stdout", new=stdout), patch("sys.stderr", new=stderr):
|
|
122
|
+
recall.main()
|
|
123
|
+
|
|
124
|
+
raw = stdout.getvalue()
|
|
125
|
+
if not raw.strip():
|
|
126
|
+
return None, raw
|
|
127
|
+
return json.loads(raw)["hookSpecificOutput"]["additionalContext"], raw
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ReserveBankSlotsUnitTests(unittest.TestCase):
|
|
131
|
+
"""`_reserve_bank_slots` in isolation."""
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _mk(bank, n, base):
|
|
135
|
+
return [
|
|
136
|
+
dict(_memory(f"{bank}-{i}", base - i * 0.001), **{recall.SOURCE_BANK_KEY: bank})
|
|
137
|
+
for i in range(n)
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
def _select(
|
|
141
|
+
self,
|
|
142
|
+
own_n,
|
|
143
|
+
add_n,
|
|
144
|
+
cap=SHIPPED_CAP,
|
|
145
|
+
own_floor=SHIPPED_OWN_FLOOR,
|
|
146
|
+
add_floor=SHIPPED_ADDITIONAL_FLOOR,
|
|
147
|
+
favour=PROFILE,
|
|
148
|
+
):
|
|
149
|
+
"""Run the real `_reserve_bank_slots` under a chosen score regime.
|
|
150
|
+
|
|
151
|
+
`favour` picks which bank's candidates outrank the other's end to end.
|
|
152
|
+
Both regimes matter: the crowd-out this fixes is symmetric in the code
|
|
153
|
+
even though the profile-favoured direction is the one seen in
|
|
154
|
+
production, and a suite that only ever ranks profile above own cannot
|
|
155
|
+
tell a working additional-bank floor from a missing one.
|
|
156
|
+
"""
|
|
157
|
+
hi, lo = (0.9, 0.5) if favour == PROFILE else (0.5, 0.9)
|
|
158
|
+
merged = self._mk(PROFILE, add_n, hi) + self._mk(OWN, own_n, lo)
|
|
159
|
+
recall._sort_by_final_score(merged)
|
|
160
|
+
sel, r_own, r_add = recall._reserve_bank_slots(
|
|
161
|
+
merged, cap, OWN, own_floor, add_floor
|
|
162
|
+
)
|
|
163
|
+
banks = [m[recall.SOURCE_BANK_KEY] for m in sel]
|
|
164
|
+
return sel, banks.count(OWN), banks.count(PROFILE), r_own, r_add
|
|
165
|
+
|
|
166
|
+
def test_profile_bank_cannot_take_every_slot(self):
|
|
167
|
+
"""THE BUG. Profile outranks own on every candidate; without floors the
|
|
168
|
+
head-slice hands it all six slots."""
|
|
169
|
+
merged = self._mk(PROFILE, 10, 0.9) + self._mk(OWN, 10, 0.5)
|
|
170
|
+
recall._sort_by_final_score(merged)
|
|
171
|
+
baseline, _, _ = recall._reserve_bank_slots(merged, SHIPPED_CAP, OWN, 0, 0)
|
|
172
|
+
self.assertEqual(
|
|
173
|
+
[m[recall.SOURCE_BANK_KEY] for m in baseline].count(OWN),
|
|
174
|
+
0,
|
|
175
|
+
"precondition: with floors off the profile bank takes all six slots",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
_, own_n, add_n, r_own, r_add = self._select(10, 10)
|
|
179
|
+
self.assertEqual(own_n, 2, "own bank must keep its reserved floor")
|
|
180
|
+
self.assertEqual(add_n, 4, "the non-reserved slots still go to relevance")
|
|
181
|
+
self.assertEqual(r_own, 2, "both own slots were won by reservation")
|
|
182
|
+
self.assertEqual(r_add, 0)
|
|
183
|
+
|
|
184
|
+
def test_own_bank_cannot_take_every_slot_either(self):
|
|
185
|
+
"""B2 anchor — the ADDITIONAL floor, in the score regime where it is the
|
|
186
|
+
only thing standing between the profile bank and zero slots.
|
|
187
|
+
|
|
188
|
+
Own outranks profile on every candidate here, so the head-slice and the
|
|
189
|
+
own floor both point the same way; delete `additional_take` and the
|
|
190
|
+
profile bank gets nothing. This is the mirror of the test above and the
|
|
191
|
+
one the original suite was missing.
|
|
192
|
+
"""
|
|
193
|
+
merged = self._mk(PROFILE, 10, 0.5) + self._mk(OWN, 10, 0.9)
|
|
194
|
+
recall._sort_by_final_score(merged)
|
|
195
|
+
baseline, _, _ = recall._reserve_bank_slots(merged, SHIPPED_CAP, OWN, 0, 0)
|
|
196
|
+
self.assertEqual(
|
|
197
|
+
[m[recall.SOURCE_BANK_KEY] for m in baseline].count(PROFILE),
|
|
198
|
+
0,
|
|
199
|
+
"precondition: with floors off the own bank takes all six slots",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
_, own_n, add_n, r_own, r_add = self._select(10, 10, favour=OWN)
|
|
203
|
+
self.assertEqual(
|
|
204
|
+
add_n,
|
|
205
|
+
SHIPPED_ADDITIONAL_FLOOR,
|
|
206
|
+
"additional-bank floor did not bind — the profile bank was zeroed",
|
|
207
|
+
)
|
|
208
|
+
self.assertEqual(own_n, 5)
|
|
209
|
+
self.assertEqual(
|
|
210
|
+
r_add,
|
|
211
|
+
SHIPPED_ADDITIONAL_FLOOR,
|
|
212
|
+
"the profile slot was won by reservation, not by score",
|
|
213
|
+
)
|
|
214
|
+
self.assertEqual(r_own, 0)
|
|
215
|
+
|
|
216
|
+
def test_scores_still_decide_composition_at_the_deployed_cap(self):
|
|
217
|
+
"""B1 anchor — floors that consume the whole cap are not floors.
|
|
218
|
+
|
|
219
|
+
At the fleet's deployed cap of 6, floors of 4/2 would sum to the cap:
|
|
220
|
+
`remaining` would be 0, the global-relevance fill loop would never run,
|
|
221
|
+
and every turn where both banks return would produce the identical 4/2
|
|
222
|
+
split no matter what `scores.final` said. `_reservable_slots` bounds the
|
|
223
|
+
floors to half the cap, so composition still moves with the scores —
|
|
224
|
+
asserted here for BOTH the shipped 2/1 and an operator's oversized 4/2.
|
|
225
|
+
"""
|
|
226
|
+
for own_floor, add_floor in ((SHIPPED_OWN_FLOOR, SHIPPED_ADDITIONAL_FLOOR), (4, 2)):
|
|
227
|
+
with self.subTest(floors=(own_floor, add_floor)):
|
|
228
|
+
_, own_hi, _, _, _ = self._select(
|
|
229
|
+
10, 10, own_floor=own_floor, add_floor=add_floor, favour=OWN
|
|
230
|
+
)
|
|
231
|
+
_, own_lo, _, _, _ = self._select(
|
|
232
|
+
10, 10, own_floor=own_floor, add_floor=add_floor, favour=PROFILE
|
|
233
|
+
)
|
|
234
|
+
self.assertGreater(
|
|
235
|
+
own_hi,
|
|
236
|
+
own_lo,
|
|
237
|
+
"composition is identical across score regimes — the floors "
|
|
238
|
+
"have become a fixed quota and scores.final is inert",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def test_floors_never_claim_more_than_half_the_cap(self):
|
|
242
|
+
"""The headroom invariant itself, across caps. At least ceil(cap/2)
|
|
243
|
+
slots are always awarded on pure global relevance, so an oversized floor
|
|
244
|
+
pair can never turn into a quota at any cap an operator picks."""
|
|
245
|
+
for cap in (2, 4, 6, 8, 12):
|
|
246
|
+
with self.subTest(cap=cap):
|
|
247
|
+
# Floors far larger than the cap, i.e. the worst case.
|
|
248
|
+
_, own_n, add_n, r_own, r_add = self._select(
|
|
249
|
+
20, 20, cap=cap, own_floor=99, add_floor=99
|
|
250
|
+
)
|
|
251
|
+
self.assertEqual(own_n + add_n, cap)
|
|
252
|
+
self.assertLessEqual(
|
|
253
|
+
r_own + r_add,
|
|
254
|
+
cap // 2,
|
|
255
|
+
"reservation claimed more than half the cap",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def test_floor_is_not_a_quota_when_own_bank_is_silent(self):
|
|
259
|
+
"""A timed-out own bank must not waste its reserved slots."""
|
|
260
|
+
_, own_n, add_n, _, _ = self._select(0, 10)
|
|
261
|
+
self.assertEqual(own_n, 0)
|
|
262
|
+
self.assertEqual(add_n, 6, "unclaimed own slots go to the profile bank")
|
|
263
|
+
|
|
264
|
+
def test_floor_is_not_a_quota_when_profile_bank_is_silent(self):
|
|
265
|
+
_, own_n, add_n, _, _ = self._select(10, 0)
|
|
266
|
+
self.assertEqual(own_n, 6)
|
|
267
|
+
self.assertEqual(add_n, 0)
|
|
268
|
+
|
|
269
|
+
def test_partial_own_results_take_only_what_they_have(self):
|
|
270
|
+
_, own_n, add_n, _, _ = self._select(1, 10)
|
|
271
|
+
self.assertEqual(own_n, 1)
|
|
272
|
+
self.assertEqual(add_n, 5)
|
|
273
|
+
|
|
274
|
+
def test_zero_floors_are_the_pre_fix_head_slice(self):
|
|
275
|
+
_, own_n, add_n, r_own, r_add = self._select(10, 10, own_floor=0, add_floor=0)
|
|
276
|
+
self.assertEqual(own_n, 0)
|
|
277
|
+
self.assertEqual(add_n, 6)
|
|
278
|
+
self.assertEqual((r_own, r_add), (0, 0))
|
|
279
|
+
|
|
280
|
+
def test_own_floor_wins_when_floors_exceed_the_reservation_budget(self):
|
|
281
|
+
"""Floors of 4/2 against a cap of 4 can't both be honoured. The own
|
|
282
|
+
floor takes the whole reservation budget (half the cap = 2) and the
|
|
283
|
+
additional floor gets nothing — but the other two slots are still
|
|
284
|
+
awarded on relevance, so the profile bank is not shut out."""
|
|
285
|
+
_, own_n, add_n, _, _ = self._select(10, 10, cap=4, own_floor=4, add_floor=2)
|
|
286
|
+
self.assertEqual(own_n, 2, "own floor clamped to the reservation budget")
|
|
287
|
+
self.assertEqual(add_n, 2, "the remaining half went to global relevance")
|
|
288
|
+
|
|
289
|
+
def test_selection_is_returned_in_relevance_order(self):
|
|
290
|
+
"""Reservation changes WHICH memories are injected, never the order."""
|
|
291
|
+
sel, _, _, _, _ = self._select(10, 10)
|
|
292
|
+
scores = [recall._result_final_score(m) for m in sel]
|
|
293
|
+
self.assertEqual(scores, sorted(scores, reverse=True))
|
|
294
|
+
|
|
295
|
+
def test_no_cap_is_passthrough(self):
|
|
296
|
+
merged = self._mk(PROFILE, 3, 0.9) + self._mk(OWN, 3, 0.5)
|
|
297
|
+
sel, r_own, r_add = recall._reserve_bank_slots(merged, 0, OWN, 4, 2)
|
|
298
|
+
self.assertEqual(len(sel), 6)
|
|
299
|
+
self.assertEqual((r_own, r_add), (0, 0))
|
|
300
|
+
|
|
301
|
+
def test_result_set_smaller_than_cap_is_passthrough(self):
|
|
302
|
+
merged = self._mk(PROFILE, 2, 0.9) + self._mk(OWN, 2, 0.5)
|
|
303
|
+
sel, r_own, r_add = recall._reserve_bank_slots(merged, 6, OWN, 4, 2)
|
|
304
|
+
self.assertEqual(len(sel), 4)
|
|
305
|
+
self.assertEqual((r_own, r_add), (0, 0))
|
|
306
|
+
|
|
307
|
+
def test_no_duplicates_and_exact_cap(self):
|
|
308
|
+
_, own_n, add_n, _, _ = self._select(10, 10)
|
|
309
|
+
self.assertEqual(own_n + add_n, 6)
|
|
310
|
+
sel, _, _, _, _ = self._select(10, 10)
|
|
311
|
+
self.assertEqual(len({id(m) for m in sel}), 6)
|
|
312
|
+
|
|
313
|
+
def test_untagged_results_count_as_additional(self):
|
|
314
|
+
"""A result with no stamped source bank must never be credited to the
|
|
315
|
+
own bank — the own-bank number is never optimistic."""
|
|
316
|
+
merged = [_memory(f"untagged-{i}", 0.9 - i * 0.01) for i in range(10)]
|
|
317
|
+
sel, r_own, _ = recall._reserve_bank_slots(merged, 6, OWN, 4, 2)
|
|
318
|
+
self.assertEqual(len(sel), 6)
|
|
319
|
+
self.assertEqual(r_own, 0)
|
|
320
|
+
self.assertEqual(
|
|
321
|
+
recall._injected_bank_composition(sel, OWN)["injected_own_bank_count"], 0
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class _LogTestBase(unittest.TestCase):
|
|
326
|
+
def setUp(self):
|
|
327
|
+
self._tmpdir = tempfile.mkdtemp(prefix="recall-slots-test-")
|
|
328
|
+
self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
|
|
329
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
|
|
330
|
+
|
|
331
|
+
def tearDown(self):
|
|
332
|
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
|
333
|
+
if self._prev is None:
|
|
334
|
+
os.environ.pop("CLAUDE_PLUGIN_DATA", None)
|
|
335
|
+
else:
|
|
336
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
|
|
337
|
+
|
|
338
|
+
def _read_log(self):
|
|
339
|
+
path = os.path.join(self._tmpdir, "state", "recall_log.jsonl")
|
|
340
|
+
if not os.path.isfile(path):
|
|
341
|
+
return []
|
|
342
|
+
with open(path, encoding="utf-8") as f:
|
|
343
|
+
return [json.loads(line) for line in f if line.strip()]
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class SlotReservationIntegrationTests(_LogTestBase):
|
|
347
|
+
"""Wired through main(): the injected block itself must carry own-bank
|
|
348
|
+
memories, not just the right count of memories."""
|
|
349
|
+
|
|
350
|
+
# Every profile memory outranks every own memory — the production shape.
|
|
351
|
+
PROFILE_MEMS = [_memory(f"profile-fact-{i}", 0.99 - i * 0.001) for i in range(10)]
|
|
352
|
+
OWN_MEMS = [_memory(f"own-fact-{i}", 0.5 - i * 0.001) for i in range(10)]
|
|
353
|
+
# The inverted regime. Not hypothetical — `scores.final` is a per-query
|
|
354
|
+
# rerank, so which bank leads flips with the prompt. It is also the only
|
|
355
|
+
# regime in which the ADDITIONAL floor is load-bearing.
|
|
356
|
+
OWN_MEMS_HIGH = [_memory(f"own-fact-{i}", 0.99 - i * 0.001) for i in range(10)]
|
|
357
|
+
PROFILE_MEMS_LOW = [_memory(f"profile-fact-{i}", 0.5 - i * 0.001) for i in range(10)]
|
|
358
|
+
|
|
359
|
+
def _client(self, **kw):
|
|
360
|
+
return _PerBankClient({OWN: self.OWN_MEMS, PROFILE: self.PROFILE_MEMS}, **kw)
|
|
361
|
+
|
|
362
|
+
def _client_own_favoured(self, **kw):
|
|
363
|
+
return _PerBankClient(
|
|
364
|
+
{OWN: self.OWN_MEMS_HIGH, PROFILE: self.PROFILE_MEMS_LOW}, **kw
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def test_without_floors_profile_bank_starves_own_bank(self):
|
|
368
|
+
"""Precondition/regression anchor: this is today's behaviour."""
|
|
369
|
+
ctx, _ = _run_main_with(
|
|
370
|
+
self._client(), config_extra={"recallMaxMemories": SHIPPED_CAP}
|
|
371
|
+
)
|
|
372
|
+
self.assertIsNotNone(ctx)
|
|
373
|
+
self.assertNotIn("own-fact-", ctx)
|
|
374
|
+
e = self._read_log()[0]
|
|
375
|
+
self.assertEqual(e["result_count"], 6)
|
|
376
|
+
self.assertEqual(e["injected_own_bank_count"], 0)
|
|
377
|
+
self.assertEqual(e["injected_additional_bank_count"], 6)
|
|
378
|
+
|
|
379
|
+
def test_without_floors_own_bank_starves_profile_bank(self):
|
|
380
|
+
"""The mirror precondition, in the inverted score regime — without the
|
|
381
|
+
additional floor the profile bank reaches the prompt zero times."""
|
|
382
|
+
ctx, _ = _run_main_with(
|
|
383
|
+
self._client_own_favoured(), config_extra={"recallMaxMemories": SHIPPED_CAP}
|
|
384
|
+
)
|
|
385
|
+
self.assertIsNotNone(ctx)
|
|
386
|
+
self.assertNotIn("profile-fact-", ctx)
|
|
387
|
+
e = self._read_log()[0]
|
|
388
|
+
self.assertEqual(e["result_count"], 6)
|
|
389
|
+
self.assertEqual(e["injected_own_bank_count"], 6)
|
|
390
|
+
self.assertEqual(e["injected_additional_bank_count"], 0)
|
|
391
|
+
|
|
392
|
+
def test_floors_guarantee_profile_memories_reach_the_prompt(self):
|
|
393
|
+
"""B2 — end-to-end proof that `recallAdditionalBankMinSlots` does
|
|
394
|
+
something. Deleting the knob makes this fail: own outranks profile on
|
|
395
|
+
every candidate, so only the floor puts a profile fact in the prompt."""
|
|
396
|
+
ctx, _ = _run_main_with(
|
|
397
|
+
self._client_own_favoured(),
|
|
398
|
+
config_extra={
|
|
399
|
+
"recallMaxMemories": SHIPPED_CAP,
|
|
400
|
+
"recallOwnBankMinSlots": SHIPPED_OWN_FLOOR,
|
|
401
|
+
"recallAdditionalBankMinSlots": SHIPPED_ADDITIONAL_FLOOR,
|
|
402
|
+
},
|
|
403
|
+
)
|
|
404
|
+
self.assertIsNotNone(ctx)
|
|
405
|
+
profile_lines = [ln for ln in ctx.splitlines() if "profile-fact-" in ln]
|
|
406
|
+
self.assertEqual(
|
|
407
|
+
len(profile_lines),
|
|
408
|
+
SHIPPED_ADDITIONAL_FLOOR,
|
|
409
|
+
"additional-bank floor did not reach the injected block",
|
|
410
|
+
)
|
|
411
|
+
e = self._read_log()[0]
|
|
412
|
+
self.assertEqual(e["injected_additional_bank_count"], SHIPPED_ADDITIONAL_FLOOR)
|
|
413
|
+
self.assertEqual(e["injected_own_bank_count"], 5)
|
|
414
|
+
self.assertEqual(
|
|
415
|
+
e["reserved_additional_slots"],
|
|
416
|
+
SHIPPED_ADDITIONAL_FLOOR,
|
|
417
|
+
"the profile slot must be recorded as won by reservation, not score",
|
|
418
|
+
)
|
|
419
|
+
self.assertEqual(e["reserved_own_slots"], 0)
|
|
420
|
+
|
|
421
|
+
def test_floors_guarantee_own_bank_memories_reach_the_prompt(self):
|
|
422
|
+
ctx, _ = _run_main_with(
|
|
423
|
+
self._client(),
|
|
424
|
+
config_extra={
|
|
425
|
+
"recallMaxMemories": SHIPPED_CAP,
|
|
426
|
+
"recallOwnBankMinSlots": SHIPPED_OWN_FLOOR,
|
|
427
|
+
"recallAdditionalBankMinSlots": SHIPPED_ADDITIONAL_FLOOR,
|
|
428
|
+
},
|
|
429
|
+
)
|
|
430
|
+
self.assertIsNotNone(ctx)
|
|
431
|
+
own_lines = [ln for ln in ctx.splitlines() if "own-fact-" in ln]
|
|
432
|
+
profile_lines = [ln for ln in ctx.splitlines() if "profile-fact-" in ln]
|
|
433
|
+
self.assertEqual(
|
|
434
|
+
len(own_lines), SHIPPED_OWN_FLOOR, "own bank did not get its floor"
|
|
435
|
+
)
|
|
436
|
+
# The other four are still won on relevance, not handed out by quota.
|
|
437
|
+
self.assertEqual(len(profile_lines), 4)
|
|
438
|
+
|
|
439
|
+
def test_log_row_records_injected_bank_composition(self):
|
|
440
|
+
"""The field that would have caught the own-bank timeout outage."""
|
|
441
|
+
_run_main_with(
|
|
442
|
+
self._client(),
|
|
443
|
+
config_extra={
|
|
444
|
+
"recallMaxMemories": SHIPPED_CAP,
|
|
445
|
+
"recallOwnBankMinSlots": SHIPPED_OWN_FLOOR,
|
|
446
|
+
"recallAdditionalBankMinSlots": SHIPPED_ADDITIONAL_FLOOR,
|
|
447
|
+
},
|
|
448
|
+
)
|
|
449
|
+
e = self._read_log()[0]
|
|
450
|
+
self.assertEqual(e["result_count"], 6)
|
|
451
|
+
self.assertEqual(e["injected_own_bank_count"], SHIPPED_OWN_FLOOR)
|
|
452
|
+
self.assertEqual(e["injected_additional_bank_count"], 4)
|
|
453
|
+
self.assertEqual(e["reserved_own_slots"], SHIPPED_OWN_FLOOR)
|
|
454
|
+
self.assertEqual(e["reserved_additional_slots"], 0)
|
|
455
|
+
|
|
456
|
+
def test_own_bank_timeout_is_visible_in_the_log_row(self):
|
|
457
|
+
"""A fully timed-out own bank must NOT look healthy: result_count is
|
|
458
|
+
still 6, but the composition fields expose the collapse."""
|
|
459
|
+
import socket
|
|
460
|
+
|
|
461
|
+
_run_main_with(
|
|
462
|
+
self._client(bank_exc={OWN: socket.timeout("timed out")}),
|
|
463
|
+
config_extra={
|
|
464
|
+
"recallMaxMemories": SHIPPED_CAP,
|
|
465
|
+
"recallOwnBankMinSlots": SHIPPED_OWN_FLOOR,
|
|
466
|
+
"recallAdditionalBankMinSlots": SHIPPED_ADDITIONAL_FLOOR,
|
|
467
|
+
},
|
|
468
|
+
)
|
|
469
|
+
e = self._read_log()[0]
|
|
470
|
+
self.assertEqual(e["result_count"], 6, "volume telemetry still reads healthy")
|
|
471
|
+
self.assertEqual(e["injected_own_bank_count"], 0, "composition exposes it")
|
|
472
|
+
self.assertEqual(e["injected_additional_bank_count"], 6)
|
|
473
|
+
self.assertTrue(e["deadline_hit"])
|
|
474
|
+
|
|
475
|
+
def test_source_bank_key_never_leaks_into_the_injected_block(self):
|
|
476
|
+
ctx, _ = _run_main_with(
|
|
477
|
+
self._client(),
|
|
478
|
+
config_extra={
|
|
479
|
+
"recallMaxMemories": SHIPPED_CAP,
|
|
480
|
+
"recallOwnBankMinSlots": SHIPPED_OWN_FLOOR,
|
|
481
|
+
"recallAdditionalBankMinSlots": SHIPPED_ADDITIONAL_FLOOR,
|
|
482
|
+
},
|
|
483
|
+
)
|
|
484
|
+
self.assertNotIn(recall.SOURCE_BANK_KEY, ctx)
|
|
485
|
+
self.assertNotIn("ken-profile", ctx)
|
|
486
|
+
|
|
487
|
+
def test_serial_mode_tags_source_banks_too(self):
|
|
488
|
+
"""The serial rollback path (HINDSIGHT_RECALL_PARALLEL=false) must not
|
|
489
|
+
silently lose reservation."""
|
|
490
|
+
ctx, _ = _run_main_with(
|
|
491
|
+
self._client(),
|
|
492
|
+
config_extra={
|
|
493
|
+
"recallMaxMemories": SHIPPED_CAP,
|
|
494
|
+
"recallOwnBankMinSlots": SHIPPED_OWN_FLOOR,
|
|
495
|
+
"recallAdditionalBankMinSlots": SHIPPED_ADDITIONAL_FLOOR,
|
|
496
|
+
"recallParallel": False,
|
|
497
|
+
},
|
|
498
|
+
)
|
|
499
|
+
self.assertIsNotNone(ctx)
|
|
500
|
+
self.assertEqual(
|
|
501
|
+
len([ln for ln in ctx.splitlines() if "own-fact-" in ln]), SHIPPED_OWN_FLOOR
|
|
502
|
+
)
|
|
503
|
+
e = self._read_log()[0]
|
|
504
|
+
self.assertEqual(e["recall_mode"], "serial")
|
|
505
|
+
self.assertEqual(e["injected_own_bank_count"], SHIPPED_OWN_FLOOR)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
if __name__ == "__main__":
|
|
509
|
+
unittest.main()
|
|
@@ -9,8 +9,10 @@ Two single-concern guarantees:
|
|
|
9
9
|
the one recorded in ``recall_log.jsonl`` (acceptance: no ``<channel``
|
|
10
10
|
substring in the logged query). The multi-turn fixture is a regression
|
|
11
11
|
guard for the future ``recallContextTurns`` default flip (A2): it locks
|
|
12
|
-
in that the composed query — both its
|
|
13
|
-
trailing latest-query segment — is envelope-free.
|
|
12
|
+
in that the composed query — both its prior-context turns and its
|
|
13
|
+
trailing latest-query segment — is envelope-free. Since #3757 the wire
|
|
14
|
+
query is additionally term-shaped, so these assert the hygiene invariant
|
|
15
|
+
(which terms may appear) rather than a byte-exact string.
|
|
14
16
|
|
|
15
17
|
2. Telemetry — every non-cache recall log carries per-bank latency +
|
|
16
18
|
timeout flags, directives-fetch latency, total critical-path wall time,
|
|
@@ -130,7 +132,14 @@ class SingleTurnEnvelopeStrip(unittest.TestCase):
|
|
|
130
132
|
self.assertEqual(len(wrapped_client.queries), 1)
|
|
131
133
|
self.assertEqual(len(bare_client.queries), 1)
|
|
132
134
|
self.assertEqual(wrapped_client.queries[0], bare_client.queries[0])
|
|
133
|
-
|
|
135
|
+
# #3757 shapes the wire query (drops stopwords, caps BM25 terms), so
|
|
136
|
+
# this is no longer byte-identical to BARE. The guarantee under test
|
|
137
|
+
# is envelope hygiene: every surviving term came from the inner text,
|
|
138
|
+
# and the question's content words are all still there.
|
|
139
|
+
terms = wrapped_client.queries[0].lower().split()
|
|
140
|
+
self.assertTrue(set(terms) <= set(BARE.split()), terms)
|
|
141
|
+
for word in ("decide", "auth", "flow"):
|
|
142
|
+
self.assertIn(word, terms)
|
|
134
143
|
|
|
135
144
|
def test_no_channel_substring_or_attrs_in_query(self):
|
|
136
145
|
client = _RecordingClient(memories=[_memory("m")])
|
|
@@ -212,8 +221,16 @@ class MultiTurnComposedEnvelopeStrip(unittest.TestCase):
|
|
|
212
221
|
q = client.queries[0]
|
|
213
222
|
self.assertNotIn("<channel", q)
|
|
214
223
|
self.assertNotIn("chat_id", q)
|
|
215
|
-
|
|
216
|
-
|
|
224
|
+
# #3757: the "Prior context:" header is scaffolding, not content —
|
|
225
|
+
# it is no longer on the wire (it cost two BM25 terms and matched
|
|
226
|
+
# a large fraction of every bank). What must survive is the prior
|
|
227
|
+
# turns' CONTENT, which is what this test actually guards.
|
|
228
|
+
self.assertNotIn("Prior context:", q)
|
|
229
|
+
self.assertIn("ORCHID", q)
|
|
230
|
+
self.assertIn("ORCHID_PRIMARY", q)
|
|
231
|
+
# The latest turn's content words are the tail of the wire query
|
|
232
|
+
# (shaping preserves source order and drops only stopwords here).
|
|
233
|
+
self.assertTrue(q.rstrip().endswith("decide auth flow"), q)
|
|
217
234
|
finally:
|
|
218
235
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
219
236
|
|