switchroom 0.19.41 → 0.19.43
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 +23 -3
- package/dist/auth-broker/index.js +54 -9
- package/dist/cli/notion-write-pretool.mjs +23 -3
- package/dist/cli/switchroom.js +1956 -900
- package/dist/host-control/main.js +227 -30
- package/dist/vault/approvals/kernel-server.js +54 -9
- package/dist/vault/broker/server.js +54 -9
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +82 -4
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1318 -693
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +56 -58
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
- package/telegram-plugin/gateway/model-command.ts +21 -109
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +62 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
- 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,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})
|
|
@@ -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)
|