diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index d67189e0fc..d0cf7a23a8 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -534,6 +534,41 @@ class HonchoClientConfig: pass return None + # Honcho enforces a 100-char limit on session IDs. Long gateway session keys + # (Matrix "!room:server" + thread event IDs, Telegram supergroup reply + # chains, Slack thread IDs with long workspace prefixes) can overflow this + # limit after sanitization; the Honcho API then rejects every call for that + # session with "session_id too long". See issue #13868. + _HONCHO_SESSION_ID_MAX_LEN = 100 + _HONCHO_SESSION_ID_HASH_LEN = 8 + + @classmethod + def _enforce_session_id_limit(cls, sanitized: str, original: str) -> str: + """Truncate a sanitized session ID to Honcho's 100-char limit. + + The common case (short keys) short-circuits with no modification. + For over-limit keys, keep a prefix of the sanitized ID and append a + deterministic ``-`` suffix so two distinct long keys + that share a leading segment don't collide onto the same truncated ID. + The hash is taken over the *original* pre-sanitization key, so two + inputs that sanitize to the same string still collide intentionally + (same logical session), but two inputs that only share a prefix do not. + """ + max_len = cls._HONCHO_SESSION_ID_MAX_LEN + if len(sanitized) <= max_len: + return sanitized + + import hashlib + + hash_len = cls._HONCHO_SESSION_ID_HASH_LEN + digest = hashlib.sha256(original.encode("utf-8")).hexdigest()[:hash_len] + # max_len - hash_len - 1 (for the '-' separator) chars of the sanitized + # prefix, then '-'. Strip any trailing hyphen from the prefix so + # the result doesn't double up on separators. + prefix_len = max_len - hash_len - 1 + prefix = sanitized[:prefix_len].rstrip("-") + return f"{prefix}-{digest}" + def resolve_session_name( self, cwd: str | None = None, @@ -578,7 +613,7 @@ class HonchoClientConfig: if gateway_session_key: sanitized = re.sub(r'[^a-zA-Z0-9_-]+', '-', gateway_session_key).strip('-') if sanitized: - return sanitized + return self._enforce_session_id_limit(sanitized, gateway_session_key) # per-session: inherit Hermes session_id (new Honcho session each run) if self.session_strategy == "per-session" and session_id: diff --git a/tests/honcho_plugin/test_client.py b/tests/honcho_plugin/test_client.py index 7b6bd46f1a..e96339bb00 100644 --- a/tests/honcho_plugin/test_client.py +++ b/tests/honcho_plugin/test_client.py @@ -656,6 +656,82 @@ class TestResolveSessionNameGatewayKey: assert ":" not in result +class TestResolveSessionNameLengthLimit: + """Regression tests for Honcho's 100-char session ID limit (issue #13868). + + Long gateway session keys (Matrix room+event IDs, Telegram supergroup + reply chains, Slack thread IDs with long workspace prefixes) can overflow + Honcho's 100-char session_id limit after sanitization. Before this fix, + every Honcho API call for those sessions 400'd with "session_id too long". + """ + + HONCHO_MAX = 100 + + def test_short_gateway_key_unchanged(self): + """Short keys must not get a hash suffix appended.""" + config = HonchoClientConfig() + result = config.resolve_session_name( + gateway_session_key="agent:main:telegram:dm:8439114563", + ) + # Unchanged fast-path: sanitize only, no truncation, no hash suffix. + assert result == "agent-main-telegram-dm-8439114563" + assert len(result) <= self.HONCHO_MAX + + def test_key_at_exact_limit_unchanged(self): + """A sanitized key that is exactly 100 chars must be returned as-is.""" + key = "a" * self.HONCHO_MAX + config = HonchoClientConfig() + result = config.resolve_session_name(gateway_session_key=key) + assert result == key + assert len(result) == self.HONCHO_MAX + + def test_long_gateway_key_truncated_to_limit(self): + """An over-limit sanitized key must truncate to exactly 100 chars.""" + key = "!roomid:matrix.example.org|" + "$event_" + ("a" * 300) + config = HonchoClientConfig() + result = config.resolve_session_name(gateway_session_key=key) + assert result is not None + assert len(result) == self.HONCHO_MAX + + def test_truncation_is_deterministic(self): + """Same long key must always produce the same truncated session ID.""" + key = "matrix-" + ("a" * 300) + config = HonchoClientConfig() + first = config.resolve_session_name(gateway_session_key=key) + second = config.resolve_session_name(gateway_session_key=key) + assert first == second + + def test_truncated_result_respects_char_allowlist(self): + """Truncated result must still match Honcho's [a-zA-Z0-9_-] allowlist.""" + import re + key = "slack:T12345:thread-reply:" + ("x" * 300) + ":with:colons:and:slashes/here" + config = HonchoClientConfig() + result = config.resolve_session_name(gateway_session_key=key) + assert result is not None + assert re.fullmatch(r"[a-zA-Z0-9_-]+", result) + + def test_distinct_long_keys_do_not_collide(self): + """Two long keys sharing a prefix must produce different truncated IDs.""" + prefix = "matrix:!room:example.org|" + "a" * 200 + key_a = prefix + "-suffix-alpha" + key_b = prefix + "-suffix-beta" + config = HonchoClientConfig() + result_a = config.resolve_session_name(gateway_session_key=key_a) + result_b = config.resolve_session_name(gateway_session_key=key_b) + assert result_a != result_b + assert len(result_a) == self.HONCHO_MAX + assert len(result_b) == self.HONCHO_MAX + + def test_truncated_result_has_hash_suffix(self): + """Truncated IDs must end with '-<8 hex chars>' for collision resistance.""" + import re + key = "matrix-" + ("a" * 300) + config = HonchoClientConfig() + result = config.resolve_session_name(gateway_session_key=key) + # Last 9 chars: '-' + 8 hex chars. + assert re.search(r"-[0-9a-f]{8}$", result) + + class TestResetHonchoClient: def test_reset_clears_singleton(self): import plugins.memory.honcho.client as mod