switchroom 0.19.40 → 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 +810 -241
- package/dist/host-control/main.js +94 -19
- 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/bridge/bridge.ts +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +2 -2
- package/telegram-plugin/dist/gateway/gateway.js +332 -124
- package/telegram-plugin/dist/server.js +2 -2
- package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
- package/telegram-plugin/gateway/checklist-fallback.ts +370 -0
- package/telegram-plugin/gateway/gateway.ts +76 -76
- 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/gateway/turn-record-status.ts +80 -0
- package/telegram-plugin/tests/checklist-fallback.test.ts +317 -0
- package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -6
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
- package/telegram-plugin/tests/turn-record-status.test.ts +62 -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
|
@@ -37,9 +37,9 @@ Flow:
|
|
|
37
37
|
2. Resolve the sidechain transcript (agent_transcript_path → derived
|
|
38
38
|
subagents/ dir → project-dir scan).
|
|
39
39
|
3. Volume gate: skip sub-agents below the floor (< 6 human turns OR
|
|
40
|
-
< 2,000 chars of
|
|
41
|
-
Task including 10-second forks, and
|
|
42
|
-
extraction.
|
|
40
|
+
< 2,000 chars of RETAINED text — the chars the text-only path keeps,
|
|
41
|
+
#3994) — SubagentStop fires for every Task including 10-second forks, and
|
|
42
|
+
each retain is an LLM-backed extraction.
|
|
43
43
|
4. Retain a bounded window (last N=40 human turns), tagged ``sidechain`` +
|
|
44
44
|
``parent_session:<id>``, with a deterministic content-derived document_id
|
|
45
45
|
so re-fires upsert instead of duplicating.
|
|
@@ -62,7 +62,7 @@ from lib.bank import derive_bank_id, ensure_bank_mission
|
|
|
62
62
|
from lib.client import HindsightClient
|
|
63
63
|
from lib.config import debug_log, load_config
|
|
64
64
|
from lib.content import (
|
|
65
|
-
|
|
65
|
+
_extract_text_content,
|
|
66
66
|
_is_tool_result_only_user_message,
|
|
67
67
|
slice_last_turns_by_user_boundary,
|
|
68
68
|
transcript_first_line_is_sidechain,
|
|
@@ -78,8 +78,9 @@ from retain import build_retain_payload, read_transcript
|
|
|
78
78
|
# Retain the last N human turns of the sidechain. The window is formatted on the
|
|
79
79
|
# TEXT-ONLY path (``retainToolCalls`` is forced False for the sidechain — see
|
|
80
80
|
# ``run_subagent_retain``), so tool_use inputs and tool_result bodies are dropped
|
|
81
|
-
# entirely rather than passed through / truncated. ``
|
|
82
|
-
#
|
|
81
|
+
# entirely rather than passed through / truncated. ``_extract_text_content`` is
|
|
82
|
+
# imported here so the volume gate's char count measures the SAME text the retain
|
|
83
|
+
# path keeps (#3994) — gate and payload never diverge.
|
|
83
84
|
SIDECHAIN_WINDOW_TURNS = 40
|
|
84
85
|
|
|
85
86
|
# Volume gate floors — SubagentStop fires for every Task, so skip trivial forks.
|
|
@@ -262,59 +263,63 @@ def count_human_turns(messages: list) -> int:
|
|
|
262
263
|
return n
|
|
263
264
|
|
|
264
265
|
|
|
265
|
-
def
|
|
266
|
-
"""Total chars of
|
|
266
|
+
def retained_text_char_count(messages: list, stop_at: int | None = None) -> int:
|
|
267
|
+
"""Total chars of the content the TEXT-ONLY retain path actually keeps.
|
|
267
268
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
269
|
+
#3994 — GATE COUNTS WHAT IS RETAINED. The sidechain payload is built with
|
|
270
|
+
``retainToolCalls = False`` (``run_subagent_retain``), so what reaches memory
|
|
271
|
+
is exactly ``_extract_text_content``: assistant ``text`` blocks, channel-
|
|
272
|
+
message tool_use text, and plain-string user turns — and NOTHING else. This
|
|
273
|
+
gate counts the same thing, per message, so "cleared the floor" now means
|
|
274
|
+
"has >= N chars of RETAINABLE prose", not "emitted N chars of tool traffic
|
|
275
|
+
the payload then drops".
|
|
274
276
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
``
|
|
280
|
-
|
|
281
|
-
|
|
277
|
+
The previous revision counted ``tool_use`` name+input serialized size on top
|
|
278
|
+
of text. Those chars are no longer in the payload, so the old gate could
|
|
279
|
+
clear on tool volume that contributed zero to the stored memory (a tool-heavy
|
|
280
|
+
/ prose-light fork passed, then retained a near-empty document). Counting via
|
|
281
|
+
``_extract_text_content`` closes that mismatch: gate and payload measure the
|
|
282
|
+
identical char set. The 2,000-char floor was re-measured against this metric
|
|
283
|
+
— see ``docs/measurements/subagent-volume-gate-3994.md`` and the replay
|
|
284
|
+
harness ``scripts/tests/data/replay_volume_gate_3994.py``.
|
|
282
285
|
|
|
283
286
|
``stop_at`` (review finding 4 — early short-circuit): return as soon as the
|
|
284
287
|
running total reaches this many chars. The gate only needs to know whether
|
|
285
|
-
the floor is CLEARED, not the exact size — so on a large worker transcript
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
288
|
+
the floor is CLEARED, not the exact size — so on a large worker transcript we
|
|
289
|
+
stop the walk the moment the floor is met (the returned value is then a
|
|
290
|
+
floor, ``>= stop_at``, sufficient for the ``>=`` comparison and the skip
|
|
291
|
+
log's "chars>=N" read).
|
|
289
292
|
"""
|
|
290
293
|
total = 0
|
|
291
294
|
for m in messages:
|
|
292
295
|
if not isinstance(m, dict):
|
|
293
296
|
continue
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if b.get("type") == "text":
|
|
299
|
-
total += len(b.get("text", ""))
|
|
300
|
-
elif b.get("type") == "tool_use":
|
|
301
|
-
# Command / input is a process fact; count its serialized size.
|
|
302
|
-
total += len(b.get("name", "")) + len(json.dumps(b.get("input", {}), ensure_ascii=False))
|
|
297
|
+
# Count exactly the chars the text-only retain path keeps for this
|
|
298
|
+
# message — identical extraction to ``prepare_retention_transcript``'s
|
|
299
|
+
# ``include_tool_calls=False`` branch, so gate and payload never diverge.
|
|
300
|
+
total += len(_extract_text_content(m.get("content", ""), role=m.get("role", "")))
|
|
303
301
|
if stop_at is not None and total >= stop_at:
|
|
304
302
|
return total
|
|
305
303
|
return total
|
|
306
304
|
|
|
307
305
|
|
|
306
|
+
# Back-compat alias: the previous name measured a superset (text + tool_use).
|
|
307
|
+
# Kept so an out-of-tree caller does not break, but it now returns the
|
|
308
|
+
# recalibrated text-only count (#3994).
|
|
309
|
+
non_tool_result_char_count = retained_text_char_count
|
|
310
|
+
|
|
311
|
+
|
|
308
312
|
def passes_volume_gate(messages: list, config: dict) -> tuple:
|
|
309
313
|
"""Return ``(passed, human_turns, char_count)`` for the volume gate.
|
|
310
314
|
|
|
311
315
|
Skip sub-agents below EITHER floor: < ``MIN_HUMAN_TURNS`` human turns OR
|
|
312
|
-
< ``MIN_NON_TOOL_RESULT_CHARS`` chars of
|
|
313
|
-
|
|
314
|
-
|
|
316
|
+
< ``MIN_NON_TOOL_RESULT_CHARS`` chars of RETAINED text (the chars the
|
|
317
|
+
text-only path actually keeps, #3994). The char walk short-circuits at the
|
|
318
|
+
floor (finding 4) — ``char_count`` is exact when below the floor and a lower
|
|
319
|
+
bound (``>= floor``) once cleared.
|
|
315
320
|
"""
|
|
316
321
|
turns = count_human_turns(messages)
|
|
317
|
-
chars =
|
|
322
|
+
chars = retained_text_char_count(messages, stop_at=MIN_NON_TOOL_RESULT_CHARS)
|
|
318
323
|
passed = turns >= MIN_HUMAN_TURNS and chars >= MIN_NON_TOOL_RESULT_CHARS
|
|
319
324
|
return passed, turns, chars
|
|
320
325
|
|
|
@@ -521,7 +526,23 @@ def run_subagent_retain(hook_input: dict) -> dict:
|
|
|
521
526
|
document_id=None, # content-derived from the slice's first/last uuids
|
|
522
527
|
)
|
|
523
528
|
if built is None:
|
|
529
|
+
# #4001 — this must NOT be silent. It is the "should never happen" branch
|
|
530
|
+
# (the volume gate guarantees >= MIN_NON_TOOL_RESULT_CHARS of retainable
|
|
531
|
+
# text, so a window that cleared the gate should always format to
|
|
532
|
+
# something), so if it fires it means the gate and the formatter disagree
|
|
533
|
+
# about what counts as retainable — a real regression that would
|
|
534
|
+
# otherwise vanish with debug off in the shipped settings. Emit
|
|
535
|
+
# unconditionally on stderr so it surfaces in the gateway log regardless
|
|
536
|
+
# of the debug flag; keep the debug_log for the verbose trace.
|
|
524
537
|
debug_log(config, "SubagentStop: empty transcript after formatting, skipping")
|
|
538
|
+
print(
|
|
539
|
+
"[Hindsight] SubagentStop: sidechain window formatted to EMPTY on the "
|
|
540
|
+
f"text-only path despite clearing the volume gate "
|
|
541
|
+
f"(session={session_id} agent={agent_id} turns={turns} chars>={chars}) "
|
|
542
|
+
"— retain skipped. This should not happen; if it recurs the volume "
|
|
543
|
+
"gate and the text-only formatter have diverged (investigate #3994).",
|
|
544
|
+
file=sys.stderr,
|
|
545
|
+
)
|
|
525
546
|
return {"status": "skipped", "reason": "empty transcript after formatting"}
|
|
526
547
|
|
|
527
548
|
payload = built["payload"]
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Replay harness for the #3994 sub-agent volume-gate recalibration.
|
|
3
|
+
|
|
4
|
+
WHAT THIS ANSWERS
|
|
5
|
+
-----------------
|
|
6
|
+
The sidechain retain now counts, in its volume gate, ONLY the chars the
|
|
7
|
+
text-only retain path keeps (``subagent_retain.retained_text_char_count`` ->
|
|
8
|
+
``lib.content._extract_text_content``). The previous gate summed text PLUS
|
|
9
|
+
``tool_use`` name+input serialized size — chars the payload no longer contains.
|
|
10
|
+
This harness re-measures the ``MIN_NON_TOOL_RESULT_CHARS`` floor against the
|
|
11
|
+
CORRECTED metric and shows, per transcript:
|
|
12
|
+
|
|
13
|
+
* old_metric — pre-#3994 count (text + tool_use name+input)
|
|
14
|
+
* new_metric — post-#3994 count (text-only, == what is retained)
|
|
15
|
+
* payload_chars— the ACTUAL text-only formatted payload size
|
|
16
|
+
(``prepare_retention_transcript(..., include_tool_calls=False)``)
|
|
17
|
+
* human_turns — genuine human turns (``count_human_turns``)
|
|
18
|
+
* gate decision at a swept set of candidate floors, old vs new
|
|
19
|
+
|
|
20
|
+
The headline the recalibration turns on: how many tool-heavy / prose-light
|
|
21
|
+
forks the OLD gate wrongly PASSED (clearing on tool volume) that the NEW gate
|
|
22
|
+
correctly SKIPS, while NO real worker (substantial text-only payload) is newly
|
|
23
|
+
skipped at the chosen floor.
|
|
24
|
+
|
|
25
|
+
DATA
|
|
26
|
+
----
|
|
27
|
+
Two sources, ``--dir`` preferred:
|
|
28
|
+
|
|
29
|
+
* ``--dir <path>``: replay real ``*.jsonl`` sidechain transcripts (one Claude
|
|
30
|
+
Code sidechain per file). Fleet transcripts are NOT committed to this repo
|
|
31
|
+
(they are private agent memory), so this is how an operator re-runs the
|
|
32
|
+
measurement against ground truth.
|
|
33
|
+
* default (no ``--dir``): a SYNTHETIC corpus parameterized by the empirical
|
|
34
|
+
distributions already measured and cited in ``subagent_retain.py`` /
|
|
35
|
+
``test_subagent_retain_learnings.py`` — real klanker sidechain text-only
|
|
36
|
+
sizes (p10 5,035 / p50 10,058 / p90 26,040 chars; 0 empty, 0 < 500 over 200
|
|
37
|
+
transcripts) plus the trivial-fork and tool-heavy/prose-light shapes the
|
|
38
|
+
gate exists to reject. Deterministic (fixed seed) so the committed report is
|
|
39
|
+
reproducible.
|
|
40
|
+
|
|
41
|
+
Run: python3 scripts/tests/data/replay_volume_gate_3994.py [--dir DIR] [--json]
|
|
42
|
+
|
|
43
|
+
The committed narrative report lives at
|
|
44
|
+
``docs/measurements/subagent-volume-gate-3994.md`` and is regenerated from this
|
|
45
|
+
harness's output.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
import argparse
|
|
49
|
+
import json
|
|
50
|
+
import os
|
|
51
|
+
import random
|
|
52
|
+
import sys
|
|
53
|
+
|
|
54
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
55
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(HERE, "..", ".."))
|
|
56
|
+
if SCRIPTS_DIR not in sys.path:
|
|
57
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
58
|
+
|
|
59
|
+
import subagent_retain # noqa: E402
|
|
60
|
+
from lib.content import ( # noqa: E402
|
|
61
|
+
_extract_message_blocks,
|
|
62
|
+
prepare_retention_transcript,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
CANDIDATE_FLOORS = [500, 1000, 1500, 2000, 3000, 5000]
|
|
66
|
+
SHIPPED_FLOOR = subagent_retain.MIN_NON_TOOL_RESULT_CHARS # 2000
|
|
67
|
+
MIN_TURNS = subagent_retain.MIN_HUMAN_TURNS # 6
|
|
68
|
+
|
|
69
|
+
# A payload this size or larger is a "real worker" whose learnings we must not
|
|
70
|
+
# lose. Set well under the measured klanker p10 (5,035) so the "newly skipped
|
|
71
|
+
# real worker" check is strict, not self-serving.
|
|
72
|
+
REAL_WORKER_PAYLOAD_FLOOR = 2000
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _old_metric(messages):
|
|
76
|
+
"""Pre-#3994 count: text + tool_use name+input serialized size."""
|
|
77
|
+
total = 0
|
|
78
|
+
for m in messages:
|
|
79
|
+
if not isinstance(m, dict):
|
|
80
|
+
continue
|
|
81
|
+
for b in _extract_message_blocks(m.get("content", ""), role=m.get("role", "")):
|
|
82
|
+
if not isinstance(b, dict) or b.get("type") == "tool_result":
|
|
83
|
+
continue
|
|
84
|
+
if b.get("type") == "text":
|
|
85
|
+
total += len(b.get("text", ""))
|
|
86
|
+
elif b.get("type") == "tool_use":
|
|
87
|
+
total += len(b.get("name", "")) + len(
|
|
88
|
+
json.dumps(b.get("input", {}), ensure_ascii=False)
|
|
89
|
+
)
|
|
90
|
+
return total
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _payload_chars(messages):
|
|
94
|
+
"""Actual text-only formatted payload size (what is retained)."""
|
|
95
|
+
text, _ = prepare_retention_transcript(
|
|
96
|
+
messages, ["user", "assistant"], True, include_tool_calls=False
|
|
97
|
+
)
|
|
98
|
+
return len(text or "")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def measure(messages):
|
|
102
|
+
new_metric = subagent_retain.retained_text_char_count(messages)
|
|
103
|
+
return {
|
|
104
|
+
"human_turns": subagent_retain.count_human_turns(messages),
|
|
105
|
+
"old_metric": _old_metric(messages),
|
|
106
|
+
"new_metric": new_metric,
|
|
107
|
+
"payload_chars": _payload_chars(messages),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Synthetic corpus (deterministic) — models the documented distributions.
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def _user(text):
|
|
116
|
+
return {"role": "user", "content": text}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _assistant_prose(text, *, tool_bulk=0):
|
|
120
|
+
content = [{"type": "text", "text": text}]
|
|
121
|
+
if tool_bulk:
|
|
122
|
+
content.append(
|
|
123
|
+
{"type": "tool_use", "name": "Bash",
|
|
124
|
+
"input": {"command": "grep -rn x " + ("q" * tool_bulk)}}
|
|
125
|
+
)
|
|
126
|
+
return {"role": "assistant", "content": content}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _tool_result_user(bulk):
|
|
130
|
+
return {"role": "user",
|
|
131
|
+
"content": [{"type": "tool_result", "tool_use_id": "t", "content": "Z" * bulk}]}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _real_worker(target_text_chars, rng):
|
|
135
|
+
"""A genuine worker: 6-14 human turns, prose totalling ~target text chars,
|
|
136
|
+
interleaved with heavy tool traffic (the payload drops the tool traffic)."""
|
|
137
|
+
turns = rng.randint(6, 14)
|
|
138
|
+
per = max(80, target_text_chars // turns)
|
|
139
|
+
msgs = []
|
|
140
|
+
for i in range(turns):
|
|
141
|
+
msgs.append(_user(f"step {i}: continue the investigation and report findings"))
|
|
142
|
+
prose = (f"Finding {i}: confirmed the guard only probes observation rows; "
|
|
143
|
+
"world/experience facts never traverse the semantic dedup path. ")
|
|
144
|
+
prose = (prose * ((per // len(prose)) + 1))[:per]
|
|
145
|
+
msgs.append(_assistant_prose(prose, tool_bulk=rng.randint(2000, 8000)))
|
|
146
|
+
msgs.append(_tool_result_user(rng.randint(2000, 6000)))
|
|
147
|
+
return msgs
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _trivial_fork(rng):
|
|
151
|
+
"""A 10-second fork: 2-4 turns, almost no prose. Both gates should SKIP."""
|
|
152
|
+
turns = rng.randint(2, 4)
|
|
153
|
+
msgs = []
|
|
154
|
+
for i in range(turns):
|
|
155
|
+
msgs.append(_user("go"))
|
|
156
|
+
msgs.append(_assistant_prose("ok", tool_bulk=rng.randint(0, 500)))
|
|
157
|
+
return msgs
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _tool_heavy_prose_light(rng):
|
|
161
|
+
"""The recalibration target: clears MIN_TURNS human turns and emits a LOT of
|
|
162
|
+
tool traffic, but almost NO retainable prose. OLD gate passes (tool chars),
|
|
163
|
+
NEW gate skips (text-only ~0). Retaining it stores a near-empty document."""
|
|
164
|
+
turns = rng.randint(6, 10)
|
|
165
|
+
msgs = []
|
|
166
|
+
for i in range(turns):
|
|
167
|
+
msgs.append(_user("go")) # genuine human turn, but tiny
|
|
168
|
+
# No text block at all — pure tool_use with a large command body.
|
|
169
|
+
msgs.append({"role": "assistant",
|
|
170
|
+
"content": [{"type": "tool_use", "name": "Bash",
|
|
171
|
+
"input": {"command": "true " + ("x" * rng.randint(3000, 9000))}}]})
|
|
172
|
+
msgs.append(_tool_result_user(rng.randint(3000, 8000)))
|
|
173
|
+
return msgs
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def synthetic_corpus(seed=1994):
|
|
177
|
+
rng = random.Random(seed)
|
|
178
|
+
corpus = []
|
|
179
|
+
# Real workers across the measured size range (p10..p90+).
|
|
180
|
+
for _ in range(120):
|
|
181
|
+
target = int(rng.triangular(1500, 30000, 10058)) # low, high, mode≈p50
|
|
182
|
+
corpus.append(("real_worker", _real_worker(target, rng)))
|
|
183
|
+
for _ in range(60):
|
|
184
|
+
corpus.append(("trivial_fork", _trivial_fork(rng)))
|
|
185
|
+
for _ in range(40):
|
|
186
|
+
corpus.append(("tool_heavy_prose_light", _tool_heavy_prose_light(rng)))
|
|
187
|
+
return corpus
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _load_dir(path):
|
|
191
|
+
corpus = []
|
|
192
|
+
for name in sorted(os.listdir(path)):
|
|
193
|
+
if not name.endswith(".jsonl"):
|
|
194
|
+
continue
|
|
195
|
+
msgs = subagent_retain.read_transcript(os.path.join(path, name))
|
|
196
|
+
corpus.append((name, msgs))
|
|
197
|
+
return corpus
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def run(corpus):
|
|
201
|
+
rows = []
|
|
202
|
+
for label, msgs in corpus:
|
|
203
|
+
m = measure(msgs)
|
|
204
|
+
m["label"] = label
|
|
205
|
+
rows.append(m)
|
|
206
|
+
|
|
207
|
+
def gate(row, floor):
|
|
208
|
+
return row["human_turns"] >= MIN_TURNS and row["new_metric"] >= floor
|
|
209
|
+
|
|
210
|
+
def old_gate(row, floor):
|
|
211
|
+
return row["human_turns"] >= MIN_TURNS and row["old_metric"] >= floor
|
|
212
|
+
|
|
213
|
+
real = [r for r in rows if r["payload_chars"] >= REAL_WORKER_PAYLOAD_FLOOR]
|
|
214
|
+
empty_ish = [r for r in rows if r["payload_chars"] < 500]
|
|
215
|
+
|
|
216
|
+
floor_sweep = {}
|
|
217
|
+
for f in CANDIDATE_FLOORS:
|
|
218
|
+
newly_skipped_real = [
|
|
219
|
+
r for r in real if old_gate(r, SHIPPED_FLOOR) and not gate(r, f)
|
|
220
|
+
]
|
|
221
|
+
# Tool-heavy/prose-light forks the OLD gate passed that NEW gate skips.
|
|
222
|
+
old_pass_new_skip_emptyish = [
|
|
223
|
+
r for r in empty_ish if old_gate(r, SHIPPED_FLOOR) and not gate(r, f)
|
|
224
|
+
]
|
|
225
|
+
floor_sweep[f] = {
|
|
226
|
+
"passes_new": sum(1 for r in rows if gate(r, f)),
|
|
227
|
+
"real_workers_newly_skipped_vs_old_2000": len(newly_skipped_real),
|
|
228
|
+
"emptyish_forks_now_correctly_skipped": len(old_pass_new_skip_emptyish),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
summary = {
|
|
232
|
+
"corpus_size": len(rows),
|
|
233
|
+
"shipped_floor": SHIPPED_FLOOR,
|
|
234
|
+
"min_turns": MIN_TURNS,
|
|
235
|
+
"counts_by_label": _counts(rows),
|
|
236
|
+
"payload_percentiles_all": _pcts([r["payload_chars"] for r in rows]),
|
|
237
|
+
"payload_percentiles_gate_passers_new_2000": _pcts(
|
|
238
|
+
[r["payload_chars"] for r in rows if gate(r, SHIPPED_FLOOR)]
|
|
239
|
+
),
|
|
240
|
+
"old_gate_passed_but_payload_under_500": sum(
|
|
241
|
+
1 for r in rows if old_gate(r, SHIPPED_FLOOR) and r["payload_chars"] < 500
|
|
242
|
+
),
|
|
243
|
+
"new_gate_passed_but_payload_under_500": sum(
|
|
244
|
+
1 for r in rows if gate(r, SHIPPED_FLOOR) and r["payload_chars"] < 500
|
|
245
|
+
),
|
|
246
|
+
"floor_sweep": floor_sweep,
|
|
247
|
+
}
|
|
248
|
+
return summary, rows
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _counts(rows):
|
|
252
|
+
out = {}
|
|
253
|
+
for r in rows:
|
|
254
|
+
out[r["label"]] = out.get(r["label"], 0) + 1
|
|
255
|
+
return out
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _pcts(values):
|
|
259
|
+
if not values:
|
|
260
|
+
return {}
|
|
261
|
+
s = sorted(values)
|
|
262
|
+
|
|
263
|
+
def p(q):
|
|
264
|
+
return s[min(len(s) - 1, int(q * len(s)))]
|
|
265
|
+
|
|
266
|
+
return {"n": len(s), "min": s[0], "p10": p(0.10), "p50": p(0.50),
|
|
267
|
+
"p90": p(0.90), "max": s[-1]}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def main():
|
|
271
|
+
ap = argparse.ArgumentParser()
|
|
272
|
+
ap.add_argument("--dir", help="directory of real *.jsonl sidechain transcripts")
|
|
273
|
+
ap.add_argument("--json", action="store_true", help="emit JSON only")
|
|
274
|
+
args = ap.parse_args()
|
|
275
|
+
|
|
276
|
+
if args.dir:
|
|
277
|
+
corpus = _load_dir(args.dir)
|
|
278
|
+
source = f"real transcripts from {args.dir}"
|
|
279
|
+
else:
|
|
280
|
+
corpus = synthetic_corpus()
|
|
281
|
+
source = "synthetic corpus (distribution-parameterized, seed=1994)"
|
|
282
|
+
|
|
283
|
+
summary, _ = run(corpus)
|
|
284
|
+
summary["data_source"] = source
|
|
285
|
+
|
|
286
|
+
if args.json:
|
|
287
|
+
print(json.dumps(summary, indent=2))
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
print(f"# Volume-gate recalibration replay (#3994)\nsource: {source}\n")
|
|
291
|
+
print(json.dumps(summary, indent=2))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
main()
|
|
@@ -58,11 +58,21 @@ class FakeDaemon:
|
|
|
58
58
|
self.max_inflight_seen = 0
|
|
59
59
|
self._inflight = 0
|
|
60
60
|
self.membership_error = False
|
|
61
|
+
# #3291 partial-failure simulation: raise on the retain of the Nth
|
|
62
|
+
# DISTINCT document_id seen (distinct index, POST order = slice order),
|
|
63
|
+
# so a slice fails persistently (all bounded retries) without a fixed id.
|
|
64
|
+
self.fail_distinct_indices = set()
|
|
65
|
+
self._seen_doc_order = []
|
|
61
66
|
|
|
62
67
|
def retain(self, bank_id, content, document_id="conversation", context=None,
|
|
63
68
|
metadata=None, tags=None, timeout=15, async_processing=True,
|
|
64
69
|
observation_scopes=None):
|
|
65
70
|
self.observation_scopes_seen.append(observation_scopes)
|
|
71
|
+
if document_id not in self._seen_doc_order:
|
|
72
|
+
self._seen_doc_order.append(document_id)
|
|
73
|
+
distinct_idx = self._seen_doc_order.index(document_id)
|
|
74
|
+
if distinct_idx in self.fail_distinct_indices:
|
|
75
|
+
raise RuntimeError(f"simulated POST failure for slice #{distinct_idx}")
|
|
66
76
|
self._inflight += 1
|
|
67
77
|
self.max_inflight_seen = max(self.max_inflight_seen, self._inflight)
|
|
68
78
|
try:
|
|
@@ -79,6 +89,12 @@ class FakeDaemon:
|
|
|
79
89
|
return {did for did, d in self.docs.items()
|
|
80
90
|
if d.get("bank_id") == bank_id and session_id in did}
|
|
81
91
|
|
|
92
|
+
def document_exists(self, bank_id, document_id, timeout=30):
|
|
93
|
+
"""Tri-state presence check (mirrors HindsightClient.document_exists):
|
|
94
|
+
True when the slice doc is durable, False on a confirmed miss."""
|
|
95
|
+
d = self.docs.get(document_id)
|
|
96
|
+
return bool(d is not None and d.get("bank_id") == bank_id)
|
|
97
|
+
|
|
82
98
|
|
|
83
99
|
def _write_registry(path, rows, with_session_id=False):
|
|
84
100
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
@@ -166,6 +182,7 @@ class FromLogsTestBase(unittest.TestCase):
|
|
|
166
182
|
self._patches = [
|
|
167
183
|
mock.patch.object(HindsightClient, "retain", self._fake_retain),
|
|
168
184
|
mock.patch.object(HindsightClient, "list_session_document_ids", self._fake_membership),
|
|
185
|
+
mock.patch.object(HindsightClient, "document_exists", self._fake_doc_exists),
|
|
169
186
|
mock.patch("backfill_transcripts.get_api_url", return_value="http://fake"),
|
|
170
187
|
mock.patch("backfill_transcripts._SLEEP", self._fake_sleep),
|
|
171
188
|
]
|
|
@@ -178,6 +195,9 @@ class FromLogsTestBase(unittest.TestCase):
|
|
|
178
195
|
def _fake_membership(self, *a, **kw):
|
|
179
196
|
return self.daemon.list_session_document_ids(*a, **kw)
|
|
180
197
|
|
|
198
|
+
def _fake_doc_exists(self, *a, **kw):
|
|
199
|
+
return self.daemon.document_exists(*a, **kw)
|
|
200
|
+
|
|
181
201
|
def _fake_sleep(self, secs):
|
|
182
202
|
self.sleeps.append(secs)
|
|
183
203
|
|
|
@@ -448,6 +468,95 @@ class TestFromLogs(FromLogsTestBase):
|
|
|
448
468
|
spaced = [s for s in self.sleeps if abs(s - 1.5) < 1e-9]
|
|
449
469
|
self.assertEqual(len(spaced), 3)
|
|
450
470
|
|
|
471
|
+
# --- #3291: partial POST failure then re-run must GAP-COMPLETE, not skip --
|
|
472
|
+
# Run 1: slices 0..k-1 commit (async_processing=False ⇒ durable), slice k
|
|
473
|
+
# fails ⇒ session recorded ``failed``. Run 2 (the fix): the committed
|
|
474
|
+
# prefix must NOT classify the session ``present`` and skip it forever —
|
|
475
|
+
# it must recompute the deterministic slice ids and POST ONLY the missing
|
|
476
|
+
# slice(s). Pre-fix behaviour: run 2 sees the prefix via session
|
|
477
|
+
# membership → ``has_documents`` → skip → the tail is lost silently. -----
|
|
478
|
+
def test_partial_post_failure_then_rerun_gap_completes(self):
|
|
479
|
+
self._write_yaml({"clerk": None})
|
|
480
|
+
self._write_settings("clerk", "clerk")
|
|
481
|
+
base = 1_700_000_000_000
|
|
482
|
+
# 3 human turns, slice_turns=1 ⇒ 3 non-overlapping slices posted in order.
|
|
483
|
+
path, (first, last) = self._transcript("clerk", "sess-partial", 3, base)
|
|
484
|
+
self._registry("clerk", [{
|
|
485
|
+
"turn_key": "123:_:t", "chat_id": "123", "started_at": str(first + 100),
|
|
486
|
+
"ended_at": str(last), "ended_via": "sigterm",
|
|
487
|
+
}])
|
|
488
|
+
|
|
489
|
+
# RUN 1 — the LAST slice (distinct index 2) fails every (bounded) attempt.
|
|
490
|
+
self.daemon.fail_distinct_indices = {2}
|
|
491
|
+
report1 = self._run("clerk")
|
|
492
|
+
ar1 = report1["agents"]["clerk"]
|
|
493
|
+
|
|
494
|
+
# Total-loss restore attempted all 3 slices; 2 committed, 1 failed.
|
|
495
|
+
self.assertEqual(ar1["sessions_total_loss"], 1)
|
|
496
|
+
self.assertEqual(ar1["sessions_restored"], 0) # NOT fully restored
|
|
497
|
+
self.assertEqual(ar1["posts_ok"], 2)
|
|
498
|
+
self.assertEqual(ar1["posts_failed"], 1)
|
|
499
|
+
entry1 = next(s for s in ar1["sessions"] if s["class"] == "total_loss")
|
|
500
|
+
all_ids = entry1["document_ids"]
|
|
501
|
+
self.assertEqual(len(all_ids), 3)
|
|
502
|
+
committed_after_1 = set(self.daemon.docs)
|
|
503
|
+
self.assertEqual(len(committed_after_1), 2) # durable committed prefix
|
|
504
|
+
missing = [d for d in all_ids if d not in committed_after_1]
|
|
505
|
+
self.assertEqual(len(missing), 1)
|
|
506
|
+
# Progress marked failed (NOT done) — the re-run must revisit it.
|
|
507
|
+
with open(os.environ["HINDSIGHT_BACKFILL_PROGRESS"]) as f:
|
|
508
|
+
prog = json.load(f)
|
|
509
|
+
self.assertEqual(prog["clerk/sess-partial"]["status"], "failed")
|
|
510
|
+
|
|
511
|
+
# RUN 2 — no injected failure this time. The fix must gap-complete.
|
|
512
|
+
self.daemon.fail_distinct_indices = set()
|
|
513
|
+
posts_before = len(self.daemon.posts)
|
|
514
|
+
report2 = self._run("clerk")
|
|
515
|
+
ar2 = report2["agents"]["clerk"]
|
|
516
|
+
|
|
517
|
+
# It must NOT be skipped as an already-present session.
|
|
518
|
+
self.assertEqual(ar2["sessions_has_documents"], 0)
|
|
519
|
+
# Gap completion: exactly the missing slice posted, the 2 present skipped.
|
|
520
|
+
self.assertEqual(ar2["sessions_gap_completed"], 1)
|
|
521
|
+
self.assertEqual(ar2["slices_gap_filled"], 1)
|
|
522
|
+
self.assertEqual(ar2["slices_gap_present"], 2)
|
|
523
|
+
run2_posts = [did for did, _ in self.daemon.posts[posts_before:]]
|
|
524
|
+
self.assertEqual(run2_posts, missing) # ONLY the missing slice
|
|
525
|
+
# All three slices are now durable — the session is fully recovered.
|
|
526
|
+
self.assertEqual(set(self.daemon.docs), set(all_ids))
|
|
527
|
+
with open(os.environ["HINDSIGHT_BACKFILL_PROGRESS"]) as f:
|
|
528
|
+
prog2 = json.load(f)
|
|
529
|
+
self.assertEqual(prog2["clerk/sess-partial"]["status"], "done")
|
|
530
|
+
# Watermark advanced to the true tail slice on completion.
|
|
531
|
+
self.assertIsNotNone(watermark.load("sess-partial"))
|
|
532
|
+
|
|
533
|
+
# --- #3291 self-heal: a persistently-failing slice must surface as an
|
|
534
|
+
# operator-visible INCOMPLETE metric + convergence WARN, never a silent
|
|
535
|
+
# trap. -----------------------------------------------------------------
|
|
536
|
+
def test_incomplete_session_surfaces_convergence_metric(self):
|
|
537
|
+
self._write_yaml({"clerk": None})
|
|
538
|
+
self._write_settings("clerk", "clerk")
|
|
539
|
+
base = 1_700_000_000_000
|
|
540
|
+
path, (first, last) = self._transcript("clerk", "sess-stuck", 2, base)
|
|
541
|
+
self._registry("clerk", [{
|
|
542
|
+
"turn_key": "123:_:t", "chat_id": "123", "started_at": str(first + 100),
|
|
543
|
+
"ended_at": str(last), "ended_via": "sigterm",
|
|
544
|
+
}])
|
|
545
|
+
# The first slice fails every attempt → session left INCOMPLETE.
|
|
546
|
+
self.daemon.fail_distinct_indices = {0}
|
|
547
|
+
|
|
548
|
+
report = self._run("clerk")
|
|
549
|
+
ar = report["agents"]["clerk"]
|
|
550
|
+
# Metric: one session did not fully recover.
|
|
551
|
+
self.assertEqual(ar["sessions_incomplete"], 1)
|
|
552
|
+
self.assertEqual(ar["sessions_restored"], 0)
|
|
553
|
+
self.assertEqual(report["rollup"]["sessions_incomplete"], 1)
|
|
554
|
+
# Operator-visible: the formatted report carries the count + a WARN that
|
|
555
|
+
# tells them to re-run (so a non-converging session is never silent).
|
|
556
|
+
text = bf.format_report_from_logs(report)
|
|
557
|
+
self.assertIn("incomplete=1", text)
|
|
558
|
+
self.assertIn("CONVERGENCE", text)
|
|
559
|
+
|
|
451
560
|
# --- direct session_id join (migrated registry) -------------------------
|
|
452
561
|
def test_direct_sessionid_join(self):
|
|
453
562
|
self._write_yaml({"clerk": None})
|