switchroom 0.19.28 → 0.19.29
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 +12 -1
- package/dist/auth-broker/index.js +14 -2
- package/dist/cli/notion-write-pretool.mjs +14 -1
- package/dist/cli/switchroom.js +71 -11
- package/dist/host-control/main.js +17 -3
- package/dist/vault/approvals/kernel-server.js +19 -3
- package/dist/vault/broker/server.js +19 -3
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +9 -0
- package/telegram-plugin/dist/gateway/gateway.js +23 -7
- package/vendor/hindsight-memory/CHANGELOG.md +50 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +1 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +4 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +91 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +6 -0
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +1 -0
- package/vendor/hindsight-memory/scripts/retain.py +41 -1
- package/vendor/hindsight-memory/scripts/subagent_retain.py +1 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +23 -1
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +5 -1
- package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +325 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +160 -1
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +40 -2
|
@@ -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 debug_log, load_config
|
|
31
|
+
from lib.config import classify_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,
|
|
@@ -266,6 +266,14 @@ def build_retain_payload(
|
|
|
266
266
|
|
|
267
267
|
Returns ``{payload, document_id, message_count, last_uuid, ordered_uuids,
|
|
268
268
|
transcript}`` or ``None`` when the slice formats to nothing.
|
|
269
|
+
|
|
270
|
+
NEVER raises on a bad ``observationScopes``. Every retain producer builds
|
|
271
|
+
its payload here, so this seam sees the typo — but it is also the seam the
|
|
272
|
+
memory itself is made at, and a config typo must not be able to destroy
|
|
273
|
+
one. An off-list value is dropped from the payload (so the engine's own
|
|
274
|
+
default stands, exactly as before this feature existed) and shouted about
|
|
275
|
+
on stderr; the memory is still built, still POSTed, still queued on
|
|
276
|
+
failure. See ``lib.config.classify_observation_scopes``.
|
|
269
277
|
"""
|
|
270
278
|
retain_roles = config.get("retainRoles", ["user", "assistant"])
|
|
271
279
|
include_tool_calls = config.get("retainToolCalls", True)
|
|
@@ -359,6 +367,36 @@ def build_retain_payload(
|
|
|
359
367
|
except Exception:
|
|
360
368
|
pass
|
|
361
369
|
|
|
370
|
+
# Per-row observation scope (switchroom). None unless the operator set
|
|
371
|
+
# memory.observation_scopes — and a None is dropped at the wire by
|
|
372
|
+
# HindsightClient._retain_one, so the default request body is unchanged.
|
|
373
|
+
# Carried ON THE PAYLOAD so it survives the pending-retains queue: a retain
|
|
374
|
+
# that fails and drains hours later must land in the SAME scope it would
|
|
375
|
+
# have landed in inline.
|
|
376
|
+
#
|
|
377
|
+
# CLASSIFIED, NOT VALIDATED. This is the one seam every retain producer
|
|
378
|
+
# funnels through, which makes it the tempting place to reject a typo — and
|
|
379
|
+
# the worst possible place to raise from. Raising here does not "fail the
|
|
380
|
+
# retain", it DELETES the turn: the exception propagates out of run_retain,
|
|
381
|
+
# past retain.main's pending_enqueue (so nothing is queued and the
|
|
382
|
+
# watermark never advances), session_end.py catches it with no payload to
|
|
383
|
+
# queue, and session_start.py's reconciler swallows it into debug_log and
|
|
384
|
+
# aborts the loop that would have re-derived it. Every producer loses its
|
|
385
|
+
# memory outright, silently, for as long as the bad value sits in the
|
|
386
|
+
# config — switchroom #3244's exact shape.
|
|
387
|
+
#
|
|
388
|
+
# So a bad value degrades to the PRE-FEATURE behaviour (field omitted, the
|
|
389
|
+
# engine's own default scope stands) and is shouted about on stderr. Wrong
|
|
390
|
+
# scope is recoverable; a lost turn is not.
|
|
391
|
+
scope, scope_error = classify_observation_scopes(config)
|
|
392
|
+
if scope_error:
|
|
393
|
+
print(
|
|
394
|
+
f"[Hindsight] observation_scopes IGNORED for this retain: {scope_error} "
|
|
395
|
+
"The memory is being retained at the engine's default scope rather "
|
|
396
|
+
"than dropped — fix the value, then `switchroom apply` and restart "
|
|
397
|
+
"the agent.",
|
|
398
|
+
file=sys.stderr,
|
|
399
|
+
)
|
|
362
400
|
payload = {
|
|
363
401
|
"api_url": api_url,
|
|
364
402
|
"api_token": api_token,
|
|
@@ -368,6 +406,7 @@ def build_retain_payload(
|
|
|
368
406
|
"context": config.get("retainContext", "claude-code"),
|
|
369
407
|
"metadata": metadata,
|
|
370
408
|
"tags": tags,
|
|
409
|
+
"observation_scopes": scope,
|
|
371
410
|
}
|
|
372
411
|
return {
|
|
373
412
|
"payload": payload,
|
|
@@ -573,6 +612,7 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
|
|
|
573
612
|
tags=payload["tags"],
|
|
574
613
|
timeout=15,
|
|
575
614
|
async_processing=False,
|
|
615
|
+
observation_scopes=payload.get("observation_scopes"),
|
|
576
616
|
)
|
|
577
617
|
except Exception as e:
|
|
578
618
|
print(f"[Hindsight] Retain failed: {e}", file=sys.stderr)
|
|
@@ -443,6 +443,7 @@ def run_subagent_retain(hook_input: dict) -> dict:
|
|
|
443
443
|
tags=payload["tags"],
|
|
444
444
|
timeout=15,
|
|
445
445
|
async_processing=False,
|
|
446
|
+
observation_scopes=payload.get("observation_scopes"),
|
|
446
447
|
)
|
|
447
448
|
except Exception as e:
|
|
448
449
|
print(f"[Hindsight] Sidechain retain failed: {e}", file=sys.stderr)
|
|
@@ -35,6 +35,8 @@ class FakeDaemon:
|
|
|
35
35
|
|
|
36
36
|
def __init__(self):
|
|
37
37
|
self.docs = {} # document_id -> {content, ...}
|
|
38
|
+
# switchroom: the observation_scopes kwarg each POST carried.
|
|
39
|
+
self.observation_scopes_seen = []
|
|
38
40
|
self.posts = [] # [(document_id, async_processing)]
|
|
39
41
|
self.mission_patches = [] # set_bank_mission calls (must stay empty)
|
|
40
42
|
self.fail = False
|
|
@@ -42,7 +44,9 @@ class FakeDaemon:
|
|
|
42
44
|
self._inflight = 0
|
|
43
45
|
|
|
44
46
|
def retain(self, bank_id, content, document_id="conversation", context=None,
|
|
45
|
-
metadata=None, tags=None, timeout=15, async_processing=True
|
|
47
|
+
metadata=None, tags=None, timeout=15, async_processing=True,
|
|
48
|
+
observation_scopes=None):
|
|
49
|
+
self.observation_scopes_seen.append(observation_scopes)
|
|
46
50
|
self._inflight += 1
|
|
47
51
|
self.max_inflight_seen = max(self.max_inflight_seen, self._inflight)
|
|
48
52
|
try:
|
|
@@ -167,6 +171,24 @@ class TestBackfill(BackfillTestBase):
|
|
|
167
171
|
self.assertTrue(self.daemon.posts)
|
|
168
172
|
self.assertTrue(all(async_flag is False for _, async_flag in self.daemon.posts))
|
|
169
173
|
|
|
174
|
+
# -- switchroom: per-row observation scope on the backfill path ---------
|
|
175
|
+
def test_backfill_omits_the_scope_when_unconfigured(self):
|
|
176
|
+
self._transcript("clerk", "sess-plain", 4)
|
|
177
|
+
bf.Backfill(self._config(), commit=True, delay_ms=0).run()
|
|
178
|
+
self.assertTrue(self.daemon.observation_scopes_seen)
|
|
179
|
+
self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
|
|
180
|
+
|
|
181
|
+
def test_backfill_posts_the_configured_scope(self):
|
|
182
|
+
# The backfill enumerates its own retain kwargs; a miss here would
|
|
183
|
+
# scatter every recovered historical slice into per-tag scopes while
|
|
184
|
+
# live retains pooled into the shared one.
|
|
185
|
+
os.environ["HINDSIGHT_OBSERVATION_SCOPES"] = "shared"
|
|
186
|
+
self.addCleanup(os.environ.pop, "HINDSIGHT_OBSERVATION_SCOPES", None)
|
|
187
|
+
self._transcript("clerk", "sess-scoped", 4)
|
|
188
|
+
bf.Backfill(self._config(), commit=True, delay_ms=0).run()
|
|
189
|
+
self.assertTrue(self.daemon.observation_scopes_seen)
|
|
190
|
+
self.assertTrue(all(s == "shared" for s in self.daemon.observation_scopes_seen))
|
|
191
|
+
|
|
170
192
|
# -- Dedups against a pre-existing document with the same deterministic id
|
|
171
193
|
def test_dedups_against_preexisting_document_id(self):
|
|
172
194
|
path = self._transcript("clerk", "sess-dup", 3)
|
|
@@ -53,12 +53,16 @@ class FakeDaemon:
|
|
|
53
53
|
def __init__(self):
|
|
54
54
|
self.docs = {}
|
|
55
55
|
self.posts = []
|
|
56
|
+
# switchroom: the observation_scopes kwarg each POST carried.
|
|
57
|
+
self.observation_scopes_seen = []
|
|
56
58
|
self.max_inflight_seen = 0
|
|
57
59
|
self._inflight = 0
|
|
58
60
|
self.membership_error = False
|
|
59
61
|
|
|
60
62
|
def retain(self, bank_id, content, document_id="conversation", context=None,
|
|
61
|
-
metadata=None, tags=None, timeout=15, async_processing=True
|
|
63
|
+
metadata=None, tags=None, timeout=15, async_processing=True,
|
|
64
|
+
observation_scopes=None):
|
|
65
|
+
self.observation_scopes_seen.append(observation_scopes)
|
|
62
66
|
self._inflight += 1
|
|
63
67
|
self.max_inflight_seen = max(self.max_inflight_seen, self._inflight)
|
|
64
68
|
try:
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Switchroom — per-row ``observation_scopes`` plumbing on the retain path.
|
|
2
|
+
|
|
3
|
+
Hindsight stores an ``observation_scopes`` field per retained row.
|
|
4
|
+
``"shared"`` makes consolidation write that item's observations into ONE
|
|
5
|
+
global untagged scope instead of a scope per tag. The plugin could not send
|
|
6
|
+
the field at all before this change, so a bank could never be pooled.
|
|
7
|
+
|
|
8
|
+
The load-bearing properties, each asserted as an OUTCOME on the wire body or
|
|
9
|
+
on the kwargs a callsite hands ``client.retain()``:
|
|
10
|
+
|
|
11
|
+
1. **Unset is byte-identical to before.** With no config the key is ABSENT
|
|
12
|
+
from the POST body — not present-and-null — so the engine default stands.
|
|
13
|
+
2. **Set reaches the wire**, on every part of a split retain.
|
|
14
|
+
3. **It survives the pending queue.** The scope is carried on the payload,
|
|
15
|
+
so a retain that fails now and drains hours later lands in the same scope
|
|
16
|
+
it would have landed in inline.
|
|
17
|
+
4. **Old queue entries still drain.** Entries written by a pre-feature build
|
|
18
|
+
are on disk right now and carry no such key; the drain must read them
|
|
19
|
+
with ``.get`` and post ``None``, never raise.
|
|
20
|
+
|
|
21
|
+
Stdlib-only; runs under ``python3 -m unittest discover tests/``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import contextlib
|
|
25
|
+
import io
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
import unittest
|
|
30
|
+
from unittest import mock
|
|
31
|
+
|
|
32
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
33
|
+
if SCRIPTS_DIR not in sys.path:
|
|
34
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
35
|
+
|
|
36
|
+
import drain_pending # noqa: E402
|
|
37
|
+
import retain # noqa: E402
|
|
38
|
+
from lib.client import HindsightClient # noqa: E402
|
|
39
|
+
from lib.config import ( # noqa: E402
|
|
40
|
+
OBSERVATION_SCOPES_VALUES,
|
|
41
|
+
load_config,
|
|
42
|
+
resolve_observation_scopes,
|
|
43
|
+
)
|
|
44
|
+
from lib.retain_split import retain_content_limit # noqa: E402
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _RecordingClient(HindsightClient):
|
|
48
|
+
"""Captures the request bodies instead of putting them on a socket."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, *a, **kw):
|
|
51
|
+
super().__init__(*a, **kw)
|
|
52
|
+
self.bodies = []
|
|
53
|
+
|
|
54
|
+
def _request(self, method, path, body=None, timeout=30):
|
|
55
|
+
self.bodies.append(body)
|
|
56
|
+
return {"ok": True}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _transcript(n_turns: int) -> list:
|
|
60
|
+
out = []
|
|
61
|
+
for i in range(n_turns):
|
|
62
|
+
out.append({"role": "user", "content": f"user turn {i}", "uuid": f"u{i}"})
|
|
63
|
+
out.append({"role": "assistant", "content": f"assistant turn {i}", "uuid": f"a{i}"})
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class WireBody(unittest.TestCase):
|
|
68
|
+
"""What actually goes on the wire."""
|
|
69
|
+
|
|
70
|
+
def setUp(self):
|
|
71
|
+
self.client = _RecordingClient("http://hindsight.invalid")
|
|
72
|
+
|
|
73
|
+
def test_unset_omits_the_key_entirely(self):
|
|
74
|
+
self.client.retain("bank", "some transcript", document_id="doc")
|
|
75
|
+
item = self.client.bodies[0]["items"][0]
|
|
76
|
+
# Not `is None` — ABSENT. A null would be a value the engine has to
|
|
77
|
+
# interpret; the pre-feature body simply had no such key.
|
|
78
|
+
self.assertNotIn("observation_scopes", item)
|
|
79
|
+
|
|
80
|
+
def test_explicit_none_omits_the_key_entirely(self):
|
|
81
|
+
# The default config value is None and every callsite forwards it, so
|
|
82
|
+
# the None path is the one the whole fleet takes.
|
|
83
|
+
self.client.retain(
|
|
84
|
+
"bank", "some transcript", document_id="doc", observation_scopes=None
|
|
85
|
+
)
|
|
86
|
+
self.assertNotIn("observation_scopes", self.client.bodies[0]["items"][0])
|
|
87
|
+
|
|
88
|
+
def test_unset_body_is_identical_to_a_pre_feature_body(self):
|
|
89
|
+
self.client.retain(
|
|
90
|
+
"bank", "t", document_id="doc", context="claude-code",
|
|
91
|
+
metadata={"m": "1"}, tags=["x"],
|
|
92
|
+
)
|
|
93
|
+
self.assertEqual(
|
|
94
|
+
self.client.bodies[0],
|
|
95
|
+
{
|
|
96
|
+
"items": [{
|
|
97
|
+
"content": "t",
|
|
98
|
+
"document_id": "doc",
|
|
99
|
+
"metadata": {"m": "1"},
|
|
100
|
+
"context": "claude-code",
|
|
101
|
+
"tags": ["x"],
|
|
102
|
+
}],
|
|
103
|
+
"async": True,
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def test_set_value_reaches_the_item(self):
|
|
108
|
+
self.client.retain(
|
|
109
|
+
"bank", "some transcript", document_id="doc", observation_scopes="shared"
|
|
110
|
+
)
|
|
111
|
+
self.assertEqual(
|
|
112
|
+
self.client.bodies[0]["items"][0]["observation_scopes"], "shared"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def test_every_part_of_a_split_retain_carries_it(self):
|
|
116
|
+
# A split that lands parts in DIFFERENT scopes would silently shard one
|
|
117
|
+
# memory across scopes — the exact drift this pins.
|
|
118
|
+
big = "z" * (retain_content_limit() * 3)
|
|
119
|
+
self.client.retain("bank", big, document_id="doc", observation_scopes="shared")
|
|
120
|
+
self.assertGreater(len(self.client.bodies), 1)
|
|
121
|
+
for body in self.client.bodies:
|
|
122
|
+
self.assertEqual(body["items"][0]["observation_scopes"], "shared")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ConfigResolution(unittest.TestCase):
|
|
126
|
+
"""``observationScopes`` default + the HINDSIGHT_OBSERVATION_SCOPES env."""
|
|
127
|
+
|
|
128
|
+
def test_default_is_none(self):
|
|
129
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
130
|
+
self.assertIsNone(load_config().get("observationScopes"))
|
|
131
|
+
|
|
132
|
+
def test_env_override_sets_it(self):
|
|
133
|
+
with mock.patch.dict(os.environ, {"HINDSIGHT_OBSERVATION_SCOPES": "shared"},
|
|
134
|
+
clear=True):
|
|
135
|
+
self.assertEqual(load_config().get("observationScopes"), "shared")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class PayloadBuild(unittest.TestCase):
|
|
139
|
+
"""``build_retain_payload`` is the single producer for every retain path."""
|
|
140
|
+
|
|
141
|
+
_BASE = {"retainRoles": ["user", "assistant"], "retainContext": "claude-code"}
|
|
142
|
+
|
|
143
|
+
def _build(self, config_extra):
|
|
144
|
+
config = dict(self._BASE, **config_extra)
|
|
145
|
+
return retain.build_retain_payload(
|
|
146
|
+
config, "sess", _transcript(2), _transcript(2),
|
|
147
|
+
bank_id="bank", api_url="http://fake", api_token=None,
|
|
148
|
+
)["payload"]
|
|
149
|
+
|
|
150
|
+
def test_payload_carries_none_when_unconfigured(self):
|
|
151
|
+
self.assertIsNone(self._build({})["observation_scopes"])
|
|
152
|
+
|
|
153
|
+
def test_payload_carries_the_configured_scope(self):
|
|
154
|
+
self.assertEqual(
|
|
155
|
+
self._build({"observationScopes": "shared"})["observation_scopes"],
|
|
156
|
+
"shared",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class DrainOfQueuedEntries(unittest.TestCase):
|
|
161
|
+
"""``drain_pending._retry_one`` — the durability path, over MIXED entries."""
|
|
162
|
+
|
|
163
|
+
def setUp(self):
|
|
164
|
+
self.calls = []
|
|
165
|
+
outer = self
|
|
166
|
+
|
|
167
|
+
class _Client:
|
|
168
|
+
def __init__(self, *a, **kw):
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
def retain(self, **kwargs):
|
|
172
|
+
outer.calls.append(kwargs)
|
|
173
|
+
return {"ok": True}
|
|
174
|
+
|
|
175
|
+
self.patch = mock.patch.object(drain_pending, "HindsightClient", _Client)
|
|
176
|
+
self.patch.start()
|
|
177
|
+
self.addCleanup(self.patch.stop)
|
|
178
|
+
|
|
179
|
+
_LEGACY = {
|
|
180
|
+
"api_url": "http://fake",
|
|
181
|
+
"api_token": None,
|
|
182
|
+
"bank_id": "bank",
|
|
183
|
+
"document_id": "doc",
|
|
184
|
+
"content": "old transcript",
|
|
185
|
+
"context": "claude-code",
|
|
186
|
+
"metadata": {},
|
|
187
|
+
"tags": None,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
def test_entry_written_before_the_feature_drains_with_none(self):
|
|
191
|
+
# These entries are on disk RIGHT NOW. A KeyError here would strand the
|
|
192
|
+
# last on-disk copy of a turn, which is the #3244 silent-loss shape.
|
|
193
|
+
drain_pending._retry_one(dict(self._LEGACY), timeout=15)
|
|
194
|
+
self.assertEqual(self.calls[0]["content"], "old transcript")
|
|
195
|
+
self.assertIsNone(self.calls[0]["observation_scopes"])
|
|
196
|
+
|
|
197
|
+
def test_entry_carrying_a_scope_drains_into_that_scope(self):
|
|
198
|
+
entry = dict(self._LEGACY, observation_scopes="shared")
|
|
199
|
+
drain_pending._retry_one(entry, timeout=15)
|
|
200
|
+
self.assertEqual(self.calls[0]["observation_scopes"], "shared")
|
|
201
|
+
|
|
202
|
+
def test_scope_survives_a_json_round_trip_through_the_queue_file(self):
|
|
203
|
+
# The queue is JSON on disk; the scope must come back out of it.
|
|
204
|
+
entry = json.loads(json.dumps(dict(self._LEGACY, observation_scopes="shared")))
|
|
205
|
+
drain_pending._retry_one(entry, timeout=15)
|
|
206
|
+
self.assertEqual(self.calls[0]["observation_scopes"], "shared")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class ValueValidation(unittest.TestCase):
|
|
210
|
+
"""An off-list scope must not reach the wire — and must not cost a memory.
|
|
211
|
+
|
|
212
|
+
The value is invisible after the write: a typo would keep retaining
|
|
213
|
+
happily, the engine would apply its own default scope, and the damage
|
|
214
|
+
(a bank whose observations never merged) surfaces only much later. The
|
|
215
|
+
`memory.observation_scopes` zod enum is the primary gate, but it cannot
|
|
216
|
+
see a hand-edited settings.json or a raw HINDSIGHT_OBSERVATION_SCOPES
|
|
217
|
+
export — which is why this second gate exists here.
|
|
218
|
+
|
|
219
|
+
TWO different obligations, and they are not the same severity:
|
|
220
|
+
|
|
221
|
+
* `resolve_observation_scopes` is the strict VALIDATOR and RAISES. Callers
|
|
222
|
+
that can safely stop (a config check, a hand-run script) use it.
|
|
223
|
+
* the retain path uses the non-raising classifier, drops the bad field and
|
|
224
|
+
shouts. A misconfigured scope is recoverable; a deleted turn is not, and
|
|
225
|
+
raising at the build seam deleted turns — see
|
|
226
|
+
`test_a_typo_never_reaches_the_payload_BUT_the_memory_survives`.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
_BASE = {"retainRoles": ["user", "assistant"], "retainContext": "claude-code"}
|
|
230
|
+
|
|
231
|
+
def _build(self, config_extra):
|
|
232
|
+
config = dict(self._BASE, **config_extra)
|
|
233
|
+
return retain.build_retain_payload(
|
|
234
|
+
config, "sess", _transcript(2), _transcript(2),
|
|
235
|
+
bank_id="bank", api_url="http://fake", api_token=None,
|
|
236
|
+
)["payload"]
|
|
237
|
+
|
|
238
|
+
def test_every_accepted_value_resolves_to_itself(self):
|
|
239
|
+
for value in OBSERVATION_SCOPES_VALUES:
|
|
240
|
+
with self.subTest(value=value):
|
|
241
|
+
self.assertEqual(
|
|
242
|
+
resolve_observation_scopes({"observationScopes": value}), value
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def test_unset_and_empty_resolve_to_none(self):
|
|
246
|
+
# Empty is UNSET, not a typo — matches the plugin's "an empty export
|
|
247
|
+
# hands authority back to the config file" idiom.
|
|
248
|
+
self.assertIsNone(resolve_observation_scopes({}))
|
|
249
|
+
self.assertIsNone(resolve_observation_scopes({"observationScopes": None}))
|
|
250
|
+
self.assertIsNone(resolve_observation_scopes({"observationScopes": ""}))
|
|
251
|
+
self.assertIsNone(resolve_observation_scopes({"observationScopes": " "}))
|
|
252
|
+
|
|
253
|
+
def test_typo_raises_and_names_the_accepted_set(self):
|
|
254
|
+
with self.assertRaises(ValueError) as ctx:
|
|
255
|
+
resolve_observation_scopes({"observationScopes": "shred"})
|
|
256
|
+
msg = str(ctx.exception)
|
|
257
|
+
self.assertIn("shred", msg)
|
|
258
|
+
for value in OBSERVATION_SCOPES_VALUES:
|
|
259
|
+
self.assertIn(value, msg)
|
|
260
|
+
|
|
261
|
+
def test_wrong_case_raises(self):
|
|
262
|
+
with self.assertRaises(ValueError):
|
|
263
|
+
resolve_observation_scopes({"observationScopes": "Shared"})
|
|
264
|
+
|
|
265
|
+
def test_non_string_raises(self):
|
|
266
|
+
with self.assertRaises(ValueError):
|
|
267
|
+
resolve_observation_scopes({"observationScopes": ["shared"]})
|
|
268
|
+
|
|
269
|
+
def test_a_typo_never_reaches_the_payload_BUT_the_memory_survives(self):
|
|
270
|
+
# Two outcomes, and the second is the load-bearing one.
|
|
271
|
+
#
|
|
272
|
+
# (a) The bad value does not ride to the wire — `observation_scopes` is
|
|
273
|
+
# None, so `HindsightClient._retain_one` omits the key and the
|
|
274
|
+
# engine's own default scope stands. That is the pre-feature
|
|
275
|
+
# behaviour, and it is a *recoverable* misconfiguration.
|
|
276
|
+
#
|
|
277
|
+
# (b) The PAYLOAD IS STILL BUILT. `build_retain_payload` raising here
|
|
278
|
+
# was a far worse bug than the one it fixed: the raise unwound past
|
|
279
|
+
# `retain.main`'s `pending_enqueue`, so the turn was never POSTed,
|
|
280
|
+
# never queued and never re-derivable — permanent silent memory
|
|
281
|
+
# loss for as long as the typo sat in the config. A config typo
|
|
282
|
+
# must never be able to delete a memory. End-to-end coverage of
|
|
283
|
+
# the same guarantee lives in
|
|
284
|
+
# tests/test_reconcile_durability.py::TestObservationScopes.
|
|
285
|
+
payload = self._build({"observationScopes": "shred"})
|
|
286
|
+
self.assertIsNone(payload["observation_scopes"])
|
|
287
|
+
self.assertIn("user turn 0", payload["content"])
|
|
288
|
+
self.assertTrue(payload["document_id"])
|
|
289
|
+
|
|
290
|
+
def test_a_typo_never_reaches_the_wire(self):
|
|
291
|
+
client = _RecordingClient("http://fake")
|
|
292
|
+
payload = self._build({"observationScopes": "per-tag"}) # hyphen, not underscore
|
|
293
|
+
client.retain(
|
|
294
|
+
payload["bank_id"],
|
|
295
|
+
payload["content"],
|
|
296
|
+
document_id=payload["document_id"],
|
|
297
|
+
observation_scopes=payload["observation_scopes"],
|
|
298
|
+
)
|
|
299
|
+
self.assertEqual(len(client.bodies), 1)
|
|
300
|
+
# ABSENT, not null: the request body is the pre-feature one.
|
|
301
|
+
self.assertNotIn("observation_scopes", client.bodies[0]["items"][0])
|
|
302
|
+
|
|
303
|
+
def test_a_typo_is_shouted_about_rather_than_swallowed(self):
|
|
304
|
+
# A silent downgrade to the engine default is the ORIGINAL defect this
|
|
305
|
+
# feature exists to prevent. Degrading quietly would just reintroduce
|
|
306
|
+
# it, so the build seam must say so on stderr every time it fires.
|
|
307
|
+
err = io.StringIO()
|
|
308
|
+
with contextlib.redirect_stderr(err):
|
|
309
|
+
self._build({"observationScopes": "shred"})
|
|
310
|
+
msg = err.getvalue()
|
|
311
|
+
self.assertIn("shred", msg)
|
|
312
|
+
self.assertIn("observation_scopes", msg)
|
|
313
|
+
for value in OBSERVATION_SCOPES_VALUES:
|
|
314
|
+
self.assertIn(value, msg)
|
|
315
|
+
|
|
316
|
+
def test_env_var_typo_is_caught_too(self):
|
|
317
|
+
# The env path bypasses zod entirely, so this is the only gate on it.
|
|
318
|
+
with mock.patch.dict(os.environ, {"HINDSIGHT_OBSERVATION_SCOPES": "shred"},
|
|
319
|
+
clear=True):
|
|
320
|
+
with self.assertRaises(ValueError):
|
|
321
|
+
resolve_observation_scopes(load_config())
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__": # pragma: no cover
|
|
325
|
+
unittest.main()
|
|
@@ -12,6 +12,8 @@ single deterministic id), 6 (watermark monotonicity + uuid-not-found), 7
|
|
|
12
12
|
bound ENQUEUES the remainder).
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
|
+
import contextlib
|
|
16
|
+
import io
|
|
15
17
|
import json
|
|
16
18
|
import os
|
|
17
19
|
import shutil
|
|
@@ -41,12 +43,16 @@ class FakeDaemon:
|
|
|
41
43
|
|
|
42
44
|
def __init__(self):
|
|
43
45
|
self.docs = {} # document_id -> {content, metadata, async}
|
|
46
|
+
# switchroom: the observation_scopes kwarg each POST carried.
|
|
47
|
+
self.observation_scopes_seen = []
|
|
44
48
|
self.posts = [] # [(document_id, async_processing)]
|
|
45
49
|
self.fail = False
|
|
46
50
|
self.drop_async = False
|
|
47
51
|
|
|
48
52
|
def retain(self, bank_id, content, document_id="conversation", context=None,
|
|
49
|
-
metadata=None, tags=None, timeout=15, async_processing=True
|
|
53
|
+
metadata=None, tags=None, timeout=15, async_processing=True,
|
|
54
|
+
observation_scopes=None):
|
|
55
|
+
self.observation_scopes_seen.append(observation_scopes)
|
|
50
56
|
self.posts.append((document_id, async_processing))
|
|
51
57
|
if self.fail:
|
|
52
58
|
raise RuntimeError("simulated daemon failure")
|
|
@@ -390,6 +396,159 @@ class TestSidechainSkip(DurabilityTestBase):
|
|
|
390
396
|
self.assertIn(f"user turn {i}", blob)
|
|
391
397
|
|
|
392
398
|
|
|
399
|
+
class TestObservationScopes(DurabilityTestBase):
|
|
400
|
+
"""switchroom — the per-row observation scope on the LIVE retain paths.
|
|
401
|
+
|
|
402
|
+
Asserts the OUTCOME on the wire: what scope the Stop hook, the boot
|
|
403
|
+
reconciler, and the pending-queue drain actually POST with. Each of those
|
|
404
|
+
is a separate hand-enumerated kwarg list, so each needs its own pin — a
|
|
405
|
+
miss on any one silently drops that path back to per-tag scopes.
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
def _hook(self, session="scopesess", n=5):
|
|
409
|
+
tpath = os.path.join(self.transcripts, f"{session}.jsonl")
|
|
410
|
+
_write_transcript(tpath, n, session_prefix=session)
|
|
411
|
+
return {"session_id": session, "transcript_path": tpath, "cwd": "/x"}
|
|
412
|
+
|
|
413
|
+
def _set_scope(self, value):
|
|
414
|
+
os.environ["HINDSIGHT_OBSERVATION_SCOPES"] = value
|
|
415
|
+
self.addCleanup(os.environ.pop, "HINDSIGHT_OBSERVATION_SCOPES", None)
|
|
416
|
+
|
|
417
|
+
# -- default: nothing changes -------------------------------------------
|
|
418
|
+
def test_unconfigured_stop_retain_posts_no_scope(self):
|
|
419
|
+
hook = self._hook("plainsess")
|
|
420
|
+
with mock.patch("retain.increment_turn_count", return_value=3), \
|
|
421
|
+
mock.patch("sys.stdin", _stdin(hook)):
|
|
422
|
+
retain.main()
|
|
423
|
+
self.assertTrue(self.daemon.observation_scopes_seen)
|
|
424
|
+
self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
|
|
425
|
+
|
|
426
|
+
# -- Stop hook (retain.py) ----------------------------------------------
|
|
427
|
+
def test_configured_stop_retain_posts_the_scope(self):
|
|
428
|
+
self._set_scope("shared")
|
|
429
|
+
hook = self._hook("livesess")
|
|
430
|
+
with mock.patch("retain.increment_turn_count", return_value=3), \
|
|
431
|
+
mock.patch("sys.stdin", _stdin(hook)):
|
|
432
|
+
retain.main()
|
|
433
|
+
self.assertTrue(self.daemon.observation_scopes_seen)
|
|
434
|
+
self.assertTrue(all(s == "shared" for s in self.daemon.observation_scopes_seen))
|
|
435
|
+
|
|
436
|
+
# -- boot reconciler (reconcile_tail.py) --------------------------------
|
|
437
|
+
def test_configured_boot_reconcile_posts_the_scope(self):
|
|
438
|
+
self._set_scope("shared")
|
|
439
|
+
hook = self._hook("reconsess")
|
|
440
|
+
reconcile_tail.reconcile(self._config(), hook_input=hook)
|
|
441
|
+
self.assertTrue(self.daemon.observation_scopes_seen)
|
|
442
|
+
self.assertTrue(all(s == "shared" for s in self.daemon.observation_scopes_seen))
|
|
443
|
+
|
|
444
|
+
# -- durability: the scope survives the pending queue --------------------
|
|
445
|
+
def test_scope_survives_failure_enqueue_and_drain(self):
|
|
446
|
+
self._set_scope("shared")
|
|
447
|
+
hook = self._hook("failsess")
|
|
448
|
+
|
|
449
|
+
# The daemon refuses, so the Stop retain is ENQUEUED rather than landed.
|
|
450
|
+
self.daemon.fail = True
|
|
451
|
+
with mock.patch("retain.increment_turn_count", return_value=3), \
|
|
452
|
+
mock.patch("sys.stdin", _stdin(hook)):
|
|
453
|
+
retain.main()
|
|
454
|
+
entries = self._pending_entries()
|
|
455
|
+
self.assertEqual(len(entries), 1)
|
|
456
|
+
_path, entry = entries[0]
|
|
457
|
+
# The queued entry carries the scope, so the drain does not have to
|
|
458
|
+
# re-resolve config that may have changed since.
|
|
459
|
+
self.assertEqual(entry["observation_scopes"], "shared")
|
|
460
|
+
|
|
461
|
+
# Drain it hours later: it lands in the SAME scope.
|
|
462
|
+
import drain_pending
|
|
463
|
+
seen = []
|
|
464
|
+
|
|
465
|
+
class _Client:
|
|
466
|
+
def __init__(self, *a, **kw):
|
|
467
|
+
pass
|
|
468
|
+
|
|
469
|
+
def retain(self, **kwargs):
|
|
470
|
+
seen.append(kwargs.get("observation_scopes"))
|
|
471
|
+
return {"ok": True}
|
|
472
|
+
|
|
473
|
+
with mock.patch.object(drain_pending, "HindsightClient", _Client):
|
|
474
|
+
drain_pending._retry_one(entry, timeout=15)
|
|
475
|
+
self.assertEqual(seen, ["shared"])
|
|
476
|
+
|
|
477
|
+
# -- a typo must never destroy a memory ---------------------------------
|
|
478
|
+
#
|
|
479
|
+
# These are the regression tests for the worst bug the validation
|
|
480
|
+
# introduced. `build_retain_payload` used to RAISE on an off-list value.
|
|
481
|
+
# It is called from `run_retain`, which `retain.main` calls WITHOUT a
|
|
482
|
+
# try/except before its `pending_enqueue` — so the raise unwound past the
|
|
483
|
+
# enqueue: nothing POSTed, nothing queued, watermark not advanced, the turn
|
|
484
|
+
# gone. `session_end.py` caught it but had no payload to queue, and
|
|
485
|
+
# `session_start.py` swallowed the same raise into `debug_log` and aborted
|
|
486
|
+
# the reconcile loop that would have re-derived it. Every producer lost its
|
|
487
|
+
# memory outright, permanently and silently, for as long as the bad value
|
|
488
|
+
# sat in the config — switchroom #3244's shape, which this feature cites.
|
|
489
|
+
#
|
|
490
|
+
# A misconfigured scope is recoverable. A deleted turn is not. So a bad
|
|
491
|
+
# value degrades to the PRE-FEATURE behaviour (no field on the wire, the
|
|
492
|
+
# engine's own default scope) and is shouted about on stderr.
|
|
493
|
+
|
|
494
|
+
def test_a_typo_does_not_destroy_the_live_stop_retain(self):
|
|
495
|
+
self._set_scope("shred") # not "shared"
|
|
496
|
+
hook = self._hook("typolivesess")
|
|
497
|
+
err = io.StringIO()
|
|
498
|
+
with mock.patch("retain.increment_turn_count", return_value=3), \
|
|
499
|
+
mock.patch("sys.stdin", _stdin(hook)), \
|
|
500
|
+
contextlib.redirect_stderr(err):
|
|
501
|
+
retain.main()
|
|
502
|
+
|
|
503
|
+
# OUTCOME 1: the memory LANDED. Not "an exception was caught" — the
|
|
504
|
+
# turns are in the bank.
|
|
505
|
+
# (chunked mode with retainEveryNTurns=3 slices the last 3 turns)
|
|
506
|
+
blob = self.daemon.content_blob()
|
|
507
|
+
self.assertIn("user turn 4", blob)
|
|
508
|
+
# OUTCOME 2: at the engine's own default scope (field omitted), which
|
|
509
|
+
# is exactly what an unconfigured agent does.
|
|
510
|
+
self.assertTrue(self.daemon.observation_scopes_seen)
|
|
511
|
+
self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
|
|
512
|
+
# OUTCOME 3: and it was loud. A silent downgrade would be the original
|
|
513
|
+
# "typo ignored forever" defect.
|
|
514
|
+
self.assertIn("shred", err.getvalue())
|
|
515
|
+
self.assertIn("observation_scopes", err.getvalue())
|
|
516
|
+
|
|
517
|
+
def test_a_typo_does_not_destroy_a_FAILED_stop_retain(self):
|
|
518
|
+
# The reviewer's reproduction: with the raise in place this enqueued 0
|
|
519
|
+
# entries and left the watermark unmoved, so the turn was lost for good.
|
|
520
|
+
self._set_scope("shred")
|
|
521
|
+
hook = self._hook("typofailsess")
|
|
522
|
+
|
|
523
|
+
self.daemon.fail = True
|
|
524
|
+
with mock.patch("retain.increment_turn_count", return_value=3), \
|
|
525
|
+
mock.patch("sys.stdin", _stdin(hook)), \
|
|
526
|
+
contextlib.redirect_stderr(io.StringIO()):
|
|
527
|
+
retain.main()
|
|
528
|
+
|
|
529
|
+
# OUTCOME: the payload was still built and still ENQUEUED, so the next
|
|
530
|
+
# SessionStart drain replays it. This is the assertion that matters —
|
|
531
|
+
# the memory has a durable on-disk copy.
|
|
532
|
+
entries = self._pending_entries()
|
|
533
|
+
self.assertEqual(len(entries), 1)
|
|
534
|
+
_path, entry = entries[0]
|
|
535
|
+
self.assertIn("user turn 4", entry["content"])
|
|
536
|
+
self.assertIsNone(entry["observation_scopes"])
|
|
537
|
+
|
|
538
|
+
def test_a_typo_does_not_destroy_the_boot_reconcile(self):
|
|
539
|
+
# session_start.py swallows a reconcile raise into debug_log AND aborts
|
|
540
|
+
# the loop on the first bad payload, so the recovery path that would
|
|
541
|
+
# re-derive lost turns did nothing either.
|
|
542
|
+
self._set_scope("shred")
|
|
543
|
+
hook = self._hook("typoreconsess")
|
|
544
|
+
with contextlib.redirect_stderr(io.StringIO()):
|
|
545
|
+
reconcile_tail.reconcile(self._config(), hook_input=hook)
|
|
546
|
+
blob = self.daemon.content_blob()
|
|
547
|
+
self.assertIn("user turn 0", blob)
|
|
548
|
+
self.assertTrue(self.daemon.observation_scopes_seen)
|
|
549
|
+
self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
|
|
550
|
+
|
|
551
|
+
|
|
393
552
|
def _stdin(obj):
|
|
394
553
|
import io
|
|
395
554
|
return io.StringIO(json.dumps(obj))
|