mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(gateway/bluebubbles): align iMessage delivery with non-editable UX
This commit is contained in:
parent
00c3d848d8
commit
f731c2c2bd
5 changed files with 110 additions and 3 deletions
|
|
@ -99,6 +99,7 @@ def _normalize_server_url(raw: str) -> str:
|
||||||
|
|
||||||
class BlueBubblesAdapter(BasePlatformAdapter):
|
class BlueBubblesAdapter(BasePlatformAdapter):
|
||||||
platform = Platform.BLUEBUBBLES
|
platform = Platform.BLUEBUBBLES
|
||||||
|
SUPPORTS_MESSAGE_EDITING = False
|
||||||
MAX_MESSAGE_LENGTH = MAX_TEXT_LENGTH
|
MAX_MESSAGE_LENGTH = MAX_TEXT_LENGTH
|
||||||
|
|
||||||
def __init__(self, config: PlatformConfig):
|
def __init__(self, config: PlatformConfig):
|
||||||
|
|
@ -391,6 +392,13 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||||
# Text sending
|
# Text sending
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def truncate_message(content: str, max_length: int = MAX_TEXT_LENGTH) -> List[str]:
|
||||||
|
# Use the base splitter but skip pagination indicators — iMessage
|
||||||
|
# bubbles flow naturally without "(1/3)" suffixes.
|
||||||
|
chunks = BasePlatformAdapter.truncate_message(content, max_length)
|
||||||
|
return [re.sub(r"\s*\(\d+/\d+\)$", "", c) for c in chunks]
|
||||||
|
|
||||||
async def send(
|
async def send(
|
||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
|
|
@ -398,10 +406,19 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||||
reply_to: Optional[str] = None,
|
reply_to: Optional[str] = None,
|
||||||
metadata: Optional[Dict[str, Any]] = None,
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
) -> SendResult:
|
) -> SendResult:
|
||||||
text = strip_markdown(content or "")
|
text = self.format_message(content)
|
||||||
if not text:
|
if not text:
|
||||||
return SendResult(success=False, error="BlueBubbles send requires text")
|
return SendResult(success=False, error="BlueBubbles send requires text")
|
||||||
chunks = self.truncate_message(text, max_length=self.MAX_MESSAGE_LENGTH)
|
# Split on paragraph breaks first (double newlines) so each thought
|
||||||
|
# becomes its own iMessage bubble, then truncate any that are still
|
||||||
|
# too long.
|
||||||
|
paragraphs = [p.strip() for p in re.split(r'\n\s*\n', text) if p.strip()]
|
||||||
|
chunks: List[str] = []
|
||||||
|
for para in (paragraphs or [text]):
|
||||||
|
if len(para) <= self.MAX_MESSAGE_LENGTH:
|
||||||
|
chunks.append(para)
|
||||||
|
else:
|
||||||
|
chunks.extend(self.truncate_message(para, max_length=self.MAX_MESSAGE_LENGTH))
|
||||||
last = SendResult(success=True)
|
last = SendResult(success=True)
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
guid = await self._resolve_chat_guid(chat_id)
|
guid = await self._resolve_chat_guid(chat_id)
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,18 @@ def build_session_context_prompt(
|
||||||
"Do not promise to perform these actions. If the user asks, explain "
|
"Do not promise to perform these actions. If the user asks, explain "
|
||||||
"that you can only read messages sent directly to you and respond."
|
"that you can only read messages sent directly to you and respond."
|
||||||
)
|
)
|
||||||
|
elif context.source.platform == Platform.BLUEBUBBLES:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"**Platform notes:** You are responding via iMessage. "
|
||||||
|
"Keep responses short and conversational — think texts, not essays. "
|
||||||
|
"Structure longer replies as separate short thoughts, each separated "
|
||||||
|
"by a blank line (double newline). Each block between blank lines "
|
||||||
|
"will be delivered as its own iMessage bubble, so write accordingly: "
|
||||||
|
"one idea per bubble, 1–3 sentences each. "
|
||||||
|
"If the user needs a detailed answer, give the short version first "
|
||||||
|
"and offer to elaborate."
|
||||||
|
)
|
||||||
|
|
||||||
# Connected platforms
|
# Connected platforms
|
||||||
platforms_list = ["local (files on this machine)"]
|
platforms_list = ["local (files on this machine)"]
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,37 @@ class TestBlueBubblesHelpers:
|
||||||
|
|
||||||
assert check_bluebubbles_requirements() is True
|
assert check_bluebubbles_requirements() is True
|
||||||
|
|
||||||
|
def test_supports_message_editing_is_false(self, monkeypatch):
|
||||||
|
adapter = _make_adapter(monkeypatch)
|
||||||
|
assert adapter.SUPPORTS_MESSAGE_EDITING is False
|
||||||
|
|
||||||
|
def test_truncate_message_omits_pagination_suffixes(self, monkeypatch):
|
||||||
|
adapter = _make_adapter(monkeypatch)
|
||||||
|
chunks = adapter.truncate_message("abcdefghij", max_length=6)
|
||||||
|
assert len(chunks) > 1
|
||||||
|
assert "".join(chunks) == "abcdefghij"
|
||||||
|
assert all("(" not in chunk for chunk in chunks)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_splits_paragraphs_into_multiple_bubbles(self, monkeypatch):
|
||||||
|
adapter = _make_adapter(monkeypatch)
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def fake_resolve_chat_guid(chat_id):
|
||||||
|
return "iMessage;-;user@example.com"
|
||||||
|
|
||||||
|
async def fake_api_post(path, payload):
|
||||||
|
sent.append(payload["message"])
|
||||||
|
return {"data": {"guid": f"msg-{len(sent)}"}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(adapter, "_resolve_chat_guid", fake_resolve_chat_guid)
|
||||||
|
monkeypatch.setattr(adapter, "_api_post", fake_api_post)
|
||||||
|
|
||||||
|
result = await adapter.send("user@example.com", "first thought\n\nsecond thought")
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert sent == ["first thought", "second thought"]
|
||||||
|
|
||||||
def test_format_message_strips_markdown(self, monkeypatch):
|
def test_format_message_strips_markdown(self, monkeypatch):
|
||||||
adapter = _make_adapter(monkeypatch)
|
adapter = _make_adapter(monkeypatch)
|
||||||
assert adapter.format_message("**Hello** `world`") == "Hello world"
|
assert adapter.format_message("**Hello** `world`") == "Hello world"
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,13 @@ class ProgressCaptureAdapter(BasePlatformAdapter):
|
||||||
return {"id": chat_id}
|
return {"id": chat_id}
|
||||||
|
|
||||||
|
|
||||||
|
class NonEditingProgressCaptureAdapter(ProgressCaptureAdapter):
|
||||||
|
SUPPORTS_MESSAGE_EDITING = False
|
||||||
|
|
||||||
|
async def edit_message(self, chat_id, message_id, content) -> SendResult:
|
||||||
|
raise AssertionError("non-editable adapters should not receive edit_message calls")
|
||||||
|
|
||||||
|
|
||||||
class FakeAgent:
|
class FakeAgent:
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
||||||
|
|
@ -502,6 +509,7 @@ async def _run_with_agent(
|
||||||
chat_id="-1001",
|
chat_id="-1001",
|
||||||
chat_type="group",
|
chat_type="group",
|
||||||
thread_id="17585",
|
thread_id="17585",
|
||||||
|
adapter_cls=ProgressCaptureAdapter,
|
||||||
):
|
):
|
||||||
if config_data:
|
if config_data:
|
||||||
import yaml
|
import yaml
|
||||||
|
|
@ -516,7 +524,7 @@ async def _run_with_agent(
|
||||||
fake_run_agent.AIAgent = agent_cls
|
fake_run_agent.AIAgent = agent_cls
|
||||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||||
|
|
||||||
adapter = ProgressCaptureAdapter(platform=platform)
|
adapter = adapter_cls(platform=platform)
|
||||||
runner = _make_runner(adapter)
|
runner = _make_runner(adapter)
|
||||||
gateway_run = importlib.import_module("gateway.run")
|
gateway_run = importlib.import_module("gateway.run")
|
||||||
if config_data and "streaming" in config_data:
|
if config_data and "streaming" in config_data:
|
||||||
|
|
@ -666,6 +674,26 @@ async def test_run_agent_interim_commentary_works_with_tool_progress_off(monkeyp
|
||||||
assert any(call["content"] == "I'll inspect the repo first." for call in adapter.sent)
|
assert any(call["content"] == "I'll inspect the repo first." for call in adapter.sent)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_agent_bluebubbles_uses_commentary_send_path_for_quick_replies(monkeypatch, tmp_path):
|
||||||
|
adapter, result = await _run_with_agent(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
CommentaryAgent,
|
||||||
|
session_id="sess-bluebubbles-commentary",
|
||||||
|
config_data={"display": {"interim_assistant_messages": True}},
|
||||||
|
platform=Platform.BLUEBUBBLES,
|
||||||
|
chat_id="iMessage;-;user@example.com",
|
||||||
|
chat_type="dm",
|
||||||
|
thread_id=None,
|
||||||
|
adapter_cls=NonEditingProgressCaptureAdapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.get("already_sent") is not True
|
||||||
|
assert [call["content"] for call in adapter.sent] == ["I'll inspect the repo first."]
|
||||||
|
assert adapter.edits == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_agent_previewed_final_marks_already_sent(monkeypatch, tmp_path):
|
async def test_run_agent_previewed_final_marks_already_sent(monkeypatch, tmp_path):
|
||||||
adapter, result = await _run_with_agent(
|
adapter, result = await _run_with_agent(
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,25 @@ class TestBuildSessionContextPrompt:
|
||||||
assert "Telegram" in prompt
|
assert "Telegram" in prompt
|
||||||
assert "Home Chat" in prompt
|
assert "Home Chat" in prompt
|
||||||
|
|
||||||
|
def test_bluebubbles_prompt_mentions_short_conversational_i_message_format(self):
|
||||||
|
config = GatewayConfig(
|
||||||
|
platforms={
|
||||||
|
Platform.BLUEBUBBLES: PlatformConfig(enabled=True, extra={"server_url": "http://localhost:1234", "password": "secret"}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
source = SessionSource(
|
||||||
|
platform=Platform.BLUEBUBBLES,
|
||||||
|
chat_id="iMessage;-;user@example.com",
|
||||||
|
chat_name="Ben",
|
||||||
|
chat_type="dm",
|
||||||
|
)
|
||||||
|
ctx = build_session_context(source, config)
|
||||||
|
prompt = build_session_context_prompt(ctx)
|
||||||
|
|
||||||
|
assert "responding via iMessage" in prompt
|
||||||
|
assert "short and conversational" in prompt
|
||||||
|
assert "blank line" in prompt
|
||||||
|
|
||||||
def test_discord_prompt(self):
|
def test_discord_prompt(self):
|
||||||
config = GatewayConfig(
|
config = GatewayConfig(
|
||||||
platforms={
|
platforms={
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue