switchroom 0.16.46 → 0.17.0
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 +83 -81
- package/dist/auth-broker/index.js +104 -88
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -15
- package/dist/cli/notion-write-pretool.mjs +85 -83
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +1720 -1392
- package/dist/cli/ui/index.html +84 -12
- package/dist/host-control/main.js +209 -173
- package/dist/vault/approvals/kernel-server.js +86 -83
- package/dist/vault/broker/server.js +284 -139
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +1 -1
- package/profiles/_base/start.sh.hbs +54 -3
- package/skills/switchroom-architecture/telegram.md +8 -15
- package/skills/switchroom-cli/SKILL.md +4 -5
- package/skills/telegram-test-harness/SKILL.md +1 -1
- package/telegram-plugin/README.md +18 -29
- package/telegram-plugin/bridge/bridge.ts +1 -41
- package/telegram-plugin/bridge/tool-filter.ts +3 -4
- package/telegram-plugin/dist/bridge/bridge.js +120 -155
- package/telegram-plugin/dist/gateway/gateway.js +1127 -1029
- package/telegram-plugin/dist/server.js +168 -203
- package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
- package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
- package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
- package/telegram-plugin/gateway/gateway.ts +566 -631
- package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
- package/telegram-plugin/gateway/inbound-spool.ts +108 -10
- package/telegram-plugin/gateway/model-command.ts +51 -3
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
- package/telegram-plugin/gateway/represent-guard.ts +28 -11
- package/telegram-plugin/gateway/status-pin-store.ts +124 -45
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
- package/telegram-plugin/history.ts +5 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
- package/telegram-plugin/registry/subagents-schema.ts +126 -1
- package/telegram-plugin/registry/turns-schema.ts +65 -1
- package/telegram-plugin/session-tail.ts +26 -4
- package/telegram-plugin/slot-banner-driver.ts +42 -2
- package/telegram-plugin/status-query-telemetry.ts +100 -0
- package/telegram-plugin/stream-reply-handler.ts +15 -16
- package/telegram-plugin/subagent-watcher.ts +182 -30
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
- package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
- package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
- package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
- package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
- package/telegram-plugin/tests/model-command.test.ts +54 -1
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
- package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
- package/telegram-plugin/tests/registry-turns.test.ts +67 -0
- package/telegram-plugin/tests/represent-guard.test.ts +42 -6
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
- package/telegram-plugin/tests/session-tail.test.ts +10 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
- package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
- package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
- package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
- package/telegram-plugin/tests/tool-filter.test.ts +1 -3
- package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
- package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
- package/telegram-plugin/tool-activity-summary.ts +19 -0
- package/telegram-plugin/turn-flush-safety.ts +16 -1
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
- package/telegram-plugin/worker-activity-feed.ts +75 -15
- package/vendor/hindsight-memory/CHANGELOG.md +24 -0
- package/vendor/hindsight-memory/README.md +5 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
- package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
- package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +74 -1
- package/vendor/hindsight-memory/scripts/retain.py +8 -1
- package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
- package/vendor/hindsight-memory/settings.json +4 -0
- package/vendor/hindsight-memory/tests/test_client.py +130 -0
- package/vendor/hindsight-memory/tests/test_config.py +47 -0
- package/vendor/hindsight-memory/tests/test_content.py +18 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
- package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
- package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
- package/telegram-plugin/silent-reply.ts +0 -58
- package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
- package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
- package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
|
@@ -40,6 +40,12 @@ DEFAULTS = {
|
|
|
40
40
|
"recallContextTurns": 1,
|
|
41
41
|
"recallMaxQueryChars": 800,
|
|
42
42
|
"recallRoles": ["user", "assistant"],
|
|
43
|
+
# Upstream 962140eef — optional recall tag filters passed through to the
|
|
44
|
+
# recall API, plus per-additional-bank overrides keyed by bank ID.
|
|
45
|
+
"recallTags": [],
|
|
46
|
+
"recallTagsMatch": "any",
|
|
47
|
+
"recallTagGroups": None,
|
|
48
|
+
"recallAdditionalBankFilters": {},
|
|
43
49
|
"recallPromptPreamble": (
|
|
44
50
|
"Relevant memories from past conversations (prioritize recent when "
|
|
45
51
|
"conflicting). Only use memories that are directly useful to continue "
|
|
@@ -63,6 +69,12 @@ DEFAULTS = {
|
|
|
63
69
|
"daemonIdleTimeout": 0,
|
|
64
70
|
"embedVersion": "latest",
|
|
65
71
|
"embedPackagePath": None,
|
|
72
|
+
# Upstream 55ef70679 — optional global HTTP request timeout override
|
|
73
|
+
# (seconds). None = keep each call's own default. NOTE: switchroom's
|
|
74
|
+
# recall.py deliberately does NOT wire this override into its client —
|
|
75
|
+
# recall carries its own 8s hook-budget timeout (see recall.py). This
|
|
76
|
+
# mainly benefits retain's 15s timeout on slow/loaded servers.
|
|
77
|
+
"requestTimeoutSeconds": None,
|
|
66
78
|
# Bank
|
|
67
79
|
"bankId": None,
|
|
68
80
|
"bankIdPrefix": "",
|
|
@@ -109,8 +121,16 @@ ENV_OVERRIDES = {
|
|
|
109
121
|
"HINDSIGHT_RECALL_SKIP_TRIVIAL": ("recallSkipTrivial", bool),
|
|
110
122
|
"HINDSIGHT_RECALL_MAX_QUERY_CHARS": ("recallMaxQueryChars", int),
|
|
111
123
|
"HINDSIGHT_RECALL_CONTEXT_TURNS": ("recallContextTurns", int),
|
|
124
|
+
# Upstream 962140eef — recall tag filters. The tags env var accepts JSON
|
|
125
|
+
# or a comma-separated list; the others must be JSON.
|
|
126
|
+
"HINDSIGHT_RECALL_TAGS": ("recallTags", list),
|
|
127
|
+
"HINDSIGHT_RECALL_TAGS_MATCH": ("recallTagsMatch", str),
|
|
128
|
+
"HINDSIGHT_RECALL_TAG_GROUPS": ("recallTagGroups", dict),
|
|
129
|
+
"HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS": ("recallAdditionalBankFilters", dict),
|
|
112
130
|
"HINDSIGHT_API_PORT": ("apiPort", int),
|
|
113
131
|
"HINDSIGHT_DAEMON_IDLE_TIMEOUT": ("daemonIdleTimeout", int),
|
|
132
|
+
# Upstream 55ef70679 — global request timeout override.
|
|
133
|
+
"HINDSIGHT_REQUEST_TIMEOUT_SECONDS": ("requestTimeoutSeconds", int),
|
|
114
134
|
"HINDSIGHT_EMBED_VERSION": ("embedVersion", str),
|
|
115
135
|
"HINDSIGHT_EMBED_PACKAGE_PATH": ("embedPackagePath", str),
|
|
116
136
|
"HINDSIGHT_DYNAMIC_BANK_ID": ("dynamicBankId", bool),
|
|
@@ -131,8 +151,27 @@ def _cast_env(value: str, typ):
|
|
|
131
151
|
if typ is float:
|
|
132
152
|
return float(value)
|
|
133
153
|
if typ is list:
|
|
134
|
-
#
|
|
135
|
-
|
|
154
|
+
# JSON list first (upstream 962140eef). A value that parses as
|
|
155
|
+
# JSON but is NOT a list (e.g. `42`, `"x"`, `{}`) is a config
|
|
156
|
+
# mistake, not a comma-separated string — return None so the
|
|
157
|
+
# default is kept (matches upstream; fail-open). Only values
|
|
158
|
+
# that don't parse as JSON at all take the comma-split path.
|
|
159
|
+
try:
|
|
160
|
+
parsed = json.loads(value)
|
|
161
|
+
except ValueError:
|
|
162
|
+
if value.lstrip().startswith(("[", "{")):
|
|
163
|
+
# Looks like intended JSON but doesn't parse —
|
|
164
|
+
# malformed config, not a comma list. Keep default.
|
|
165
|
+
return None
|
|
166
|
+
# Comma-separated → list of trimmed, non-empty strings.
|
|
167
|
+
return [t.strip() for t in value.split(",") if t.strip()]
|
|
168
|
+
return parsed if isinstance(parsed, list) else None
|
|
169
|
+
if typ is dict:
|
|
170
|
+
# JSON only (dict or list accepted — tag_groups may be a list).
|
|
171
|
+
parsed = json.loads(value)
|
|
172
|
+
if isinstance(parsed, (dict, list)):
|
|
173
|
+
return parsed
|
|
174
|
+
return None
|
|
136
175
|
return value
|
|
137
176
|
except (ValueError, AttributeError):
|
|
138
177
|
return None
|
|
@@ -221,10 +221,13 @@ def format_memories(results: list) -> str:
|
|
|
221
221
|
def format_current_time() -> str:
|
|
222
222
|
"""Format current UTC time for recall context.
|
|
223
223
|
|
|
224
|
+
The "UTC" suffix is explicit so client LLMs do not misread the
|
|
225
|
+
value as local time when reasoning about wall-clock context.
|
|
226
|
+
|
|
224
227
|
Port of: formatCurrentTimeForRecall() in index.js
|
|
225
228
|
"""
|
|
226
229
|
now = datetime.now(timezone.utc)
|
|
227
|
-
return now.strftime("%Y-%m-%d %H:%M")
|
|
230
|
+
return now.strftime("%Y-%m-%d %H:%M UTC")
|
|
228
231
|
|
|
229
232
|
|
|
230
233
|
# ---------------------------------------------------------------------------
|
|
@@ -71,8 +71,17 @@ def _is_embed_available(config: dict) -> bool:
|
|
|
71
71
|
return shutil.which("uvx") is not None or shutil.which("hindsight-embed") is not None
|
|
72
72
|
|
|
73
73
|
|
|
74
|
-
def _check_health(base_url: str, timeout: int =
|
|
75
|
-
"""Quick health check against a Hindsight server.
|
|
74
|
+
def _check_health(base_url: str, timeout: int = 10) -> bool:
|
|
75
|
+
"""Quick health check against a Hindsight server.
|
|
76
|
+
|
|
77
|
+
Default timeout is 10s (matching the recall hook budget): under load an
|
|
78
|
+
alive-but-busy daemon mid fact-extraction may not answer /health within a
|
|
79
|
+
couple of seconds. A too-short timeout yields a false negative, so
|
|
80
|
+
get_api_url() falls through to _ensure_daemon_running() ->
|
|
81
|
+
`hindsight-embed daemon start`, whose _clear_port() then SIGTERMs the
|
|
82
|
+
live daemon -- a restart/kill loop. A 10s budget lets a busy daemon
|
|
83
|
+
respond before it is declared dead.
|
|
84
|
+
"""
|
|
76
85
|
try:
|
|
77
86
|
url = f"{base_url.rstrip('/')}/health"
|
|
78
87
|
req = urllib.request.Request(url, method="GET", headers={"User-Agent": USER_AGENT})
|
|
@@ -229,6 +229,33 @@ def _resolve_sender_bank(
|
|
|
229
229
|
return additional_banks
|
|
230
230
|
|
|
231
231
|
|
|
232
|
+
def _tag_filter_sig(
|
|
233
|
+
recall_tags,
|
|
234
|
+
tags_match,
|
|
235
|
+
tag_groups,
|
|
236
|
+
additional_bank_filters,
|
|
237
|
+
) -> str:
|
|
238
|
+
"""Stable fingerprint of the recall tag-filter configuration
|
|
239
|
+
(upstream 962140eef) for cache keying. Tag filters change what the
|
|
240
|
+
recall API returns for an identical query, so they MUST be part of
|
|
241
|
+
the cache key — otherwise a config change (or per-bank filter edit)
|
|
242
|
+
within the TTL window would serve stale, differently-filtered
|
|
243
|
+
results. Empty/default filters collapse to "" so pre-existing cache
|
|
244
|
+
behaviour (and keys) are unchanged when the feature is unused."""
|
|
245
|
+
if not (recall_tags or tag_groups or additional_bank_filters):
|
|
246
|
+
return ""
|
|
247
|
+
try:
|
|
248
|
+
return json.dumps(
|
|
249
|
+
[recall_tags, tags_match, tag_groups, additional_bank_filters],
|
|
250
|
+
sort_keys=True,
|
|
251
|
+
separators=(",", ":"),
|
|
252
|
+
)
|
|
253
|
+
except (TypeError, ValueError):
|
|
254
|
+
# Unserializable config — fall back to repr; stable within a
|
|
255
|
+
# process and still distinguishes filtered from unfiltered.
|
|
256
|
+
return repr([recall_tags, tags_match, tag_groups, additional_bank_filters])
|
|
257
|
+
|
|
258
|
+
|
|
232
259
|
def _cache_key(
|
|
233
260
|
session_id: str,
|
|
234
261
|
prompt: str,
|
|
@@ -236,6 +263,7 @@ def _cache_key(
|
|
|
236
263
|
extra_banks: list,
|
|
237
264
|
active_thread_id: str | None = None,
|
|
238
265
|
active_sender: str | None = None,
|
|
266
|
+
tag_filter_sig: str = "",
|
|
239
267
|
) -> str:
|
|
240
268
|
"""Stable hash for cache keying. Session_id is included so a new
|
|
241
269
|
session always misses, regardless of the TTL setting. Extra banks
|
|
@@ -259,6 +287,9 @@ def _cache_key(
|
|
|
259
287
|
",".join(sorted(extra_banks or [])),
|
|
260
288
|
active_thread_id or "",
|
|
261
289
|
active_sender or "",
|
|
290
|
+
# Upstream 962140eef port: tag filters shape the result set, so
|
|
291
|
+
# they are part of the key (see _tag_filter_sig). "" when unused.
|
|
292
|
+
tag_filter_sig or "",
|
|
262
293
|
]
|
|
263
294
|
payload = "\x1f".join(parts)
|
|
264
295
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
@@ -703,11 +734,32 @@ def main():
|
|
|
703
734
|
additional_banks,
|
|
704
735
|
)
|
|
705
736
|
|
|
737
|
+
# Upstream 962140eef — optional recall tag filters. Resolved BEFORE the
|
|
738
|
+
# cache check so the tag-filter fingerprint is part of the cache key
|
|
739
|
+
# (filters change the result set for an identical query). Per-bank
|
|
740
|
+
# overrides in recallAdditionalBankFilters apply to any additional bank —
|
|
741
|
+
# including sender banks appended by _resolve_sender_bank above.
|
|
742
|
+
recall_tags = config.get("recallTags") or None
|
|
743
|
+
tag_groups = config.get("recallTagGroups") or None
|
|
744
|
+
tags_match = config.get("recallTagsMatch") if recall_tags or tag_groups else None
|
|
745
|
+
additional_bank_filters = config.get("recallAdditionalBankFilters") or {}
|
|
746
|
+
if not isinstance(additional_bank_filters, dict):
|
|
747
|
+
additional_bank_filters = {}
|
|
748
|
+
tag_filter_sig = _tag_filter_sig(recall_tags, tags_match, tag_groups, additional_bank_filters)
|
|
749
|
+
|
|
706
750
|
# Switchroom #424 phase 4.1 — cache check BEFORE any HTTP traffic.
|
|
707
751
|
# Whole-session-scoped, opt-in via HINDSIGHT_RECALL_CACHE_TTL_SECS.
|
|
708
752
|
cache_ttl = _cache_ttl_secs()
|
|
709
753
|
cache_key = (
|
|
710
|
-
_cache_key(
|
|
754
|
+
_cache_key(
|
|
755
|
+
session_id,
|
|
756
|
+
prompt,
|
|
757
|
+
bank_id,
|
|
758
|
+
additional_banks,
|
|
759
|
+
active_thread_id,
|
|
760
|
+
active_sender,
|
|
761
|
+
tag_filter_sig,
|
|
762
|
+
)
|
|
711
763
|
if cache_ttl > 0
|
|
712
764
|
else ""
|
|
713
765
|
)
|
|
@@ -787,6 +839,11 @@ def main():
|
|
|
787
839
|
max_tokens=config.get("recallMaxTokens", 1024),
|
|
788
840
|
budget=config.get("recallBudget", "mid"),
|
|
789
841
|
types=config.get("recallTypes"),
|
|
842
|
+
# Upstream 962140eef — optional tag filters (resolved above the
|
|
843
|
+
# cache check; part of the cache key).
|
|
844
|
+
tags=recall_tags,
|
|
845
|
+
tags_match=tags_match,
|
|
846
|
+
tag_groups=tag_groups,
|
|
790
847
|
# 8s in-script timeout leaves 4s headroom inside the 12s
|
|
791
848
|
# UserPromptSubmit hook ceiling (see hooks.json:20) for cache
|
|
792
849
|
# write + block formatting. Tightened from 10s in switchroom
|
|
@@ -809,6 +866,19 @@ def main():
|
|
|
809
866
|
# cache key reflects every bank queried; reuse that local instead of
|
|
810
867
|
# re-reading config.
|
|
811
868
|
for extra_bank_id in additional_banks:
|
|
869
|
+
# Upstream 962140eef — per-bank tag-filter overrides; fall back to
|
|
870
|
+
# the global filters when the bank has no entry. Applies uniformly
|
|
871
|
+
# to config-listed banks and sender banks appended by
|
|
872
|
+
# _resolve_sender_bank (both flow through `additional_banks`).
|
|
873
|
+
extra_filter = additional_bank_filters.get(extra_bank_id, {})
|
|
874
|
+
if not isinstance(extra_filter, dict):
|
|
875
|
+
extra_filter = {}
|
|
876
|
+
extra_tags = extra_filter.get("recallTags", recall_tags) or None
|
|
877
|
+
extra_tag_groups = extra_filter.get("recallTagGroups", tag_groups) or None
|
|
878
|
+
extra_tags_match = extra_filter.get(
|
|
879
|
+
"recallTagsMatch",
|
|
880
|
+
tags_match if extra_tags or extra_tag_groups else None,
|
|
881
|
+
)
|
|
812
882
|
try:
|
|
813
883
|
extra_response = client.recall(
|
|
814
884
|
bank_id=extra_bank_id,
|
|
@@ -816,6 +886,9 @@ def main():
|
|
|
816
886
|
max_tokens=config.get("recallMaxTokens", 1024),
|
|
817
887
|
budget=config.get("recallBudget", "mid"),
|
|
818
888
|
types=config.get("recallTypes"),
|
|
889
|
+
tags=extra_tags,
|
|
890
|
+
tags_match=extra_tags_match,
|
|
891
|
+
tag_groups=extra_tag_groups,
|
|
819
892
|
# 8s in-script timeout leaves 4s headroom inside the 12s
|
|
820
893
|
# UserPromptSubmit hook ceiling (see hooks.json:20) for cache
|
|
821
894
|
# write + block formatting. Tightened from 10s in switchroom
|
|
@@ -156,7 +156,14 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
|
|
|
156
156
|
|
|
157
157
|
api_token = config.get("hindsightApiToken")
|
|
158
158
|
try:
|
|
159
|
-
|
|
159
|
+
# Upstream 55ef70679 — honor the optional requestTimeoutSeconds
|
|
160
|
+
# override (retain runs outside the recall hook budget, so a longer
|
|
161
|
+
# timeout is safe here; recall.py deliberately omits this).
|
|
162
|
+
client = HindsightClient(
|
|
163
|
+
api_url,
|
|
164
|
+
api_token,
|
|
165
|
+
request_timeout_override=config.get("requestTimeoutSeconds"),
|
|
166
|
+
)
|
|
160
167
|
except ValueError as e:
|
|
161
168
|
print(f"[Hindsight] Invalid API URL: {e}", file=sys.stderr)
|
|
162
169
|
return {"status": "failed", "error": e, "payload": None}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Unit tests for config env casting (`lib.config._cast_env`) and the
|
|
2
|
+
client's request-timeout override clamp (`HindsightClient._resolve_timeout`).
|
|
3
|
+
|
|
4
|
+
Follow-ups from the #2816 review punch list:
|
|
5
|
+
- list cast: a value that parses as JSON but is NOT a list (e.g. `42`)
|
|
6
|
+
must return None (keep default, fail-open) instead of falling through
|
|
7
|
+
to comma-split and producing a junk one-element list. Malformed
|
|
8
|
+
intended-JSON (`[1,2`) also keeps the default; plain comma strings
|
|
9
|
+
still split.
|
|
10
|
+
- timeout override: zero/negative env values are clamped to >= 1 instead
|
|
11
|
+
of passing straight through to urlopen.
|
|
12
|
+
|
|
13
|
+
Stdlib-only.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import unittest
|
|
19
|
+
|
|
20
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
21
|
+
if SCRIPTS_DIR not in sys.path:
|
|
22
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
23
|
+
|
|
24
|
+
from lib.config import _cast_env # noqa: E402
|
|
25
|
+
from lib.client import HindsightClient # noqa: E402
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CastEnvListTests(unittest.TestCase):
|
|
29
|
+
def test_valid_json_list(self):
|
|
30
|
+
self.assertEqual(_cast_env('["a", "b"]', list), ["a", "b"])
|
|
31
|
+
|
|
32
|
+
def test_empty_json_list(self):
|
|
33
|
+
self.assertEqual(_cast_env("[]", list), [])
|
|
34
|
+
|
|
35
|
+
def test_comma_string_splits(self):
|
|
36
|
+
self.assertEqual(_cast_env("a, b ,c", list), ["a", "b", "c"])
|
|
37
|
+
|
|
38
|
+
def test_comma_string_drops_empty_tokens(self):
|
|
39
|
+
self.assertEqual(_cast_env("a,,b,", list), ["a", "b"])
|
|
40
|
+
|
|
41
|
+
def test_single_bare_string(self):
|
|
42
|
+
# Not valid JSON, no commas → one-element list.
|
|
43
|
+
self.assertEqual(_cast_env("solo", list), ["solo"])
|
|
44
|
+
|
|
45
|
+
def test_json_non_list_scalar_returns_none(self):
|
|
46
|
+
# Parses as JSON int — not a list, not a comma string. Default kept.
|
|
47
|
+
self.assertIsNone(_cast_env("42", list))
|
|
48
|
+
|
|
49
|
+
def test_json_non_list_object_returns_none(self):
|
|
50
|
+
self.assertIsNone(_cast_env('{"a": 1}', list))
|
|
51
|
+
|
|
52
|
+
def test_json_string_returns_none(self):
|
|
53
|
+
# A JSON-quoted string is valid JSON but not a list.
|
|
54
|
+
self.assertIsNone(_cast_env('"tag"', list))
|
|
55
|
+
|
|
56
|
+
def test_malformed_json_array_returns_none(self):
|
|
57
|
+
# Looks like intended JSON but doesn't parse → default kept,
|
|
58
|
+
# NOT comma-split into junk like ['["a"', '"b"'].
|
|
59
|
+
self.assertIsNone(_cast_env('["a", "b"', list))
|
|
60
|
+
|
|
61
|
+
def test_malformed_json_object_returns_none(self):
|
|
62
|
+
self.assertIsNone(_cast_env('{"a": ', list))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CastEnvOtherTypesTests(unittest.TestCase):
|
|
66
|
+
"""Guard the neighbours the list change must not disturb."""
|
|
67
|
+
|
|
68
|
+
def test_int_valid(self):
|
|
69
|
+
self.assertEqual(_cast_env("30", int), 30)
|
|
70
|
+
|
|
71
|
+
def test_int_invalid_returns_none(self):
|
|
72
|
+
self.assertIsNone(_cast_env("thirty", int))
|
|
73
|
+
|
|
74
|
+
def test_bool_true_variants(self):
|
|
75
|
+
for v in ("true", "1", "yes", "TRUE"):
|
|
76
|
+
self.assertTrue(_cast_env(v, bool), v)
|
|
77
|
+
|
|
78
|
+
def test_bool_false(self):
|
|
79
|
+
self.assertFalse(_cast_env("false", bool))
|
|
80
|
+
|
|
81
|
+
def test_dict_valid(self):
|
|
82
|
+
self.assertEqual(_cast_env('{"a": 1}', dict), {"a": 1})
|
|
83
|
+
|
|
84
|
+
def test_dict_non_container_returns_none(self):
|
|
85
|
+
self.assertIsNone(_cast_env("42", dict))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ResolveTimeoutTests(unittest.TestCase):
|
|
89
|
+
URL = "http://127.0.0.1:9999"
|
|
90
|
+
|
|
91
|
+
def _client(self, override):
|
|
92
|
+
return HindsightClient(self.URL, request_timeout_override=override)
|
|
93
|
+
|
|
94
|
+
def test_no_override_uses_caller_timeout(self):
|
|
95
|
+
self.assertEqual(self._client(None)._resolve_timeout(30), 30)
|
|
96
|
+
|
|
97
|
+
def test_valid_override_wins(self):
|
|
98
|
+
self.assertEqual(self._client(15)._resolve_timeout(30), 15)
|
|
99
|
+
|
|
100
|
+
def test_zero_override_clamped_to_one(self):
|
|
101
|
+
self.assertEqual(self._client(0)._resolve_timeout(30), 1)
|
|
102
|
+
|
|
103
|
+
def test_negative_override_clamped_to_one(self):
|
|
104
|
+
self.assertEqual(self._client(-5)._resolve_timeout(30), 1)
|
|
105
|
+
|
|
106
|
+
def test_one_passes_through(self):
|
|
107
|
+
self.assertEqual(self._client(1)._resolve_timeout(30), 1)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
unittest.main()
|
|
@@ -55,13 +55,35 @@ class _FakeClient:
|
|
|
55
55
|
self._memories = memories if memories is not None else []
|
|
56
56
|
self._recall_exc = recall_exc
|
|
57
57
|
self._list_exc = list_exc
|
|
58
|
+
# One entry per recall() call — lets tests assert the tag-filter
|
|
59
|
+
# kwargs (upstream 962140eef) that main() passed per bank.
|
|
60
|
+
self.recall_calls = []
|
|
58
61
|
|
|
59
62
|
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
60
63
|
if self._list_exc is not None:
|
|
61
64
|
raise self._list_exc
|
|
62
65
|
return {"items": list(self._directives)}
|
|
63
66
|
|
|
64
|
-
def recall(
|
|
67
|
+
def recall(
|
|
68
|
+
self,
|
|
69
|
+
bank_id,
|
|
70
|
+
query,
|
|
71
|
+
max_tokens=1024,
|
|
72
|
+
budget="mid",
|
|
73
|
+
types=None,
|
|
74
|
+
tags=None,
|
|
75
|
+
tags_match=None,
|
|
76
|
+
tag_groups=None,
|
|
77
|
+
timeout=10,
|
|
78
|
+
):
|
|
79
|
+
self.recall_calls.append(
|
|
80
|
+
{
|
|
81
|
+
"bank_id": bank_id,
|
|
82
|
+
"tags": tags,
|
|
83
|
+
"tags_match": tags_match,
|
|
84
|
+
"tag_groups": tag_groups,
|
|
85
|
+
}
|
|
86
|
+
)
|
|
65
87
|
if self._recall_exc is not None:
|
|
66
88
|
raise self._recall_exc
|
|
67
89
|
return {"results": list(self._memories)}
|
|
@@ -617,5 +639,67 @@ class OverlapGateIntegrationTests(unittest.TestCase):
|
|
|
617
639
|
self.assertIsNone(ctx)
|
|
618
640
|
|
|
619
641
|
|
|
642
|
+
class RecallTagFilterIntegrationTests(unittest.TestCase):
|
|
643
|
+
"""Upstream 962140eef port — tag filters flow through main() to each
|
|
644
|
+
per-bank recall call, composed with our additional-banks routing."""
|
|
645
|
+
|
|
646
|
+
def test_global_tags_passed_to_primary_bank(self):
|
|
647
|
+
client = _FakeClient(memories=[_memory("a fact")])
|
|
648
|
+
_run_main_with(
|
|
649
|
+
client,
|
|
650
|
+
config_extra={
|
|
651
|
+
"recallTags": ["memory_type:rule"],
|
|
652
|
+
"recallTagsMatch": "any_strict",
|
|
653
|
+
},
|
|
654
|
+
)
|
|
655
|
+
self.assertEqual(client.recall_calls[0]["tags"], ["memory_type:rule"])
|
|
656
|
+
self.assertEqual(client.recall_calls[0]["tags_match"], "any_strict")
|
|
657
|
+
|
|
658
|
+
def test_no_tags_match_sent_without_tags_or_groups(self):
|
|
659
|
+
client = _FakeClient(memories=[_memory("a fact")])
|
|
660
|
+
_run_main_with(client, config_extra={"recallTagsMatch": "all"})
|
|
661
|
+
self.assertIsNone(client.recall_calls[0]["tags"])
|
|
662
|
+
self.assertIsNone(client.recall_calls[0]["tags_match"])
|
|
663
|
+
self.assertIsNone(client.recall_calls[0]["tag_groups"])
|
|
664
|
+
|
|
665
|
+
def test_per_bank_filter_overrides_global_for_additional_bank(self):
|
|
666
|
+
client = _FakeClient(memories=[_memory("a fact")])
|
|
667
|
+
_run_main_with(
|
|
668
|
+
client,
|
|
669
|
+
config_extra={
|
|
670
|
+
"recallAdditionalBanks": ["shared-bank"],
|
|
671
|
+
"recallTags": ["tech_stack:supabase"],
|
|
672
|
+
"recallTagsMatch": "any",
|
|
673
|
+
"recallAdditionalBankFilters": {
|
|
674
|
+
"shared-bank": {
|
|
675
|
+
"recallTags": ["memory_type:rule"],
|
|
676
|
+
"recallTagsMatch": "all_strict",
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
},
|
|
680
|
+
)
|
|
681
|
+
primary, extra = client.recall_calls[0], client.recall_calls[1]
|
|
682
|
+
self.assertEqual(primary["bank_id"], "test-bank")
|
|
683
|
+
self.assertEqual(primary["tags"], ["tech_stack:supabase"])
|
|
684
|
+
self.assertEqual(primary["tags_match"], "any")
|
|
685
|
+
self.assertEqual(extra["bank_id"], "shared-bank")
|
|
686
|
+
self.assertEqual(extra["tags"], ["memory_type:rule"])
|
|
687
|
+
self.assertEqual(extra["tags_match"], "all_strict")
|
|
688
|
+
|
|
689
|
+
def test_additional_bank_without_override_inherits_global(self):
|
|
690
|
+
client = _FakeClient(memories=[_memory("a fact")])
|
|
691
|
+
_run_main_with(
|
|
692
|
+
client,
|
|
693
|
+
config_extra={
|
|
694
|
+
"recallAdditionalBanks": ["shared-bank"],
|
|
695
|
+
"recallTags": ["memory_type:rule"],
|
|
696
|
+
},
|
|
697
|
+
)
|
|
698
|
+
extra = client.recall_calls[1]
|
|
699
|
+
self.assertEqual(extra["tags"], ["memory_type:rule"])
|
|
700
|
+
# Global tags_match defaults are only sent when filters are active.
|
|
701
|
+
self.assertEqual(extra["tags_match"], None)
|
|
702
|
+
|
|
703
|
+
|
|
620
704
|
if __name__ == "__main__":
|
|
621
705
|
unittest.main()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Unit tests for the recall tag-filter port (upstream 962140eef).
|
|
2
|
+
|
|
3
|
+
Covers the switchroom-specific composition points that upstream's own tests
|
|
4
|
+
cannot: the tag-filter fingerprint (`_tag_filter_sig`) and its inclusion in
|
|
5
|
+
the recall cache key (`_cache_key`). Tag filters change what the recall API
|
|
6
|
+
returns for an identical query, so a filter change within the cache TTL must
|
|
7
|
+
produce a cache MISS — otherwise stale, differently-filtered results would
|
|
8
|
+
be served.
|
|
9
|
+
|
|
10
|
+
Stdlib-only.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import unittest
|
|
16
|
+
|
|
17
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
18
|
+
if SCRIPTS_DIR not in sys.path:
|
|
19
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
20
|
+
|
|
21
|
+
import recall # noqa: E402
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TagFilterSigTests(unittest.TestCase):
|
|
25
|
+
def test_empty_filters_collapse_to_empty_string(self):
|
|
26
|
+
# Backward-compat: unused feature must not perturb existing keys.
|
|
27
|
+
self.assertEqual(recall._tag_filter_sig(None, None, None, {}), "")
|
|
28
|
+
self.assertEqual(recall._tag_filter_sig([], None, None, {}), "")
|
|
29
|
+
|
|
30
|
+
def test_tags_produce_nonempty_sig(self):
|
|
31
|
+
self.assertNotEqual(recall._tag_filter_sig(["memory_type:rule"], "any", None, {}), "")
|
|
32
|
+
|
|
33
|
+
def test_tag_groups_alone_produce_nonempty_sig(self):
|
|
34
|
+
groups = [{"op": "all", "tags": ["a", "b"]}]
|
|
35
|
+
self.assertNotEqual(recall._tag_filter_sig(None, "any", groups, {}), "")
|
|
36
|
+
|
|
37
|
+
def test_bank_filters_alone_produce_nonempty_sig(self):
|
|
38
|
+
filters = {"profile-bank": {"recallTags": ["memory_type:rule"]}}
|
|
39
|
+
self.assertNotEqual(recall._tag_filter_sig(None, None, None, filters), "")
|
|
40
|
+
|
|
41
|
+
def test_sig_is_deterministic(self):
|
|
42
|
+
a = recall._tag_filter_sig(["t1"], "all", None, {"b": {"recallTags": ["x"]}})
|
|
43
|
+
b = recall._tag_filter_sig(["t1"], "all", None, {"b": {"recallTags": ["x"]}})
|
|
44
|
+
self.assertEqual(a, b)
|
|
45
|
+
|
|
46
|
+
def test_sig_stable_across_dict_key_order(self):
|
|
47
|
+
f1 = {"a": {"recallTags": ["x"]}, "b": {"recallTags": ["y"]}}
|
|
48
|
+
f2 = {"b": {"recallTags": ["y"]}, "a": {"recallTags": ["x"]}}
|
|
49
|
+
self.assertEqual(
|
|
50
|
+
recall._tag_filter_sig(["t"], "any", None, f1),
|
|
51
|
+
recall._tag_filter_sig(["t"], "any", None, f2),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def test_different_tags_different_sig(self):
|
|
55
|
+
self.assertNotEqual(
|
|
56
|
+
recall._tag_filter_sig(["memory_type:rule"], "any", None, {}),
|
|
57
|
+
recall._tag_filter_sig(["memory_type:fact"], "any", None, {}),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def test_different_match_mode_different_sig(self):
|
|
61
|
+
self.assertNotEqual(
|
|
62
|
+
recall._tag_filter_sig(["t"], "any", None, {}),
|
|
63
|
+
recall._tag_filter_sig(["t"], "all_strict", None, {}),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def test_unserializable_falls_back_to_repr(self):
|
|
67
|
+
# A pathological config value must not raise; it still yields a
|
|
68
|
+
# non-empty signature distinguishing it from "no filters".
|
|
69
|
+
sig = recall._tag_filter_sig([object()], "any", None, {})
|
|
70
|
+
self.assertTrue(sig)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CacheKeyTagFilterTests(unittest.TestCase):
|
|
74
|
+
"""The tag-filter fingerprint must be part of the recall cache key."""
|
|
75
|
+
|
|
76
|
+
ARGS = ("s1", "what are the rules", "clerk", ["profile"], "42", "ken")
|
|
77
|
+
|
|
78
|
+
def test_no_filters_matches_legacy_key(self):
|
|
79
|
+
# Default arg == explicit "" — pre-feature cache keys are unchanged.
|
|
80
|
+
legacy = recall._cache_key(*self.ARGS)
|
|
81
|
+
explicit = recall._cache_key(*self.ARGS, "")
|
|
82
|
+
self.assertEqual(legacy, explicit)
|
|
83
|
+
|
|
84
|
+
def test_filters_change_the_key(self):
|
|
85
|
+
sig = recall._tag_filter_sig(["memory_type:rule"], "any", None, {})
|
|
86
|
+
self.assertNotEqual(recall._cache_key(*self.ARGS), recall._cache_key(*self.ARGS, sig))
|
|
87
|
+
|
|
88
|
+
def test_different_filters_different_keys(self):
|
|
89
|
+
sig_a = recall._tag_filter_sig(["memory_type:rule"], "any", None, {})
|
|
90
|
+
sig_b = recall._tag_filter_sig(["memory_type:fact"], "any", None, {})
|
|
91
|
+
self.assertNotEqual(recall._cache_key(*self.ARGS, sig_a), recall._cache_key(*self.ARGS, sig_b))
|
|
92
|
+
|
|
93
|
+
def test_per_bank_filter_change_changes_key(self):
|
|
94
|
+
# Editing only recallAdditionalBankFilters (e.g. for a sender bank)
|
|
95
|
+
# must also invalidate the cache.
|
|
96
|
+
sig_a = recall._tag_filter_sig(["t"], "any", None, {"profile": {"recallTags": ["x"]}})
|
|
97
|
+
sig_b = recall._tag_filter_sig(["t"], "any", None, {"profile": {"recallTags": ["y"]}})
|
|
98
|
+
self.assertNotEqual(recall._cache_key(*self.ARGS, sig_a), recall._cache_key(*self.ARGS, sig_b))
|
|
99
|
+
|
|
100
|
+
def test_same_filters_same_key(self):
|
|
101
|
+
sig1 = recall._tag_filter_sig(["t"], "all", None, {})
|
|
102
|
+
sig2 = recall._tag_filter_sig(["t"], "all", None, {})
|
|
103
|
+
self.assertEqual(recall._cache_key(*self.ARGS, sig1), recall._cache_key(*self.ARGS, sig2))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
unittest.main()
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
"recallContextTurns": 1,
|
|
14
14
|
"recallMaxQueryChars": 800,
|
|
15
15
|
"recallRoles": ["user", "assistant"],
|
|
16
|
+
"recallTags": [],
|
|
17
|
+
"recallTagsMatch": "any",
|
|
18
|
+
"recallTagGroups": null,
|
|
19
|
+
"recallAdditionalBankFilters": {},
|
|
16
20
|
"recallPromptPreamble": "Relevant memories from past conversations (prioritize recent when conflicting). Only use memories that are directly useful to continue this conversation; ignore the rest:",
|
|
17
21
|
"retainRoles": ["user", "assistant"],
|
|
18
22
|
"retainEveryNTurns": 10,
|