Merge branch 'Codename-11/phone-platform' into dev

Advertise proactive phone messaging to the agent via the relay context seam
(PHONE_ENABLED-gated system-prompt block).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bailey Dixon
2026-06-28 20:03:33 -04:00
co-authored by Claude Opus 4.8
4 changed files with 138 additions and 2 deletions
+10
View File
@@ -1,5 +1,15 @@
# Hermes-Relay — Dev Log
## 2026-06-28 — Phone platform: advertise the capability to the agent
**Why.** The `phone` platform worked and was discoverable via `send_message action=list` (the channel directory includes plugin-registered platforms), but it was not *proactively* advertised: `platform_hint` only injects for the **inbound** platform of a turn (`system_prompt.py`), which never fires for a push-only platform, and the `send_message` schema's `target` examples (upstream core, no-fork) don't list `phone`. So the agent wouldn't reach for it on its own.
**What.** Added a relay-owned system-prompt context block via the existing `RELAY_AGENT_CONTEXT_ENABLED` seam (`plugin/enhancements/context_injection.py`, which wraps `AIAgent._build_system_prompt`):
- New `phone-platform` block telling the agent it can `send_message target=phone` (delivered as a notification + inbox), gated on **`phone_platform_enabled()` (PHONE_ENABLED)** AND a per-block opt-out **`RELAY_CONTEXT_PHONE_PLATFORM`** (default ON) — `plugin/config.py`. The block only appears when the platform is actually enabled, so the prompt never advertises a disabled capability.
- Auditable/removable like the media-sensitivity block (surfaces in `GET /context/injected`).
**Verification.** `python -m unittest plugin.tests.test_enhancements plugin.tests.test_phone_platform plugin.tests.test_proactive_channel` — 56 tests pass (new: phone-helper defaults, block present/absent by platform gate, per-block suppression, context-layer-off, labeled-fence in prompt, audit payload). Existing context-injection tests unchanged (new block defaults off). On the live box (RELAY_AGENT_CONTEXT_ENABLED + PHONE_ENABLED both on) the block activates on the next gateway plugin reload.
## 2026-06-28 — Phone platform (Phase 2: inbox surface + session injection)
**Why.** Phase 1 surfaced proactive messages as a transient notification only. Phase 2 adds the other two config-driven surfacings from the brief: a dedicated always-present Hermes inbox, and injection into the active chat session — selected per-message by the `surfacing` hint.
+24
View File
@@ -16,6 +16,8 @@ logger = logging.getLogger(__name__)
RELAY_AGENT_CONTEXT_ENABLED = "RELAY_AGENT_CONTEXT_ENABLED"
RELAY_CONTEXT_MEDIA_SENSITIVITY = "RELAY_CONTEXT_MEDIA_SENSITIVITY"
RELAY_CONTEXT_PHONE_PLATFORM = "RELAY_CONTEXT_PHONE_PLATFORM"
PHONE_ENABLED = "PHONE_ENABLED"
_TRUE_VALUES = {"1", "true", "yes", "on"}
_FALSE_VALUES = {"0", "false", "no", "off", ""}
@@ -101,11 +103,33 @@ def context_media_sensitivity_enabled() -> bool:
return strict_bool(RELAY_CONTEXT_MEDIA_SENSITIVITY, default=True)
def phone_platform_enabled() -> bool:
"""Whether the proactive ``phone`` platform is enabled (default OFF).
Mirrors the adapter's ``PHONE_ENABLED`` gate so the relay-owned context
block only advertises the capability when the platform is actually on.
"""
return strict_bool(PHONE_ENABLED, default=False)
def context_phone_platform_enabled() -> bool:
"""Per-block gate for the phone-platform capability hint (default ON).
Only meaningful when [phone_platform_enabled] is also true — lets an
operator keep the platform on while suppressing the system-prompt hint.
"""
return strict_bool(RELAY_CONTEXT_PHONE_PLATFORM, default=True)
__all__ = [
"RELAY_AGENT_CONTEXT_ENABLED",
"RELAY_CONTEXT_MEDIA_SENSITIVITY",
"RELAY_CONTEXT_PHONE_PLATFORM",
"PHONE_ENABLED",
"agent_context_enabled",
"context_media_sensitivity_enabled",
"phone_platform_enabled",
"context_phone_platform_enabled",
"raw_config_value",
"strict_bool",
]
+31 -2
View File
@@ -14,7 +14,12 @@ import sys
from functools import wraps
from typing import Any
from plugin.config import agent_context_enabled, context_media_sensitivity_enabled
from plugin.config import (
agent_context_enabled,
context_media_sensitivity_enabled,
context_phone_platform_enabled,
phone_platform_enabled,
)
from plugin.enhancements.registry import Enhancement
logger = logging.getLogger(__name__)
@@ -29,6 +34,17 @@ MEDIA_SENSITIVITY_INSTRUCTION = (
"media without one of those markers."
)
PHONE_PLATFORM_BLOCK_NAME = "phone-platform"
PHONE_PLATFORM_INSTRUCTION = (
"Proactive phone messaging is available. You can reach the user on their "
"paired phone by calling `send_message` with target `phone` (e.g. "
"`send_message(target=\"phone\", message=\"...\")`); it is delivered as a "
"phone notification and collected in a dedicated in-app inbox. Use it for "
"time-sensitive updates, to report that a long-running task has finished, or "
"whenever the user asks to be notified on their phone. Keep these messages "
"concise and high-signal — the user may not be looking at the screen."
)
_WRAPPED_ATTR = "_hermes_relay_context_wrapped"
_ORIGINAL_ATTR = "_hermes_relay_context_original"
@@ -39,7 +55,11 @@ def available_context_blocks() -> list[dict[str, str]]:
{
"name": MEDIA_SENSITIVITY_BLOCK_NAME,
"text": MEDIA_SENSITIVITY_INSTRUCTION,
}
},
{
"name": PHONE_PLATFORM_BLOCK_NAME,
"text": PHONE_PLATFORM_INSTRUCTION,
},
]
@@ -56,6 +76,15 @@ def get_injected_context_blocks() -> list[dict[str, str]]:
"text": MEDIA_SENSITIVITY_INSTRUCTION,
}
)
# Only advertise proactive phone messaging when the platform is actually
# enabled (PHONE_ENABLED) and the per-block hint isn't suppressed.
if phone_platform_enabled() and context_phone_platform_enabled():
blocks.append(
{
"name": PHONE_PLATFORM_BLOCK_NAME,
"text": PHONE_PLATFORM_INSTRUCTION,
}
)
return blocks
+73
View File
@@ -17,7 +17,10 @@ from plugin.enhancements import context_injection
from plugin.enhancements.context_injection import (
MEDIA_SENSITIVITY_BLOCK_NAME,
MEDIA_SENSITIVITY_INSTRUCTION,
PHONE_PLATFORM_BLOCK_NAME,
PHONE_PLATFORM_INSTRUCTION,
apply_context_injection,
get_injected_context_blocks,
injected_context_payload,
)
@@ -25,6 +28,8 @@ _ENV_KEYS = (
"HERMES_HOME",
plugin_config.RELAY_AGENT_CONTEXT_ENABLED,
plugin_config.RELAY_CONTEXT_MEDIA_SENSITIVITY,
plugin_config.RELAY_CONTEXT_PHONE_PLATFORM,
plugin_config.PHONE_ENABLED,
)
@@ -36,6 +41,8 @@ class _IsolatedEnvMixin:
os.environ["HERMES_HOME"] = self._tmpdir.name
os.environ.pop(plugin_config.RELAY_AGENT_CONTEXT_ENABLED, None)
os.environ.pop(plugin_config.RELAY_CONTEXT_MEDIA_SENSITIVITY, None)
os.environ.pop(plugin_config.RELAY_CONTEXT_PHONE_PLATFORM, None)
os.environ.pop(plugin_config.PHONE_ENABLED, None)
def tearDown(self) -> None:
for key in _ENV_KEYS:
@@ -269,5 +276,71 @@ class ContextInjectedRouteTests(_IsolatedEnvMixin, unittest.IsolatedAsyncioTestC
self.assertEqual(body["blocks"], [])
class PhonePlatformContextBlockTests(_IsolatedEnvMixin, unittest.TestCase):
def _block_names(self) -> list[str]:
return [b["name"] for b in get_injected_context_blocks()]
def test_phone_helpers_defaults(self) -> None:
# Platform off by default; the per-block hint defaults on (only matters
# once the platform is enabled).
self.assertFalse(plugin_config.phone_platform_enabled())
self.assertTrue(plugin_config.context_phone_platform_enabled())
def test_phone_block_absent_when_platform_disabled(self) -> None:
# Context layer is on by default, but PHONE_ENABLED is unset → no hint.
self.assertNotIn(PHONE_PLATFORM_BLOCK_NAME, self._block_names())
def test_phone_block_present_when_platform_enabled(self) -> None:
self._set_env(plugin_config.PHONE_ENABLED, "1")
self.assertIn(PHONE_PLATFORM_BLOCK_NAME, self._block_names())
def test_phone_block_suppressed_by_per_block_flag(self) -> None:
self._set_env(plugin_config.PHONE_ENABLED, "1")
self._set_env(plugin_config.RELAY_CONTEXT_PHONE_PLATFORM, "0")
self.assertNotIn(PHONE_PLATFORM_BLOCK_NAME, self._block_names())
def test_phone_block_absent_when_context_layer_off(self) -> None:
self._set_env(plugin_config.PHONE_ENABLED, "1")
self._set_env(plugin_config.RELAY_AGENT_CONTEXT_ENABLED, "0")
self.assertEqual(get_injected_context_blocks(), [])
def test_phone_block_appends_labeled_fence(self) -> None:
agent_class = self._agent_class()
self.assertTrue(apply_context_injection(agent_class))
self._set_env(plugin_config.RELAY_AGENT_CONTEXT_ENABLED, "1")
self._set_env(plugin_config.RELAY_CONTEXT_MEDIA_SENSITIVITY, "0")
self._set_env(plugin_config.PHONE_ENABLED, "1")
prompt = agent_class()._build_system_prompt()
self.assertIn(
"<!-- hermes-relay:phone-platform -->\n"
f"{PHONE_PLATFORM_INSTRUCTION}\n"
"<!-- /hermes-relay:phone-platform -->",
prompt,
)
self.assertEqual(prompt.count("<!-- hermes-relay:phone-platform -->"), 1)
def test_audit_payload_includes_phone_block(self) -> None:
self._set_env(plugin_config.RELAY_AGENT_CONTEXT_ENABLED, "1")
self._set_env(plugin_config.RELAY_CONTEXT_MEDIA_SENSITIVITY, "0")
self._set_env(plugin_config.PHONE_ENABLED, "1")
payload = injected_context_payload()
self.assertTrue(payload["enabled"])
self.assertEqual(
payload["blocks"],
[{"name": PHONE_PLATFORM_BLOCK_NAME, "text": PHONE_PLATFORM_INSTRUCTION}],
)
def _agent_class(self) -> type:
class DummyAgent:
def _build_system_prompt(self, system_message: str | None = None) -> str:
return "base prompt"
return DummyAgent
if __name__ == "__main__":
unittest.main()