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,241 @@
|
|
|
1
|
+
"""Switchroom: the per-bank recall request timeout is a MANAGED KEY, not a literal.
|
|
2
|
+
|
|
3
|
+
`recall.py` used to pass a bare `timeout=8` to every `client.recall()` call.
|
|
4
|
+
That literal lived inside the vendored plugin tree, which `switchroom apply`
|
|
5
|
+
rm's and re-copies — so an operator who needed a different value had to
|
|
6
|
+
hand-edit the installed plugin and watch the next apply silently revert it. The
|
|
7
|
+
live fleet did exactly that, three separate times, each time quietly restoring
|
|
8
|
+
shipped defaults while switchroom.yaml read as though it were tuned.
|
|
9
|
+
|
|
10
|
+
It is now `recallRequestTimeoutSeconds` (env:
|
|
11
|
+
HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS), resolved from
|
|
12
|
+
`memory.recall.request_timeout_seconds` and stamped/exported by switchroom.
|
|
13
|
+
|
|
14
|
+
The assertions here are on the VALUE THAT REACHES THE CLIENT, because that is
|
|
15
|
+
the thing the bug got wrong. Asserting only that the config key parses would
|
|
16
|
+
stay green with the literal still hard-coded at the callsite.
|
|
17
|
+
|
|
18
|
+
Stdlib-only (unittest); runs under ``python3 -m unittest discover tests/``
|
|
19
|
+
from ``scripts/``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import io
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import shutil
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import unittest
|
|
29
|
+
from unittest.mock import patch
|
|
30
|
+
|
|
31
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
32
|
+
if SCRIPTS_DIR not in sys.path:
|
|
33
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
34
|
+
|
|
35
|
+
import recall # noqa: E402
|
|
36
|
+
from lib.config import DEFAULTS, ENV_OVERRIDES, load_config # noqa: E402
|
|
37
|
+
|
|
38
|
+
BARE = "what did we decide about the auth flow last week"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _RecordingClient:
|
|
42
|
+
"""Captures the `timeout` kwarg of every recall() call."""
|
|
43
|
+
|
|
44
|
+
def __init__(self):
|
|
45
|
+
self.timeouts = []
|
|
46
|
+
|
|
47
|
+
def recall(self, **kwargs):
|
|
48
|
+
self.timeouts.append(kwargs.get("timeout"))
|
|
49
|
+
return {"memories": []}
|
|
50
|
+
|
|
51
|
+
def list_directives(self, *a, **kw):
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _Harness(unittest.TestCase):
|
|
56
|
+
def setUp(self):
|
|
57
|
+
self._tmpdir = tempfile.mkdtemp(prefix="recall-req-timeout-test-")
|
|
58
|
+
self._prev = os.environ.get("CLAUDE_PLUGIN_DATA")
|
|
59
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
|
|
60
|
+
|
|
61
|
+
def tearDown(self):
|
|
62
|
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
|
63
|
+
if self._prev is None:
|
|
64
|
+
os.environ.pop("CLAUDE_PLUGIN_DATA", None)
|
|
65
|
+
else:
|
|
66
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._prev
|
|
67
|
+
|
|
68
|
+
def _timeouts(self, config_extra=None):
|
|
69
|
+
client = _RecordingClient()
|
|
70
|
+
hook_input = {
|
|
71
|
+
"prompt": BARE,
|
|
72
|
+
"session_id": "test-session",
|
|
73
|
+
"transcript_path": "",
|
|
74
|
+
"cwd": "/tmp",
|
|
75
|
+
}
|
|
76
|
+
config = {
|
|
77
|
+
"autoRecall": True,
|
|
78
|
+
"bankId": "own-bank",
|
|
79
|
+
"recallMaxTokens": 1024,
|
|
80
|
+
"recallBudget": "mid",
|
|
81
|
+
"recallContextTurns": 1,
|
|
82
|
+
"recallMaxQueryChars": 800,
|
|
83
|
+
"recallPromptPreamble": "",
|
|
84
|
+
}
|
|
85
|
+
if config_extra:
|
|
86
|
+
config.update(config_extra)
|
|
87
|
+
with patch.object(recall, "load_config", return_value=config), patch.object(
|
|
88
|
+
recall, "get_api_url", return_value="http://localhost:18888"
|
|
89
|
+
), patch.object(recall, "HindsightClient", return_value=client), patch.object(
|
|
90
|
+
recall, "ensure_bank_mission", return_value=None
|
|
91
|
+
), patch.object(recall, "write_state", return_value=None), patch(
|
|
92
|
+
"sys.stdin", new=io.StringIO(json.dumps(hook_input))
|
|
93
|
+
), patch("sys.stdout", new=io.StringIO()), patch(
|
|
94
|
+
"sys.stderr", new=io.StringIO()
|
|
95
|
+
):
|
|
96
|
+
recall.main()
|
|
97
|
+
return client.timeouts
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PerBankTimeoutIsConfigured(_Harness):
|
|
101
|
+
def test_default_matches_the_shipped_default(self):
|
|
102
|
+
# Promotion to a managed key must not change what switchroom ships:
|
|
103
|
+
# with the key absent from config, the value reaching the client is
|
|
104
|
+
# exactly DEFAULTS["recallRequestTimeoutSeconds"] — not the client
|
|
105
|
+
# library's own default, and not None.
|
|
106
|
+
self.assertEqual(
|
|
107
|
+
self._timeouts(), [float(DEFAULTS["recallRequestTimeoutSeconds"])]
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def test_configured_value_reaches_the_client(self):
|
|
111
|
+
self.assertEqual(
|
|
112
|
+
self._timeouts({"recallRequestTimeoutSeconds": 13}), [13.0]
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def test_applies_to_every_bank_in_a_fan_out(self):
|
|
116
|
+
# One hung bank must not be able to outlive its siblings' budget, so
|
|
117
|
+
# the value has to reach EVERY bank, not just the own-bank slot.
|
|
118
|
+
got = self._timeouts(
|
|
119
|
+
{
|
|
120
|
+
"recallRequestTimeoutSeconds": 5,
|
|
121
|
+
"recallAdditionalBanks": ["shared-a", "shared-b"],
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
self.assertEqual(len(got), 3)
|
|
125
|
+
self.assertEqual(set(got), {5.0})
|
|
126
|
+
|
|
127
|
+
def test_applies_on_the_serial_rollback_path_too(self):
|
|
128
|
+
got = self._timeouts(
|
|
129
|
+
{"recallRequestTimeoutSeconds": 7, "recallParallel": False}
|
|
130
|
+
)
|
|
131
|
+
self.assertEqual(got, [7.0])
|
|
132
|
+
|
|
133
|
+
def test_unusable_values_fall_back_instead_of_breaking_recall(self):
|
|
134
|
+
# A malformed or non-positive timeout must not take the pre-turn hook
|
|
135
|
+
# down to a traceback, and must never become "fail instantly".
|
|
136
|
+
for bad in ("not-a-number", None, 0, -3):
|
|
137
|
+
with self.subTest(bad=bad):
|
|
138
|
+
self.assertEqual(
|
|
139
|
+
self._timeouts({"recallRequestTimeoutSeconds": bad}),
|
|
140
|
+
[float(DEFAULTS["recallRequestTimeoutSeconds"])],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ManagedKeyIsWired(unittest.TestCase):
|
|
145
|
+
def test_key_has_a_default_and_an_env_override(self):
|
|
146
|
+
# Both halves are required for the key to be settable declaratively:
|
|
147
|
+
# the default is the shipped value, the env override is what lets
|
|
148
|
+
# switchroom.yaml outrank a stale ~/.hindsight/claude-code.json.
|
|
149
|
+
# 12s, raised from the historical 8 by #3760 after measuring that 8s
|
|
150
|
+
# fired on 96.8% of one agent's own-bank recalls and returned nothing.
|
|
151
|
+
# Pinned as a literal on purpose: src/setup/hindsight-recall-tunables.ts
|
|
152
|
+
# carries the same number for the env export, which OUTRANKS this file,
|
|
153
|
+
# and hindsight-reranker-budget.test.ts fails CI if the two drift.
|
|
154
|
+
self.assertEqual(DEFAULTS["recallRequestTimeoutSeconds"], 12)
|
|
155
|
+
self.assertIn("HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS", ENV_OVERRIDES)
|
|
156
|
+
key, typ = ENV_OVERRIDES["HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS"]
|
|
157
|
+
self.assertEqual(key, "recallRequestTimeoutSeconds")
|
|
158
|
+
# int, matching the sibling HINDSIGHT_RECALL_PARALLEL_DEADLINE_SECONDS
|
|
159
|
+
# override and switchroom's zod schema, which declares
|
|
160
|
+
# `memory.recall.request_timeout_seconds` as `.int()`
|
|
161
|
+
# (src/config/schema.ts). A float cast here would accept a value the
|
|
162
|
+
# schema can never produce; an int cast against a float-typed schema
|
|
163
|
+
# would silently DROP the override (`_cast_env` returns None and the
|
|
164
|
+
# assignment is skipped), which is the exact silent-fallthrough this
|
|
165
|
+
# test class exists to prevent.
|
|
166
|
+
self.assertIs(typ, int)
|
|
167
|
+
|
|
168
|
+
def test_env_override_wins_over_the_built_in_default(self):
|
|
169
|
+
with patch.dict(
|
|
170
|
+
os.environ,
|
|
171
|
+
{"HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS": "11"},
|
|
172
|
+
clear=False,
|
|
173
|
+
):
|
|
174
|
+
self.assertEqual(load_config()["recallRequestTimeoutSeconds"], 11)
|
|
175
|
+
self.assertNotEqual(
|
|
176
|
+
load_config()["recallRequestTimeoutSeconds"],
|
|
177
|
+
DEFAULTS["recallRequestTimeoutSeconds"],
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class EnvBeatsTheUserConfigFile(unittest.TestCase):
|
|
182
|
+
"""The env export is the ONLY carrier that outranks ~/.hindsight/claude-code.json.
|
|
183
|
+
|
|
184
|
+
Load order (load_config, lib/config.py:398-455):
|
|
185
|
+
|
|
186
|
+
DEFAULTS -> plugin settings.json -> ~/.hindsight/claude-code.json -> env
|
|
187
|
+
|
|
188
|
+
Switchroom stamps the envelope into the plugin's settings.json too, but
|
|
189
|
+
claude-code.json sits ABOVE that stamp and SURVIVES `switchroom apply` — the
|
|
190
|
+
live fleet had exactly such a file pinning recallParallelDeadlineSeconds to a
|
|
191
|
+
stale 16. A settings-only stamp is therefore silently defeated, which is why
|
|
192
|
+
start.sh exports these two UNCONDITIONALLY. These tests pin that precedence
|
|
193
|
+
so a future "just make it conditional / drop the export" change fails loudly
|
|
194
|
+
instead of quietly handing authority back to a hand-edited file.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
ENVELOPE = (
|
|
198
|
+
("HINDSIGHT_RECALL_PARALLEL_DEADLINE_SECONDS", "recallParallelDeadlineSeconds", 17, 16),
|
|
199
|
+
("HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS", "recallRequestTimeoutSeconds", 13, 99),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def setUp(self):
|
|
203
|
+
self._tmp_home = tempfile.mkdtemp(prefix="recall-envelope-home-")
|
|
204
|
+
os.makedirs(os.path.join(self._tmp_home, ".hindsight"), exist_ok=True)
|
|
205
|
+
# Point the plugin-root settings.json probe at an empty dir so only the
|
|
206
|
+
# two layers under test are in play.
|
|
207
|
+
self._tmp_plugin = tempfile.mkdtemp(prefix="recall-envelope-plugin-")
|
|
208
|
+
self._stale = {key: stale for _, key, _, stale in self.ENVELOPE}
|
|
209
|
+
with open(
|
|
210
|
+
os.path.join(self._tmp_home, ".hindsight", "claude-code.json"), "w"
|
|
211
|
+
) as f:
|
|
212
|
+
json.dump(self._stale, f)
|
|
213
|
+
|
|
214
|
+
def tearDown(self):
|
|
215
|
+
shutil.rmtree(self._tmp_home, ignore_errors=True)
|
|
216
|
+
shutil.rmtree(self._tmp_plugin, ignore_errors=True)
|
|
217
|
+
|
|
218
|
+
def _patched_env(self, extra):
|
|
219
|
+
env = {"HOME": self._tmp_home, "CLAUDE_PLUGIN_ROOT": self._tmp_plugin}
|
|
220
|
+
env.update(extra)
|
|
221
|
+
return patch.dict(os.environ, env, clear=False)
|
|
222
|
+
|
|
223
|
+
def test_a_stale_user_config_wins_when_nothing_is_exported(self):
|
|
224
|
+
# Establishes that the fixture is real: without the exports the stale
|
|
225
|
+
# hand-edit IS authoritative, so the next test proves something.
|
|
226
|
+
for env_name, key, _, stale in self.ENVELOPE:
|
|
227
|
+
with self.subTest(env_name=env_name), self._patched_env({}):
|
|
228
|
+
os.environ.pop(env_name, None)
|
|
229
|
+
self.assertEqual(load_config()[key], stale)
|
|
230
|
+
|
|
231
|
+
def test_the_exported_envelope_overrides_a_stale_user_config(self):
|
|
232
|
+
exports = {env_name: str(want) for env_name, _, want, _ in self.ENVELOPE}
|
|
233
|
+
with self._patched_env(exports):
|
|
234
|
+
config = load_config()
|
|
235
|
+
for _, key, want, stale in self.ENVELOPE:
|
|
236
|
+
self.assertEqual(config[key], want)
|
|
237
|
+
self.assertNotEqual(config[key], stale)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
if __name__ == "__main__":
|
|
241
|
+
unittest.main()
|
|
@@ -12,10 +12,16 @@ Acceptance guarantees (outcomes, not code paths):
|
|
|
12
12
|
suppresses the fallback entirely (no fallback block, log False) — the
|
|
13
13
|
fact layer is not empty.
|
|
14
14
|
|
|
15
|
-
3. **
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
3. **DOES fire when a bank hit its deadline** (switchroom #3757 — inverted
|
|
16
|
+
from the original #3369 gate). The original rule suppressed the fallback
|
|
17
|
+
on ``deadline_hit`` on the theory that a timeout leaves the fact layer
|
|
18
|
+
unknown. In production a timeout was the COMMON case (96.8% of the
|
|
19
|
+
`overlord` agent's own-bank recalls over the 7 days to 2026-07-27), so the
|
|
20
|
+
rule meant a timed-out turn got neither memories NOR fallback — the agent
|
|
21
|
+
went in blind. A timeout is not an outage: the banks are healthy, we just
|
|
22
|
+
ran out of time, and a bounded transcript grep beats nothing. A hard bank
|
|
23
|
+
ERROR still suppresses it (guarantee 3b) because an unreachable store CAN
|
|
24
|
+
masquerade as an empty fact layer.
|
|
19
25
|
|
|
20
26
|
4. **Byte bound holds.** With a small ``…MaxBytes``, only the transcript TAIL
|
|
21
27
|
is read: a query-matching line that lives only in the (discarded) HEAD is
|
|
@@ -249,8 +255,15 @@ class DoesNotFireWhenFactLayerNotEmpty(_Harness):
|
|
|
249
255
|
self.assertEqual(e["result_count"], 1)
|
|
250
256
|
|
|
251
257
|
|
|
252
|
-
class
|
|
253
|
-
|
|
258
|
+
class FiresWhenDeadlineHit(_Harness):
|
|
259
|
+
"""Switchroom #3757 — a TIMEOUT must not cost the turn its fallback too.
|
|
260
|
+
|
|
261
|
+
This is the exact inversion of the original #3369 gate. Before the fix a
|
|
262
|
+
timed-out recall produced neither memories nor fallback, which on the
|
|
263
|
+
fleet's large banks was the majority of turns.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
def test_timed_out_bank_still_fires_fallback(self):
|
|
254
267
|
transcript = self._write_transcript([
|
|
255
268
|
_nested_line("user", "we should redo the auth flow with PKCE"),
|
|
256
269
|
])
|
|
@@ -268,10 +281,14 @@ class DoesNotFireWhenDeadlineHit(_Harness):
|
|
|
268
281
|
"recallParallelDeadlineSeconds": 0.5,
|
|
269
282
|
},
|
|
270
283
|
)
|
|
271
|
-
self.
|
|
284
|
+
self.assertIsNotNone(
|
|
285
|
+
context, "a timed-out recall must still get the transcript fallback"
|
|
286
|
+
)
|
|
287
|
+
self.assertIn("<hindsight_transcript_fallback>", context)
|
|
288
|
+
self.assertIn("PKCE", context)
|
|
272
289
|
e = self._read_log()[0]
|
|
273
290
|
self.assertTrue(e["deadline_hit"])
|
|
274
|
-
self.
|
|
291
|
+
self.assertTrue(e["transcript_fallback"])
|
|
275
292
|
|
|
276
293
|
|
|
277
294
|
class DoesNotFireWhenBankErrored(_Harness):
|
|
@@ -7,13 +7,16 @@ import pytest
|
|
|
7
7
|
from lib.content import (
|
|
8
8
|
_extract_text_content,
|
|
9
9
|
_is_channel_message_tool,
|
|
10
|
+
_selectivity_score,
|
|
10
11
|
compose_recall_query,
|
|
11
12
|
format_current_time,
|
|
12
13
|
format_memories,
|
|
13
14
|
prepare_retention_transcript,
|
|
15
|
+
shape_recall_query,
|
|
14
16
|
slice_last_turns_by_user_boundary,
|
|
15
17
|
strip_channel_envelope,
|
|
16
18
|
strip_memory_tags,
|
|
19
|
+
tokenize_for_bm25,
|
|
17
20
|
truncate_recall_query,
|
|
18
21
|
)
|
|
19
22
|
|
|
@@ -648,3 +651,218 @@ class TestFormatCurrentTime:
|
|
|
648
651
|
monkeypatch.delenv("TZ", raising=False)
|
|
649
652
|
out = format_current_time()
|
|
650
653
|
assert re.match(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2} (?:AM|PM)", out), out
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# ---------------------------------------------------------------------------
|
|
657
|
+
# Switchroom #3757 — BM25 query shaping
|
|
658
|
+
#
|
|
659
|
+
# The bug these guard: the recall hook composed an 800-char, ~110-token query
|
|
660
|
+
# and Hindsight OR-joined every token into one tsquery. On the live `overlord`
|
|
661
|
+
# bank that matched 119,510 of 135,565 rows and took 14.0s for the 3-arm BM25
|
|
662
|
+
# UNION — past the per-bank client timeout, so 96.8% of that agent's own-bank
|
|
663
|
+
# recalls returned NOTHING. Two of those tokens were scaffolding this hook
|
|
664
|
+
# added itself: `user` (67,363 rows = 50% of the bank) and `assistant`
|
|
665
|
+
# (29,942 = 22%).
|
|
666
|
+
# ---------------------------------------------------------------------------
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _production_shaped_query():
|
|
670
|
+
"""A composed query with the shape recall.py actually produces."""
|
|
671
|
+
msgs = _msgs(
|
|
672
|
+
("user", "the deploy went out this morning but the rollout never reached the fleet"),
|
|
673
|
+
(
|
|
674
|
+
"assistant",
|
|
675
|
+
"I checked the manifest for v0.19.17 and the digest does not match what "
|
|
676
|
+
"the agent container is running, so the pull raced the tag",
|
|
677
|
+
),
|
|
678
|
+
)
|
|
679
|
+
return compose_recall_query(
|
|
680
|
+
"why did recall for the v0.19.17 rollout return v0.18.15 instead",
|
|
681
|
+
msgs,
|
|
682
|
+
recall_context_turns=2,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
class TestComposedQueryHasNoRoleLabels:
|
|
687
|
+
def test_compose_does_not_prefix_role_labels(self):
|
|
688
|
+
# The regression this guards: `context_lines.append(f"{role}: {content}")`.
|
|
689
|
+
msgs = _msgs(("user", "prior question"), ("assistant", "prior answer"))
|
|
690
|
+
result = compose_recall_query("current question", msgs, recall_context_turns=2)
|
|
691
|
+
assert "prior question" in result
|
|
692
|
+
assert "prior answer" in result
|
|
693
|
+
assert "user:" not in result
|
|
694
|
+
assert "assistant:" not in result
|
|
695
|
+
|
|
696
|
+
def test_role_labels_are_not_bm25_tokens(self):
|
|
697
|
+
# The property that actually matters — not "the string is absent" but
|
|
698
|
+
# "the term never reaches the tsquery".
|
|
699
|
+
query = _production_shaped_query()
|
|
700
|
+
shaped = shape_recall_query(query, "why did recall for the v0.19.17 rollout "
|
|
701
|
+
"return v0.18.15 instead")
|
|
702
|
+
terms = set(tokenize_for_bm25(shaped))
|
|
703
|
+
assert "user" not in terms
|
|
704
|
+
assert "assistant" not in terms
|
|
705
|
+
assert "prior" not in terms
|
|
706
|
+
assert "context" not in terms
|
|
707
|
+
|
|
708
|
+
def test_labels_embedded_in_transcript_text_are_still_stripped(self):
|
|
709
|
+
# Defence in depth: an old composed string, or a turn that literally
|
|
710
|
+
# contains a `user:` line, must not smuggle the label back in.
|
|
711
|
+
legacy = "Prior context:\n\nuser: the deploy failed\nassistant: I checked it\n\nwhy"
|
|
712
|
+
terms = set(tokenize_for_bm25(shape_recall_query(legacy, "why")))
|
|
713
|
+
assert "user" not in terms
|
|
714
|
+
assert "assistant" not in terms
|
|
715
|
+
assert "deploy" in terms
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
class TestShapeRecallQuery:
|
|
719
|
+
def test_caps_distinct_bm25_terms(self):
|
|
720
|
+
query = " ".join(f"distinctword{i}" for i in range(200))
|
|
721
|
+
shaped = shape_recall_query(query, "", max_tokens=24)
|
|
722
|
+
assert len(set(tokenize_for_bm25(shaped))) <= 24
|
|
723
|
+
|
|
724
|
+
def test_cap_counts_compound_token_expansion(self):
|
|
725
|
+
# A compound is emitted by the server ALONGSIDE its fragments
|
|
726
|
+
# (`v0.19.17` → v0.19.17, v0, 19, 17), so a naive "count the words we
|
|
727
|
+
# sent" cap would silently ship 4x the terms it promised.
|
|
728
|
+
query = " ".join(f"v0.19.{i}" for i in range(40))
|
|
729
|
+
shaped = shape_recall_query(query, "", max_tokens=24)
|
|
730
|
+
assert len(set(tokenize_for_bm25(shaped))) <= 24
|
|
731
|
+
|
|
732
|
+
def test_cap_is_configurable(self):
|
|
733
|
+
query = " ".join(f"distinctword{i}" for i in range(200))
|
|
734
|
+
assert len(set(tokenize_for_bm25(shape_recall_query(query, "", max_tokens=8)))) <= 8
|
|
735
|
+
assert len(set(tokenize_for_bm25(shape_recall_query(query, "", max_tokens=40)))) <= 40
|
|
736
|
+
|
|
737
|
+
def test_zero_disables_shaping(self):
|
|
738
|
+
query = "Prior context:\n\nsomething\n\nlatest"
|
|
739
|
+
assert shape_recall_query(query, "latest", max_tokens=0) == query
|
|
740
|
+
|
|
741
|
+
def test_prefers_latest_turn_over_prior_context(self):
|
|
742
|
+
# A chronological truncation would keep the OLDEST context and throw
|
|
743
|
+
# away the question the user actually asked, so the latest turn takes
|
|
744
|
+
# the MAJORITY of the budget. But recency is a weight plus a bounded
|
|
745
|
+
# quota, not an absolute tier (#3760 review, Blocker 2): the subject of
|
|
746
|
+
# a conversation routinely sits in the turn BEFORE the one that refers
|
|
747
|
+
# to it as "it"/"that", and an absolute tier starves it every time.
|
|
748
|
+
prior = " ".join(f"stalecontextword{i}" for i in range(60))
|
|
749
|
+
latest = "why does the reaper skip orphaned worktrees"
|
|
750
|
+
query = f"Prior context:\n\n{prior}\n\n{latest}"
|
|
751
|
+
# Every prior token here is strictly MORE selective than anything in
|
|
752
|
+
# the question (they carry digits; the question is plain English), so
|
|
753
|
+
# pure merit ordering would hand prior context all four slots and the
|
|
754
|
+
# user would search for none of what they asked.
|
|
755
|
+
assert _selectivity_score("stalecontextword0") > _selectivity_score("worktrees")
|
|
756
|
+
# The latest-turn reserve is what stops that. At a punishing cap it is
|
|
757
|
+
# only `max_tokens // 3` slots — the question is represented, not
|
|
758
|
+
# preserved whole, which is the honest tradeoff when prior context is
|
|
759
|
+
# genuinely more discriminating.
|
|
760
|
+
tight = tokenize_for_bm25(shape_recall_query(query, latest, max_tokens=4))
|
|
761
|
+
from_question = set(tight) & {"reaper", "skip", "orphaned", "worktrees"}
|
|
762
|
+
assert len(from_question) >= max(1, 4 // 3)
|
|
763
|
+
assert len(set(tight)) <= 4
|
|
764
|
+
# With slack, the latest turn is still fully present and the leftover
|
|
765
|
+
# budget goes to context (which is the point of composing at all).
|
|
766
|
+
loose = set(tokenize_for_bm25(shape_recall_query(query, latest, max_tokens=12)))
|
|
767
|
+
assert {"reaper", "skip", "orphaned", "worktrees"} <= loose
|
|
768
|
+
assert any(t.startswith("stalecontextword") for t in loose)
|
|
769
|
+
assert len(loose) <= 12
|
|
770
|
+
|
|
771
|
+
def test_drops_english_stopwords(self):
|
|
772
|
+
latest = "what did we decide about the worktree reaper"
|
|
773
|
+
terms = set(tokenize_for_bm25(shape_recall_query(latest, latest, max_tokens=24)))
|
|
774
|
+
assert "worktree" in terms
|
|
775
|
+
assert "reaper" in terms
|
|
776
|
+
assert "decide" in terms
|
|
777
|
+
for stop in ("what", "did", "we", "about", "the"):
|
|
778
|
+
assert stop not in terms
|
|
779
|
+
|
|
780
|
+
def test_operator_stop_terms_are_dropped(self):
|
|
781
|
+
# Bank-specific high-df words the generic stoplist cannot know about.
|
|
782
|
+
latest = "the switchroom agent rollout stalled on the reaper"
|
|
783
|
+
terms = set(
|
|
784
|
+
tokenize_for_bm25(
|
|
785
|
+
shape_recall_query(latest, latest, max_tokens=24,
|
|
786
|
+
stop_terms=["switchroom", "agent"])
|
|
787
|
+
)
|
|
788
|
+
)
|
|
789
|
+
assert "reaper" in terms
|
|
790
|
+
assert "rollout" in terms
|
|
791
|
+
assert "switchroom" not in terms
|
|
792
|
+
assert "agent" not in terms
|
|
793
|
+
|
|
794
|
+
def test_short_query_survives_intact(self):
|
|
795
|
+
# No regression for the small-bank / short-prompt case: every content
|
|
796
|
+
# word is kept, so recall quality is unchanged.
|
|
797
|
+
latest = "worktree gc reaper timings"
|
|
798
|
+
terms = set(tokenize_for_bm25(shape_recall_query(latest, latest, max_tokens=24)))
|
|
799
|
+
assert terms == {"worktree", "gc", "reaper", "timings"}
|
|
800
|
+
|
|
801
|
+
def test_all_stopword_query_is_not_emptied(self):
|
|
802
|
+
# A conversational prompt must never be shaped down to nothing — an
|
|
803
|
+
# empty query would return zero memories, the exact failure we are
|
|
804
|
+
# fixing.
|
|
805
|
+
latest = "what about that"
|
|
806
|
+
shaped = shape_recall_query(latest, latest, max_tokens=24)
|
|
807
|
+
assert tokenize_for_bm25(shaped)
|
|
808
|
+
|
|
809
|
+
def test_untokenizable_query_returns_original(self):
|
|
810
|
+
assert shape_recall_query("!!! ???", "!!! ???", max_tokens=24) == "!!! ???"
|
|
811
|
+
|
|
812
|
+
def test_preserves_original_word_order(self):
|
|
813
|
+
latest = "reaper skipped orphaned worktrees before rollout"
|
|
814
|
+
shaped = shape_recall_query(latest, latest, max_tokens=24)
|
|
815
|
+
# "before" is a stopword; the survivors keep the source order.
|
|
816
|
+
assert shaped.split() == ["reaper", "skipped", "orphaned", "worktrees", "rollout"]
|
|
817
|
+
|
|
818
|
+
def test_unparseable_cap_disables_shaping_instead_of_raising(self):
|
|
819
|
+
# max_tokens arrives from settings.json / env. A config error must
|
|
820
|
+
# degrade to "send it unshaped", never raise on the recall hot path.
|
|
821
|
+
latest = "the reaper skipped orphaned worktrees before the rollout"
|
|
822
|
+
for bad in (None, "x", "", [], {}):
|
|
823
|
+
assert shape_recall_query(latest, latest, max_tokens=bad) == latest
|
|
824
|
+
|
|
825
|
+
def test_numeric_string_cap_is_honoured(self):
|
|
826
|
+
latest = "the reaper skipped orphaned worktrees before the rollout"
|
|
827
|
+
shaped = shape_recall_query(latest, latest, max_tokens="3")
|
|
828
|
+
assert len(set(tokenize_for_bm25(shaped))) <= 3
|
|
829
|
+
|
|
830
|
+
def test_stop_terms_given_as_a_bare_string_are_split_not_iterated(self):
|
|
831
|
+
# "reaper,worktrees" iterated as characters would stop-list half the
|
|
832
|
+
# alphabet and gut the query.
|
|
833
|
+
latest = "the reaper skipped orphaned worktrees before the rollout"
|
|
834
|
+
shaped = shape_recall_query(latest, latest, 24, stop_terms="reaper, worktrees")
|
|
835
|
+
terms = set(tokenize_for_bm25(shaped))
|
|
836
|
+
assert "reaper" not in terms
|
|
837
|
+
assert "worktrees" not in terms
|
|
838
|
+
assert {"skipped", "orphaned", "rollout"} <= terms
|
|
839
|
+
|
|
840
|
+
def test_preserves_original_case(self):
|
|
841
|
+
# The shaped string feeds BOTH arms. BM25 lowercases server-side, but
|
|
842
|
+
# the embedding arm does not, so shaping must not flatten `Python` to
|
|
843
|
+
# `python` or `PR` to `pr`.
|
|
844
|
+
latest = "should the Python worker open a PR against Coolify"
|
|
845
|
+
shaped = shape_recall_query(latest, latest, max_tokens=24)
|
|
846
|
+
assert shaped.split() == ["Python", "worker", "open", "PR", "Coolify"]
|
|
847
|
+
|
|
848
|
+
def test_production_shaped_query_fits_the_budget(self):
|
|
849
|
+
query = _production_shaped_query()
|
|
850
|
+
latest = "why did recall for the v0.19.17 rollout return v0.18.15 instead"
|
|
851
|
+
shaped = shape_recall_query(query, latest, max_tokens=24)
|
|
852
|
+
terms = set(tokenize_for_bm25(shaped))
|
|
853
|
+
assert len(terms) <= 24
|
|
854
|
+
# The discriminating identifiers from the latest turn survive.
|
|
855
|
+
assert "v0.19.17" in terms
|
|
856
|
+
assert "rollout" in terms
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
class TestTokenizeForBm25:
|
|
860
|
+
def test_matches_server_tokenizer_on_compounds(self):
|
|
861
|
+
# Mirrors hindsight_api/engine/search/retrieval.py::tokenize_query —
|
|
862
|
+
# fragments PLUS the intact compound.
|
|
863
|
+
tokens = tokenize_for_bm25("bumped to v0.19.17")
|
|
864
|
+
assert "v0.19.17" in tokens
|
|
865
|
+
assert {"v0", "19", "17"} <= set(tokens)
|
|
866
|
+
|
|
867
|
+
def test_empty_for_punctuation_only(self):
|
|
868
|
+
assert tokenize_for_bm25("!!! ???") == []
|