switchroom 0.19.38 → 0.19.39
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/auth-broker/index.js +3 -3
- package/dist/cli/switchroom.js +618 -378
- package/dist/host-control/main.js +4 -4
- package/dist/vault/approvals/kernel-server.js +3 -3
- package/dist/vault/broker/server.js +3 -3
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +140 -19
- package/telegram-plugin/gateway/handback-preturn-signal.ts +16 -0
- package/telegram-plugin/gateway/stream-render.ts +238 -17
- package/telegram-plugin/hooks/tool-label-pretool.mjs +119 -1
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +19 -12
- package/telegram-plugin/tests/fixtures/pretool-main-2.1.219.json +15 -0
- package/telegram-plugin/tests/fixtures/pretool-subagent-2.1.219.json +16 -0
- package/telegram-plugin/tests/queued-card-surface.test.ts +262 -0
- package/telegram-plugin/tests/sidechain-label-filter-pretool.test.ts +272 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +7 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +151 -0
- package/vendor/hindsight-memory/scripts/retain.py +19 -15
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +14 -1
- package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +192 -3
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +18 -2
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +57 -4
|
@@ -6,8 +6,30 @@ variable overrides. Full config schema matching Openclaw's 30+ options.
|
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
|
+
import re
|
|
9
10
|
import sys
|
|
10
11
|
|
|
12
|
+
#: Switchroom-local: strategies for the per-row curated observation scope.
|
|
13
|
+
#: ``curated`` (default) strips volatile provenance tags from the consolidation
|
|
14
|
+
#: scope, keeping stable semantic ones; ``shared`` pools every retain into one
|
|
15
|
+
#: bank-wide untagged scope; ``combined`` / ``off`` emit no per-row scope (the
|
|
16
|
+
#: pre-feature engine default). See ``compute_observation_scopes``.
|
|
17
|
+
OBSERVATION_SCOPE_STRATEGIES = ("curated", "shared", "combined", "off")
|
|
18
|
+
|
|
19
|
+
#: Tag patterns treated as VOLATILE per-session provenance by ``curated``:
|
|
20
|
+
#: ``parent_session:<id>`` and a bare RFC-4122 UUID (what the ``{session_id}``
|
|
21
|
+
#: retain tag resolves to on the parent path). The UUID pattern also matches
|
|
22
|
+
#: the sidechain-derived ``<uuid>-sub-<agent_id>`` form, because
|
|
23
|
+
#: ``subagent_retain.py`` sets ``sub_session_id = f"{session_id}-sub-{agent_id}"``
|
|
24
|
+
#: and the ``{session_id}`` retain tag resolves to that — a per-invocation-unique
|
|
25
|
+
#: value that, if left in scope, defeats cross-session dedup on the dominant
|
|
26
|
+
#: (sidechain) observation path. A tag matching ANY of these is dropped from the
|
|
27
|
+
#: consolidation scope but LEFT on the source fact.
|
|
28
|
+
DEFAULT_VOLATILE_SCOPE_PATTERNS = (
|
|
29
|
+
r"^parent_session:",
|
|
30
|
+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(-sub-.+)?$",
|
|
31
|
+
)
|
|
32
|
+
|
|
11
33
|
DEFAULTS = {
|
|
12
34
|
# Recall
|
|
13
35
|
"autoRecall": True,
|
|
@@ -181,6 +203,37 @@ DEFAULTS = {
|
|
|
181
203
|
# through `defaults.memory.observation_scopes`) via
|
|
182
204
|
# HINDSIGHT_OBSERVATION_SCOPES, exported ONLY when the operator opted in.
|
|
183
205
|
"observationScopes": None,
|
|
206
|
+
# Switchroom hindsight-leverage — CURATED observation scopes (default ON).
|
|
207
|
+
# `combined` (the pre-feature engine default) makes consolidation dedup an
|
|
208
|
+
# observation only against others carrying the IDENTICAL tag set. Because
|
|
209
|
+
# switchroom stamps volatile per-session provenance on every retain
|
|
210
|
+
# (`{session_id}` → a bare UUID tag, and `parent_session:<uuid>` /
|
|
211
|
+
# `sidechain` / `agent_type:*` on sub-agent retains), that turns every
|
|
212
|
+
# session into its own dedup island — cross-session dedup never happens.
|
|
213
|
+
#
|
|
214
|
+
# `curated` (default) strips ONLY the volatile provenance tags
|
|
215
|
+
# (`observationScopeVolatilePatterns`) from the CONSOLIDATION scope, keeping
|
|
216
|
+
# the stable semantic ones (`lesson`, `anti-pattern`, `agent_type:*`,
|
|
217
|
+
# `sidechain`, entities). The stripped tags STAY on the source fact (still
|
|
218
|
+
# queryable / recall-filterable) — only the observation's dedup scope is
|
|
219
|
+
# narrowed. Non-empty stable set → the explicit scope `[[stable…]]`; empty
|
|
220
|
+
# stable set (e.g. a retain tagged only with a session id) → `"shared"`,
|
|
221
|
+
# one bank-wide untagged scope. This preserves recall tag-weighting on the
|
|
222
|
+
# observation layer (the kept tags still ride the observation) while giving
|
|
223
|
+
# cross-session dedup. Docs: https://hindsight.vectorize.io/developer/api/retain
|
|
224
|
+
# ("shared") and /developer/observations (scope isolation via all_strict).
|
|
225
|
+
#
|
|
226
|
+
# Opt-out: set `observationScopeStrategy` to `combined` (or `off`) — both
|
|
227
|
+
# emit no per-row scope, restoring the exact pre-feature engine default. A
|
|
228
|
+
# manually-pinned `observationScopes` (memory.observation_scopes) STILL wins
|
|
229
|
+
# over the strategy, so operators who set `per_tag` / `all_combinations` /
|
|
230
|
+
# `shared` keep that behaviour unchanged.
|
|
231
|
+
"observationScopeStrategy": "curated",
|
|
232
|
+
# Tag patterns treated as VOLATILE (stripped from the curated scope, kept on
|
|
233
|
+
# the source fact). Defaults: `parent_session:<id>` and a bare RFC-4122
|
|
234
|
+
# UUID (what `{session_id}` resolves to). Overridable via settings.json /
|
|
235
|
+
# ~/.hindsight/claude-code.json for a bank with an unusual provenance tag.
|
|
236
|
+
"observationScopeVolatilePatterns": list(DEFAULT_VOLATILE_SCOPE_PATTERNS),
|
|
184
237
|
# Switchroom hindsight-leverage E2 / PR9 (#398) — lesson & anti-pattern
|
|
185
238
|
# tagging at retain time. When on (default), build_retain_payload scans the
|
|
186
239
|
# formatted transcript slice for explicit lesson / anti-pattern markers and
|
|
@@ -333,6 +386,11 @@ ENV_OVERRIDES = {
|
|
|
333
386
|
# defaults.memory.observation_scopes) ONLY when the operator set it; unset
|
|
334
387
|
# leaves `observationScopes` None and the field off the wire entirely.
|
|
335
388
|
"HINDSIGHT_OBSERVATION_SCOPES": ("observationScopes", str),
|
|
389
|
+
# Opt-out / override the curated default. `combined` or `off` restore the
|
|
390
|
+
# pre-feature engine default; `curated` (the shipped default) / `shared`
|
|
391
|
+
# select the computed strategies. Off-list values fall back to `curated`
|
|
392
|
+
# (shouted, never raised) — see compute_observation_scopes.
|
|
393
|
+
"HINDSIGHT_OBSERVATION_SCOPE_STRATEGY": ("observationScopeStrategy", str),
|
|
336
394
|
# Switchroom hindsight-leverage E2 / PR9 (#398) — lesson/anti-pattern tagging
|
|
337
395
|
# + recall demotion toggles and overrides.
|
|
338
396
|
"HINDSIGHT_LESSON_TAGGING": ("lessonTagging", bool),
|
|
@@ -513,6 +571,99 @@ def resolve_observation_scopes(config: dict):
|
|
|
513
571
|
return value
|
|
514
572
|
|
|
515
573
|
|
|
574
|
+
def _volatile_scope_matchers(config: dict):
|
|
575
|
+
"""Compile ``observationScopeVolatilePatterns`` to regexes, skipping bad ones.
|
|
576
|
+
|
|
577
|
+
NEVER raises: a malformed pattern is dropped (and shouted about) rather than
|
|
578
|
+
allowed to kill a retain. Falls back to the built-in defaults when the
|
|
579
|
+
config value is absent or not a list.
|
|
580
|
+
"""
|
|
581
|
+
raw = config.get("observationScopeVolatilePatterns")
|
|
582
|
+
if not isinstance(raw, (list, tuple)):
|
|
583
|
+
raw = DEFAULT_VOLATILE_SCOPE_PATTERNS
|
|
584
|
+
matchers = []
|
|
585
|
+
for pat in raw:
|
|
586
|
+
if not isinstance(pat, str):
|
|
587
|
+
continue
|
|
588
|
+
try:
|
|
589
|
+
matchers.append(re.compile(pat))
|
|
590
|
+
except re.error as e:
|
|
591
|
+
print(
|
|
592
|
+
f"[Hindsight] observationScopeVolatilePatterns entry {pat!r} is not "
|
|
593
|
+
f"a valid regex ({e}); ignoring it for scope curation.",
|
|
594
|
+
file=sys.stderr,
|
|
595
|
+
)
|
|
596
|
+
return matchers
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _is_volatile_scope_tag(tag: str, matchers) -> bool:
|
|
600
|
+
return any(m.search(tag) for m in matchers)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def compute_observation_scopes(tags, config: dict):
|
|
604
|
+
"""Resolve the per-row ``observation_scopes`` value for one retain.
|
|
605
|
+
|
|
606
|
+
Returns ``(value, error)`` — exactly like :func:`classify_observation_scopes`
|
|
607
|
+
— and MUST NEVER RAISE (a bad config must degrade the SCOPE, never lose the
|
|
608
|
+
turn; see ``retain.build_retain_payload``). ``value`` is what goes on the
|
|
609
|
+
wire: ``None`` (omit the field entirely → engine default), a bare string
|
|
610
|
+
(``"shared"`` / an operator-pinned Hindsight scope), or an explicit
|
|
611
|
+
``list[list[str]]`` tag matrix. The wire body is byte-identical to the
|
|
612
|
+
pre-feature client whenever ``value`` is ``None``.
|
|
613
|
+
|
|
614
|
+
Precedence:
|
|
615
|
+
|
|
616
|
+
1. A manually-pinned ``observationScopes`` (``memory.observation_scopes``)
|
|
617
|
+
WINS — its classified value (or its degrade-to-None-with-error on a typo)
|
|
618
|
+
is returned unchanged, so existing operator overrides keep working.
|
|
619
|
+
2. Otherwise ``observationScopeStrategy`` decides:
|
|
620
|
+
|
|
621
|
+
* ``combined`` / ``off`` → ``None`` (pre-feature engine default; opt-out).
|
|
622
|
+
* ``shared`` → ``"shared"`` uniformly.
|
|
623
|
+
* ``curated`` (default) → strip volatile provenance tags from the scope
|
|
624
|
+
(keeping them on the source fact); non-empty stable set → ``[[stable…]]``
|
|
625
|
+
(deterministically sorted), empty stable set → ``"shared"``.
|
|
626
|
+
* anything else → treated as ``curated`` and shouted about.
|
|
627
|
+
|
|
628
|
+
Docs: https://hindsight.vectorize.io/developer/api/retain (``shared`` == the
|
|
629
|
+
explicit ``[[]]`` scope; a custom ``list[list[str]]`` is consolidated with
|
|
630
|
+
``all_strict`` matching so scopes stay isolated) and /developer/observations.
|
|
631
|
+
"""
|
|
632
|
+
# 1. Operator-pinned scope wins (back-compat). Only fall through to the
|
|
633
|
+
# strategy when observationScopes is genuinely UNSET — a typo'd pin
|
|
634
|
+
# degrades to the engine default WITH its error, never silently curated.
|
|
635
|
+
manual, manual_error = classify_observation_scopes(config)
|
|
636
|
+
if manual is not None or manual_error is not None:
|
|
637
|
+
return manual, manual_error
|
|
638
|
+
|
|
639
|
+
strategy_raw = config.get("observationScopeStrategy")
|
|
640
|
+
strategy = strategy_raw.strip().lower() if isinstance(strategy_raw, str) else ""
|
|
641
|
+
if not strategy:
|
|
642
|
+
strategy = "curated"
|
|
643
|
+
|
|
644
|
+
error = None
|
|
645
|
+
if strategy not in OBSERVATION_SCOPE_STRATEGIES:
|
|
646
|
+
error = (
|
|
647
|
+
f"observationScopeStrategy={strategy_raw!r} is not one of "
|
|
648
|
+
f"{', '.join(OBSERVATION_SCOPE_STRATEGIES)}; using 'curated'."
|
|
649
|
+
)
|
|
650
|
+
strategy = "curated"
|
|
651
|
+
|
|
652
|
+
if strategy in ("combined", "off"):
|
|
653
|
+
return None, error
|
|
654
|
+
if strategy == "shared":
|
|
655
|
+
return "shared", error
|
|
656
|
+
|
|
657
|
+
# curated
|
|
658
|
+
matchers = _volatile_scope_matchers(config)
|
|
659
|
+
stable = sorted(
|
|
660
|
+
{t for t in (tags or []) if isinstance(t, str) and t and not _is_volatile_scope_tag(t, matchers)}
|
|
661
|
+
)
|
|
662
|
+
if stable:
|
|
663
|
+
return [stable], error
|
|
664
|
+
return "shared", error
|
|
665
|
+
|
|
666
|
+
|
|
516
667
|
def _cast_env(value: str, typ):
|
|
517
668
|
"""Cast environment variable string to target type. Returns None on failure."""
|
|
518
669
|
try:
|
|
@@ -28,7 +28,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
28
28
|
from lib import watermark
|
|
29
29
|
from lib.bank import derive_bank_id, ensure_bank_mission
|
|
30
30
|
from lib.client import HindsightClient
|
|
31
|
-
from lib.config import
|
|
31
|
+
from lib.config import compute_observation_scopes, debug_log, load_config
|
|
32
32
|
from lib.content import (
|
|
33
33
|
prepare_retention_transcript,
|
|
34
34
|
slice_last_turns_by_user_boundary,
|
|
@@ -267,13 +267,13 @@ def build_retain_payload(
|
|
|
267
267
|
Returns ``{payload, document_id, message_count, last_uuid, ordered_uuids,
|
|
268
268
|
transcript}`` or ``None`` when the slice formats to nothing.
|
|
269
269
|
|
|
270
|
-
NEVER raises on a bad ``observationScopes
|
|
271
|
-
its payload here, so this seam sees the typo —
|
|
272
|
-
memory itself is made at, and a config typo must
|
|
273
|
-
one. An off-list value
|
|
274
|
-
default stands
|
|
275
|
-
on stderr; the memory is still built, still
|
|
276
|
-
failure. See ``lib.config.
|
|
270
|
+
NEVER raises on a bad ``observationScopes`` / ``observationScopeStrategy``.
|
|
271
|
+
Every retain producer builds its payload here, so this seam sees the typo —
|
|
272
|
+
but it is also the seam the memory itself is made at, and a config typo must
|
|
273
|
+
not be able to destroy one. An off-list value degrades the SCOPE (the
|
|
274
|
+
engine's own default stands for a bad pin; ``curated`` stands for a bad
|
|
275
|
+
strategy) and is shouted about on stderr; the memory is still built, still
|
|
276
|
+
POSTed, still queued on failure. See ``lib.config.compute_observation_scopes``.
|
|
277
277
|
"""
|
|
278
278
|
retain_roles = config.get("retainRoles", ["user", "assistant"])
|
|
279
279
|
include_tool_calls = config.get("retainToolCalls", True)
|
|
@@ -367,12 +367,16 @@ def build_retain_payload(
|
|
|
367
367
|
except Exception:
|
|
368
368
|
pass
|
|
369
369
|
|
|
370
|
-
# Per-row observation scope (switchroom).
|
|
371
|
-
#
|
|
372
|
-
#
|
|
373
|
-
#
|
|
374
|
-
#
|
|
375
|
-
#
|
|
370
|
+
# Per-row observation scope (switchroom). Default `curated`: volatile
|
|
371
|
+
# per-session provenance tags are stripped from the CONSOLIDATION scope
|
|
372
|
+
# (kept on the source fact) so observations dedup across sessions instead of
|
|
373
|
+
# per-session — a non-empty stable tag set yields an explicit `[[stable…]]`
|
|
374
|
+
# scope, an all-volatile/empty set yields `"shared"`. A None (opt-out via
|
|
375
|
+
# observationScopeStrategy=combined/off, or a manual observationScopes typo)
|
|
376
|
+
# is dropped at the wire by HindsightClient._retain_one, so that request
|
|
377
|
+
# body is byte-identical to the pre-feature default. Carried ON THE PAYLOAD
|
|
378
|
+
# so it survives the pending-retains queue: a retain that fails and drains
|
|
379
|
+
# hours later must land in the SAME scope it would have landed in inline.
|
|
376
380
|
#
|
|
377
381
|
# CLASSIFIED, NOT VALIDATED. This is the one seam every retain producer
|
|
378
382
|
# funnels through, which makes it the tempting place to reject a typo — and
|
|
@@ -388,7 +392,7 @@ def build_retain_payload(
|
|
|
388
392
|
# So a bad value degrades to the PRE-FEATURE behaviour (field omitted, the
|
|
389
393
|
# engine's own default scope stands) and is shouted about on stderr. Wrong
|
|
390
394
|
# scope is recoverable; a lost turn is not.
|
|
391
|
-
scope, scope_error =
|
|
395
|
+
scope, scope_error = compute_observation_scopes(tags, config)
|
|
392
396
|
if scope_error:
|
|
393
397
|
print(
|
|
394
398
|
f"[Hindsight] observation_scopes IGNORED for this retain: {scope_error} "
|
|
@@ -172,12 +172,25 @@ class TestBackfill(BackfillTestBase):
|
|
|
172
172
|
self.assertTrue(all(async_flag is False for _, async_flag in self.daemon.posts))
|
|
173
173
|
|
|
174
174
|
# -- switchroom: per-row observation scope on the backfill path ---------
|
|
175
|
-
def
|
|
175
|
+
def test_backfill_omits_the_scope_when_opted_out(self):
|
|
176
|
+
# The curated default now emits a scope on every path; the opt-out
|
|
177
|
+
# (observationScopeStrategy=combined) must stay byte-identical to the
|
|
178
|
+
# pre-feature body — no scope on the wire, engine default in force.
|
|
179
|
+
os.environ["HINDSIGHT_OBSERVATION_SCOPE_STRATEGY"] = "combined"
|
|
180
|
+
self.addCleanup(os.environ.pop, "HINDSIGHT_OBSERVATION_SCOPE_STRATEGY", None)
|
|
176
181
|
self._transcript("clerk", "sess-plain", 4)
|
|
177
182
|
bf.Backfill(self._config(), commit=True, delay_ms=0).run()
|
|
178
183
|
self.assertTrue(self.daemon.observation_scopes_seen)
|
|
179
184
|
self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
|
|
180
185
|
|
|
186
|
+
def test_backfill_curates_the_scope_by_default(self):
|
|
187
|
+
# Default ON: every recovered slice carries a curated scope; a miss here
|
|
188
|
+
# would silently drop the backfill path back to the pre-feature default.
|
|
189
|
+
self._transcript("clerk", "sess-plain", 4)
|
|
190
|
+
bf.Backfill(self._config(), commit=True, delay_ms=0).run()
|
|
191
|
+
self.assertTrue(self.daemon.observation_scopes_seen)
|
|
192
|
+
self.assertTrue(all(s is not None for s in self.daemon.observation_scopes_seen))
|
|
193
|
+
|
|
181
194
|
def test_backfill_posts_the_configured_scope(self):
|
|
182
195
|
# The backfill enumerates its own retain kwargs; a miss here would
|
|
183
196
|
# scatter every recovered historical slice into per-tag scopes while
|
|
@@ -38,6 +38,8 @@ import retain # noqa: E402
|
|
|
38
38
|
from lib.client import HindsightClient # noqa: E402
|
|
39
39
|
from lib.config import ( # noqa: E402
|
|
40
40
|
OBSERVATION_SCOPES_VALUES,
|
|
41
|
+
OBSERVATION_SCOPE_STRATEGIES,
|
|
42
|
+
compute_observation_scopes,
|
|
41
43
|
load_config,
|
|
42
44
|
resolve_observation_scopes,
|
|
43
45
|
)
|
|
@@ -134,6 +136,18 @@ class ConfigResolution(unittest.TestCase):
|
|
|
134
136
|
clear=True):
|
|
135
137
|
self.assertEqual(load_config().get("observationScopes"), "shared")
|
|
136
138
|
|
|
139
|
+
def test_strategy_default_is_curated(self):
|
|
140
|
+
# switchroom ships curated ON out of the box — a fresh install with no
|
|
141
|
+
# settings.json key and no env override still gets the curated scope.
|
|
142
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
143
|
+
self.assertEqual(load_config().get("observationScopeStrategy"), "curated")
|
|
144
|
+
|
|
145
|
+
def test_strategy_env_override_opts_out(self):
|
|
146
|
+
with mock.patch.dict(
|
|
147
|
+
os.environ, {"HINDSIGHT_OBSERVATION_SCOPE_STRATEGY": "combined"}, clear=True
|
|
148
|
+
):
|
|
149
|
+
self.assertEqual(load_config().get("observationScopeStrategy"), "combined")
|
|
150
|
+
|
|
137
151
|
|
|
138
152
|
class PayloadBuild(unittest.TestCase):
|
|
139
153
|
"""``build_retain_payload`` is the single producer for every retain path."""
|
|
@@ -147,10 +161,33 @@ class PayloadBuild(unittest.TestCase):
|
|
|
147
161
|
bank_id="bank", api_url="http://fake", api_token=None,
|
|
148
162
|
)["payload"]
|
|
149
163
|
|
|
150
|
-
def
|
|
151
|
-
|
|
164
|
+
def test_payload_omits_scope_when_opted_out(self):
|
|
165
|
+
# Opt-out (strategy=combined) is byte-identical to the pre-feature body:
|
|
166
|
+
# no scope on the payload, so the engine default stands.
|
|
167
|
+
self.assertIsNone(
|
|
168
|
+
self._build({"observationScopeStrategy": "combined"})["observation_scopes"]
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def test_payload_curates_the_scope_by_default(self):
|
|
172
|
+
# No strategy key at all → the shipped default (curated) fires, so a
|
|
173
|
+
# scope IS carried. A retain carrying only volatile/no stable tags
|
|
174
|
+
# curates down to the bank-wide "shared" scope.
|
|
175
|
+
self.assertEqual(self._build({})["observation_scopes"], "shared")
|
|
176
|
+
|
|
177
|
+
def test_payload_curates_stable_tags_into_an_explicit_scope(self):
|
|
178
|
+
# A stable semantic tag survives onto the consolidation scope as an
|
|
179
|
+
# explicit list-of-lists, while a volatile session-id tag is stripped.
|
|
180
|
+
payload = self._build(
|
|
181
|
+
{"retainTags": ["team:acme", "{session_id}"]}
|
|
182
|
+
)
|
|
183
|
+
# {session_id} → "sess" (not a UUID / parent_session:*), so it is NOT
|
|
184
|
+
# volatile here and rides the scope alongside the stable tag.
|
|
185
|
+
self.assertEqual(
|
|
186
|
+
sorted(payload["observation_scopes"][0]), ["sess", "team:acme"]
|
|
187
|
+
)
|
|
152
188
|
|
|
153
|
-
def
|
|
189
|
+
def test_payload_carries_the_manually_pinned_scope(self):
|
|
190
|
+
# A hand-pinned observationScopes still wins over the strategy.
|
|
154
191
|
self.assertEqual(
|
|
155
192
|
self._build({"observationScopes": "shared"})["observation_scopes"],
|
|
156
193
|
"shared",
|
|
@@ -205,6 +242,16 @@ class DrainOfQueuedEntries(unittest.TestCase):
|
|
|
205
242
|
drain_pending._retry_one(entry, timeout=15)
|
|
206
243
|
self.assertEqual(self.calls[0]["observation_scopes"], "shared")
|
|
207
244
|
|
|
245
|
+
def test_curated_list_scope_survives_the_queue_round_trip(self):
|
|
246
|
+
# The curated default emits a list[list[str]] scope. A retain that fails
|
|
247
|
+
# inline and drains hours later must land in the SAME curated scope, so
|
|
248
|
+
# the list value has to survive JSON on disk and reach client.retain
|
|
249
|
+
# intact — otherwise a retried turn shards away from its inline siblings.
|
|
250
|
+
scope = [["agent_type:worker", "sidechain"]]
|
|
251
|
+
entry = json.loads(json.dumps(dict(self._LEGACY, observation_scopes=scope)))
|
|
252
|
+
drain_pending._retry_one(entry, timeout=15)
|
|
253
|
+
self.assertEqual(self.calls[0]["observation_scopes"], scope)
|
|
254
|
+
|
|
208
255
|
|
|
209
256
|
class ValueValidation(unittest.TestCase):
|
|
210
257
|
"""An off-list scope must not reach the wire — and must not cost a memory.
|
|
@@ -321,5 +368,147 @@ class ValueValidation(unittest.TestCase):
|
|
|
321
368
|
resolve_observation_scopes(load_config())
|
|
322
369
|
|
|
323
370
|
|
|
371
|
+
class ComputeCuratedScopes(unittest.TestCase):
|
|
372
|
+
"""``compute_observation_scopes`` — the curated-default resolver, asserted as
|
|
373
|
+
exact (input tags, config) → emitted ``observation_scopes`` value mappings.
|
|
374
|
+
|
|
375
|
+
This is the load-bearing new behaviour: which tags survive onto the
|
|
376
|
+
consolidation scope and which are stripped as volatile provenance. The
|
|
377
|
+
function NEVER raises — a bad config degrades the scope, never the turn.
|
|
378
|
+
"""
|
|
379
|
+
|
|
380
|
+
_UUID = "0291c461-864d-4284-b2b3-3fba9bf3142c"
|
|
381
|
+
|
|
382
|
+
def _compute(self, tags, **config):
|
|
383
|
+
return compute_observation_scopes(tags, config)
|
|
384
|
+
|
|
385
|
+
# --- default (curated) ------------------------------------------------
|
|
386
|
+
def test_curated_strips_parent_session_keeps_stable(self):
|
|
387
|
+
value, err = self._compute(["parent_session:abc", "sidechain", "agent_type:worker"])
|
|
388
|
+
self.assertIsNone(err)
|
|
389
|
+
self.assertEqual(value, [["agent_type:worker", "sidechain"]]) # sorted, stripped
|
|
390
|
+
|
|
391
|
+
def test_curated_strips_bare_uuid_session_tag(self):
|
|
392
|
+
value, err = self._compute([self._UUID, "lesson"])
|
|
393
|
+
self.assertIsNone(err)
|
|
394
|
+
self.assertEqual(value, [["lesson"]])
|
|
395
|
+
|
|
396
|
+
def test_curated_strips_sidechain_sub_session_tag(self):
|
|
397
|
+
# The dominant observation path: subagent_retain sets
|
|
398
|
+
# `sub_session_id = f"{session_id}-sub-{agent_id}"`, and `retainTags:
|
|
399
|
+
# ["{session_id}"]` resolves to `<parent-uuid>-sub-<agent_id>`. That is
|
|
400
|
+
# per-invocation-unique, so if it survives into the scope every sidechain
|
|
401
|
+
# retain gets its own island and never dedups. It must be treated as
|
|
402
|
+
# volatile (like the bare UUID it derives from) and stripped, while the
|
|
403
|
+
# stable semantic tags stay in scope.
|
|
404
|
+
sub_tag = f"{self._UUID}-sub-af5fba739c0ee6b38"
|
|
405
|
+
value, err = self._compute([sub_tag, "sidechain", "agent_type:worker"])
|
|
406
|
+
self.assertIsNone(err)
|
|
407
|
+
self.assertEqual(value, [["agent_type:worker", "sidechain"]])
|
|
408
|
+
|
|
409
|
+
def test_curated_all_volatile_falls_back_to_shared(self):
|
|
410
|
+
# A retain tagged ONLY with volatile provenance has no stable scope, so
|
|
411
|
+
# it pools into the one bank-wide untagged scope instead of an island.
|
|
412
|
+
value, err = self._compute([self._UUID, "parent_session:xyz"])
|
|
413
|
+
self.assertIsNone(err)
|
|
414
|
+
self.assertEqual(value, "shared")
|
|
415
|
+
|
|
416
|
+
def test_curated_no_tags_is_shared(self):
|
|
417
|
+
self.assertEqual(self._compute([])[0], "shared")
|
|
418
|
+
self.assertEqual(self._compute(None)[0], "shared")
|
|
419
|
+
|
|
420
|
+
def test_curated_scope_is_deterministically_sorted(self):
|
|
421
|
+
# Same tag set in any order → identical scope (so it dedups, not shards).
|
|
422
|
+
a, _ = self._compute(["z:1", "a:2", "m:3"])
|
|
423
|
+
b, _ = self._compute(["m:3", "z:1", "a:2"])
|
|
424
|
+
self.assertEqual(a, b)
|
|
425
|
+
self.assertEqual(a, [["a:2", "m:3", "z:1"]])
|
|
426
|
+
|
|
427
|
+
def test_curated_dedups_repeated_tags(self):
|
|
428
|
+
value, _ = self._compute(["lesson", "lesson", "sidechain"])
|
|
429
|
+
self.assertEqual(value, [["lesson", "sidechain"]])
|
|
430
|
+
|
|
431
|
+
# --- opt-out ----------------------------------------------------------
|
|
432
|
+
def test_combined_emits_no_scope(self):
|
|
433
|
+
self.assertEqual(
|
|
434
|
+
self._compute(["lesson"], observationScopeStrategy="combined"), (None, None)
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
def test_off_emits_no_scope(self):
|
|
438
|
+
self.assertEqual(
|
|
439
|
+
self._compute(["lesson"], observationScopeStrategy="off"), (None, None)
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def test_shared_strategy_is_uniform(self):
|
|
443
|
+
value, err = self._compute(["lesson", "sidechain"], observationScopeStrategy="shared")
|
|
444
|
+
self.assertIsNone(err)
|
|
445
|
+
self.assertEqual(value, "shared")
|
|
446
|
+
|
|
447
|
+
# --- precedence / robustness -----------------------------------------
|
|
448
|
+
def test_manual_pin_wins_over_strategy(self):
|
|
449
|
+
# An operator who pinned per_tag keeps it even though curated is default.
|
|
450
|
+
value, err = self._compute(
|
|
451
|
+
["lesson"], observationScopes="per_tag", observationScopeStrategy="curated"
|
|
452
|
+
)
|
|
453
|
+
self.assertIsNone(err)
|
|
454
|
+
self.assertEqual(value, "per_tag")
|
|
455
|
+
|
|
456
|
+
def test_manual_pin_typo_degrades_to_none_with_error_not_curated(self):
|
|
457
|
+
# A typo'd pin must NOT silently fall through to curated — it degrades to
|
|
458
|
+
# the engine default (None) and carries an error, exactly like the
|
|
459
|
+
# pre-strategy behaviour, so the misconfiguration stays visible.
|
|
460
|
+
value, err = self._compute(["lesson"], observationScopes="shred")
|
|
461
|
+
self.assertIsNone(value)
|
|
462
|
+
self.assertIsNotNone(err)
|
|
463
|
+
self.assertIn("shred", err)
|
|
464
|
+
|
|
465
|
+
def test_unknown_strategy_degrades_to_curated_with_error(self):
|
|
466
|
+
value, err = self._compute(["lesson"], observationScopeStrategy="curatd")
|
|
467
|
+
self.assertEqual(value, [["lesson"]])
|
|
468
|
+
self.assertIsNotNone(err)
|
|
469
|
+
self.assertIn("curatd", err)
|
|
470
|
+
for s in OBSERVATION_SCOPE_STRATEGIES:
|
|
471
|
+
self.assertIn(s, err)
|
|
472
|
+
|
|
473
|
+
def test_empty_strategy_string_is_curated(self):
|
|
474
|
+
# An empty export hands authority back to the default, same idiom as
|
|
475
|
+
# the bare-string scope path.
|
|
476
|
+
self.assertEqual(self._compute(["lesson"], observationScopeStrategy="")[0], [["lesson"]])
|
|
477
|
+
self.assertEqual(self._compute(["lesson"], observationScopeStrategy=" ")[0], [["lesson"]])
|
|
478
|
+
|
|
479
|
+
def test_strategy_is_case_insensitive(self):
|
|
480
|
+
self.assertEqual(
|
|
481
|
+
self._compute(["lesson"], observationScopeStrategy="COMBINED"), (None, None)
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
def test_custom_volatile_patterns_override_defaults(self):
|
|
485
|
+
# An operator can declare a bank-specific provenance tag volatile.
|
|
486
|
+
value, err = self._compute(
|
|
487
|
+
["run:42", "lesson"],
|
|
488
|
+
observationScopeVolatilePatterns=[r"^run:"],
|
|
489
|
+
)
|
|
490
|
+
self.assertIsNone(err)
|
|
491
|
+
self.assertEqual(value, [["lesson"]])
|
|
492
|
+
|
|
493
|
+
def test_bad_volatile_pattern_is_skipped_not_fatal(self):
|
|
494
|
+
# A malformed regex must not kill the retain — it is dropped and the
|
|
495
|
+
# remaining (valid) matchers still curate.
|
|
496
|
+
err_out = io.StringIO()
|
|
497
|
+
with contextlib.redirect_stderr(err_out):
|
|
498
|
+
value, err = self._compute(
|
|
499
|
+
[self._UUID, "lesson"],
|
|
500
|
+
observationScopeVolatilePatterns=["(", r"^parent_session:",
|
|
501
|
+
r"^[0-9a-fA-F-]{36}$"],
|
|
502
|
+
)
|
|
503
|
+
self.assertIsNone(err)
|
|
504
|
+
self.assertEqual(value, [["lesson"]])
|
|
505
|
+
|
|
506
|
+
def test_never_raises_on_junk_tags(self):
|
|
507
|
+
# Non-string tags in the list must be ignored, not explode.
|
|
508
|
+
value, err = self._compute(["lesson", None, 42, "", "sidechain"])
|
|
509
|
+
self.assertIsNone(err)
|
|
510
|
+
self.assertEqual(value, [["lesson", "sidechain"]])
|
|
511
|
+
|
|
512
|
+
|
|
324
513
|
if __name__ == "__main__": # pragma: no cover
|
|
325
514
|
unittest.main()
|
|
@@ -414,8 +414,15 @@ class TestObservationScopes(DurabilityTestBase):
|
|
|
414
414
|
os.environ["HINDSIGHT_OBSERVATION_SCOPES"] = value
|
|
415
415
|
self.addCleanup(os.environ.pop, "HINDSIGHT_OBSERVATION_SCOPES", None)
|
|
416
416
|
|
|
417
|
-
|
|
418
|
-
|
|
417
|
+
def _set_strategy(self, value):
|
|
418
|
+
os.environ["HINDSIGHT_OBSERVATION_SCOPE_STRATEGY"] = value
|
|
419
|
+
self.addCleanup(os.environ.pop, "HINDSIGHT_OBSERVATION_SCOPE_STRATEGY", None)
|
|
420
|
+
|
|
421
|
+
# -- opt-out: byte-identical to pre-feature -----------------------------
|
|
422
|
+
def test_opted_out_stop_retain_posts_no_scope(self):
|
|
423
|
+
# observationScopeStrategy=combined restores the pre-feature body: no
|
|
424
|
+
# scope on the wire so the engine default stands.
|
|
425
|
+
self._set_strategy("combined")
|
|
419
426
|
hook = self._hook("plainsess")
|
|
420
427
|
with mock.patch("retain.increment_turn_count", return_value=3), \
|
|
421
428
|
mock.patch("sys.stdin", _stdin(hook)):
|
|
@@ -423,6 +430,15 @@ class TestObservationScopes(DurabilityTestBase):
|
|
|
423
430
|
self.assertTrue(self.daemon.observation_scopes_seen)
|
|
424
431
|
self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
|
|
425
432
|
|
|
433
|
+
# -- default ON: a curated scope reaches the Stop-hook POST --------------
|
|
434
|
+
def test_default_stop_retain_posts_a_curated_scope(self):
|
|
435
|
+
hook = self._hook("plainsess")
|
|
436
|
+
with mock.patch("retain.increment_turn_count", return_value=3), \
|
|
437
|
+
mock.patch("sys.stdin", _stdin(hook)):
|
|
438
|
+
retain.main()
|
|
439
|
+
self.assertTrue(self.daemon.observation_scopes_seen)
|
|
440
|
+
self.assertTrue(all(s is not None for s in self.daemon.observation_scopes_seen))
|
|
441
|
+
|
|
426
442
|
# -- Stop hook (retain.py) ----------------------------------------------
|
|
427
443
|
def test_configured_stop_retain_posts_the_scope(self):
|
|
428
444
|
self._set_scope("shared")
|
|
@@ -339,9 +339,10 @@ class RunSubagentRetain(unittest.TestCase):
|
|
|
339
339
|
self.assertEqual(captured["context"], "claude-code-sidechain")
|
|
340
340
|
self.assertEqual(captured["metadata"]["parent_session_id"], "parentsess")
|
|
341
341
|
|
|
342
|
-
def
|
|
343
|
-
# switchroom —
|
|
344
|
-
#
|
|
342
|
+
def test_sidechain_retain_omits_the_scope_when_opted_out(self):
|
|
343
|
+
# switchroom — opt-out (observationScopeStrategy=combined) must be
|
|
344
|
+
# byte-identical to the pre-feature body: the sidechain POST carries no
|
|
345
|
+
# scope, so the engine default stands.
|
|
345
346
|
with tempfile.TemporaryDirectory() as d:
|
|
346
347
|
sc = os.path.join(d, "agent-af5.jsonl")
|
|
347
348
|
_write_sidechain(sc, 8, chars_per_msg=400)
|
|
@@ -352,10 +353,62 @@ class RunSubagentRetain(unittest.TestCase):
|
|
|
352
353
|
"transcript_path": os.path.join(d, "parentsess.jsonl"),
|
|
353
354
|
"cwd": d,
|
|
354
355
|
}
|
|
355
|
-
result, captured = self._run(
|
|
356
|
+
result, captured = self._run(
|
|
357
|
+
hook_input, config_extra={"observationScopeStrategy": "combined"}
|
|
358
|
+
)
|
|
356
359
|
self.assertEqual(result["status"], "ok")
|
|
357
360
|
self.assertIsNone(captured["observation_scopes"])
|
|
358
361
|
|
|
362
|
+
def test_sidechain_retain_curates_the_scope_by_default(self):
|
|
363
|
+
# switchroom default ON: every VOLATILE per-session provenance tag is
|
|
364
|
+
# STRIPPED from the consolidation scope so sidechain observations dedup
|
|
365
|
+
# across parent sessions, while the stable semantic tags (`sidechain`,
|
|
366
|
+
# `agent_type:*`) are KEPT on the scope (so recall's sidechain:0.8
|
|
367
|
+
# demotion still fires on the observation).
|
|
368
|
+
#
|
|
369
|
+
# Two volatile tags must be stripped, and the second is the one the
|
|
370
|
+
# Fable review caught leaking:
|
|
371
|
+
# 1. `parent_session:<uuid>` — the explicit parent link.
|
|
372
|
+
# 2. `<uuid>-sub-<agent_id>` — what `retainTags: ["{session_id}"]`
|
|
373
|
+
# resolves to on the sidechain path, since subagent_retain sets
|
|
374
|
+
# `sub_session_id = f"{session_id}-sub-{agent_id}"`. This is
|
|
375
|
+
# per-invocation-unique; if it survives into the scope, sidechain
|
|
376
|
+
# retains (the dominant observation volume) never dedup and the
|
|
377
|
+
# feature silently fails on its primary path.
|
|
378
|
+
#
|
|
379
|
+
# The session_id is a real RFC-4122 UUID here (not a synthetic slug)
|
|
380
|
+
# precisely because the strip is anchored on the UUID shape — a slug
|
|
381
|
+
# would not exercise the volatile pattern and the test would pass on the
|
|
382
|
+
# pre-fix code. Assert EXACT scope equality so the test fails on the bug
|
|
383
|
+
# it guards, not merely on a membership check.
|
|
384
|
+
session_id = "a1b2c3d4-e5f6-4a8b-9c0d-1e2f3a4b5c6d"
|
|
385
|
+
with tempfile.TemporaryDirectory() as d:
|
|
386
|
+
sc = os.path.join(d, "agent-af5.jsonl")
|
|
387
|
+
_write_sidechain(sc, 8, chars_per_msg=400)
|
|
388
|
+
hook_input = {
|
|
389
|
+
"session_id": session_id,
|
|
390
|
+
"agent_id": "af5",
|
|
391
|
+
"agent_type": "worker",
|
|
392
|
+
"agent_transcript_path": sc,
|
|
393
|
+
"transcript_path": os.path.join(d, f"{session_id}.jsonl"),
|
|
394
|
+
"cwd": d,
|
|
395
|
+
}
|
|
396
|
+
result, captured = self._run(hook_input)
|
|
397
|
+
self.assertEqual(result["status"], "ok")
|
|
398
|
+
scope = captured["observation_scopes"]
|
|
399
|
+
# EXACT curated scope: only the stable semantic tags survive, sorted.
|
|
400
|
+
self.assertEqual(scope, [["agent_type:worker", "sidechain"]])
|
|
401
|
+
|
|
402
|
+
# And the specific volatile tags are provably gone from the scope.
|
|
403
|
+
scope_tags = {t for group in scope for t in group}
|
|
404
|
+
sub_session_tag = f"{session_id}-sub-af5"
|
|
405
|
+
self.assertNotIn(f"parent_session:{session_id}", scope_tags)
|
|
406
|
+
self.assertNotIn(sub_session_tag, scope_tags)
|
|
407
|
+
|
|
408
|
+
# The source-fact tags are untouched — the stripped tags stay queryable.
|
|
409
|
+
self.assertIn(f"parent_session:{session_id}", captured["tags"])
|
|
410
|
+
self.assertIn(sub_session_tag, captured["tags"])
|
|
411
|
+
|
|
359
412
|
def test_sidechain_retain_posts_the_configured_scope(self):
|
|
360
413
|
# switchroom — sidechain retains are their own hand-enumerated kwarg
|
|
361
414
|
# list; without this pin they would silently keep per-tag scopes while
|