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:
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user