switchroom 0.19.23 → 0.19.25
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 +18 -7
- package/dist/auth-broker/index.js +117 -33
- package/dist/cli/autoaccept-poll.js +0 -1
- package/dist/cli/drive-write-pretool.mjs +5 -0
- package/dist/cli/ms-365-write-pretool.mjs +5 -0
- package/dist/cli/notion-write-pretool.mjs +18 -6
- package/dist/cli/switchroom.js +2916 -1481
- package/dist/host-control/main.js +116 -34
- package/dist/vault/approvals/kernel-server.js +115 -33
- package/dist/vault/broker/server.js +281 -76
- package/examples/switchroom.yaml +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +52 -13
- package/profiles/_shared/dev-protocol.md.hbs +3 -4
- package/skills/dev-protocol/SKILL.md +22 -15
- package/skills/switchroom-health/SKILL.md +19 -0
- package/skills/switchroom-release/SKILL.md +2 -1
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +9 -2
- package/telegram-plugin/dist/gateway/gateway.js +6925 -6734
- package/telegram-plugin/gateway/gateway.ts +34 -35
- package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +53 -21
- package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
- package/telegram-plugin/gateway/turn-end.ts +1 -1
- package/telegram-plugin/quota-bar-format.ts +4 -1
- package/telegram-plugin/reply-owner-resolve.ts +110 -9
- package/telegram-plugin/send-gate-degraded.test.ts +45 -16
- package/telegram-plugin/send-gate.ts +185 -24
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +9 -9
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +42 -0
- package/telegram-plugin/tests/latest-turn-lookup.test.ts +77 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +23 -1
- package/telegram-plugin/tests/quota-bar-format.test.ts +50 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +531 -0
- package/telegram-plugin/tests/secret-detect-false-positives.test.ts +1 -1
- package/telegram-plugin/tests/send-reply-golden.test.ts +296 -28
- package/telegram-plugin/tests/stream-controller-send-gate.test.ts +134 -28
- package/telegram-plugin/tests/stream-render-golden.test.ts +25 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +61 -19
- package/vendor/hindsight-memory/scripts/lib/content.py +376 -1
- package/vendor/hindsight-memory/scripts/lib/english_words.txt +10799 -0
- package/vendor/hindsight-memory/scripts/recall.py +503 -252
- package/vendor/hindsight-memory/scripts/tests/test_recall_bank_slots.py +509 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +22 -5
- package/vendor/hindsight-memory/scripts/tests/test_recall_error_text.py +147 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_hook_budget.py +266 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +0 -401
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +261 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +473 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +25 -8
- package/vendor/hindsight-memory/tests/test_content.py +218 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Switchroom structural fix #7 — the `error` STRING on a recall_log row.
|
|
2
|
+
|
|
3
|
+
Every failure channel on a `recall_log.jsonl` row used to be a boolean:
|
|
4
|
+
`deadline_hit`, `bank_errored`, and per-bank `timed_out` / `errored`. They say
|
|
5
|
+
THAT something broke and never WHICH thing, so a log full of `errored: true`
|
|
6
|
+
cannot distinguish a connection refused from a 500 from a bad bank id from an
|
|
7
|
+
auth failure — four different fixes, one indistinguishable row.
|
|
8
|
+
|
|
9
|
+
That gap is not cosmetic here. `recall.py` deliberately exits 0 on a bank
|
|
10
|
+
failure (blocking the user's prompt would be worse) and Claude Code swallows
|
|
11
|
+
hook stderr on a zero exit, so the `[Hindsight] Recall failed: …` line that
|
|
12
|
+
carries the reason reaches nobody. The JSONL row is the ONLY place the reason
|
|
13
|
+
can survive, and until now it did not carry one.
|
|
14
|
+
|
|
15
|
+
These tests pin the behaviour the watchdog and an operator's `jq` depend on:
|
|
16
|
+
|
|
17
|
+
* `error_text` is type-PREFIXED, because the message alone is frequently
|
|
18
|
+
generic or empty and the exception class is the diagnostic part.
|
|
19
|
+
* It is BOUNDED, because rows are trimmed by line count and one
|
|
20
|
+
traceback-shaped message would evict real history.
|
|
21
|
+
* It collapses whitespace to one line, so the JSONL stays greppable.
|
|
22
|
+
* `recall_error_summary` reports the OWN bank's failure in preference to a
|
|
23
|
+
side bank's — same rule as `degraded_recall_notice`, because a shared
|
|
24
|
+
profile bank timing out does not mean the agent lost its memory.
|
|
25
|
+
* A side-bank failure is NAMED, never reported as if it were the own bank's.
|
|
26
|
+
* A healthy row gets `None`, not `""` — an empty string would count as a
|
|
27
|
+
failure under a truthiness test somewhere downstream.
|
|
28
|
+
|
|
29
|
+
Stdlib-only, no network, no server.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
import unittest
|
|
35
|
+
|
|
36
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
37
|
+
if SCRIPTS_DIR not in sys.path:
|
|
38
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
39
|
+
|
|
40
|
+
from recall import ( # noqa: E402
|
|
41
|
+
ERROR_TEXT_MAX_CHARS,
|
|
42
|
+
error_text,
|
|
43
|
+
recall_error_summary,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
OWN = "overlord"
|
|
47
|
+
SIDE = "ken-profile"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def timing(bank_id, *, timed_out=False, errored=False, error=None, elapsed_ms=120):
|
|
51
|
+
"""A `bank_timings` entry in the shape recall.py now appends."""
|
|
52
|
+
return {
|
|
53
|
+
"bank_id": bank_id,
|
|
54
|
+
"elapsed_ms": elapsed_ms,
|
|
55
|
+
"timed_out": timed_out,
|
|
56
|
+
"errored": errored,
|
|
57
|
+
"error": error,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ErrorTextTests(unittest.TestCase):
|
|
62
|
+
def test_prefixes_the_exception_type(self):
|
|
63
|
+
# The observed fleet failure renders as its class plus its message;
|
|
64
|
+
# `TimeoutError` alone is what tells an operator this is the 8s bank
|
|
65
|
+
# deadline rather than a 500.
|
|
66
|
+
self.assertEqual(
|
|
67
|
+
error_text(TimeoutError("read timed out")),
|
|
68
|
+
"TimeoutError: read timed out",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def test_survives_an_exception_with_no_message(self):
|
|
72
|
+
# The class is the ONLY information here — dropping it would log an
|
|
73
|
+
# empty string and lose the row entirely.
|
|
74
|
+
self.assertEqual(error_text(ConnectionRefusedError()), "ConnectionRefusedError:")
|
|
75
|
+
|
|
76
|
+
def test_passes_a_plain_string_through(self):
|
|
77
|
+
self.assertEqual(error_text("bank not found"), "bank not found")
|
|
78
|
+
|
|
79
|
+
def test_none_for_no_failure(self):
|
|
80
|
+
self.assertIsNone(error_text(None))
|
|
81
|
+
|
|
82
|
+
def test_empty_string_becomes_none_not_empty(self):
|
|
83
|
+
# "" would be falsey but PRESENT, which reads as a failure with no
|
|
84
|
+
# reason to anything that tests for the key.
|
|
85
|
+
self.assertIsNone(error_text(""))
|
|
86
|
+
self.assertIsNone(error_text(" \n "))
|
|
87
|
+
|
|
88
|
+
def test_collapses_newlines_so_the_jsonl_stays_greppable(self):
|
|
89
|
+
self.assertEqual(error_text("line one\n line two\ttab"), "line one line two tab")
|
|
90
|
+
|
|
91
|
+
def test_bounded(self):
|
|
92
|
+
got = error_text("x" * 5000)
|
|
93
|
+
self.assertEqual(len(got), ERROR_TEXT_MAX_CHARS)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class RecallErrorSummaryTests(unittest.TestCase):
|
|
97
|
+
def test_none_when_every_bank_answered(self):
|
|
98
|
+
self.assertIsNone(
|
|
99
|
+
recall_error_summary(OWN, [timing(OWN), timing(SIDE)], directives_timed_out=False)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def test_reports_the_own_bank_failure(self):
|
|
103
|
+
got = recall_error_summary(
|
|
104
|
+
OWN,
|
|
105
|
+
[timing(OWN, timed_out=True, error="TimeoutError: read timed out"), timing(SIDE)],
|
|
106
|
+
)
|
|
107
|
+
self.assertEqual(got, "TimeoutError: read timed out")
|
|
108
|
+
|
|
109
|
+
def test_prefers_the_own_bank_over_a_simultaneous_side_failure(self):
|
|
110
|
+
got = recall_error_summary(
|
|
111
|
+
OWN,
|
|
112
|
+
[
|
|
113
|
+
timing(SIDE, errored=True, error="HTTPError: 500"),
|
|
114
|
+
timing(OWN, timed_out=True, error="TimeoutError: own"),
|
|
115
|
+
],
|
|
116
|
+
)
|
|
117
|
+
self.assertEqual(got, "TimeoutError: own")
|
|
118
|
+
|
|
119
|
+
def test_matches_the_own_bank_by_id_not_by_position(self):
|
|
120
|
+
# Fan-out completion order is not stable; a positional match would
|
|
121
|
+
# attribute the side bank's failure to the agent's own memory.
|
|
122
|
+
got = recall_error_summary(
|
|
123
|
+
OWN, [timing(SIDE, errored=True, error="HTTPError: 500"), timing(OWN)]
|
|
124
|
+
)
|
|
125
|
+
self.assertIn(SIDE, got)
|
|
126
|
+
self.assertIn("additional bank", got)
|
|
127
|
+
|
|
128
|
+
def test_reports_directive_timeout_when_no_bank_failed(self):
|
|
129
|
+
got = recall_error_summary(OWN, [timing(OWN), timing(SIDE)], directives_timed_out=True)
|
|
130
|
+
self.assertEqual(got, "directives fetch timed out")
|
|
131
|
+
|
|
132
|
+
def test_tolerates_an_empty_or_missing_timing_list(self):
|
|
133
|
+
self.assertIsNone(recall_error_summary(OWN, []))
|
|
134
|
+
self.assertIsNone(recall_error_summary(OWN, None))
|
|
135
|
+
|
|
136
|
+
def test_tolerates_legacy_rows_without_the_error_key(self):
|
|
137
|
+
# Rows written before this field shipped are still in the append-only
|
|
138
|
+
# log and must not raise here.
|
|
139
|
+
legacy = {"bank_id": OWN, "elapsed_ms": 8017, "timed_out": True, "errored": False}
|
|
140
|
+
self.assertIsNone(recall_error_summary(OWN, [legacy]))
|
|
141
|
+
|
|
142
|
+
def test_ignores_non_dict_entries(self):
|
|
143
|
+
self.assertIsNone(recall_error_summary(OWN, ["garbage", None]))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
unittest.main()
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Switchroom #3760 review, Major 4 — the recall hook must never spend past its
|
|
2
|
+
UserPromptSubmit ceiling.
|
|
3
|
+
|
|
4
|
+
#3757 inverted the transcript-fallback gate: a per-bank TIMEOUT no longer
|
|
5
|
+
suppresses the bounded transcript grep, because timing out was the common case
|
|
6
|
+
and suppressing on it left the agent with neither memories nor fallback. That
|
|
7
|
+
gate was also, incidentally, the thing protecting the hook ceiling. Without it
|
|
8
|
+
the arithmetic was: 10s shared recall deadline + a flat 1.5s grep = 11.5s
|
|
9
|
+
against a 12s ceiling, leaving ~0.5s for interpreter startup, config load, the
|
|
10
|
+
transcript read, output formatting and the log write — all of which sit outside
|
|
11
|
+
``recall_start_monotonic``. An overrun is not a degraded recall: Claude Code
|
|
12
|
+
kills the hook and the turn loses memories, the fallback AND the directives,
|
|
13
|
+
which is strictly worse than the bug #3757 fixes.
|
|
14
|
+
|
|
15
|
+
The serial rollback path had the mirror problem — no outer deadline at all, so
|
|
16
|
+
raising the per-bank timeout 8s -> 12s let two banks spend 24s.
|
|
17
|
+
|
|
18
|
+
Acceptance guarantees (outcomes, not code paths):
|
|
19
|
+
|
|
20
|
+
1. **The ceiling constant cannot drift from the shipped hook config.**
|
|
21
|
+
2. **The transcript fallback spends at most what is left**, and declines
|
|
22
|
+
entirely rather than gambling the hook when nothing is left.
|
|
23
|
+
3. **The serial path clamps each bank to the remaining budget**, so N banks
|
|
24
|
+
can no longer sum past the ceiling.
|
|
25
|
+
|
|
26
|
+
Stdlib-only (unittest).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import builtins
|
|
30
|
+
import io
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import shutil
|
|
34
|
+
import sys
|
|
35
|
+
import tempfile
|
|
36
|
+
import time
|
|
37
|
+
import unittest
|
|
38
|
+
from unittest.mock import patch
|
|
39
|
+
|
|
40
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
41
|
+
if SCRIPTS_DIR not in sys.path:
|
|
42
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
43
|
+
|
|
44
|
+
import recall # noqa: E402
|
|
45
|
+
|
|
46
|
+
PLUGIN_ROOT = os.path.abspath(os.path.join(SCRIPTS_DIR, ".."))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CeilingMatchesTheShippedHookConfig(unittest.TestCase):
|
|
50
|
+
def test_hook_ceiling_matches_hooks_json(self):
|
|
51
|
+
"""`HOOK_CEILING_SECONDS` mirrors a value that lives in another file in
|
|
52
|
+
this same package. Nothing at runtime reconciles them, so this test is
|
|
53
|
+
the reconciliation: if someone retunes the UserPromptSubmit timeout, the
|
|
54
|
+
budget arithmetic must move with it or every clamp here is wrong."""
|
|
55
|
+
with open(os.path.join(PLUGIN_ROOT, "hooks", "hooks.json"), encoding="utf-8") as f:
|
|
56
|
+
hooks = json.load(f)["hooks"]
|
|
57
|
+
timeouts = [
|
|
58
|
+
hook["timeout"]
|
|
59
|
+
for entry in hooks["UserPromptSubmit"]
|
|
60
|
+
for hook in entry["hooks"]
|
|
61
|
+
if "recall.py" in hook.get("command", "")
|
|
62
|
+
]
|
|
63
|
+
self.assertEqual(len(timeouts), 1, "expected exactly one recall.py UserPromptSubmit hook")
|
|
64
|
+
self.assertEqual(float(timeouts[0]), recall.HOOK_CEILING_SECONDS)
|
|
65
|
+
|
|
66
|
+
def test_reserve_leaves_the_shared_deadline_room(self):
|
|
67
|
+
"""The shared recall deadline plus the tail reserve must fit inside the
|
|
68
|
+
ceiling, or a fully-elapsed recall breaches it before the fallback is
|
|
69
|
+
even considered."""
|
|
70
|
+
from lib.config import DEFAULTS
|
|
71
|
+
|
|
72
|
+
self.assertLess(
|
|
73
|
+
float(DEFAULTS["recallParallelDeadlineSeconds"]) + recall.HOOK_TAIL_RESERVE_SECONDS,
|
|
74
|
+
recall.HOOK_CEILING_SECONDS,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _transcript(tmpdir, turns):
|
|
79
|
+
path = os.path.join(tmpdir, "transcript.jsonl")
|
|
80
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
81
|
+
for i, (role, text) in enumerate(turns):
|
|
82
|
+
f.write(json.dumps({
|
|
83
|
+
"type": role,
|
|
84
|
+
"uuid": f"u-{i}",
|
|
85
|
+
"message": {"role": role, "content": text},
|
|
86
|
+
}) + "\n")
|
|
87
|
+
return path
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class FallbackSpendsOnlyWhatIsLeft(unittest.TestCase):
|
|
91
|
+
"""The flat 1.5s bound is now ``min(configured, remaining hook budget)``."""
|
|
92
|
+
|
|
93
|
+
def setUp(self):
|
|
94
|
+
recall._begin_hook_budget()
|
|
95
|
+
self._tmpdir = tempfile.mkdtemp(prefix="recall-budget-test-")
|
|
96
|
+
self._path = _transcript(self._tmpdir, [
|
|
97
|
+
("user", "the nginx TLS cert on the webkite container expired"),
|
|
98
|
+
("assistant", "I renewed the nginx TLS cert and redeployed webkite"),
|
|
99
|
+
])
|
|
100
|
+
|
|
101
|
+
def tearDown(self):
|
|
102
|
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
|
103
|
+
|
|
104
|
+
def test_declines_entirely_when_no_budget_remains(self):
|
|
105
|
+
for budget in (0, -2500):
|
|
106
|
+
block, telemetry = recall._transcript_grep_fallback(
|
|
107
|
+
self._path, "nginx TLS cert webkite", {}, budget_ms=budget
|
|
108
|
+
)
|
|
109
|
+
self.assertIsNone(block, f"fallback ran with {budget}ms of budget")
|
|
110
|
+
self.assertFalse(telemetry["fired"])
|
|
111
|
+
|
|
112
|
+
def test_no_budget_means_no_transcript_read_at_all(self):
|
|
113
|
+
"""The decline has to happen BEFORE the work, not after. The grep reads
|
|
114
|
+
up to `recallTranscriptFallbackMaxBytes` (256 KiB by default) off disk
|
|
115
|
+
and parses it, and only then starts checking the deadline per turn — so
|
|
116
|
+
relying on the in-loop deadline check to notice an exhausted budget
|
|
117
|
+
still spends the read. With nothing left, nothing is opened."""
|
|
118
|
+
opened = []
|
|
119
|
+
real_open = builtins.open
|
|
120
|
+
|
|
121
|
+
def _tracking_open(path, *args, **kwargs):
|
|
122
|
+
opened.append(str(path))
|
|
123
|
+
return real_open(path, *args, **kwargs)
|
|
124
|
+
|
|
125
|
+
with patch.object(builtins, "open", _tracking_open):
|
|
126
|
+
block, telemetry = recall._transcript_grep_fallback(
|
|
127
|
+
self._path, "nginx TLS cert webkite", {}, budget_ms=0
|
|
128
|
+
)
|
|
129
|
+
self.assertIsNone(block)
|
|
130
|
+
self.assertFalse(telemetry["fired"])
|
|
131
|
+
self.assertEqual(
|
|
132
|
+
[p for p in opened if p == self._path],
|
|
133
|
+
[],
|
|
134
|
+
"the transcript was read despite the hook budget being exhausted",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def test_a_healthy_budget_still_allows_the_fallback_to_fire(self):
|
|
138
|
+
"""The clamp must not become a permanent off switch — with the ceiling
|
|
139
|
+
untouched the fallback behaves exactly as #3757 shipped it."""
|
|
140
|
+
block, telemetry = recall._transcript_grep_fallback(
|
|
141
|
+
self._path,
|
|
142
|
+
"nginx TLS cert webkite",
|
|
143
|
+
{},
|
|
144
|
+
budget_ms=recall._remaining_hook_budget_seconds() * 1000.0,
|
|
145
|
+
)
|
|
146
|
+
self.assertTrue(telemetry["fired"], f"fallback did not fire: {telemetry}")
|
|
147
|
+
self.assertIsNotNone(block)
|
|
148
|
+
|
|
149
|
+
def test_budget_is_measured_from_this_invocation_not_process_lifetime(self):
|
|
150
|
+
"""Re-anchoring at main() is what makes the clamp correct in any process
|
|
151
|
+
that runs the hook more than once. Without it the budget decays with
|
|
152
|
+
process age and the fallback silently stops firing."""
|
|
153
|
+
recall._begin_hook_budget()
|
|
154
|
+
first = recall._remaining_hook_budget_seconds()
|
|
155
|
+
time.sleep(0.05)
|
|
156
|
+
stale = recall._remaining_hook_budget_seconds()
|
|
157
|
+
self.assertLess(stale, first)
|
|
158
|
+
recall._begin_hook_budget()
|
|
159
|
+
self.assertGreater(recall._remaining_hook_budget_seconds(), stale)
|
|
160
|
+
|
|
161
|
+
def test_import_cost_is_charged_against_the_ceiling(self):
|
|
162
|
+
"""`recall_start_monotonic` is taken well into main(), so budgeting from
|
|
163
|
+
it ignores interpreter startup and import cost — the exact spend that
|
|
164
|
+
made the flat 1.5s bound unsafe."""
|
|
165
|
+
self.assertGreater(recall._IMPORT_ELAPSED_SECONDS, 0.0)
|
|
166
|
+
recall._begin_hook_budget()
|
|
167
|
+
self.assertLessEqual(
|
|
168
|
+
recall._remaining_hook_budget_seconds(),
|
|
169
|
+
recall.HOOK_CEILING_SECONDS
|
|
170
|
+
- recall.HOOK_TAIL_RESERVE_SECONDS
|
|
171
|
+
- recall._IMPORT_ELAPSED_SECONDS,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class SerialPathCannotSumPastTheCeiling(unittest.TestCase):
|
|
176
|
+
"""`HINDSIGHT_RECALL_PARALLEL=false` has no shared deadline — bank latencies
|
|
177
|
+
SUM. #3757 raised the per-bank timeout 8s -> 12s, which on that path meant
|
|
178
|
+
two banks could spend 24s against a 12s ceiling. Each bank is now clamped to
|
|
179
|
+
what the hook has left when its turn comes."""
|
|
180
|
+
|
|
181
|
+
class _Client:
|
|
182
|
+
def __init__(self):
|
|
183
|
+
self.timeouts = []
|
|
184
|
+
|
|
185
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
186
|
+
return {"items": []}
|
|
187
|
+
|
|
188
|
+
def recall(self, bank_id, query, **kwargs):
|
|
189
|
+
self.timeouts.append(kwargs.get("timeout"))
|
|
190
|
+
return {"results": []}
|
|
191
|
+
|
|
192
|
+
def setUp(self):
|
|
193
|
+
self._tmpdir = tempfile.mkdtemp(prefix="recall-serial-budget-")
|
|
194
|
+
self._env = {}
|
|
195
|
+
self._env["CLAUDE_PLUGIN_DATA"] = os.environ.get("CLAUDE_PLUGIN_DATA")
|
|
196
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
|
|
197
|
+
|
|
198
|
+
def tearDown(self):
|
|
199
|
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
|
200
|
+
for key, value in self._env.items():
|
|
201
|
+
if value is None:
|
|
202
|
+
os.environ.pop(key, None)
|
|
203
|
+
else:
|
|
204
|
+
os.environ[key] = value
|
|
205
|
+
|
|
206
|
+
def _run(self, client, remaining_seconds):
|
|
207
|
+
config = {
|
|
208
|
+
"autoRecall": True,
|
|
209
|
+
"bankId": "own-bank",
|
|
210
|
+
"additionalBanks": ["second-bank"],
|
|
211
|
+
"recallMaxTokens": 1024,
|
|
212
|
+
"recallBudget": "low",
|
|
213
|
+
"recallContextTurns": 1,
|
|
214
|
+
"recallMaxQueryChars": 800,
|
|
215
|
+
"recallPromptPreamble": "",
|
|
216
|
+
"recallTranscriptFallback": False,
|
|
217
|
+
"recallRequestTimeoutSeconds": 12,
|
|
218
|
+
# The rollback lever this test exists for.
|
|
219
|
+
"recallParallel": False,
|
|
220
|
+
}
|
|
221
|
+
hook_input = {
|
|
222
|
+
"prompt": "did the nginx TLS cert renewal land",
|
|
223
|
+
"session_id": "serial-budget",
|
|
224
|
+
"transcript_path": "",
|
|
225
|
+
"cwd": "/tmp",
|
|
226
|
+
}
|
|
227
|
+
stdout, stderr = io.StringIO(), io.StringIO()
|
|
228
|
+
with patch.object(recall, "load_config", return_value=config), patch.object(
|
|
229
|
+
recall, "get_api_url", return_value="http://localhost:18888"
|
|
230
|
+
), patch.object(recall, "HindsightClient", return_value=client), patch.object(
|
|
231
|
+
recall, "ensure_bank_mission", return_value=None
|
|
232
|
+
), patch.object(recall, "write_state", return_value=None), patch.object(
|
|
233
|
+
recall, "_remaining_hook_budget_seconds", return_value=remaining_seconds
|
|
234
|
+
), patch("sys.stdin", new=io.StringIO(json.dumps(hook_input))), patch(
|
|
235
|
+
"sys.stdout", new=stdout
|
|
236
|
+
), patch("sys.stderr", new=stderr):
|
|
237
|
+
recall.main()
|
|
238
|
+
|
|
239
|
+
def test_bank_timeout_is_clamped_to_the_remaining_budget(self):
|
|
240
|
+
client = self._Client()
|
|
241
|
+
self._run(client, remaining_seconds=3.0)
|
|
242
|
+
self.assertTrue(client.timeouts, "no bank was queried")
|
|
243
|
+
for timeout in client.timeouts:
|
|
244
|
+
self.assertLessEqual(
|
|
245
|
+
timeout, 3.0, "a serial bank was allowed its full configured timeout"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def test_configured_timeout_still_applies_when_budget_is_ample(self):
|
|
249
|
+
# The clamp is a ceiling, not a replacement: with the whole budget
|
|
250
|
+
# available the operator's configured timeout is what binds.
|
|
251
|
+
client = self._Client()
|
|
252
|
+
self._run(client, remaining_seconds=60.0)
|
|
253
|
+
self.assertTrue(client.timeouts)
|
|
254
|
+
for timeout in client.timeouts:
|
|
255
|
+
self.assertEqual(timeout, 12.0)
|
|
256
|
+
|
|
257
|
+
def test_banks_are_skipped_once_the_budget_is_gone(self):
|
|
258
|
+
client = self._Client()
|
|
259
|
+
self._run(client, remaining_seconds=-1.0)
|
|
260
|
+
self.assertEqual(
|
|
261
|
+
client.timeouts, [], "a bank was queried with the hook ceiling already breached"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__": # pragma: no cover
|
|
266
|
+
unittest.main()
|