fix(live-qa): nous_subscription managed table indexes only selectable features (KeyError 'modal' crashed status/tools/dashboard toolsets); restore PluginManager public API (has_portable_mcp_servers, get_telegram_handler_factories, register_approval_transport), extract_json_candidate public name, canonical_whatsapp_identifier main semantics

This commit is contained in:
Teknium
2026-09-03 08:16:44 -07:00
parent 022785a541
commit aec50eb597
5 changed files with 56 additions and 5 deletions

View File

@@ -87,5 +87,10 @@ def canonical_whatsapp_identifier(identifier: str) -> str:
"""Stable sender identity across phone-JID/LID variants (DM ``chat_id`` and group
``participant_id`` alike): the shortest alias from :func:`expand_whatsapp_aliases`, which
degrades to the normalized input when no mapping files exist. ``""`` for empty input."""
aliases = expand_whatsapp_aliases(identifier)
return min(aliases, key=lambda c: (len(c), c)) if aliases else ""
normalized = normalize_whatsapp_identifier(identifier)
if not normalized:
return ""
# expand_whatsapp_aliases includes ``normalized`` itself, so min() degrades to it
# when no lid-mapping files are present.
aliases = expand_whatsapp_aliases(normalized)
return min(aliases, key=lambda c: (len(c), c))

View File

@@ -452,10 +452,12 @@ def get_nous_subscription_features(config: Optional[Dict[str, object]] = None, *
use_gateway = {key: value == "nous" for key, value in selected.items()}
# Managed availability per feature. A stored VENDOR selection pins the category to direct
# credentials — managed availability must not light it up (the runtime errors, not reroutes).
# Features without a config selection field (modal) have no pin and read as unselected.
managed = {
key: (
managed_tools_flag and is_managed_tool_gateway_ready(spec.gateway)
and account_info.tool_gateway_entitled_for(spec.coverage) and (selected[key] is None or use_gateway[key])
and account_info.tool_gateway_entitled_for(spec.coverage)
and (selected.get(key) is None or use_gateway.get(key, False))
)
for key, spec in _FEATURES.items()
}

View File

@@ -1264,6 +1264,25 @@ class PluginManager(PluginLoaderMixin, PluginDispatchMixin, PluginLedgerMixin):
logger.log(*verdict.log)
return False
def register_approval_transport(self, name: str, present_fn: Callable, *, plugin_id: str) -> None:
"""Manager-level registration (public API kept for out-of-tree plugins); the PluginContext
method is the tracked path plugins normally use. Same validation, no unload tracking."""
from hermes_cli.approval_transport import RegisteredApprovalTransport
clean = str(name).strip().lower()
if clean == "builtin":
raise ValueError("approval transport name 'builtin' is reserved")
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", clean):
raise ValueError("approval transport name must match [a-z0-9][a-z0-9_-]{0,63}")
if not callable(present_fn):
raise TypeError("approval transport present_fn must be callable")
if clean in self._approval_transports:
owner = self._approval_transports[clean].plugin_id
raise ValueError(f"approval transport {clean!r} is already registered by {owner!r}")
self._approval_transports[clean] = RegisteredApprovalTransport(
name=clean, present=present_fn, plugin_id=plugin_id, profile_home=str(get_hermes_home().resolve()),
)
logger.info("Plugin %s registered approval transport: %s", plugin_id, clean)
def get_approval_transport(self, name: str):
"""Return a transport only inside the profile that registered it."""
registered = self._approval_transports.get(str(name).strip().lower())
@@ -1323,6 +1342,10 @@ class PluginManager(PluginLoaderMixin, PluginDispatchMixin, PluginLedgerMixin):
adapter)`` at connect (see :meth:`PluginContext.register_platform_handler`)."""
return list(self._platform_handler_factories.get((platform or "").strip().lower(), []))
def get_telegram_handler_factories(self) -> List[tuple]:
"""Back-compat alias for ``get_platform_handler_factories("telegram")``."""
return self.get_platform_handler_factories("telegram")
def list_plugins(self) -> List[Dict[str, Any]]:
"""Return a list of info dicts for all discovered plugins."""
return [
@@ -1354,6 +1377,9 @@ class PluginManager(PluginLoaderMixin, PluginDispatchMixin, PluginLedgerMixin):
} for qualified, entry in sorted(self._plugin_skills.items())
]
def has_portable_mcp_servers(self) -> bool:
return bool(self._portable_mcp_servers)
def get_portable_mcp_servers(self) -> Dict[str, Dict[str, Any]]:
"""Return a defensive copy of enabled portable MCP server configs."""
return {name: dict(config) for name, config in self._portable_mcp_servers.items()}

View File

@@ -186,6 +186,24 @@ def _capture_checklist(monkeypatch, *, selected_idx):
return captured
def test_logged_in_entitled_account_yields_a_state_for_every_feature(monkeypatch):
"""The logged-in + entitled branch must produce a state for EVERY feature, including
those with no config selection field (modal). Regression: the managed-availability
table indexed the selection map by every feature key and raised KeyError('modal'),
crashing `hermes status`, `hermes tools`, and the dashboard toolsets API."""
monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _pool_account())
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda gateway: True)
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
result = ns.get_nous_subscription_features({"model": {"provider": "nous"}})
assert set(result.features) == set(ns._FEATURES)
assert result.modal.available is True # entitled + gateway ready → managed modal is offered
def test_prompt_enable_tool_gateway_pool_offers_covered_tools_only(monkeypatch):
"""Pool user's checklist lists web/image/tts/browser and never video."""
monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _pool_account())

View File

@@ -56,7 +56,7 @@ def append_output_contract(context: Optional[str], schema: Dict[str, Any]) -> st
return f"{base}\n\n{block}" if base else block
def _extract_json_candidate(text: str) -> str:
def extract_json_candidate(text: str) -> str:
"""Strip markdown fences and prose around the outermost ``{...}``/``[...]``."""
raw = (text or "").strip()
if raw.startswith("```"):
@@ -78,7 +78,7 @@ def _extract_json_candidate(text: str) -> str:
def validate_output(text: str, schema: Dict[str, Any]) -> Tuple[bool, List[str]]:
"""``(True, [])`` or ``(False, errors)`` with strings suitable for the retry turn."""
candidate = _extract_json_candidate(text or "")
candidate = extract_json_candidate(text or "")
if not candidate.strip():
return False, ["Response was empty — expected a JSON object matching the schema."]
try: