switchroom 0.19.33 → 0.19.35
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 +1 -0
- package/dist/auth-broker/index.js +11088 -10989
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +10947 -10361
- package/dist/host-control/main.js +9512 -9410
- package/dist/vault/approvals/kernel-server.js +454 -423
- package/dist/vault/broker/server.js +1658 -1636
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +172 -27
- package/telegram-plugin/gateway/gateway.ts +5 -2
- package/telegram-plugin/gateway/model-command.ts +245 -23
- package/telegram-plugin/tests/model-command.test.ts +415 -4
- package/vendor/hindsight-memory/scripts/subagent_retain.py +103 -7
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +377 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Sidechain retains carry LEARNINGS, not raw transcripts and tools.
|
|
3
|
+
|
|
4
|
+
Ken, 2026-07-29: "I don't want sub-agents' transcripts but definitely their
|
|
5
|
+
learnings should be captured but not raw transcripts and tools."
|
|
6
|
+
|
|
7
|
+
``run_subagent_retain`` forces ``retainToolCalls = False`` on its config COPY,
|
|
8
|
+
which routes the window through ``_prepare_text_transcript`` /
|
|
9
|
+
``_extract_text_content`` instead of ``_prepare_json_transcript`` /
|
|
10
|
+
``_extract_message_blocks``. These tests pin the observable consequences:
|
|
11
|
+
|
|
12
|
+
1. Structural exclusion — a ``Write`` body, a ``Bash`` command and a
|
|
13
|
+
``tool_result`` marker are all absent from the POSTed content, while an
|
|
14
|
+
assistant prose finding survives; and the content is NOT JSON (proving the
|
|
15
|
+
text path, not the JSON path, produced it).
|
|
16
|
+
2. Nothing goes silently empty — the text-only formatter still yields a
|
|
17
|
+
substantive transcript for a representative sidechain fixture.
|
|
18
|
+
|
|
19
|
+
Stdlib-only, hermetic (no network, no live container); runs under
|
|
20
|
+
``python3 -m unittest discover tests/``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import unittest
|
|
29
|
+
from unittest import mock
|
|
30
|
+
|
|
31
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
32
|
+
if SCRIPTS_DIR not in sys.path:
|
|
33
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
34
|
+
|
|
35
|
+
import subagent_retain # noqa: E402
|
|
36
|
+
from lib.content import prepare_retention_transcript # noqa: E402
|
|
37
|
+
|
|
38
|
+
# Mirrors tests/test_subagent_retain.py CONFIG, including its
|
|
39
|
+
# ``retainToolCalls: True`` — the point is that the sidechain path must override
|
|
40
|
+
# it regardless of what the operator configured.
|
|
41
|
+
CONFIG = {
|
|
42
|
+
"autoRetain": True,
|
|
43
|
+
"retainMode": "chunked",
|
|
44
|
+
"retainRoles": ["user", "assistant"],
|
|
45
|
+
"retainToolCalls": True,
|
|
46
|
+
"retainTags": ["{session_id}"],
|
|
47
|
+
"retainMetadata": {},
|
|
48
|
+
"retainContext": "claude-code",
|
|
49
|
+
"hindsightApiToken": None,
|
|
50
|
+
"debug": False,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Distinctive markers. Each must be absent from the retained content.
|
|
54
|
+
WRITE_BODY_MARKER = "MARKER_WRITE_FILE_BODY_9f3a"
|
|
55
|
+
BASH_COMMAND_MARKER = "MARKER_BASH_COMMAND_c71d"
|
|
56
|
+
TOOL_RESULT_MARKER = "MARKER_TOOL_RESULT_PAYLOAD_4e82"
|
|
57
|
+
|
|
58
|
+
# The one thing that MUST survive: the sub-agent's own prose finding.
|
|
59
|
+
FINDING = (
|
|
60
|
+
"Durable finding: the recall hook's stderr warning is swallowed by the CLI, "
|
|
61
|
+
"so directives_omitted on the recall_log row is the only visible signal."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _entry(role: str, content, uuid: str) -> str:
|
|
66
|
+
"""One nested Claude Code sidechain jsonl entry."""
|
|
67
|
+
return json.dumps(
|
|
68
|
+
{
|
|
69
|
+
"type": role,
|
|
70
|
+
"isSidechain": True,
|
|
71
|
+
"agentId": "af5fba739c0ee6b38",
|
|
72
|
+
"sessionId": "parentsess",
|
|
73
|
+
"uuid": uuid,
|
|
74
|
+
"message": {"role": role, "content": content},
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _tool_heavy_sidechain(path: str, *, human_turns: int = 8) -> None:
|
|
80
|
+
"""A sidechain transcript that is mostly tool traffic plus one prose finding.
|
|
81
|
+
|
|
82
|
+
Shaped to clear the volume gate (>= MIN_HUMAN_TURNS human turns and
|
|
83
|
+
>= MIN_NON_TOOL_RESULT_CHARS of non-tool-result chars) so the retain
|
|
84
|
+
actually happens — the gate counts tool_use inputs, which this has in bulk.
|
|
85
|
+
"""
|
|
86
|
+
lines = []
|
|
87
|
+
for i in range(human_turns):
|
|
88
|
+
lines.append(_entry("user", f"instruction {i}: keep going with the audit task", f"u{i}"))
|
|
89
|
+
lines.append(
|
|
90
|
+
_entry(
|
|
91
|
+
"assistant",
|
|
92
|
+
[
|
|
93
|
+
{
|
|
94
|
+
"type": "thinking",
|
|
95
|
+
"thinking": "internal reasoning that must never be retained",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"type": "tool_use",
|
|
99
|
+
"id": f"tu_w{i}",
|
|
100
|
+
"name": "Write",
|
|
101
|
+
# ~50 KB body — the class of payload this change exists to drop.
|
|
102
|
+
"input": {
|
|
103
|
+
"file_path": f"/tmp/scratch/out{i}.txt",
|
|
104
|
+
"content": WRITE_BODY_MARKER + ("Z" * 50_000),
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"type": "tool_use",
|
|
109
|
+
"id": f"tu_b{i}",
|
|
110
|
+
"name": "Bash",
|
|
111
|
+
"input": {"command": f"{BASH_COMMAND_MARKER} --iteration {i}"},
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
f"a{i}",
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
lines.append(
|
|
118
|
+
_entry(
|
|
119
|
+
"user",
|
|
120
|
+
[
|
|
121
|
+
{
|
|
122
|
+
"type": "tool_result",
|
|
123
|
+
"tool_use_id": f"tu_b{i}",
|
|
124
|
+
"content": TOOL_RESULT_MARKER + " " + ("q" * 4000),
|
|
125
|
+
}
|
|
126
|
+
],
|
|
127
|
+
f"tr{i}",
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# The sub-agent's own prose, last — the learning that must survive.
|
|
132
|
+
lines.append(_entry("user", "wrap up and report what you learned", "ufinal"))
|
|
133
|
+
lines.append(_entry("assistant", [{"type": "text", "text": FINDING}], "afinal"))
|
|
134
|
+
|
|
135
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
136
|
+
f.write("\n".join(lines) + "\n")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@contextlib.contextmanager
|
|
140
|
+
def _lock_acquired(blocking=False):
|
|
141
|
+
yield True
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _run_sidechain_retain(sidechain_path: str, tmpdir: str, config_extra=None):
|
|
145
|
+
"""Drive run_subagent_retain with a stub client, returning (result, kwargs)."""
|
|
146
|
+
captured = {}
|
|
147
|
+
|
|
148
|
+
class _Client:
|
|
149
|
+
def __init__(self, *a, **k):
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
def retain(self, **kwargs):
|
|
153
|
+
captured.update(kwargs)
|
|
154
|
+
return {"ok": True}
|
|
155
|
+
|
|
156
|
+
config = dict(CONFIG, **(config_extra or {}))
|
|
157
|
+
hook_input = {
|
|
158
|
+
"session_id": "parentsess",
|
|
159
|
+
"agent_id": "af5",
|
|
160
|
+
"agent_type": "worker",
|
|
161
|
+
"agent_transcript_path": sidechain_path,
|
|
162
|
+
"transcript_path": os.path.join(tmpdir, "parentsess.jsonl"),
|
|
163
|
+
"cwd": tmpdir,
|
|
164
|
+
}
|
|
165
|
+
with mock.patch("subagent_retain.load_config", return_value=config), \
|
|
166
|
+
mock.patch("subagent_retain.get_api_url", return_value="http://x"), \
|
|
167
|
+
mock.patch("subagent_retain.ensure_bank_mission"), \
|
|
168
|
+
mock.patch("subagent_retain.derive_bank_id", return_value="agentbank"), \
|
|
169
|
+
mock.patch("subagent_retain.HindsightClient", _Client), \
|
|
170
|
+
mock.patch("subagent_retain.inflight_lock", _lock_acquired):
|
|
171
|
+
result = subagent_retain.run_subagent_retain(hook_input)
|
|
172
|
+
return result, captured
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class SidechainRetainsLearningsNotTools(unittest.TestCase):
|
|
176
|
+
"""The retained content is the sub-agent's prose, not its tool traffic."""
|
|
177
|
+
|
|
178
|
+
def setUp(self):
|
|
179
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
180
|
+
self.addCleanup(self._tmp.cleanup)
|
|
181
|
+
self.sidechain = os.path.join(self._tmp.name, "agent-af5.jsonl")
|
|
182
|
+
_tool_heavy_sidechain(self.sidechain)
|
|
183
|
+
self.result, self.captured = _run_sidechain_retain(self.sidechain, self._tmp.name)
|
|
184
|
+
self.assertEqual(self.result["status"], "ok", self.result)
|
|
185
|
+
self.content = self.captured["content"]
|
|
186
|
+
|
|
187
|
+
def test_sub_agent_prose_finding_survives(self):
|
|
188
|
+
self.assertIn(FINDING, self.content)
|
|
189
|
+
|
|
190
|
+
def test_write_tool_body_is_excluded(self):
|
|
191
|
+
self.assertNotIn(WRITE_BODY_MARKER, self.content)
|
|
192
|
+
# And the 50 KB body itself is nowhere in the payload.
|
|
193
|
+
self.assertNotIn("Z" * 200, self.content)
|
|
194
|
+
|
|
195
|
+
def test_bash_command_is_excluded(self):
|
|
196
|
+
self.assertNotIn(BASH_COMMAND_MARKER, self.content)
|
|
197
|
+
|
|
198
|
+
def test_tool_result_body_is_excluded(self):
|
|
199
|
+
self.assertNotIn(TOOL_RESULT_MARKER, self.content)
|
|
200
|
+
|
|
201
|
+
def test_thinking_blocks_are_excluded(self):
|
|
202
|
+
self.assertNotIn("internal reasoning that must never be retained", self.content)
|
|
203
|
+
|
|
204
|
+
def test_content_is_not_the_json_transcript_form(self):
|
|
205
|
+
"""Proves we are on the text path, not _prepare_json_transcript.
|
|
206
|
+
|
|
207
|
+
The JSON path emits a ``json.dumps`` list of ``{role, content: [blocks]}``;
|
|
208
|
+
the text path emits ``[role: x]...[x:end]`` markers, which is not JSON.
|
|
209
|
+
The mission header is prepended to both, so strip it first — otherwise
|
|
210
|
+
this would pass for the wrong reason.
|
|
211
|
+
"""
|
|
212
|
+
body = self.content[len(subagent_retain.SIDECHAIN_MISSION_HEADER):].strip()
|
|
213
|
+
self.assertTrue(body, "no transcript body after the mission header")
|
|
214
|
+
with self.assertRaises(json.JSONDecodeError):
|
|
215
|
+
json.loads(body)
|
|
216
|
+
# Positive check on the text-path shape.
|
|
217
|
+
self.assertIn("[role: assistant]", body)
|
|
218
|
+
|
|
219
|
+
def test_payload_is_far_smaller_than_the_raw_transcript(self):
|
|
220
|
+
"""Volume, not just shape: the whole point is fewer chars to extract."""
|
|
221
|
+
raw_bytes = os.path.getsize(self.sidechain)
|
|
222
|
+
self.assertLess(
|
|
223
|
+
len(self.content),
|
|
224
|
+
raw_bytes / 5,
|
|
225
|
+
f"retained {len(self.content)} chars from a {raw_bytes}-byte transcript "
|
|
226
|
+
"— expected a large reduction on the text-only path",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def test_operator_config_is_not_mutated(self):
|
|
230
|
+
"""The override lands on the sidechain COPY, not the parent's config."""
|
|
231
|
+
self.assertIs(CONFIG["retainToolCalls"], True)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class ProseFreeSidechainDegradesGracefully(unittest.TestCase):
|
|
235
|
+
"""The degenerate case — a worker with NO assistant prose at all.
|
|
236
|
+
|
|
237
|
+
Worth pinning because the volume gate counts ``tool_use`` inputs
|
|
238
|
+
(``non_tool_result_char_count``) while the retained content no longer
|
|
239
|
+
includes them, so gate and payload now measure different things. The
|
|
240
|
+
reassuring outcome, verified here rather than assumed: it still retains
|
|
241
|
+
cleanly, because the gate requires ``MIN_HUMAN_TURNS`` GENUINE human turns
|
|
242
|
+
and each one is a plain-string user message that survives the text path. So
|
|
243
|
+
``build_retain_payload`` does not return None and nothing goes silently
|
|
244
|
+
empty — while the tool traffic is still excluded.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
def _prose_free_sidechain(self, path: str, human_turns: int = 8) -> None:
|
|
248
|
+
lines = []
|
|
249
|
+
for i in range(human_turns):
|
|
250
|
+
# tool_result-only user messages don't count as human turns, so the
|
|
251
|
+
# instruction turns are plain strings — but they are the USER's
|
|
252
|
+
# words. Keep them short so the retained text is dominated by
|
|
253
|
+
# nothing at all once tools are stripped.
|
|
254
|
+
lines.append(_entry("user", "go", f"u{i}"))
|
|
255
|
+
lines.append(
|
|
256
|
+
_entry(
|
|
257
|
+
"assistant",
|
|
258
|
+
[
|
|
259
|
+
{
|
|
260
|
+
"type": "tool_use",
|
|
261
|
+
"id": f"tu{i}",
|
|
262
|
+
"name": "Bash",
|
|
263
|
+
"input": {"command": "true " + ("x" * 4000)},
|
|
264
|
+
}
|
|
265
|
+
],
|
|
266
|
+
f"a{i}",
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
270
|
+
f.write("\n".join(lines) + "\n")
|
|
271
|
+
|
|
272
|
+
def test_retains_the_human_turns_and_still_drops_the_tool_traffic(self):
|
|
273
|
+
with tempfile.TemporaryDirectory() as d:
|
|
274
|
+
sc = os.path.join(d, "agent-af5.jsonl")
|
|
275
|
+
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
|
+
msgs = subagent_retain.read_transcript(sc)
|
|
281
|
+
passed, turns, chars = subagent_retain.passes_volume_gate(msgs, CONFIG)
|
|
282
|
+
self.assertTrue(passed, f"gate did not pass: turns={turns} chars={chars}")
|
|
283
|
+
|
|
284
|
+
result, captured = _run_sidechain_retain(sc, d)
|
|
285
|
+
|
|
286
|
+
# Retains, does not crash and does not enqueue a doomed pending retain.
|
|
287
|
+
self.assertEqual(result["status"], "ok", result)
|
|
288
|
+
body = captured["content"][len(subagent_retain.SIDECHAIN_MISSION_HEADER):].strip()
|
|
289
|
+
# The human instruction turns survive — nothing went silently empty.
|
|
290
|
+
self.assertIn("[role: user]", body)
|
|
291
|
+
self.assertIn("go", body)
|
|
292
|
+
# The 4 KB Bash command bodies did NOT.
|
|
293
|
+
self.assertNotIn("x" * 200, body)
|
|
294
|
+
self.assertNotIn("true ", body)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class TextOnlyPathIsNotEmpty(unittest.TestCase):
|
|
298
|
+
"""A representative sidechain still yields a substantive transcript.
|
|
299
|
+
|
|
300
|
+
Prior measurement over 200 real klanker sidechain transcripts found 0 empty
|
|
301
|
+
and 0 under 500 chars (p10 5,035 / p50 10,058 / p90 26,040 chars). A floor
|
|
302
|
+
assertion, deliberately not an exact size.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
# Well under the measured p10 (5,035) — this guards "silently empty",
|
|
306
|
+
# not the exact reduction ratio.
|
|
307
|
+
MIN_TRANSCRIPT_CHARS = 500
|
|
308
|
+
|
|
309
|
+
def _representative_messages(self):
|
|
310
|
+
"""Prose-bearing assistant turns interleaved with tool traffic."""
|
|
311
|
+
msgs = []
|
|
312
|
+
for i in range(10):
|
|
313
|
+
msgs.append({"role": "user", "content": f"step {i}: continue the investigation"})
|
|
314
|
+
msgs.append(
|
|
315
|
+
{
|
|
316
|
+
"role": "assistant",
|
|
317
|
+
"content": [
|
|
318
|
+
{"type": "thinking", "thinking": "internal " * 50},
|
|
319
|
+
{
|
|
320
|
+
"type": "text",
|
|
321
|
+
"text": (
|
|
322
|
+
f"Step {i}: confirmed the guard only probes observation rows, "
|
|
323
|
+
"so world/experience facts never traverse the semantic dedup "
|
|
324
|
+
"path. That is why overlap persists as extra rows."
|
|
325
|
+
),
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
"type": "tool_use",
|
|
329
|
+
"id": f"tu{i}",
|
|
330
|
+
"name": "Bash",
|
|
331
|
+
"input": {"command": "grep -rn dedup " + ("x" * 3000)},
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
}
|
|
335
|
+
)
|
|
336
|
+
msgs.append(
|
|
337
|
+
{
|
|
338
|
+
"role": "user",
|
|
339
|
+
"content": [
|
|
340
|
+
{"type": "tool_result", "tool_use_id": f"tu{i}", "content": "y" * 5000}
|
|
341
|
+
],
|
|
342
|
+
}
|
|
343
|
+
)
|
|
344
|
+
return msgs
|
|
345
|
+
|
|
346
|
+
def test_text_only_transcript_is_non_empty_and_non_trivial(self):
|
|
347
|
+
transcript, count = prepare_retention_transcript(
|
|
348
|
+
self._representative_messages(),
|
|
349
|
+
["user", "assistant"],
|
|
350
|
+
True,
|
|
351
|
+
include_tool_calls=False,
|
|
352
|
+
)
|
|
353
|
+
self.assertIsNotNone(transcript, "text-only path formatted to nothing")
|
|
354
|
+
self.assertGreaterEqual(
|
|
355
|
+
len(transcript),
|
|
356
|
+
self.MIN_TRANSCRIPT_CHARS,
|
|
357
|
+
f"text-only transcript is only {len(transcript)} chars — below the "
|
|
358
|
+
f"{self.MIN_TRANSCRIPT_CHARS}-char floor",
|
|
359
|
+
)
|
|
360
|
+
self.assertGreater(count, 0)
|
|
361
|
+
# The prose is what carries; the tool traffic is what went.
|
|
362
|
+
self.assertIn("never traverse the semantic dedup", transcript)
|
|
363
|
+
self.assertNotIn("grep -rn dedup", transcript)
|
|
364
|
+
|
|
365
|
+
def test_text_only_is_materially_smaller_than_json_form(self):
|
|
366
|
+
msgs = self._representative_messages()
|
|
367
|
+
text_form, _ = prepare_retention_transcript(
|
|
368
|
+
msgs, ["user", "assistant"], True, include_tool_calls=False
|
|
369
|
+
)
|
|
370
|
+
json_form, _ = prepare_retention_transcript(
|
|
371
|
+
msgs, ["user", "assistant"], True, include_tool_calls=True
|
|
372
|
+
)
|
|
373
|
+
self.assertLess(len(text_form), len(json_form) / 5)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
if __name__ == "__main__":
|
|
377
|
+
unittest.main()
|