switchroom 0.21.14 → 0.21.16
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/bin/rules-sentinel-hook.sh +101 -0
- package/dist/agent-scheduler/index.js +7 -2
- package/dist/auth-broker/index.js +7 -2
- package/dist/cli/notion-write-pretool.mjs +7 -2
- package/dist/cli/switchroom.js +2601 -1009
- package/dist/host-control/main.js +8 -3
- package/dist/vault/approvals/kernel-server.js +7 -2
- package/dist/vault/broker/server.js +7 -2
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +9 -0
- package/profiles/_shared/delegation-golden-rule.md.hbs +2 -0
- package/skills/mental-model-curator/SKILL.md +187 -56
- package/telegram-plugin/dist/gateway/gateway.js +11 -6
- package/vendor/hindsight-memory/hooks/hooks.json +10 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +22 -0
- package/vendor/hindsight-memory/scripts/lib/directives.py +45 -7
- package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +236 -0
- package/vendor/hindsight-memory/scripts/lib/watermark.py +27 -0
- package/vendor/hindsight-memory/scripts/prefetch.py +156 -0
- package/vendor/hindsight-memory/scripts/recall.py +520 -5
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +4 -12
- package/vendor/hindsight-memory/scripts/retain.py +167 -28
- package/vendor/hindsight-memory/scripts/tests/test_config_retain_tool_calls_env.py +98 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +52 -0
- package/vendor/hindsight-memory/scripts/tests/test_incremental_sweep.py +293 -0
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +247 -0
- package/vendor/hindsight-memory/scripts/tests/test_profile_capture_nudge.py +335 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_buffer.py +143 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +193 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_cap_truncation.py +133 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_junk_gate.py +168 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_score_floor.py +124 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_timestamp.py +376 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_delta.py +304 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_stop_hook_prefetch_gate.py +109 -0
|
@@ -366,6 +366,7 @@ def select_retain_window(
|
|
|
366
366
|
overlap_turns: int,
|
|
367
367
|
all_messages: list,
|
|
368
368
|
force: bool = False,
|
|
369
|
+
since_uuid: str | None = None,
|
|
369
370
|
) -> tuple:
|
|
370
371
|
"""Decide which messages to retain and whether to send as a full window.
|
|
371
372
|
|
|
@@ -396,6 +397,17 @@ def select_retain_window(
|
|
|
396
397
|
the whole session even if per-turn windowing had an edge. This costs a
|
|
397
398
|
full sweep only ONCE per session (at end), not per turn.
|
|
398
399
|
|
|
400
|
+
``since_uuid`` (memory-RFC P1) makes that final sweep INCREMENTAL: when
|
|
401
|
+
``force=True`` and a committed watermark uuid is supplied, only the
|
|
402
|
+
transcript tail after it is retained, instead of re-storing the whole
|
|
403
|
+
session on top of the per-window documents that already carry it. The value
|
|
404
|
+
is resolved by the caller (``run_retain`` reads the watermark, wrapped) and
|
|
405
|
+
passed in — this function stays pure and IO-free. ``since_uuid=None``
|
|
406
|
+
reproduces the whole-session sweep exactly, which is what the vendor
|
|
407
|
+
full-session path and the ``retainEveryNTurns==1`` path pass. The slice
|
|
408
|
+
degrades to the whole transcript on a missing/compacted anchor
|
|
409
|
+
(``watermark.tail_after``), so a forced sweep never emits an empty slice.
|
|
410
|
+
|
|
399
411
|
Durability invariant (jtbd-memory-survives-restart UAT): the window
|
|
400
412
|
always extends to the END of the transcript (``slice_last_turns_by_user_boundary``
|
|
401
413
|
returns ``messages[start:]``), so the turn that just completed — the one
|
|
@@ -412,8 +424,21 @@ def select_retain_window(
|
|
|
412
424
|
window_turns = max(retain_every_n, 1) + overlap_turns
|
|
413
425
|
messages_to_retain = slice_last_turns_by_user_boundary(all_messages, window_turns)
|
|
414
426
|
return messages_to_retain, True
|
|
427
|
+
# Forced (SessionEnd) chunked sweep with a committed watermark uuid: retain
|
|
428
|
+
# only the transcript tail after it, instead of re-storing the whole session
|
|
429
|
+
# on top of the N per-window documents that already carry the same content
|
|
430
|
+
# (memory-RFC P1). ``since_uuid`` is None on the vendor full-session path and
|
|
431
|
+
# at retainEveryNTurns==1 (the caller withholds it there — see run_retain),
|
|
432
|
+
# which preserves today's whole-session behaviour byte-for-byte.
|
|
433
|
+
#
|
|
434
|
+
# tail_after is PURE and IO-free (the watermark READ happens in run_retain,
|
|
435
|
+
# wrapped) and degrades to the whole transcript on a missing/compacted
|
|
436
|
+
# anchor, so this branch can never emit an empty slice into the retain seam.
|
|
437
|
+
if force and since_uuid is not None:
|
|
438
|
+
return watermark.tail_after(all_messages, since_uuid), True
|
|
415
439
|
# Full session: vendor full-session mode, OR a forced (SessionEnd) chunked
|
|
416
|
-
# sweep. Retain all messages, always as a
|
|
440
|
+
# sweep with no watermark to slice against. Retain all messages, always as a
|
|
441
|
+
# full window.
|
|
417
442
|
return list(all_messages), True
|
|
418
443
|
|
|
419
444
|
|
|
@@ -702,7 +727,7 @@ def build_retain_payload(
|
|
|
702
727
|
}
|
|
703
728
|
|
|
704
729
|
|
|
705
|
-
def run_retain(hook_input: dict, force: bool = False) -> dict:
|
|
730
|
+
def run_retain(hook_input: dict, force: bool = False, delta: bool = False) -> dict:
|
|
706
731
|
"""Run the auto-retain flow.
|
|
707
732
|
|
|
708
733
|
Returns a status dict::
|
|
@@ -717,6 +742,22 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
|
|
|
717
742
|
process (the SessionStart drainer, see ``drain_pending.py`` and
|
|
718
743
|
``lib/pending.py``). ``status="skipped"`` is the normal early-exit
|
|
719
744
|
cases (auto-retain disabled, empty transcript, throttled chunk).
|
|
745
|
+
|
|
746
|
+
``delta`` (M4 P-RET, per-turn delta retain — called ONLY from
|
|
747
|
+
``prefetch.py``, itself gated on the ``memoryPrefetchEnabled`` kill
|
|
748
|
+
switch, default off): bypasses ``retainEveryNTurns`` entirely (the
|
|
749
|
+
caller already decided this turn fires) and slices ONLY the
|
|
750
|
+
transcript tail after the committed watermark. Ignored when
|
|
751
|
+
``force=True`` (SessionEnd always takes the force path below).
|
|
752
|
+
|
|
753
|
+
**Document-id safety invariant (red-team M4 Finding A, BINDING):** a
|
|
754
|
+
delta retain is ALWAYS posted under a distinct, content-derived
|
|
755
|
+
``document_id`` — the SAME strategy the ``retainEveryNTurns>1``
|
|
756
|
+
chunked path already uses — and NEVER under the bare ``{session_id}``
|
|
757
|
+
id the full-session/n==1 path reserves. Posting a tail slice under
|
|
758
|
+
``{session_id}`` would UPSERT-OVERWRITE the whole session's stored
|
|
759
|
+
memory with just the latest turn on every successful turn (see
|
|
760
|
+
``retain.py`` §4.3 note below and ``tests/test_retain_delta.py``).
|
|
720
761
|
"""
|
|
721
762
|
# /private-mode enforcement (switchroom privacy PR1) — FIRST, before
|
|
722
763
|
# load_config(). An OPEN private interval means the operator is discussing
|
|
@@ -785,35 +826,109 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
|
|
|
785
826
|
retain_every_n = max(1, config.get("retainEveryNTurns", 1))
|
|
786
827
|
retain_full_window = False
|
|
787
828
|
messages_to_retain = all_messages
|
|
788
|
-
|
|
789
|
-
#
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
829
|
+
since_uuid = None
|
|
830
|
+
# True only for the per-turn delta path (M4 P-RET) — threads a distinct,
|
|
831
|
+
# content-derived document_id below (Finding A) instead of the
|
|
832
|
+
# retainEveryNTurns>1 / n==1 strategy.
|
|
833
|
+
delta_document_id_override = False
|
|
834
|
+
|
|
835
|
+
if delta and not force:
|
|
836
|
+
# M4 P-RET — per-turn delta retain, called ONLY from prefetch.py
|
|
837
|
+
# (itself gated on memoryPrefetchEnabled, default off). Bypasses the
|
|
838
|
+
# retainEveryNTurns throttle entirely: the caller already decided
|
|
839
|
+
# this turn fires, every turn, superseding the P5 cadence on a
|
|
840
|
+
# flipped agent (config comment mandated at the switchroom.yaml
|
|
841
|
+
# seam — see P-CFG). Slices ONLY the transcript tail after the
|
|
842
|
+
# committed watermark; the §4.3 HAZARD discipline applies here too —
|
|
843
|
+
# any watermark-read failure MUST degrade to the full window, never
|
|
844
|
+
# raise, never emit an empty slice.
|
|
845
|
+
try:
|
|
846
|
+
_wm = watermark.load(session_id)
|
|
847
|
+
since_uuid = _wm.get("last_uuid") if _wm else None
|
|
848
|
+
except Exception as e: # noqa: BLE001 - degrade, never raise into this seam
|
|
849
|
+
print(
|
|
850
|
+
"[Hindsight] watermark read failed for delta retain; "
|
|
851
|
+
f"falling back to full-window slice: {e}",
|
|
852
|
+
file=sys.stderr,
|
|
853
|
+
)
|
|
854
|
+
since_uuid = None
|
|
855
|
+
messages_to_retain = watermark.tail_after(all_messages, since_uuid)
|
|
856
|
+
retain_full_window = True
|
|
857
|
+
delta_document_id_override = True
|
|
811
858
|
debug_log(
|
|
812
859
|
config,
|
|
813
|
-
f"
|
|
860
|
+
f"Delta retain firing (since_uuid={since_uuid!r}, "
|
|
861
|
+
f"{len(messages_to_retain)}/{len(all_messages)} messages)",
|
|
814
862
|
)
|
|
815
863
|
else:
|
|
816
|
-
|
|
864
|
+
# Respect retainEveryNTurns in both modes, unless force=True (SessionEnd final retain)
|
|
865
|
+
if retain_every_n > 1 and not force:
|
|
866
|
+
turn_count = increment_turn_count(session_id)
|
|
867
|
+
if turn_count % retain_every_n != 0:
|
|
868
|
+
next_at = ((turn_count // retain_every_n) + 1) * retain_every_n
|
|
869
|
+
debug_log(config, f"Turn {turn_count}/{retain_every_n}, skipping retain (next at turn {next_at})")
|
|
870
|
+
return {"status": "skipped", "reason": "throttled"}
|
|
871
|
+
|
|
872
|
+
# Window selection is decoupled from the throttle above — see
|
|
873
|
+
# select_retain_window() for the switchroom-divergence rationale
|
|
874
|
+
# (Phase 6b: chunked window-slicing now works at retainEveryNTurns=1).
|
|
875
|
+
overlap_turns = config.get("retainOverlapTurns", 0)
|
|
876
|
+
|
|
877
|
+
# Incremental SessionEnd sweep (memory-RFC P1): a forced chunked sweep at
|
|
878
|
+
# retainEveryNTurns>1 retains only the transcript tail after the committed
|
|
879
|
+
# watermark, not the whole session on top of the N per-window documents that
|
|
880
|
+
# already carry it. The watermark READ is filesystem IO on a JSON file that
|
|
881
|
+
# could be truncated / permission-broken / absent, so it lives HERE (not in
|
|
882
|
+
# the pure select_retain_window) inside a catch-all try/except.
|
|
883
|
+
#
|
|
884
|
+
# §4.3 HAZARD: this is the one seam where a raise DELETES a turn rather than
|
|
885
|
+
# degrading it. Every failure mode below MUST degrade to since_uuid=None,
|
|
886
|
+
# which reproduces today's whole-session sweep exactly. The except catches
|
|
887
|
+
# Exception (not a named subset) on purpose — any raise here is worse than a
|
|
888
|
+
# wrong value. The n==1 and full-session paths deliberately never compute a
|
|
889
|
+
# watermark UNLESS the M4 delta mechanism is enabled for this agent
|
|
890
|
+
# (``memoryPrefetchEnabled`` — red-team Finding A2): with the mechanism off
|
|
891
|
+
# (the fleet default) the document id is {session_id} and a tail slice
|
|
892
|
+
# would TRUNCATE the stored document (§4.2), so they keep the full-session
|
|
893
|
+
# sweep. With it ON, a per-turn delta retain has already been posting
|
|
894
|
+
# content-derived slice documents and advancing this same watermark every
|
|
895
|
+
# turn, so the force sweep MUST also go incremental — otherwise it
|
|
896
|
+
# blindly re-posts the whole session under {session_id} on top of the
|
|
897
|
+
# per-turn delta documents (redundant storage, not the destructive
|
|
898
|
+
# truncation Finding A guards, but still wrong for cadence-1 agents).
|
|
899
|
+
if force and retain_mode == "chunked" and (
|
|
900
|
+
retain_every_n > 1 or config.get("memoryPrefetchEnabled")
|
|
901
|
+
):
|
|
902
|
+
try:
|
|
903
|
+
_wm = watermark.load(session_id)
|
|
904
|
+
since_uuid = _wm.get("last_uuid") if _wm else None
|
|
905
|
+
except Exception as e: # noqa: BLE001 - degrade, never raise into this seam
|
|
906
|
+
print(
|
|
907
|
+
"[Hindsight] watermark read failed for incremental sweep; "
|
|
908
|
+
f"falling back to full-session sweep: {e}",
|
|
909
|
+
file=sys.stderr,
|
|
910
|
+
)
|
|
911
|
+
since_uuid = None
|
|
912
|
+
|
|
913
|
+
messages_to_retain, retain_full_window = select_retain_window(
|
|
914
|
+
retain_mode, retain_every_n, overlap_turns, all_messages, force=force,
|
|
915
|
+
since_uuid=since_uuid,
|
|
916
|
+
)
|
|
917
|
+
if retain_mode == "chunked" and not force:
|
|
918
|
+
window_turns = max(retain_every_n, 1) + overlap_turns
|
|
919
|
+
debug_log(
|
|
920
|
+
config,
|
|
921
|
+
f"Chunked retain firing (window: {window_turns} human turns, {len(messages_to_retain)} messages)",
|
|
922
|
+
)
|
|
923
|
+
elif retain_mode == "chunked" and force:
|
|
924
|
+
_swept = "incremental (tail after watermark)" if since_uuid is not None else "full-session"
|
|
925
|
+
debug_log(
|
|
926
|
+
config,
|
|
927
|
+
f"Chunked retain, forced {_swept} sweep (SessionEnd): "
|
|
928
|
+
f"{len(messages_to_retain)}/{len(all_messages)} messages",
|
|
929
|
+
)
|
|
930
|
+
else:
|
|
931
|
+
debug_log(config, f"Full session retain: {len(all_messages)} messages")
|
|
817
932
|
|
|
818
933
|
# Resolve API URL
|
|
819
934
|
def _dbg(*a):
|
|
@@ -853,7 +968,14 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
|
|
|
853
968
|
# - Full-session mode / n==1: unchanged — a stable ``session_id`` base with
|
|
854
969
|
# a compaction chunk counter so a shrinking transcript doesn't overwrite
|
|
855
970
|
# the pre-compaction document with a shorter one.
|
|
856
|
-
if
|
|
971
|
+
if delta_document_id_override:
|
|
972
|
+
# M4 P-RET (red-team Finding A, BINDING): a delta retain ALWAYS gets
|
|
973
|
+
# a content-derived slice id — mirroring the retainEveryNTurns>1
|
|
974
|
+
# strategy — and must NEVER fall through to the bare {session_id}
|
|
975
|
+
# id below, which the full-session/n==1 path reserves and which a
|
|
976
|
+
# tail-slice POST would silently truncate.
|
|
977
|
+
document_id = None # build_retain_payload computes the content-derived id
|
|
978
|
+
elif retain_mode == "chunked" and retain_every_n > 1:
|
|
857
979
|
document_id = None # build_retain_payload computes the content-derived id
|
|
858
980
|
else:
|
|
859
981
|
chunk_index, compacted = track_retention(session_id, len(all_messages))
|
|
@@ -953,6 +1075,23 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
|
|
|
953
1075
|
|
|
954
1076
|
|
|
955
1077
|
def main():
|
|
1078
|
+
# M4 P-WIRE — when the prefetch mechanism is on for this agent,
|
|
1079
|
+
# `prefetch.py`'s OWN Stop-hook entry already performs a delta retain
|
|
1080
|
+
# every turn (see `prefetch.run_prefetch`, step 1). Running THIS Stop
|
|
1081
|
+
# hook's own (non-delta) retain as well would double-retain the same
|
|
1082
|
+
# turn: two independent POSTs, and at `retainEveryNTurns > 1` two
|
|
1083
|
+
# independent `increment_turn_count` calls drifting the cadence
|
|
1084
|
+
# throttle out of sync with itself. Skip here — flag OFF (the default,
|
|
1085
|
+
# every agent today) leaves this function's behaviour byte-identical to
|
|
1086
|
+
# pre-M4; this check is the ONLY new code on that path.
|
|
1087
|
+
try:
|
|
1088
|
+
_cfg = load_config()
|
|
1089
|
+
if _cfg.get("memoryPrefetchEnabled", False):
|
|
1090
|
+
debug_log(_cfg, "Stop retain: memoryPrefetchEnabled, delegating to prefetch.py")
|
|
1091
|
+
return
|
|
1092
|
+
except Exception: # pragma: no cover - defensive, never block the legacy path
|
|
1093
|
+
pass
|
|
1094
|
+
|
|
956
1095
|
try:
|
|
957
1096
|
hook_input = json.load(sys.stdin)
|
|
958
1097
|
except (json.JSONDecodeError, EOFError):
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Switchroom — `retainToolCalls` must have an env channel, default unchanged.
|
|
2
|
+
|
|
3
|
+
RFC memory-redesign P4. `retainToolCalls` (whether retain stores tool_use
|
|
4
|
+
inputs + tool_result content) had a DEFAULTS entry of True but NO entry in
|
|
5
|
+
`ENV_OVERRIDES` and no scaffold stamp, so an operator could not reach it: env
|
|
6
|
+
is the TOP of the plugin's config precedence chain (DEFAULTS -> settings.json
|
|
7
|
+
-> ~/.hindsight/claude-code.json -> env), and without an env key
|
|
8
|
+
`HINDSIGHT_RETAIN_TOOL_CALLS` could not steer the plugin at all — including a
|
|
9
|
+
docker-exec'd retain/backfill that does not inherit the supervised settings.
|
|
10
|
+
|
|
11
|
+
This PR ships the SETTER ONLY. The invariants under test:
|
|
12
|
+
|
|
13
|
+
1. `HINDSIGHT_RETAIN_TOOL_CALLS` is wired in `ENV_OVERRIDES` and maps to the
|
|
14
|
+
`retainToolCalls` config key.
|
|
15
|
+
2. The shipped default is unchanged — `True` — so absent any override the
|
|
16
|
+
fleet behaves byte-identically (the load resolves True with no env set).
|
|
17
|
+
3. Setting the env `false` actually LANDS as Python `False` over the True
|
|
18
|
+
default (proving `false` is reachable, which is the whole point of the
|
|
19
|
+
knob), and `true`/`1`/`yes` resolve back to True.
|
|
20
|
+
|
|
21
|
+
Stdlib-only.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import unittest
|
|
27
|
+
from unittest import mock
|
|
28
|
+
|
|
29
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
30
|
+
if SCRIPTS_DIR not in sys.path:
|
|
31
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
32
|
+
|
|
33
|
+
from lib.config import DEFAULTS, ENV_OVERRIDES, load_config # noqa: E402
|
|
34
|
+
|
|
35
|
+
ENV_NAME = "HINDSIGHT_RETAIN_TOOL_CALLS"
|
|
36
|
+
CONFIG_KEY = "retainToolCalls"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _load_with(env):
|
|
40
|
+
"""load_config() with a hermetic environment (no plugin/user settings)."""
|
|
41
|
+
with mock.patch.dict(os.environ, env, clear=True):
|
|
42
|
+
os.environ["CLAUDE_PLUGIN_ROOT"] = os.path.join(SCRIPTS_DIR, "does-not-exist")
|
|
43
|
+
os.environ["HOME"] = os.path.join(SCRIPTS_DIR, "does-not-exist")
|
|
44
|
+
return load_config()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RetainToolCallsHasAChannel(unittest.TestCase):
|
|
48
|
+
def test_env_name_is_wired(self):
|
|
49
|
+
self.assertIn(
|
|
50
|
+
ENV_NAME,
|
|
51
|
+
ENV_OVERRIDES,
|
|
52
|
+
f"{ENV_NAME} exported nowhere / never read — the drift P4 closes",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def test_env_name_maps_to_the_expected_config_key(self):
|
|
56
|
+
self.assertEqual(ENV_OVERRIDES[ENV_NAME][0], CONFIG_KEY)
|
|
57
|
+
|
|
58
|
+
def test_env_name_is_typed_bool(self):
|
|
59
|
+
self.assertIs(ENV_OVERRIDES[ENV_NAME][1], bool)
|
|
60
|
+
|
|
61
|
+
def test_target_key_exists_in_defaults(self):
|
|
62
|
+
self.assertIn(CONFIG_KEY, DEFAULTS)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DefaultIsUnchanged(unittest.TestCase):
|
|
66
|
+
"""The critical P4 invariant: default stays True, byte-identical fleet."""
|
|
67
|
+
|
|
68
|
+
def test_shipped_default_is_true(self):
|
|
69
|
+
self.assertIs(DEFAULTS[CONFIG_KEY], True)
|
|
70
|
+
|
|
71
|
+
def test_no_env_reproduces_the_true_default(self):
|
|
72
|
+
cfg = _load_with({})
|
|
73
|
+
self.assertIs(cfg[CONFIG_KEY], True)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ValuesActuallyLand(unittest.TestCase):
|
|
77
|
+
"""The setter must be reachable: env `false` lands as Python False."""
|
|
78
|
+
|
|
79
|
+
def test_false_env_overrides_the_true_default(self):
|
|
80
|
+
cfg = _load_with({ENV_NAME: "false"})
|
|
81
|
+
self.assertIs(cfg[CONFIG_KEY], False)
|
|
82
|
+
self.assertNotEqual(cfg[CONFIG_KEY], DEFAULTS[CONFIG_KEY])
|
|
83
|
+
|
|
84
|
+
def test_zero_and_no_also_resolve_false(self):
|
|
85
|
+
for falsey in ("0", "no", "False"):
|
|
86
|
+
with self.subTest(value=falsey):
|
|
87
|
+
cfg = _load_with({ENV_NAME: falsey})
|
|
88
|
+
self.assertIs(cfg[CONFIG_KEY], False)
|
|
89
|
+
|
|
90
|
+
def test_true_env_resolves_true(self):
|
|
91
|
+
for truthy in ("true", "1", "yes", "TRUE"):
|
|
92
|
+
with self.subTest(value=truthy):
|
|
93
|
+
cfg = _load_with({ENV_NAME: truthy})
|
|
94
|
+
self.assertIs(cfg[CONFIG_KEY], True)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
unittest.main()
|
|
@@ -243,6 +243,58 @@ class FormatActiveDirectivesBlockTests(unittest.TestCase):
|
|
|
243
243
|
# The in-prompt footer is still emitted (agent-facing signal).
|
|
244
244
|
self.assertIn("(+4 more, omitted)", out)
|
|
245
245
|
|
|
246
|
+
def test_overflow_footer_is_loud_and_operator_directed(self):
|
|
247
|
+
"""memory-RFC P7 — the in-prompt overflow notice must be LOUD and tell
|
|
248
|
+
the agent to surface the drop to the operator this turn.
|
|
249
|
+
|
|
250
|
+
The pre-P7 block emitted only the quiet `(+N more, omitted)`
|
|
251
|
+
parenthetical, which reaches the agent but neither states that explicit
|
|
252
|
+
instructions were dropped nor directs the agent to relay it. This
|
|
253
|
+
asserts the OUTCOME of the loud in-turn channel: the rendered block, on
|
|
254
|
+
overflow, names the dropped count, the total, the cap, states the loss
|
|
255
|
+
in unmistakable terms, and instructs the agent to tell the operator.
|
|
256
|
+
A test that only checked `(+N more, omitted)` would still pass on the
|
|
257
|
+
pre-P7 code, so it would not guard this change; these assertions fail
|
|
258
|
+
without it.
|
|
259
|
+
"""
|
|
260
|
+
total = MAX_DIRECTIVES + 3
|
|
261
|
+
directives = [
|
|
262
|
+
_directive(f"d{i}", f"c{i}", priority=total - i) for i in range(total)
|
|
263
|
+
]
|
|
264
|
+
with patch("sys.stderr", new=StringIO()):
|
|
265
|
+
out = format_active_directives_block(directives)
|
|
266
|
+
# A loud, explicitly-labelled overflow banner (not a lone parenthetical).
|
|
267
|
+
self.assertIn("DIRECTIVE OVERFLOW", out)
|
|
268
|
+
# Names the loss in unmistakable terms, with count/total/cap.
|
|
269
|
+
self.assertIn("DROPPED", out)
|
|
270
|
+
self.assertIn("3 of", out) # omitted of total
|
|
271
|
+
self.assertIn(str(total), out)
|
|
272
|
+
self.assertIn(f"MAX_DIRECTIVES={MAX_DIRECTIVES}", out)
|
|
273
|
+
# Directs the agent to surface it to the OPERATOR this turn — the
|
|
274
|
+
# in-turn operator-visible channel P7 adds.
|
|
275
|
+
self.assertIn("operator", out.lower())
|
|
276
|
+
self.assertIn("ACTION", out)
|
|
277
|
+
# The literal marker is retained for the recall_log/doctor cross-checks
|
|
278
|
+
# and the dedup parser.
|
|
279
|
+
self.assertIn("(+3 more, omitted)", out)
|
|
280
|
+
|
|
281
|
+
def test_no_overflow_banner_when_under_cap(self):
|
|
282
|
+
"""The loud banner must appear ONLY on overflow — an under-cap block is
|
|
283
|
+
unchanged and carries neither the banner nor the action directive."""
|
|
284
|
+
directives = [_directive(f"d{i}", f"c{i}", priority=5 - i) for i in range(3)]
|
|
285
|
+
with patch("sys.stderr", new=StringIO()) as fake_err:
|
|
286
|
+
out = format_active_directives_block(directives)
|
|
287
|
+
self.assertNotIn("DIRECTIVE OVERFLOW", out)
|
|
288
|
+
self.assertNotIn("ACTION", out)
|
|
289
|
+
self.assertNotIn("more, omitted", out)
|
|
290
|
+
self.assertEqual(fake_err.getvalue(), "")
|
|
291
|
+
|
|
292
|
+
def test_max_directives_is_unchanged_by_p7(self):
|
|
293
|
+
"""P7 is the loud-channel PR only. Raising MAX_DIRECTIVES is a separate,
|
|
294
|
+
later change (RFC §5 P7: order is non-negotiable). Pin the constant so a
|
|
295
|
+
cap bump cannot ride in on this change unnoticed."""
|
|
296
|
+
self.assertEqual(MAX_DIRECTIVES, 30)
|
|
297
|
+
|
|
246
298
|
def test_count_omitted_directives_matches_the_rendered_footer(self):
|
|
247
299
|
"""The recall_log's `directives_omitted` number must equal what the
|
|
248
300
|
block actually dropped — it is the operator-visible record of it."""
|