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:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user