Skip to content

fix(imessage): dedup flooding, per-message tracking, streaming cursor strip, read receipts#5432

Closed
ZK-Snarky wants to merge 11 commits into
NousResearch:mainfrom
ZK-Snarky:fix/imessage-dedup-flooding
Closed

fix(imessage): dedup flooding, per-message tracking, streaming cursor strip, read receipts#5432
ZK-Snarky wants to merge 11 commits into
NousResearch:mainfrom
ZK-Snarky:fix/imessage-dedup-flooding

Conversation

@ZK-Snarky

@ZK-Snarky ZK-Snarky commented Apr 6, 2026

Copy link
Copy Markdown

Summary

Fixes 6 critical bugs in the iMessage platform adapter that caused 150+ duplicate messages in 90 seconds, duplicate agent responses, and a streaming cursor appearing as a white block in iMessage.

Relationship to the imessage Skill

Hermes has two separate iMessage integration points that are complementary:

  • skills/apple/imessage/SKILL.md — teaches the agent to USE imsg as a tool to send iMessages on behalf of the user ("text mom I'll be late"). Agent as sender.
  • gateway/platforms/imessage.py (this PR) — makes iMessage an INPUT channel so the user can talk TO Hermes via iMessage and receive responses. Agent as receiver.

This PR is the receiving side that was missing. The skill already works correctly and is unchanged.

Why AppleScript for sending instead of imsg send

The skill uses imsg send --to <phone> directly, which works fine from a terminal session. The gateway adapter uses AppleScript instead because imsg send hangs indefinitely when spawned via asyncio.create_subprocess_exec from a launchd agent. This is a known macOS restriction on XPC messaging from non-Aqua launchd contexts. AppleScript via /usr/bin/osascript does not have this restriction.

Why polling instead of imsg watch

imsg watch exits immediately (or hangs without producing output) when spawned as a subprocess from launchd. Root cause is FSEvents subscription restrictions in the launchd sandbox context — the watch command registers for filesystem change notifications on chat.db which are blocked. Polling imsg chats on an interval is the reliable workaround.

Bugs Fixed

1. Flooding / 150+ duplicate dispatches

When Hermes sends a reply via AppleScript, Messages.app updates last_message_at for the chat. The next poll saw activity, fetched history, and re-dispatched the original inbound message because only a chat-level timestamp was tracked.

Fix: Track seen message rowids per chat (_seen_message_ids). Only dispatch messages with unseen rowids. Pre-populate rowids at startup so historical messages are not dispatched after a restart. Cap per-chat set to 200 entries.

2. Concurrent poll overlaps

Poll interval was 10s but agent responses take 15–60s. While an agent was processing, multiple polls fired and re-evaluated the same chat.

Fix: Per-chat asyncio.Lock (_chat_locks). _check_new_messages skips any chat whose lock is held. Lock is held for the duration of all dispatches for that chat.

3. Duplicate responses

Multiple dispatches of the same message (from bugs 1 and 2) each independently triggered the agent and sent a response.

Fix: Resolved by 1 and 2. Additionally, a _recent_dispatches cache drops any (chat_rowid, text_hash) combination seen within 60 seconds as a last-resort safety net.

4. Streaming cursor showing as white block in iMessage

The gateway appends a cursor character () to intermediate streaming updates and calls send(). iMessage has no edit-message API, so the first partial-text send lands permanently as a white/black block character.

Fix: send() detects content ending with any known cursor character and returns SendResult(success=True) immediately without sending. The stream consumer interprets the missing message_id as a failed initial send, disables its streaming session, and lets the normal final-response path deliver the complete answer as one clean message.

5. Typing indicator

Fix: send_typing() calls imsg typing --chat-id <id> --duration 45s. stop_typing() calls imsg typing --chat-id <id> --stop true. Both on_processing_start and on_processing_complete lifecycle hooks are implemented to start and stop the indicator around agent processing.

6. Poll interval too aggressive

Fix: DEFAULT_POLL_INTERVAL raised from 10.0 to 30.0 seconds.

7. Hardcoded FDA warning path

The Full Disk Access permission warning logged a hardcoded user-specific path.

Fix: Replaced with a generic instruction.

Files Changed

  • gateway/platforms/imessage.py only — no other files touched.

Add write_memory=True param to delegate_task. When enabled, each subagent
gets a subagent_memory_write tool that writes findings to the parent agents
active memory provider -- builtin MEMORY.md, mem0, Honcho, or any other
registered provider.

Constraints enforced in make_subagent_memory_writer:
- Append-only (no read, replace, or delete)
- Max 3 writes per subagent run, 400 chars each
- Content tagged [subagent] prefix for parent identification
- Thread-safe rate limiting (lock per writer closure)
- Graceful no-op when parent has no memory store

How it works:
- delegate_task creates a write callback closing over parent._memory_store
- The callback is stored on the child as _subagent_memory_writer
- The subagent_memory_write tool schema is injected into child.tools
- run_agent.py dispatches subagent_memory_write to the handler in both
  the sequential and concurrent tool execution paths
- on_memory_write() notifies external providers (mem0, Honcho, etc.)
  so the write mirrors to the active plugin backend

The full memory tool remains blocked for subagents. write_memory=False
by default -- subagents cannot write memory without explicit opt-in.

Fixes: NousResearch#5338 (previous approach used fabric_write,
which is an optional plugin and silently does nothing on non-Fabric deploys)
… strip, read receipts

Fixes 6 critical bugs in the iMessage platform adapter:

1. FLOODING / 150+ DUPLICATE DISPATCHES
   Root cause: timestamp-only deduplication re-dispatched inbound messages
   after every outgoing reply because AppleScript updates last_message_at for
   the chat, causing the next poll to fetch history and re-evaluate messages
   whose timestamps predated the updated watermark.
   Fix: track seen message rowids per chat (_seen_message_ids dict). Only
   dispatch messages whose rowid has not been seen before. Pre-populate rowids
   at startup so historical messages are never dispatched after a restart.
   Cap set to 200 entries per chat to bound memory usage.

2. CONCURRENT POLL OVERLAPS
   Root cause: 10s poll interval vs 15-60s agent response time means 2-6
   concurrent polls fire while a chat is being processed, multiplying
   duplicates from bug NousResearch#1.
   Fix: per-chat asyncio.Lock (_chat_locks dict). _check_new_messages skips
   any chat whose lock is currently held. Lock is held for the duration of
   all dispatches for that chat.

3. DUPLICATE RESPONSES
   Root cause: multiple dispatches of the same message (bugs NousResearch#1+NousResearch#2) each
   independently triggered the agent and sent a reply.
   Fix: resolved by NousResearch#1 and NousResearch#2. Additionally, a recent-dispatch cache
   (_recent_dispatches dict) drops any (chat_rowid, text_hash) combination
   seen within the last 60 seconds as a last-resort safety net.

4. STREAMING CURSOR WHITE BLOCK IN IMESSAGE
   Root cause: GatewayStreamConsumer appends a cursor character (" ▉") to
   intermediate streaming updates and calls adapter.send(). iMessage has no
   edit-message API so the first partial-text send lands as a permanent
   message in the conversation, showing as a white/black block.
   Fix: IMessageAdapter.send() detects content ending with any known cursor
   character and returns SendResult(success=True) immediately (no message_id).
   The stream consumer interprets the missing message_id as a failed initial
   send, disables its streaming session, and lets the normal final-response
   path deliver the complete answer as one clean message.

5. NO READ RECEIPTS
   Fix: on_processing_start hook fires an AppleScript "mark targetChat as
   read" command (fire-and-forget) when message processing begins.

6. POLL INTERVAL TOO AGGRESSIVE
   Fix: DEFAULT_POLL_INTERVAL raised from 10.0 to 30.0 seconds.

7. HARDCODED FDA WARNING PATH
   Fix: removed hardcoded /Users/clawdolf path from the Full Disk Access
   warning log; replaced with a generic instruction.
@ZK-Snarky ZK-Snarky marked this pull request as draft April 6, 2026 06:08
ZK-Snarky and others added 9 commits April 5, 2026 23:14
…ken AppleScript read receipt

- send_typing() now calls imsg typing --chat-id --duration 45s
- stop_typing() calls imsg typing --chat-id --stop true
- on_processing_start() starts typing indicator
- on_processing_complete() stops typing indicator
- Remove broken AppleScript mark-as-read (no reliable API without private frameworks)
imsg watch --since-rowid --json works correctly with FDA granted.
Replaces 30-second poll latency with near-instant message delivery.
Watch tasks restart automatically on unexpected exit.
All dedup mechanisms retained as safety nets.
…unrelated gateway changes; document registry bypass

Critical: write_memory was added to delegate_task() but never forwarded
from the two hardcoded dispatch branches in run_agent.py (_invoke_tool
and _execute_tool_calls_sequential). Both branches call delegate_task()
directly, bypassing the registry handler that correctly passes
write_memory. The result: write_memory=True from the model was silently
ignored and the entire feature was a no-op.

Also:
- Reverted unrelated iMessage gateway additions (gateway/config.py,
  gateway/run.py) that were accidentally bundled into this PR
- Added explanatory comment in subagent_memory_tool.py documenting why
  this tool intentionally does not use registry.register()
- Added TestRunAgentDispatchForwardsWriteMemory test class to cover the
  _invoke_tool dispatch path — this test would have caught the bug
…g newline

Root cause: stream consumer sends final accumulated text without cursor as
a new "first message" when no message_id was established. Our send()
suppressed cursor sends but returned no message_id, so stream consumer
never tracked a session, and the final cursor-free send went through as a
real message. Then the normal response path also fired — two identical sends.

Fix:
- send() now returns a fake message_id on cursor suppression so stream
  consumer establishes a session (already_sent=True)
- edit_message() implemented: suppresses cursor edits, delivers only the
  final cursor-free edit as the one real AppleScript send
- Dedup via stream consumer built-in last_sent_text check handles the
  double final-edit call at got_done
- strip leading newlines in both send() and edit_message() to kill the
  blank line at top of first message
…sage

iMessage cannot edit messages so streaming is wrong by design.
Replace complex edit_message workaround with simple stream suppression:
- Track chats mid-stream in _stream_chats set
- Suppress cursor sends (mark in-stream)
- Suppress final got_done stream send (clear state)
- Normal response path delivers one complete message unobstructed
No duplicates, no blank lines, no streaming artifacts.
Replace full-suppression with delta streaming:
- Each stream consumer flush sends only NEW content since last send
- Cursor stripped, delta extracted, delivered as separate iMessage bubble
- User sees response arriving progressively in real time
- Fake message_id on first streaming send -> already_sent=True -> normal
  path skips, no duplicate final send
- edit_message() delegates to send() for same delta behavior
- _last_sent_content tracks per-chat what was last sent

imsg typing/react both require session-scoped access unavailable from
launchd. Typing indicator left as no-op pending alternative approach.
- imsg watch for instant message pickup (no 30s poll delay)
- Delta streaming: each flush sends only new content as separate bubble
- edit_message() delegates to send() for same delta behavior
- _stream_chats + _last_sent_content track streaming state per chat
- _send_chunk intact for AppleScript delivery
- No duplicate sends: fake message_id -> already_sent=True -> normal path skips
Remove delta streaming -- stream consumer flushes at timed intervals
not sentence boundaries, producing awkward mid-sentence splits.
Response is fast enough that intermediate bubbles add no value.
Simple suppression: cursor sends and got_done sends are swallowed,
normal response path delivers one complete clean message.
@teknium1

teknium1 commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Closing in favor of #6437 which uses BlueBubbles webhooks (no polling dedup needed). Your insights on streaming cursor stripping and per-message tracking were valuable context. Thank you @ZK-Snarky!

@teknium1 teknium1 closed this Apr 9, 2026
@ZK-Snarky ZK-Snarky deleted the fix/imessage-dedup-flooding branch May 9, 2026 23:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants