switchroom 0.19.27 → 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 +16 -2
- package/dist/auth-broker/index.js +141 -8
- package/dist/cli/autoaccept-poll.js +225 -17
- package/dist/cli/notion-write-pretool.mjs +18 -2
- package/dist/cli/switchroom.js +864 -43
- package/dist/host-control/main.js +144 -9
- package/dist/vault/approvals/kernel-server.js +146 -9
- package/dist/vault/broker/server.js +146 -9
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +79 -15
- package/telegram-plugin/dist/bridge/bridge.js +1 -0
- package/telegram-plugin/dist/gateway/gateway.js +585 -50
- package/telegram-plugin/dist/server.js +1 -0
- package/telegram-plugin/edit-flood-fuse.ts +230 -27
- package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
- package/telegram-plugin/gateway/gateway.ts +9 -2
- package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
- package/telegram-plugin/mcp-credential-failure.ts +459 -0
- package/telegram-plugin/operator-events.ts +38 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +58 -1
- package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
- package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
- package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
- 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 +437 -11
- 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 +199 -28
- 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_drain_circuit_breaker.py +401 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
- package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +325 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +160 -1
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +40 -2
- package/vendor/hindsight-memory/settings.json +1 -1
- package/vendor/hindsight-memory/tests/test_hooks.py +11 -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
|
|
|
@@ -556,6 +562,112 @@ def _dir_bytes(d: str, names) -> int:
|
|
|
556
562
|
return total
|
|
557
563
|
|
|
558
564
|
|
|
565
|
+
def _append_ledger(line: str) -> None:
|
|
566
|
+
"""Append one line to ``pending-evictions.log``, rotating at its cap.
|
|
567
|
+
|
|
568
|
+
Split out of ``_log_eviction`` (#3688 review R1-M2) so the OTHER way a
|
|
569
|
+
payload leaves this module for good — ``_trim_dir`` shedding an archived
|
|
570
|
+
copy — can be ledgered on exactly the same terms instead of leaving no
|
|
571
|
+
durable trace at all. Best-effort: a ledger that cannot be written *or
|
|
572
|
+
read back* must never take the caller down with it — hence the guard
|
|
573
|
+
catches ``ValueError`` as well as ``OSError`` (see there).
|
|
574
|
+
|
|
575
|
+
ROTATION PRIORITISES ``evicted=`` INSIDE THE SAME BUDGET, and that is a
|
|
576
|
+
direct consequence of sharing the file. Before #3688 every line here
|
|
577
|
+
was an eviction, so "keep the newest ``EVICTIONS_LOG_KEEP_LINES``
|
|
578
|
+
lines" and "keep the newest 2,000 evictions" were the same sentence.
|
|
579
|
+
They are not any more: a single ``collapse_duplicates`` run over the
|
|
580
|
+
measured 1,060-file backlog writes ~192 ``trimmed=`` lines, and a plain
|
|
581
|
+
tail-rotate would let that noise push real ``evicted=`` lines out of
|
|
582
|
+
the 7-day window ``switchroom doctor`` reads — i.e. adding
|
|
583
|
+
observability for the BENIGN channel would have removed it for the one
|
|
584
|
+
channel that means memory is actually gone.
|
|
585
|
+
|
|
586
|
+
So the keep window is filled evictions-first and only then topped up
|
|
587
|
+
with the newest remaining lines. The budget is unchanged at
|
|
588
|
+
``EVICTIONS_LOG_KEEP_LINES`` lines TOTAL — a ledger of nothing but
|
|
589
|
+
evictions rotates exactly as it did before #3688 — and chronological
|
|
590
|
+
order is preserved because the selection is by index over the original
|
|
591
|
+
lines, which ``switchroom doctor``'s ``$1 >= cutoff`` awk depends on.
|
|
592
|
+
"""
|
|
593
|
+
log = evictions_log_path()
|
|
594
|
+
try:
|
|
595
|
+
with open(log, "a", encoding="utf-8") as f:
|
|
596
|
+
print(line, file=f)
|
|
597
|
+
# Bounded, not append-forever. `switchroom doctor` windows this by
|
|
598
|
+
# timestamp so a single legitimate eviction can't turn the row red
|
|
599
|
+
# permanently, but the FILE still needs a ceiling of its own.
|
|
600
|
+
if os.path.getsize(log) > EVICTIONS_LOG_MAX_BYTES:
|
|
601
|
+
with open(log, encoding="utf-8") as f:
|
|
602
|
+
lines = f.readlines()
|
|
603
|
+
evictions = [i for i, ln in enumerate(lines) if "evicted=" in ln]
|
|
604
|
+
others = [i for i, ln in enumerate(lines) if "evicted=" not in ln]
|
|
605
|
+
keep_idx = set(evictions[-EVICTIONS_LOG_KEEP_LINES:])
|
|
606
|
+
room = EVICTIONS_LOG_KEEP_LINES - len(keep_idx)
|
|
607
|
+
if room > 0:
|
|
608
|
+
keep_idx.update(others[-room:])
|
|
609
|
+
kept = [ln for i, ln in enumerate(lines) if i in keep_idx]
|
|
610
|
+
tmp = log + ".tmp"
|
|
611
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
612
|
+
f.writelines(kept)
|
|
613
|
+
os.chmod(tmp, 0o600)
|
|
614
|
+
os.replace(tmp, log)
|
|
615
|
+
except (OSError, ValueError):
|
|
616
|
+
# ValueError, not just OSError, because BOTH codec errors are
|
|
617
|
+
# ValueError subclasses and neither is an OSError (#3688 re-review
|
|
618
|
+
# B1). The rotate READS the ledger back (``readlines()`` above) —
|
|
619
|
+
# something no revision before #3688 did — so a ledger holding one
|
|
620
|
+
# non-UTF-8 byte raises ``UnicodeDecodeError`` here, and a name
|
|
621
|
+
# carrying a surrogate raises ``UnicodeEncodeError`` at the
|
|
622
|
+
# ``print`` above. Under a bare ``except OSError`` both escape this
|
|
623
|
+
# "best-effort" guard: ``_trim_dir`` → ``_log_archive_trim`` → here
|
|
624
|
+
# runs inside ``archive_reconciled``/``enqueue``, so one corrupt
|
|
625
|
+
# ledger byte would take down every drain — on the sidecar,
|
|
626
|
+
# identically, every 900s forever. A ledger that cannot be written
|
|
627
|
+
# must never take the caller down with it, and that has to include
|
|
628
|
+
# the codec.
|
|
629
|
+
pass
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _log_archive_trim(
|
|
633
|
+
name: str, size: int, reason: str, archive: str, depth: int, nbytes: int
|
|
634
|
+
) -> None:
|
|
635
|
+
"""Ledger ONE archived copy shed by ``_trim_dir``.
|
|
636
|
+
|
|
637
|
+
Until #3688 review R1-M2 a trim was stderr-only: an entry could enter
|
|
638
|
+
``pending-duplicate/`` (or ``pending-evicted/``, or
|
|
639
|
+
``pending-reconciled/``) and then be deleted from it with no durable
|
|
640
|
+
record anywhere, while an *eviction* — the other end of the same
|
|
641
|
+
payload's life — has had ``pending-evictions.log`` since #3599. stderr
|
|
642
|
+
is gone with the process; the ledger is what an operator can still read
|
|
643
|
+
a week later. Same file, because it answers one question ("what left
|
|
644
|
+
this agent's queue, and when").
|
|
645
|
+
|
|
646
|
+
THE FIRST TOKEN IS DELIBERATELY ``trimmed=``, NOT ``evicted=``.
|
|
647
|
+
``switchroom doctor``'s probe counts eviction ledger lines with
|
|
648
|
+
``awk '$1 >= cutoff && /evicted=/'`` (src/cli/doctor.ts, see
|
|
649
|
+
``buildPendingRetainsProbeScript``), and that count FAILS the
|
|
650
|
+
pending-retains row. A trim is not an eviction — nothing was shed from
|
|
651
|
+
the live queue and, for ``pending-duplicate/``, a byte-identical copy is
|
|
652
|
+
still queued — so a trim must not be able to turn that row red. No field
|
|
653
|
+
on this line may ever be formatted such that ``evicted=`` appears in it;
|
|
654
|
+
``ArchiveTrimLedgerTest`` pins that.
|
|
655
|
+
"""
|
|
656
|
+
_append_ledger(
|
|
657
|
+
"%s trimmed=%s bytes=%d archive=%s reason=archive-%s "
|
|
658
|
+
"archive_depth=%d archive_bytes=%d"
|
|
659
|
+
% (
|
|
660
|
+
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
661
|
+
name,
|
|
662
|
+
size,
|
|
663
|
+
archive,
|
|
664
|
+
reason,
|
|
665
|
+
depth,
|
|
666
|
+
nbytes,
|
|
667
|
+
)
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
|
|
559
671
|
def _trim_dir(a: str, max_entries: int, max_bytes: int) -> int:
|
|
560
672
|
"""Keep ``a`` under its count/byte caps, deleting OLDEST first.
|
|
561
673
|
|
|
@@ -610,6 +722,21 @@ def _trim_dir(a: str, max_entries: int, max_bytes: int) -> int:
|
|
|
610
722
|
``pending-evicted/`` is a different story again — see ``_evict_to_fit``:
|
|
611
723
|
an entry only reaches it through the ledgered eviction path, and under
|
|
612
724
|
sustained ENOSPC it may not reach it at all.
|
|
725
|
+
|
|
726
|
+
EVERY DROP IS LEDGERED, AND ONLY A DROP (#3688 review R1-M2, tightened
|
|
727
|
+
by re-review B2). One ``trimmed=… archive=…`` line per shed copy goes
|
|
728
|
+
to ``pending-evictions.log`` via ``_log_archive_trim``, so the trim
|
|
729
|
+
horizon above is observable after the fact and not only in a stderr
|
|
730
|
+
stream nobody kept. That line is deliberately NOT counted as an
|
|
731
|
+
eviction by doctor — see ``_log_archive_trim``.
|
|
732
|
+
|
|
733
|
+
A victim whose ``os.remove`` FAILS (EACCES, read-only mount) is neither
|
|
734
|
+
ledgered nor counted nor named on stderr: it is still on disk, so a
|
|
735
|
+
``trimmed=`` line for it would be a false claim of deletion in exactly
|
|
736
|
+
the record an operator consults after suspected loss, and the returned
|
|
737
|
+
count is what callers report as "copies shed". ``TrimDirBoundaryTest``
|
|
738
|
+
pins that a failed remove yields ``0``, no ledger line, and every file
|
|
739
|
+
still present.
|
|
613
740
|
"""
|
|
614
741
|
# `.json` ONLY, and that is load-bearing beyond "skip stray files": a
|
|
615
742
|
# `.dead` marker is named `<entry>.json.dead`, so it can never be
|
|
@@ -622,14 +749,35 @@ def _trim_dir(a: str, max_entries: int, max_bytes: int) -> int:
|
|
|
622
749
|
except OSError:
|
|
623
750
|
return 0
|
|
624
751
|
dropped = []
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
):
|
|
752
|
+
label = os.path.basename(a.rstrip("/"))
|
|
753
|
+
nbytes = _dir_bytes(a, names)
|
|
754
|
+
while names and (len(names) > max_entries or nbytes > max_bytes):
|
|
755
|
+
reason = "count" if len(names) > max_entries else "bytes"
|
|
756
|
+
victim = names.pop(0)
|
|
757
|
+
vpath = os.path.join(a, victim)
|
|
628
758
|
try:
|
|
629
|
-
os.
|
|
759
|
+
vsize = os.path.getsize(vpath)
|
|
630
760
|
except OSError:
|
|
631
|
-
|
|
632
|
-
|
|
761
|
+
vsize = 0
|
|
762
|
+
try:
|
|
763
|
+
os.remove(vpath)
|
|
764
|
+
except OSError:
|
|
765
|
+
# The copy is still on disk, so the running total would drift.
|
|
766
|
+
# Re-measure rather than assume — this preserves the original
|
|
767
|
+
# recompute-every-iteration semantics on the one path where a
|
|
768
|
+
# decrement would be wrong (EACCES / read-only mount).
|
|
769
|
+
nbytes = _dir_bytes(a, names)
|
|
770
|
+
# NOT counted and NOT ledgered (#3688 re-review B2). The ledger
|
|
771
|
+
# is the forensic record an operator reads AFTER suspected data
|
|
772
|
+
# loss; a `trimmed=` line for a copy still sitting on disk sends
|
|
773
|
+
# them hunting a payload that never left, and the returned count
|
|
774
|
+
# (and the stderr line below) would name files that were not
|
|
775
|
+
# dropped. A ledger that claims deletions which never happened
|
|
776
|
+
# is worse than no ledger.
|
|
777
|
+
continue
|
|
778
|
+
nbytes -= vsize
|
|
779
|
+
dropped.append(victim)
|
|
780
|
+
_log_archive_trim(victim, vsize, reason, label, len(names), nbytes)
|
|
633
781
|
if dropped:
|
|
634
782
|
shown = ", ".join(dropped[:10])
|
|
635
783
|
if len(dropped) > 10:
|
|
@@ -736,6 +884,47 @@ def archive_duplicate(path: str) -> Optional[str]:
|
|
|
736
884
|
return dest
|
|
737
885
|
|
|
738
886
|
|
|
887
|
+
def _attempt_count(entry: dict) -> int:
|
|
888
|
+
"""How many retries this copy has BURNED, for survivor selection.
|
|
889
|
+
|
|
890
|
+
Not ``int(entry.get("attempt_count", 1) or 1)`` (#3688 review R1-L3).
|
|
891
|
+
That expression had two defects, both latent rather than reachable
|
|
892
|
+
today — ``_build_entry`` seeds ``1`` and ``update_attempt`` only ever
|
|
893
|
+
increments — which is exactly why they need pinning rather than
|
|
894
|
+
ignoring: nothing would have caught them turning real.
|
|
895
|
+
|
|
896
|
+
* ``0 or 1`` is ``1``, so a NEVER-ATTEMPTED copy scored the same as one
|
|
897
|
+
that had already burned an attempt, and lost the tie-break to it if
|
|
898
|
+
it happened to be the newer file. That is backwards from the rule
|
|
899
|
+
``collapse_duplicates`` documents ("the copy with the most retries
|
|
900
|
+
left before ``MAX_ATTEMPTS`` wins") — a 0 must beat a 1.
|
|
901
|
+
* ``int("many")`` RAISES. A single semantically-malformed entry — valid
|
|
902
|
+
JSON, so ``iter_entries`` hands it over rather than quarantining it —
|
|
903
|
+
took the whole collapse pass down with it, and with it (before the
|
|
904
|
+
guard in ``drain_pending._drain_backlog_impl``) the whole drain.
|
|
905
|
+
Unparseable now scores ``MAX_ATTEMPTS``: we cannot tell how many
|
|
906
|
+
attempts it has left, so it is never PREFERRED as the survivor, but
|
|
907
|
+
it is never the reason a collapse fails either. Losing it costs
|
|
908
|
+
nothing anyway — every copy in a group is byte-identical, and the
|
|
909
|
+
loser is archived, not deleted.
|
|
910
|
+
|
|
911
|
+
A missing or ``None`` count reads as ``1``, matching what
|
|
912
|
+
``_build_entry`` writes, so an entry from an older build that predates
|
|
913
|
+
the field is treated as freshly queued rather than exhausted.
|
|
914
|
+
"""
|
|
915
|
+
raw = entry.get("attempt_count", 1)
|
|
916
|
+
if raw is None:
|
|
917
|
+
return 1
|
|
918
|
+
# bool is an int subclass; True would silently score 1. Neither True nor
|
|
919
|
+
# False is an attempt count, so both take the unparseable branch.
|
|
920
|
+
if isinstance(raw, bool):
|
|
921
|
+
return MAX_ATTEMPTS
|
|
922
|
+
try:
|
|
923
|
+
return int(raw)
|
|
924
|
+
except (TypeError, ValueError):
|
|
925
|
+
return MAX_ATTEMPTS
|
|
926
|
+
|
|
927
|
+
|
|
739
928
|
def collapse_duplicates() -> int:
|
|
740
929
|
"""Collapse entries sharing ``(bank_id, part_position, sha256(content))``.
|
|
741
930
|
|
|
@@ -754,7 +943,8 @@ def collapse_duplicates() -> int:
|
|
|
754
943
|
memory; the one with the most attempts left is the one most likely to
|
|
755
944
|
get there before ``MAX_ATTEMPTS`` promotes it to ``.dead``. Picking the
|
|
756
945
|
oldest outright would systematically keep the most-attempted copy —
|
|
757
|
-
exactly backwards.
|
|
946
|
+
exactly backwards. ``_attempt_count`` defines "lowest", including what
|
|
947
|
+
a ``0`` and what a malformed count are worth.
|
|
758
948
|
|
|
759
949
|
An entry whose key is ``None`` (no ``content``) is never grouped: its
|
|
760
950
|
identity cannot be established, so it is always kept.
|
|
@@ -773,10 +963,7 @@ def collapse_duplicates() -> int:
|
|
|
773
963
|
for key, members in groups.items():
|
|
774
964
|
if len(members) < 2:
|
|
775
965
|
continue
|
|
776
|
-
survivor = min(
|
|
777
|
-
members,
|
|
778
|
-
key=lambda m: (int(m[2].get("attempt_count", 1) or 1), m[0]),
|
|
779
|
-
)
|
|
966
|
+
survivor = min(members, key=lambda m: (_attempt_count(m[2]), m[0]))
|
|
780
967
|
for member in members:
|
|
781
968
|
if member is survivor:
|
|
782
969
|
continue
|
|
@@ -1047,23 +1234,7 @@ def _log_eviction(name: str, size: int, reason: str, depth: int, nbytes: int) ->
|
|
|
1047
1234
|
depth,
|
|
1048
1235
|
nbytes,
|
|
1049
1236
|
)
|
|
1050
|
-
|
|
1051
|
-
try:
|
|
1052
|
-
with open(log, "a", encoding="utf-8") as f:
|
|
1053
|
-
print(line, file=f)
|
|
1054
|
-
# Bounded, not append-forever. `switchroom doctor` windows this by
|
|
1055
|
-
# timestamp so a single legitimate eviction can't turn the row red
|
|
1056
|
-
# permanently, but the FILE still needs a ceiling of its own.
|
|
1057
|
-
if os.path.getsize(log) > EVICTIONS_LOG_MAX_BYTES:
|
|
1058
|
-
with open(log, encoding="utf-8") as f:
|
|
1059
|
-
kept = f.readlines()[-(EVICTIONS_LOG_KEEP_LINES):]
|
|
1060
|
-
tmp = log + ".tmp"
|
|
1061
|
-
with open(tmp, "w", encoding="utf-8") as f:
|
|
1062
|
-
f.writelines(kept)
|
|
1063
|
-
os.chmod(tmp, 0o600)
|
|
1064
|
-
os.replace(tmp, log)
|
|
1065
|
-
except OSError:
|
|
1066
|
-
pass
|
|
1237
|
+
_append_ledger(line)
|
|
1067
1238
|
print(
|
|
1068
1239
|
"[Hindsight] pending-retains FULL - evicted OLDEST entry to keep the "
|
|
1069
1240
|
"newest memory: %s (%d bytes, %s; queue now %d entries / %d bytes). "
|
|
@@ -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:
|