switchroom 0.19.28 → 0.19.30
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 +1041 -374
- package/dist/host-control/main.js +28 -10
- 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
|
@@ -172,6 +172,15 @@ DEFAULTS = {
|
|
|
172
172
|
"retainContext": "claude-code",
|
|
173
173
|
"retainTags": [],
|
|
174
174
|
"retainMetadata": {},
|
|
175
|
+
# Switchroom-local: per-row Hindsight `observation_scopes` on every retain.
|
|
176
|
+
# `"shared"` makes consolidation write this item's observations into ONE
|
|
177
|
+
# global untagged scope instead of a scope per tag — what a set of agents
|
|
178
|
+
# pooling one bank needs. `None` (the default) omits the field from the
|
|
179
|
+
# wire body entirely, leaving the engine's own default in force. Set by
|
|
180
|
+
# start.sh from `agents.<name>.memory.observation_scopes` (cascading
|
|
181
|
+
# through `defaults.memory.observation_scopes`) via
|
|
182
|
+
# HINDSIGHT_OBSERVATION_SCOPES, exported ONLY when the operator opted in.
|
|
183
|
+
"observationScopes": None,
|
|
175
184
|
# Switchroom hindsight-leverage E2 / PR9 (#398) — lesson & anti-pattern
|
|
176
185
|
# tagging at retain time. When on (default), build_retain_payload scans the
|
|
177
186
|
# formatted transcript slice for explicit lesson / anti-pattern markers and
|
|
@@ -319,6 +328,11 @@ ENV_OVERRIDES = {
|
|
|
319
328
|
"HINDSIGHT_AUTO_RECALL": ("autoRecall", bool),
|
|
320
329
|
"HINDSIGHT_AUTO_RETAIN": ("autoRetain", bool),
|
|
321
330
|
"HINDSIGHT_RETAIN_MODE": ("retainMode", str),
|
|
331
|
+
# Switchroom-local: per-row observation scope on retains. Set by start.sh
|
|
332
|
+
# from agents.<name>.memory.observation_scopes (cascading through
|
|
333
|
+
# defaults.memory.observation_scopes) ONLY when the operator set it; unset
|
|
334
|
+
# leaves `observationScopes` None and the field off the wire entirely.
|
|
335
|
+
"HINDSIGHT_OBSERVATION_SCOPES": ("observationScopes", str),
|
|
322
336
|
# Switchroom hindsight-leverage E2 / PR9 (#398) — lesson/anti-pattern tagging
|
|
323
337
|
# + recall demotion toggles and overrides.
|
|
324
338
|
"HINDSIGHT_LESSON_TAGGING": ("lessonTagging", bool),
|
|
@@ -422,6 +436,83 @@ ENV_OVERRIDES = {
|
|
|
422
436
|
}
|
|
423
437
|
|
|
424
438
|
|
|
439
|
+
#: Switchroom-local: the `observation_scopes` values Hindsight accepts as a
|
|
440
|
+
#: bare string (`MemoryItem.observation_scopes`, typed
|
|
441
|
+
#: `Literal["per_tag","combined","all_combinations","shared"] | list[list[str]]
|
|
442
|
+
#: | None` server-side). The explicit list-of-lists tag matrix is deliberately
|
|
443
|
+
#: NOT exposed through switchroom config: unbounded, no safe fleet-wide
|
|
444
|
+
#: default, no caller needs it. Paired with `OBSERVATION_SCOPES` in
|
|
445
|
+
#: src/memory/observation-scopes.ts, which the zod enum reads — widening the
|
|
446
|
+
#: set means widening BOTH.
|
|
447
|
+
OBSERVATION_SCOPES_VALUES = ("per_tag", "combined", "all_combinations", "shared")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def classify_observation_scopes(config: dict):
|
|
451
|
+
"""Classify ``observationScopes`` WITHOUT raising: ``(value, error)``.
|
|
452
|
+
|
|
453
|
+
Exactly one of the two is non-``None``:
|
|
454
|
+
|
|
455
|
+
* ``(None, None)`` — unset. Do not put the field on the wire at all;
|
|
456
|
+
the shipped default, byte-for-byte the pre-plumbing request body.
|
|
457
|
+
* ``(value, None)`` — a valid member of :data:`OBSERVATION_SCOPES_VALUES`.
|
|
458
|
+
* ``(None, reason)`` — an off-list or non-string value, with a
|
|
459
|
+
human-readable reason naming the accepted set.
|
|
460
|
+
|
|
461
|
+
THIS FUNCTION MUST NEVER RAISE, and callers on the retain path must never
|
|
462
|
+
turn its ``error`` into one. A bad scope is a misconfiguration; losing the
|
|
463
|
+
turn is data loss. Those are not the same severity and must not share a
|
|
464
|
+
failure mode — see ``retain.build_retain_payload`` for the consequence
|
|
465
|
+
chain (a raise there propagated out of ``run_retain`` and past
|
|
466
|
+
``retain.main``'s ``pending_enqueue``, so the turn was never queued,
|
|
467
|
+
the watermark never advanced, and the boot reconciler swallowed the same
|
|
468
|
+
raise into ``debug_log`` — the memory was gone, permanently and silently).
|
|
469
|
+
That is switchroom #3244's shape, which this very feature cites.
|
|
470
|
+
|
|
471
|
+
An empty/whitespace-only value is treated as UNSET, matching the plugin's
|
|
472
|
+
existing "an empty export hands authority back to the config file" idiom
|
|
473
|
+
(see ``_cast_env``): an absent knob, not a typo'd one.
|
|
474
|
+
"""
|
|
475
|
+
raw = config.get("observationScopes")
|
|
476
|
+
if raw is None:
|
|
477
|
+
return None, None
|
|
478
|
+
if not isinstance(raw, str):
|
|
479
|
+
return None, (
|
|
480
|
+
"observationScopes must be a string, one of "
|
|
481
|
+
f"{', '.join(OBSERVATION_SCOPES_VALUES)}; got {type(raw).__name__} ({raw!r}). "
|
|
482
|
+
"Set it via `memory.observation_scopes` in switchroom.yaml."
|
|
483
|
+
)
|
|
484
|
+
value = raw.strip()
|
|
485
|
+
if not value:
|
|
486
|
+
return None, None
|
|
487
|
+
if value not in OBSERVATION_SCOPES_VALUES:
|
|
488
|
+
return None, (
|
|
489
|
+
f"observationScopes={raw!r} is not a valid Hindsight observation scope. "
|
|
490
|
+
f"Accepted values: {', '.join(OBSERVATION_SCOPES_VALUES)}. "
|
|
491
|
+
"Set it via `memory.observation_scopes` in switchroom.yaml "
|
|
492
|
+
"(a typo there is rejected at `switchroom apply`)."
|
|
493
|
+
)
|
|
494
|
+
return value, None
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def resolve_observation_scopes(config: dict):
|
|
498
|
+
"""Strict form of :func:`classify_observation_scopes` — raises on a bad value.
|
|
499
|
+
|
|
500
|
+
``None`` means "do not put the field on the wire at all".
|
|
501
|
+
|
|
502
|
+
Raises ``ValueError`` on any value outside
|
|
503
|
+
:data:`OBSERVATION_SCOPES_VALUES`. This is the VALIDATOR, for callers that
|
|
504
|
+
genuinely want to fail — a config check, a test, a hand-run script that
|
|
505
|
+
should stop before it writes anything. **It is deliberately NOT what the
|
|
506
|
+
retain path calls**: a retain must never be destroyed by a config typo, so
|
|
507
|
+
``retain.build_retain_payload`` uses the non-raising classifier and shouts
|
|
508
|
+
instead. See ``classify_observation_scopes``.
|
|
509
|
+
"""
|
|
510
|
+
value, error = classify_observation_scopes(config)
|
|
511
|
+
if error:
|
|
512
|
+
raise ValueError(error)
|
|
513
|
+
return value
|
|
514
|
+
|
|
515
|
+
|
|
425
516
|
def _cast_env(value: str, typ):
|
|
426
517
|
"""Cast environment variable string to target type. Returns None on failure."""
|
|
427
518
|
try:
|
|
@@ -41,12 +41,18 @@ Each entry is a JSON file ``<unix-ms>-<short-uuid>.json`` containing::
|
|
|
41
41
|
"context": "<retainContext>",
|
|
42
42
|
"metadata": {...},
|
|
43
43
|
"tags": [...] or null,
|
|
44
|
+
"observation_scopes": "<scope>" or null,
|
|
44
45
|
"failed_at": "<ISO-8601 UTC>",
|
|
45
46
|
"error_class": "<exception class name>",
|
|
46
47
|
"error_message": "<str(e)>",
|
|
47
48
|
"attempt_count": 1
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
``observation_scopes`` is ABSENT from every entry queued by a build that
|
|
52
|
+
predates it, and those entries are on disk right now. Readers must use
|
|
53
|
+
``entry.get("observation_scopes")``, never ``entry[...]`` — a KeyError in
|
|
54
|
+
``drain_pending._retry_one`` would strand the last on-disk copy of a turn.
|
|
55
|
+
|
|
50
56
|
The file is written via ``write tmp + rename`` so concurrent agents
|
|
51
57
|
sharing ``$HOME`` (legacy installs) never observe a half-written entry.
|
|
52
58
|
|
|
@@ -312,6 +312,7 @@ def _post_inline(
|
|
|
312
312
|
tags=payload["tags"],
|
|
313
313
|
timeout=15,
|
|
314
314
|
async_processing=False,
|
|
315
|
+
observation_scopes=payload.get("observation_scopes"),
|
|
315
316
|
)
|
|
316
317
|
except Exception as e:
|
|
317
318
|
debug_log(config, f"reconcile_tail: inline POST failed, enqueuing: {e}")
|
|
@@ -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()
|