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
|
@@ -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))
|
|
@@ -289,7 +289,7 @@ class BoundedRead(unittest.TestCase):
|
|
|
289
289
|
|
|
290
290
|
|
|
291
291
|
class RunSubagentRetain(unittest.TestCase):
|
|
292
|
-
def _run(self, hook_input):
|
|
292
|
+
def _run(self, hook_input, config_extra=None):
|
|
293
293
|
captured = {}
|
|
294
294
|
|
|
295
295
|
class _Client:
|
|
@@ -300,7 +300,8 @@ class RunSubagentRetain(unittest.TestCase):
|
|
|
300
300
|
captured.update(kwargs)
|
|
301
301
|
return {"ok": True}
|
|
302
302
|
|
|
303
|
-
|
|
303
|
+
config = dict(CONFIG, **(config_extra or {}))
|
|
304
|
+
with mock.patch("subagent_retain.load_config", return_value=config), \
|
|
304
305
|
mock.patch("subagent_retain.get_api_url", return_value="http://x"), \
|
|
305
306
|
mock.patch("subagent_retain.ensure_bank_mission"), \
|
|
306
307
|
mock.patch("subagent_retain.derive_bank_id", return_value="agentbank"), \
|
|
@@ -336,6 +337,43 @@ class RunSubagentRetain(unittest.TestCase):
|
|
|
336
337
|
self.assertEqual(captured["context"], "claude-code-sidechain")
|
|
337
338
|
self.assertEqual(captured["metadata"]["parent_session_id"], "parentsess")
|
|
338
339
|
|
|
340
|
+
def test_sidechain_retain_omits_the_scope_when_unconfigured(self):
|
|
341
|
+
# switchroom — default behaviour must be byte-identical: the sidechain
|
|
342
|
+
# POST carries no scope, so the engine default stands.
|
|
343
|
+
with tempfile.TemporaryDirectory() as d:
|
|
344
|
+
sc = os.path.join(d, "agent-af5.jsonl")
|
|
345
|
+
_write_sidechain(sc, 8, chars_per_msg=400)
|
|
346
|
+
hook_input = {
|
|
347
|
+
"session_id": "parentsess",
|
|
348
|
+
"agent_id": "af5",
|
|
349
|
+
"agent_transcript_path": sc,
|
|
350
|
+
"transcript_path": os.path.join(d, "parentsess.jsonl"),
|
|
351
|
+
"cwd": d,
|
|
352
|
+
}
|
|
353
|
+
result, captured = self._run(hook_input)
|
|
354
|
+
self.assertEqual(result["status"], "ok")
|
|
355
|
+
self.assertIsNone(captured["observation_scopes"])
|
|
356
|
+
|
|
357
|
+
def test_sidechain_retain_posts_the_configured_scope(self):
|
|
358
|
+
# switchroom — sidechain retains are their own hand-enumerated kwarg
|
|
359
|
+
# list; without this pin they would silently keep per-tag scopes while
|
|
360
|
+
# the parent session's retains moved to the shared one.
|
|
361
|
+
with tempfile.TemporaryDirectory() as d:
|
|
362
|
+
sc = os.path.join(d, "agent-af5.jsonl")
|
|
363
|
+
_write_sidechain(sc, 8, chars_per_msg=400)
|
|
364
|
+
hook_input = {
|
|
365
|
+
"session_id": "parentsess",
|
|
366
|
+
"agent_id": "af5",
|
|
367
|
+
"agent_transcript_path": sc,
|
|
368
|
+
"transcript_path": os.path.join(d, "parentsess.jsonl"),
|
|
369
|
+
"cwd": d,
|
|
370
|
+
}
|
|
371
|
+
result, captured = self._run(
|
|
372
|
+
hook_input, config_extra={"observationScopes": "shared"}
|
|
373
|
+
)
|
|
374
|
+
self.assertEqual(result["status"], "ok")
|
|
375
|
+
self.assertEqual(captured["observation_scopes"], "shared")
|
|
376
|
+
|
|
339
377
|
def test_document_id_is_stable_across_refires(self):
|
|
340
378
|
with tempfile.TemporaryDirectory() as d:
|
|
341
379
|
sc = os.path.join(d, "agent-af5.jsonl")
|