switchroom 0.19.41 → 0.19.42
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 +22 -2
- package/dist/auth-broker/index.js +22 -2
- package/dist/cli/notion-write-pretool.mjs +22 -2
- package/dist/cli/switchroom.js +772 -234
- package/dist/host-control/main.js +23 -3
- package/dist/vault/approvals/kernel-server.js +22 -2
- package/dist/vault/broker/server.js +22 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +97 -83
- package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
- package/telegram-plugin/gateway/gateway.ts +7 -7
- package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
- package/telegram-plugin/gateway/model-command.ts +21 -109
- package/telegram-plugin/gateway/stream-render.ts +57 -0
- package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
- package/vendor/hindsight-memory/CHANGELOG.md +28 -0
- package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
- package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +18 -3
- package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
- package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
- package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Characterisation of ``recall._overlap_tokens`` — the transcript-fallback tokenizer.
|
|
2
|
+
|
|
3
|
+
WHY THIS FILE EXISTS
|
|
4
|
+
--------------------
|
|
5
|
+
``_overlap_tokens`` is the keyword tokenizer behind the #3369 transcript-grep
|
|
6
|
+
recall fallback: a recent transcript turn is injected only if its token set
|
|
7
|
+
intersects the query's (``recall._build_transcript_fallback``). When the
|
|
8
|
+
lexical-overlap recall GATE was removed (#3761, commit e3b9d62f) the gate's
|
|
9
|
+
characterisation tests went with it — but ``_overlap_tokens`` itself stayed
|
|
10
|
+
load-bearing for the fallback. That left a real, shipped tokenizer with no
|
|
11
|
+
direct unit coverage: a change to its rules (letters-only vs alphanumeric, the
|
|
12
|
+
length floor, stopword handling) could silently move which transcript turns
|
|
13
|
+
recall can surface, and nothing would fail.
|
|
14
|
+
|
|
15
|
+
These tests pin the tokenizer's contract directly (#3777), including the #3578
|
|
16
|
+
decision that DIGITS CARRY SIGNAL (issue numbers, ports, versions). They assert
|
|
17
|
+
OUTCOMES of the tokenizer, not code paths, and each would fail on a real
|
|
18
|
+
regression of the documented behaviour.
|
|
19
|
+
|
|
20
|
+
Stdlib-only; runs under ``python3 -m unittest discover tests/``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import unittest
|
|
26
|
+
|
|
27
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
28
|
+
if SCRIPTS_DIR not in sys.path:
|
|
29
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
30
|
+
|
|
31
|
+
import recall # noqa: E402
|
|
32
|
+
|
|
33
|
+
tok = recall._overlap_tokens
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OverlapTokensCharacterisation(unittest.TestCase):
|
|
37
|
+
"""Pure-function contract for ``recall._overlap_tokens`` (#3777)."""
|
|
38
|
+
|
|
39
|
+
# --- empty / non-string inputs -------------------------------------------
|
|
40
|
+
|
|
41
|
+
def test_empty_string_is_empty_set(self):
|
|
42
|
+
self.assertEqual(tok(""), set())
|
|
43
|
+
|
|
44
|
+
def test_none_is_empty_set(self):
|
|
45
|
+
self.assertEqual(tok(None), set())
|
|
46
|
+
|
|
47
|
+
def test_non_string_scalar_is_empty_set(self):
|
|
48
|
+
self.assertEqual(tok(123), set())
|
|
49
|
+
|
|
50
|
+
def test_non_string_container_is_empty_set(self):
|
|
51
|
+
self.assertEqual(tok(["deploy", "server"]), set())
|
|
52
|
+
self.assertEqual(tok({"deploy": 1}), set())
|
|
53
|
+
|
|
54
|
+
def test_whitespace_only_is_empty_set(self):
|
|
55
|
+
self.assertEqual(tok(" \t\n "), set())
|
|
56
|
+
|
|
57
|
+
# --- basic tokenisation ---------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def test_simple_words_split_on_whitespace(self):
|
|
60
|
+
self.assertEqual(tok("deploy staging server"), {"deploy", "staging", "server"})
|
|
61
|
+
|
|
62
|
+
def test_lowercased(self):
|
|
63
|
+
self.assertEqual(tok("Deploy STAGING Server"), {"deploy", "staging", "server"})
|
|
64
|
+
|
|
65
|
+
def test_case_folds_to_a_single_token(self):
|
|
66
|
+
self.assertEqual(tok("DEPLOY deploy Deploy"), {"deploy"})
|
|
67
|
+
|
|
68
|
+
def test_duplicates_collapse_to_a_set(self):
|
|
69
|
+
self.assertEqual(tok("server server server"), {"server"})
|
|
70
|
+
|
|
71
|
+
def test_trailing_token_without_separator_is_flushed(self):
|
|
72
|
+
# The final run has no trailing delimiter; it must still be emitted.
|
|
73
|
+
self.assertEqual(tok("deploy server"), {"deploy", "server"})
|
|
74
|
+
|
|
75
|
+
# --- punctuation as separators -------------------------------------------
|
|
76
|
+
|
|
77
|
+
def test_punctuation_is_a_separator(self):
|
|
78
|
+
self.assertEqual(tok("deploy, the staging server!"), {"deploy", "staging", "server"})
|
|
79
|
+
|
|
80
|
+
def test_hyphen_splits_tokens(self):
|
|
81
|
+
self.assertEqual(tok("multi-word thing"), {"multi", "word", "thing"})
|
|
82
|
+
|
|
83
|
+
def test_underscore_splits_tokens(self):
|
|
84
|
+
# '_' is not alphanumeric, so it is a separator (documented behaviour).
|
|
85
|
+
self.assertEqual(tok("foo_bar baz"), {"foo", "bar", "baz"})
|
|
86
|
+
|
|
87
|
+
def test_dotted_version_splits_on_dots(self):
|
|
88
|
+
self.assertEqual(tok("v0.19.24"), {"v0", "19", "24"})
|
|
89
|
+
|
|
90
|
+
# --- length floor ---------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def test_single_alpha_char_is_dropped(self):
|
|
93
|
+
self.assertEqual(tok("x y z deploy"), {"deploy"})
|
|
94
|
+
|
|
95
|
+
def test_single_digit_char_is_dropped_as_noise(self):
|
|
96
|
+
# A lone digit is noise, not an identifier — dropped by the > 1 guard,
|
|
97
|
+
# exactly like a lone letter.
|
|
98
|
+
self.assertEqual(tok("python 9 angular"), {"python", "angular"})
|
|
99
|
+
|
|
100
|
+
def test_two_char_token_survives_the_floor(self):
|
|
101
|
+
self.assertEqual(tok("pr go"), {"pr", "go"})
|
|
102
|
+
|
|
103
|
+
# --- stopwords ------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def test_stopwords_removed(self):
|
|
106
|
+
self.assertEqual(tok("the is a to of"), set())
|
|
107
|
+
|
|
108
|
+
def test_content_survives_with_stopwords_present(self):
|
|
109
|
+
self.assertEqual(tok("what is the deploy status"), {"deploy", "status"})
|
|
110
|
+
|
|
111
|
+
# --- #3578: digits carry signal ------------------------------------------
|
|
112
|
+
|
|
113
|
+
def test_pure_digit_issue_number_is_a_token(self):
|
|
114
|
+
self.assertEqual(tok("PR 3993"), {"pr", "3993"})
|
|
115
|
+
|
|
116
|
+
def test_hash_prefixed_issue_number_keeps_the_digits(self):
|
|
117
|
+
self.assertEqual(tok("#3541 shipped"), {"3541", "shipped"})
|
|
118
|
+
|
|
119
|
+
def test_colon_prefixed_port_keeps_the_digits(self):
|
|
120
|
+
self.assertEqual(tok(":9077 port"), {"9077", "port"})
|
|
121
|
+
|
|
122
|
+
def test_mixed_alphanumeric_run_stays_intact(self):
|
|
123
|
+
self.assertEqual(tok("abc123def"), {"abc123def"})
|
|
124
|
+
|
|
125
|
+
def test_identifier_overlap_between_query_and_transcript(self):
|
|
126
|
+
# The #3578 motivating case: a transcript turn whose only tie to the
|
|
127
|
+
# query is an issue number must now share a token (it did not before).
|
|
128
|
+
query = tok("did PR 3993 land and what did v0.19.24 change")
|
|
129
|
+
turn = tok("3993 shipped in 0.19.24 last night")
|
|
130
|
+
self.assertIn("3993", query & turn)
|
|
131
|
+
self.assertTrue(query & turn)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
unittest.main()
|
|
@@ -115,21 +115,15 @@ def _run_main_with(client, prompt="what did we decide", config_extra=None):
|
|
|
115
115
|
return json.loads(raw)["hookSpecificOutput"]["additionalContext"], raw
|
|
116
116
|
|
|
117
117
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
`_build_transcript_fallback`'s keyword match — deleting it with the
|
|
128
|
-
gate would silently break the #3369 fallback."""
|
|
129
|
-
self.assertEqual(
|
|
130
|
-
recall._overlap_tokens("Deploy the staging server!"),
|
|
131
|
-
{"deploy", "staging", "server"},
|
|
132
|
-
)
|
|
118
|
+
#
|
|
119
|
+
# NOTE (#3999): the former ``GateHelpersAreGoneTests`` hasattr guard
|
|
120
|
+
# (``assertFalse(hasattr(recall, "containment_overlap"))``) was dropped as
|
|
121
|
+
# redundant — a re-added-but-unwired helper does nothing, and a re-WIRED gate is
|
|
122
|
+
# already caught by the outcome assertions in ``NoLexicalDroppingIntegrationTests``
|
|
123
|
+
# below (which fail if any candidate is dropped for lexical reasons). Direct
|
|
124
|
+
# characterisation of the surviving ``_overlap_tokens`` tokenizer now lives in
|
|
125
|
+
# ``test_overlap_tokens.py`` (#3777), not in a hasattr check here.
|
|
126
|
+
#
|
|
133
127
|
|
|
134
128
|
|
|
135
129
|
class NoLexicalDroppingIntegrationTests(unittest.TestCase):
|
|
@@ -151,17 +145,20 @@ class NoLexicalDroppingIntegrationTests(unittest.TestCase):
|
|
|
151
145
|
self.assertIn("vegan dinner recipes", ctx)
|
|
152
146
|
|
|
153
147
|
def test_identifier_only_match_survives(self):
|
|
154
|
-
"""
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
148
|
+
"""A memory whose ONLY tie to the prompt is an identifier survives.
|
|
149
|
+
|
|
150
|
+
This was the measured root cause of the removed gate: `_overlap_tokens`
|
|
151
|
+
dropped digits, so an identifier-only memory scored 0.0 and was always
|
|
152
|
+
discarded. Two things now hold: (a) #3578 gave the tokenizer digit
|
|
153
|
+
vision, so query and memory now DO share the identifier token; and (b)
|
|
154
|
+
with the gate gone, the memory is injected regardless of overlap."""
|
|
159
155
|
query = PREAMBLE + "did PR #3541 land, and what did v0.19.24 change"
|
|
160
156
|
mem = "#3541 shipped in 0.19.24"
|
|
161
|
-
|
|
157
|
+
# Post-#3578 the identifier is visible on both sides.
|
|
158
|
+
self.assertIn(
|
|
159
|
+
"3541",
|
|
162
160
|
recall._overlap_tokens(query) & recall._overlap_tokens(mem),
|
|
163
|
-
|
|
164
|
-
"fixture drifted: it must share no ALPHABETIC token with the query",
|
|
161
|
+
"the shared identifier must now be a token on both sides (#3578)",
|
|
165
162
|
)
|
|
166
163
|
ctx, _ = _run_main_with(_FakeClient([_memory(mem, score=0.95)]), prompt=query)
|
|
167
164
|
self.assertIsNotNone(ctx)
|
|
@@ -62,6 +62,7 @@ from lib.content import ( # noqa: E402
|
|
|
62
62
|
BM25_STOPWORDS,
|
|
63
63
|
_selectivity_score,
|
|
64
64
|
common_english_words,
|
|
65
|
+
shape_recall_query,
|
|
65
66
|
tokenize_for_bm25,
|
|
66
67
|
)
|
|
67
68
|
|
|
@@ -469,5 +470,52 @@ class ShapingDoesNotMoveTheOverlapGate(_Harness):
|
|
|
469
470
|
self.assertIn("did we decide about the", context)
|
|
470
471
|
|
|
471
472
|
|
|
473
|
+
class SingleCharSubjectSurvives(unittest.TestCase):
|
|
474
|
+
"""#3766 — the ``len(t) > 1`` filter used to run BEFORE the stopword
|
|
475
|
+
fallback, dropping a single-char SUBJECT ('C', 'R', a single-digit version)
|
|
476
|
+
outright and then letting the fallback pick multi-char STOPWORDS instead. The
|
|
477
|
+
length guard now lives only in the fallback, so a single-char content word
|
|
478
|
+
survives while single-char stopwords ('a', 'i') are still removed.
|
|
479
|
+
|
|
480
|
+
These call ``shape_recall_query`` directly — the shaped string is what the
|
|
481
|
+
BM25 arm searches for, and a subject that vanishes from it is a subject the
|
|
482
|
+
recall cannot match on. Each assertion fails if the length filter moves back
|
|
483
|
+
ahead of the stopword filter.
|
|
484
|
+
"""
|
|
485
|
+
|
|
486
|
+
def test_single_letter_subject_survives_among_stopwords(self):
|
|
487
|
+
# 'r' is the only content word; all the rest are stopwords. Pre-fix it
|
|
488
|
+
# was dropped and the fallback kept 'still'/'faster'/'python' but never
|
|
489
|
+
# 'r'; post-fix 'r' is a first-class term.
|
|
490
|
+
out = shape_recall_query("is R still faster than python", max_tokens=24)
|
|
491
|
+
self.assertIn("r", out.lower().split())
|
|
492
|
+
|
|
493
|
+
def test_subject_is_a_lone_single_char_and_all_else_stopwords(self):
|
|
494
|
+
# "what about C for this" — every other token is a stopword. The subject
|
|
495
|
+
# 'c' MUST be what searches; pre-fix the query came back as stopwords.
|
|
496
|
+
out = shape_recall_query("what about C for this", max_tokens=24)
|
|
497
|
+
terms = out.lower().split()
|
|
498
|
+
self.assertIn("c", terms)
|
|
499
|
+
for stop in ("what", "about", "for", "this"):
|
|
500
|
+
self.assertNotIn(stop, terms, f"stopword {stop!r} reached the wire over the subject")
|
|
501
|
+
|
|
502
|
+
def test_single_digit_version_survives(self):
|
|
503
|
+
# 'single-digit versions' (#3766): "python 9 or python 3".
|
|
504
|
+
out = shape_recall_query("python 9 or python 3", max_tokens=24)
|
|
505
|
+
terms = out.lower().split()
|
|
506
|
+
self.assertIn("9", terms)
|
|
507
|
+
self.assertIn("3", terms)
|
|
508
|
+
|
|
509
|
+
def test_single_char_stopwords_are_still_removed(self):
|
|
510
|
+
# The guard must not become "keep every single char": 'a' and 'i' are
|
|
511
|
+
# stopwords and must not survive just because they are one character.
|
|
512
|
+
out = shape_recall_query("a deploy i triggered", max_tokens=24)
|
|
513
|
+
terms = out.lower().split()
|
|
514
|
+
self.assertIn("deploy", terms)
|
|
515
|
+
self.assertIn("triggered", terms)
|
|
516
|
+
self.assertNotIn("a", terms)
|
|
517
|
+
self.assertNotIn("i", terms)
|
|
518
|
+
|
|
519
|
+
|
|
472
520
|
if __name__ == "__main__": # pragma: no cover
|
|
473
521
|
unittest.main()
|
|
@@ -7,6 +7,7 @@ failure. Stdlib-only; runs under ``python3 -m unittest discover tests/``.
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import contextlib
|
|
10
|
+
import io
|
|
10
11
|
import json
|
|
11
12
|
import os
|
|
12
13
|
import sys
|
|
@@ -255,13 +256,105 @@ class VolumeGate(unittest.TestCase):
|
|
|
255
256
|
msgs = []
|
|
256
257
|
for i in range(20):
|
|
257
258
|
msgs.append({"role": "assistant", "content": "z" * 500})
|
|
258
|
-
total = subagent_retain.
|
|
259
|
+
total = subagent_retain.retained_text_char_count(
|
|
259
260
|
msgs, stop_at=subagent_retain.MIN_NON_TOOL_RESULT_CHARS
|
|
260
261
|
)
|
|
261
262
|
self.assertGreaterEqual(total, subagent_retain.MIN_NON_TOOL_RESULT_CHARS)
|
|
262
263
|
# Full sum would be 20*500=10000; short-circuit stops well before.
|
|
263
264
|
self.assertLess(total, 10000)
|
|
264
265
|
|
|
266
|
+
def test_char_count_is_text_only_not_tool_use(self):
|
|
267
|
+
"""#3994: the count measures only what the text-only retain path keeps.
|
|
268
|
+
|
|
269
|
+
A message with a small text block and a huge tool_use input must count
|
|
270
|
+
ONLY the text-block chars. The pre-#3994 metric added the tool_use
|
|
271
|
+
name+input serialized size; this asserts that contribution is gone, so a
|
|
272
|
+
revert goes RED.
|
|
273
|
+
"""
|
|
274
|
+
msgs = [
|
|
275
|
+
{
|
|
276
|
+
"role": "assistant",
|
|
277
|
+
"content": [
|
|
278
|
+
{"type": "text", "text": "T" * 100},
|
|
279
|
+
{
|
|
280
|
+
"type": "tool_use",
|
|
281
|
+
"name": "Write",
|
|
282
|
+
"input": {"content": "Z" * 50_000},
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
}
|
|
286
|
+
]
|
|
287
|
+
self.assertEqual(subagent_retain.retained_text_char_count(msgs), 100)
|
|
288
|
+
# Alias preserves back-compat but returns the recalibrated value.
|
|
289
|
+
self.assertEqual(subagent_retain.non_tool_result_char_count(msgs), 100)
|
|
290
|
+
|
|
291
|
+
def test_tool_heavy_prose_light_fork_now_skips(self):
|
|
292
|
+
"""#3994 headline: a fork that clears MIN_HUMAN_TURNS and emits a LOT of
|
|
293
|
+
tool traffic but almost no prose PASSED the old gate (tool_use chars) and
|
|
294
|
+
must now FAIL (text-only chars below the floor). The retained document
|
|
295
|
+
for such a fork was near-empty — the mismatch this recalibration closes.
|
|
296
|
+
"""
|
|
297
|
+
msgs = []
|
|
298
|
+
for i in range(8): # >= MIN_HUMAN_TURNS genuine human turns
|
|
299
|
+
msgs.append({"role": "user", "content": "go"})
|
|
300
|
+
msgs.append(
|
|
301
|
+
{
|
|
302
|
+
"role": "assistant",
|
|
303
|
+
"content": [
|
|
304
|
+
# Pure tool traffic, no assistant prose. ~5 KB command
|
|
305
|
+
# each — bulk the OLD gate counted, the payload drops.
|
|
306
|
+
{"type": "tool_use", "name": "Bash",
|
|
307
|
+
"input": {"command": "true " + ("x" * 5000)}},
|
|
308
|
+
],
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
msgs.append(
|
|
312
|
+
{"role": "user",
|
|
313
|
+
"content": [{"type": "tool_result", "tool_use_id": "t", "content": "Z" * 5000}]}
|
|
314
|
+
)
|
|
315
|
+
passed, turns, chars = subagent_retain.passes_volume_gate(msgs, CONFIG)
|
|
316
|
+
self.assertGreaterEqual(turns, subagent_retain.MIN_HUMAN_TURNS)
|
|
317
|
+
self.assertFalse(passed, f"tool-heavy/prose-light fork must skip: chars={chars}")
|
|
318
|
+
self.assertLess(chars, subagent_retain.MIN_NON_TOOL_RESULT_CHARS)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class EmptyWindowSkipIsVisible(unittest.TestCase):
|
|
322
|
+
"""#4001: the 'formatted to empty despite clearing the gate' skip must not be
|
|
323
|
+
debug-log-only (silent with debug off in shipped settings)."""
|
|
324
|
+
|
|
325
|
+
def _run_with_empty_build(self):
|
|
326
|
+
"""Drive run_subagent_retain to the build_retain_payload==None branch."""
|
|
327
|
+
with tempfile.TemporaryDirectory() as d:
|
|
328
|
+
sc = os.path.join(d, "agent-af5.jsonl")
|
|
329
|
+
_write_sidechain(sc, 8, chars_per_msg=500) # clears the volume gate
|
|
330
|
+
hook_input = {
|
|
331
|
+
"session_id": "parentsess",
|
|
332
|
+
"agent_id": "af5",
|
|
333
|
+
"agent_type": "worker",
|
|
334
|
+
"agent_transcript_path": sc,
|
|
335
|
+
"transcript_path": os.path.join(d, "parentsess.jsonl"),
|
|
336
|
+
"cwd": d,
|
|
337
|
+
}
|
|
338
|
+
captured_err = io.StringIO()
|
|
339
|
+
with mock.patch("subagent_retain.load_config", return_value=dict(CONFIG)), \
|
|
340
|
+
mock.patch("subagent_retain.get_api_url", return_value="http://x"), \
|
|
341
|
+
mock.patch("subagent_retain.ensure_bank_mission"), \
|
|
342
|
+
mock.patch("subagent_retain.derive_bank_id", return_value="agentbank"), \
|
|
343
|
+
mock.patch("subagent_retain.HindsightClient"), \
|
|
344
|
+
mock.patch("subagent_retain.inflight_lock", _lock_acquired), \
|
|
345
|
+
mock.patch("subagent_retain.build_retain_payload", return_value=None), \
|
|
346
|
+
mock.patch("sys.stderr", new=captured_err):
|
|
347
|
+
result = subagent_retain.run_subagent_retain(hook_input)
|
|
348
|
+
return result, captured_err.getvalue()
|
|
349
|
+
|
|
350
|
+
def test_empty_window_skip_emits_unconditional_stderr(self):
|
|
351
|
+
# debug is False (shipped default) — the skip must STILL be visible.
|
|
352
|
+
result, err = self._run_with_empty_build()
|
|
353
|
+
self.assertEqual(result["status"], "skipped")
|
|
354
|
+
self.assertEqual(result["reason"], "empty transcript after formatting")
|
|
355
|
+
self.assertIn("formatted to EMPTY", err)
|
|
356
|
+
self.assertIn("parentsess", err)
|
|
357
|
+
|
|
265
358
|
|
|
266
359
|
class BoundedRead(unittest.TestCase):
|
|
267
360
|
def test_read_transcript_max_bytes_reads_only_tail(self):
|
|
@@ -76,12 +76,24 @@ def _entry(role: str, content, uuid: str) -> str:
|
|
|
76
76
|
)
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
# Per-turn assistant prose. Substantive text blocks so the fixture clears the
|
|
80
|
+
# RECALIBRATED (text-only) volume gate on genuine retainable prose — not on the
|
|
81
|
+
# tool_use volume the payload drops (#3994). ~180 chars each * 8 turns > the
|
|
82
|
+
# 2,000-char floor, all of it text the text-only path keeps.
|
|
83
|
+
TURN_PROSE = (
|
|
84
|
+
"Reasoning step {i}: ruled out the consolidator dedup theory — world and "
|
|
85
|
+
"experience facts never traverse the observation dedup path, so overlap "
|
|
86
|
+
"persists as extra rows. Recording the constraint before moving on."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
79
90
|
def _tool_heavy_sidechain(path: str, *, human_turns: int = 8) -> None:
|
|
80
|
-
"""A sidechain transcript that is mostly tool traffic plus
|
|
91
|
+
"""A sidechain transcript that is mostly tool traffic plus real prose.
|
|
81
92
|
|
|
82
93
|
Shaped to clear the volume gate (>= MIN_HUMAN_TURNS human turns and
|
|
83
|
-
>= MIN_NON_TOOL_RESULT_CHARS of
|
|
84
|
-
|
|
94
|
+
>= MIN_NON_TOOL_RESULT_CHARS of RETAINED text) on the assistant PROSE — the
|
|
95
|
+
metric the recalibrated gate counts (#3994) — while the bulk of the bytes on
|
|
96
|
+
disk is the tool traffic the text-only retain path drops.
|
|
85
97
|
"""
|
|
86
98
|
lines = []
|
|
87
99
|
for i in range(human_turns):
|
|
@@ -94,6 +106,10 @@ def _tool_heavy_sidechain(path: str, *, human_turns: int = 8) -> None:
|
|
|
94
106
|
"type": "thinking",
|
|
95
107
|
"thinking": "internal reasoning that must never be retained",
|
|
96
108
|
},
|
|
109
|
+
{
|
|
110
|
+
"type": "text",
|
|
111
|
+
"text": TURN_PROSE.format(i=i),
|
|
112
|
+
},
|
|
97
113
|
{
|
|
98
114
|
"type": "tool_use",
|
|
99
115
|
"id": f"tu_w{i}",
|
|
@@ -142,7 +158,15 @@ def _lock_acquired(blocking=False):
|
|
|
142
158
|
|
|
143
159
|
|
|
144
160
|
def _run_sidechain_retain(sidechain_path: str, tmpdir: str, config_extra=None):
|
|
145
|
-
"""Drive run_subagent_retain with a stub client
|
|
161
|
+
"""Drive run_subagent_retain with a stub client.
|
|
162
|
+
|
|
163
|
+
Returns ``(result, captured_retain_kwargs, passed_config)`` where
|
|
164
|
+
``passed_config`` is the EXACT dict object ``run_subagent_retain`` receives
|
|
165
|
+
from ``load_config`` — the object the production code actually holds and
|
|
166
|
+
could mutate. Tests assert on THAT object, never on the module-level
|
|
167
|
+
``CONFIG`` template (which the code never sees; asserting on it is vacuous —
|
|
168
|
+
it passes even if the retainToolCalls-copy fix is deleted). See #3999.
|
|
169
|
+
"""
|
|
146
170
|
captured = {}
|
|
147
171
|
|
|
148
172
|
class _Client:
|
|
@@ -153,7 +177,9 @@ def _run_sidechain_retain(sidechain_path: str, tmpdir: str, config_extra=None):
|
|
|
153
177
|
captured.update(kwargs)
|
|
154
178
|
return {"ok": True}
|
|
155
179
|
|
|
156
|
-
|
|
180
|
+
# A FRESH copy per run — this is the object the code receives via
|
|
181
|
+
# load_config and is expected NOT to mutate.
|
|
182
|
+
passed_config = dict(CONFIG, **(config_extra or {}))
|
|
157
183
|
hook_input = {
|
|
158
184
|
"session_id": "parentsess",
|
|
159
185
|
"agent_id": "af5",
|
|
@@ -162,14 +188,14 @@ def _run_sidechain_retain(sidechain_path: str, tmpdir: str, config_extra=None):
|
|
|
162
188
|
"transcript_path": os.path.join(tmpdir, "parentsess.jsonl"),
|
|
163
189
|
"cwd": tmpdir,
|
|
164
190
|
}
|
|
165
|
-
with mock.patch("subagent_retain.load_config", return_value=
|
|
191
|
+
with mock.patch("subagent_retain.load_config", return_value=passed_config), \
|
|
166
192
|
mock.patch("subagent_retain.get_api_url", return_value="http://x"), \
|
|
167
193
|
mock.patch("subagent_retain.ensure_bank_mission"), \
|
|
168
194
|
mock.patch("subagent_retain.derive_bank_id", return_value="agentbank"), \
|
|
169
195
|
mock.patch("subagent_retain.HindsightClient", _Client), \
|
|
170
196
|
mock.patch("subagent_retain.inflight_lock", _lock_acquired):
|
|
171
197
|
result = subagent_retain.run_subagent_retain(hook_input)
|
|
172
|
-
return result, captured
|
|
198
|
+
return result, captured, passed_config
|
|
173
199
|
|
|
174
200
|
|
|
175
201
|
class SidechainRetainsLearningsNotTools(unittest.TestCase):
|
|
@@ -180,7 +206,9 @@ class SidechainRetainsLearningsNotTools(unittest.TestCase):
|
|
|
180
206
|
self.addCleanup(self._tmp.cleanup)
|
|
181
207
|
self.sidechain = os.path.join(self._tmp.name, "agent-af5.jsonl")
|
|
182
208
|
_tool_heavy_sidechain(self.sidechain)
|
|
183
|
-
self.result, self.captured = _run_sidechain_retain(
|
|
209
|
+
self.result, self.captured, self.passed_config = _run_sidechain_retain(
|
|
210
|
+
self.sidechain, self._tmp.name
|
|
211
|
+
)
|
|
184
212
|
self.assertEqual(self.result["status"], "ok", self.result)
|
|
185
213
|
self.content = self.captured["content"]
|
|
186
214
|
|
|
@@ -227,21 +255,46 @@ class SidechainRetainsLearningsNotTools(unittest.TestCase):
|
|
|
227
255
|
)
|
|
228
256
|
|
|
229
257
|
def test_operator_config_is_not_mutated(self):
|
|
230
|
-
"""The override lands on the sidechain COPY
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
258
|
+
"""The ``retainToolCalls = False`` override lands on the sidechain COPY
|
|
259
|
+
(``sub_config = dict(config)``), never on the config the hook received.
|
|
260
|
+
|
|
261
|
+
Asserts on ``passed_config`` — the EXACT object ``run_subagent_retain``
|
|
262
|
+
got from ``load_config`` and could have mutated in place — not on the
|
|
263
|
+
module-level ``CONFIG`` template. The old assertion (`CONFIG[...] is
|
|
264
|
+
True`) was vacuous: the harness always hands the code a fresh copy, so
|
|
265
|
+
``CONFIG`` stays True even if the production copy is deleted and the code
|
|
266
|
+
mutates its input directly. This version goes RED in exactly that case.
|
|
267
|
+
"""
|
|
268
|
+
self.assertEqual(
|
|
269
|
+
self.passed_config["retainToolCalls"],
|
|
270
|
+
True,
|
|
271
|
+
"run_subagent_retain mutated its caller's config (retainToolCalls "
|
|
272
|
+
"flipped to False in place) instead of overriding on a copy",
|
|
273
|
+
)
|
|
236
274
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
275
|
+
def test_override_is_applied_on_the_copy_the_client_sees(self):
|
|
276
|
+
"""The flip must still REACH the payload: the stub client's retained
|
|
277
|
+
content is on the text-only path (no tool bodies), which only happens if
|
|
278
|
+
the sidechain copy carried ``retainToolCalls = False``. Together with
|
|
279
|
+
the test above this pins BOTH halves — copied AND applied — so deleting
|
|
280
|
+
either the copy or the override turns one of them RED."""
|
|
281
|
+
self.assertNotIn(WRITE_BODY_MARKER, self.captured["content"])
|
|
282
|
+
self.assertNotIn(BASH_COMMAND_MARKER, self.captured["content"])
|
|
283
|
+
# Positive shape check: the text-only formatter's role markers, not JSON.
|
|
284
|
+
self.assertIn("[role: assistant]", self.captured["content"])
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class ProseFreeSidechainIsSkipped(unittest.TestCase):
|
|
288
|
+
"""The degenerate case — a worker with NO assistant prose, only tool traffic.
|
|
289
|
+
|
|
290
|
+
Post-#3994 the volume gate counts ONLY the chars the text-only path keeps
|
|
291
|
+
(``retained_text_char_count`` -> ``_extract_text_content``), so a fork whose
|
|
292
|
+
bulk is entirely tool_use inputs + tool_result bodies now FAILS the char
|
|
293
|
+
floor: once tools are stripped there is almost nothing to retain, and the
|
|
294
|
+
tiny "go" instruction turns fall far under ``MIN_NON_TOOL_RESULT_CHARS``.
|
|
295
|
+
That is the correct outcome — the old gate cleared on tool-command volume and
|
|
296
|
+
then retained a near-empty document (the mismatch #3994 closes). This pins
|
|
297
|
+
the SKIP so a revert to counting tool_use chars goes RED here.
|
|
245
298
|
"""
|
|
246
299
|
|
|
247
300
|
def _prose_free_sidechain(self, path: str, human_turns: int = 8) -> None:
|
|
@@ -249,8 +302,8 @@ class ProseFreeSidechainDegradesGracefully(unittest.TestCase):
|
|
|
249
302
|
for i in range(human_turns):
|
|
250
303
|
# tool_result-only user messages don't count as human turns, so the
|
|
251
304
|
# instruction turns are plain strings — but they are the USER's
|
|
252
|
-
# words. Keep them short so
|
|
253
|
-
#
|
|
305
|
+
# words. Keep them short so, once tools are stripped, there is almost
|
|
306
|
+
# no retainable text left.
|
|
254
307
|
lines.append(_entry("user", "go", f"u{i}"))
|
|
255
308
|
lines.append(
|
|
256
309
|
_entry(
|
|
@@ -269,29 +322,32 @@ class ProseFreeSidechainDegradesGracefully(unittest.TestCase):
|
|
|
269
322
|
with open(path, "w", encoding="utf-8") as f:
|
|
270
323
|
f.write("\n".join(lines) + "\n")
|
|
271
324
|
|
|
272
|
-
def
|
|
325
|
+
def test_prose_free_fork_fails_the_recalibrated_char_floor(self):
|
|
273
326
|
with tempfile.TemporaryDirectory() as d:
|
|
274
327
|
sc = os.path.join(d, "agent-af5.jsonl")
|
|
275
328
|
self._prose_free_sidechain(sc)
|
|
276
|
-
|
|
277
|
-
# Precondition: the gate genuinely PASSES on tool_use chars alone,
|
|
278
|
-
# so this is the "cleared the gate with no prose" case and not just
|
|
279
|
-
# a trivial-fork skip.
|
|
280
329
|
msgs = subagent_retain.read_transcript(sc)
|
|
330
|
+
|
|
331
|
+
# It clears MIN_HUMAN_TURNS (8 genuine "go" turns)...
|
|
332
|
+
self.assertGreaterEqual(
|
|
333
|
+
subagent_retain.count_human_turns(msgs), subagent_retain.MIN_HUMAN_TURNS
|
|
334
|
+
)
|
|
335
|
+
# ...but the recalibrated (text-only) char count is far below the
|
|
336
|
+
# floor, because every 4 KB Bash body is excluded from the count.
|
|
281
337
|
passed, turns, chars = subagent_retain.passes_volume_gate(msgs, CONFIG)
|
|
282
|
-
self.
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
self.
|
|
294
|
-
self.
|
|
338
|
+
self.assertFalse(
|
|
339
|
+
passed,
|
|
340
|
+
f"prose-free fork should now SKIP: turns={turns} chars={chars} "
|
|
341
|
+
f"(floor {subagent_retain.MIN_NON_TOOL_RESULT_CHARS})",
|
|
342
|
+
)
|
|
343
|
+
self.assertLess(chars, subagent_retain.MIN_NON_TOOL_RESULT_CHARS)
|
|
344
|
+
|
|
345
|
+
# End to end: the retain is skipped by the volume gate; nothing POSTs.
|
|
346
|
+
result, captured, _ = _run_sidechain_retain(sc, d)
|
|
347
|
+
|
|
348
|
+
self.assertEqual(result["status"], "skipped", result)
|
|
349
|
+
self.assertEqual(result["reason"], "volume gate")
|
|
350
|
+
self.assertEqual(captured, {}, "a skipped fork must not POST a retain")
|
|
295
351
|
|
|
296
352
|
|
|
297
353
|
class TextOnlyPathIsNotEmpty(unittest.TestCase):
|