feat(discord): agent-facing add_reaction/remove_reaction (photon parity) - #79074
feat(discord): agent-facing add_reaction/remove_reaction (photon parity)#79074vel007-ai wants to merge 2 commits into
Conversation
The `send_message(action="react")` tool dispatches to an adapter's public
`add_reaction` / `remove_reaction`, which only photon implemented, so
reacting on Discord silently failed with "platform does not support
message reactions". The Discord adapter already had the mechanics: they
were private and reachable only from the 👀/✅ lifecycle hooks.
Expose them on the photon contract:
- `add_reaction(chat_id, emoji, message_id=None)` and `remove_reaction`,
returning the same `{"success", "message_id"} / {"success", "error"}`
shapes, and deliberately NOT gated by DISCORD_REACTIONS (that env var
mutes the automatic ack, not an explicit agent intent).
- Default the target to the message that triggered this turn, recorded
per chat at processing start. Auto-threaded turns address the thread
while their trigger message lives in the parent channel, so both are
recorded as keys and both channels are tried on fetch.
- Track the emoji we added per (chat, message) so an unreact retracts it
rather than stripping the lifecycle ✅. Bounded, and lost on restart,
which matches photon's best-effort retraction.
A bare 'platform' target fell through to the home channel, which is right for a send and wrong for a reaction: the agent means "this conversation", and the home channel would put the emoji on an unrelated message. Prefer the chat the turn is running in when it is on the same platform, keeping the home-channel fallback for every other case.
|
Cross-linking: while using this in anger I hit the limitation that this PR alone doesn't solve the agent-facing case — This PR still stands on its own for photon parity and the non-agent callers. #80817 covers the agent-facing half by adding |
|
Cross-linking the convergence work: #89405 has been updated to incorporate the agent-facing adapter reaction helper goals from this PR while keeping the final surface plugin-owned through hooks, manifests, shared request builders, and adapter delivery helpers. Attribution for this contribution is included in #89405: #79074 by @vel007-ai. |
What does this PR do?
send_message(action="react")resolves reactions by duck-typing publicadd_reaction/remove_reactioncoroutines on the live adapter (tools/send_message_tool.py). Photon ships the pair; the Discord adapter has only the private_add_reaction/_remove_reactionhelpers it uses for the 👀/✅ lifecycle ack. So reacting on Discord fails withPlatform 'discord' does not support message reactions, even though the adapter is already reacting to messages several times per turn.This exposes the pair on photon's contract, and fixes the target resolution that made it unusable in practice.
Same shape as #77994 (Matrix), which is the sibling half of this gap.
1. Discord adapter gets the public pair.
{"success", "message_id"}/{"success", "error"}).DISCORD_REACTIONS— that env var exists to mute the automatic per-message ack, not explicit agent intent. Same policy photon documents forPHOTON_REACTIONS.on_processing_start(before the env gate, for the same reason).chat_idset to the new thread, while the message that triggered it lives in the parent channel. Both are recorded as keys, and both channels are tried on fetch, so a react from inside an auto-thread lands on the message that started it.remove_reactionretracts only the emoji this process placed, tracked per(chat, message)and bounded. Without that, an unreact would have to guess, and stripping the lifecycle ✅ is the wrong guess. Lost on restart, which matches photon's documented best-effort retraction.2.
react/unreactwith no chat now mean "here".A bare
platformtarget fell through toget_home_channel(). That is right for a send and wrong for a reaction: the agent means this conversation, and the home channel would put the emoji on an unrelated message in a different channel. The current turn's chat (HERMES_SESSION_CHAT_ID) now wins when it is on the same platform, with the home-channel fallback intact for every other case.This matters more than it looks: the system prompt tells the agent
Platform: discordbut never its channel id, so without this, the only reliably correct call is one the agent has no way to construct.Related Issue
Partially addresses #29026 — that request covers both directions (the agent reading reactions as input, and placing them). This is the outbound half only; inbound Discord reaction events are #46855.
Type of Change
Changes Made
plugins/platforms/discord/adapter.py— publicadd_reaction/remove_reaction,_fetch_reaction_target(thread → parent fallback),_last_inbound_by_chatrecorded inon_processing_start, bounded_agent_reactionstracking for retraction.tools/send_message_tool.py—_current_turn_chat_id()preferred over the home channel forreact/unreact;actionschema description updated to say Discord is supported and that a bare platform target means the current chat.tests/gateway/test_discord_reactions.py— 8 tests: explicit id, turn default, thread-parent fallback, env not gating agent intent, both error paths, retract-what-we-added, retract-with-nothing-tracked.tests/tools/test_send_message_react.py— 2 tests: bare target resolves to the current turn; a turn on a different platform does not leak into it (home fallback still wins).How to Test
pytest tests/gateway/test_discord_reactions.py tests/tools/test_send_message_react.py -qsend_message(action="react", target="discord", emoji="🟢"). The 🟢 lands on the message that mentioned it, alongside the lifecycle ✅ — including when auto-threading moved the turn into a new thread.send_message(action="unreact", target="discord")retracts the 🟢 and leaves the ✅.Checklist
Code
pytest tests/ -qand all tests pass — partially: the two suites above pass (14 passed). The full run in my environment has ~136 pre-existing failures from optional deps I don't have installed (aiohttp, browser/slack extras); I verified the Discord/tools files I touch have an identical pass/fail set before and after this change.Documentation & Housekeeping
actiondescription now names Discord and the current-chat default)