fix(imessage): dedup flooding, per-message tracking, streaming cursor strip, read receipts#5432
Closed
ZK-Snarky wants to merge 11 commits into
Closed
fix(imessage): dedup flooding, per-message tracking, streaming cursor strip, read receipts#5432ZK-Snarky wants to merge 11 commits into
ZK-Snarky wants to merge 11 commits into
Conversation
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.
…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.
4 tasks
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! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 sendThe skill uses
imsg send --to <phone>directly, which works fine from a terminal session. The gateway adapter uses AppleScript instead becauseimsg sendhangs indefinitely when spawned viaasyncio.create_subprocess_execfrom a launchd agent. This is a known macOS restriction on XPC messaging from non-Aqua launchd contexts. AppleScript via/usr/bin/osascriptdoes not have this restriction.Why polling instead of
imsg watchimsg watchexits 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 onchat.dbwhich are blocked. Pollingimsg chatson 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_atfor 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_messagesskips 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_dispatchescache 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 callssend(). 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 returnsSendResult(success=True)immediately without sending. The stream consumer interprets the missingmessage_idas 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()callsimsg typing --chat-id <id> --duration 45s.stop_typing()callsimsg typing --chat-id <id> --stop true. Bothon_processing_startandon_processing_completelifecycle hooks are implemented to start and stop the indicator around agent processing.6. Poll interval too aggressive
Fix:
DEFAULT_POLL_INTERVALraised 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.pyonly — no other files touched.