diff --git a/AGENTS.md b/AGENTS.md index f886ee72..9aae7e66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ There is intentionally no after-agent safety net that opens a PR for the agent. All tools live in `agent/tools/` and are flat-imported via `agent/tools/__init__.py`. The set is intentionally small and curated — see README "Tools — Curated, Not Accumulated". Wired into `get_agent`: -`http_request`, `fetch_url`, `web_search`, `linear_comment`, `linear_create_issue`, `linear_delete_issue`, `linear_get_issue`, `linear_get_issue_comments`, `linear_list_teams`, `linear_update_issue`, `request_pr_review`, `schedule_thread_wakeup`, `slack_read_thread_messages`, `slack_thread_reply`. +`http_request`, `fetch_url`, `web_search`, `linear_comment`, `linear_create_issue`, `linear_delete_issue`, `linear_get_issue`, `linear_get_issue_comments`, `linear_list_teams`, `linear_update_issue`, `request_pr_review`, `schedule_thread_wakeup`, `slack_add_reaction`, `slack_read_thread_messages`, `slack_thread_reply`. Reviewer-only tools (in `agent/reviewer.py`): `add_finding`, `update_finding`, `list_findings`, `publish_review`. The review-style analyzer uses `save_review_style` (exported as `save_review_style_prompt`). diff --git a/CLAUDE.md b/CLAUDE.md index 2f41c049..2c35b827 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ There is intentionally no after-agent safety net that opens a PR for the agent. All tools live in `agent/tools/` and are flat-imported via `agent/tools/__init__.py`. The set is intentionally small and curated — see README "Tools — Curated, Not Accumulated". Wired into `get_agent`: -`http_request`, `fetch_url`, `web_search`, `linear_comment`, `linear_create_issue`, `linear_delete_issue`, `linear_get_issue`, `linear_get_issue_comments`, `linear_list_teams`, `linear_update_issue`, `request_pr_review`, `schedule_thread_wakeup`, `slack_read_thread_messages`, `slack_thread_reply`. +`http_request`, `fetch_url`, `web_search`, `linear_comment`, `linear_create_issue`, `linear_delete_issue`, `linear_get_issue`, `linear_get_issue_comments`, `linear_list_teams`, `linear_update_issue`, `request_pr_review`, `schedule_thread_wakeup`, `slack_add_reaction`, `slack_read_thread_messages`, `slack_thread_reply`. Reviewer-only tools (in `agent/reviewer.py`): `add_finding`, `update_finding`, `list_findings`, `publish_review`. The review-style analyzer uses `save_review_style` (exported as `save_review_style_prompt`). diff --git a/README.md b/README.md index 88b1058c..d3e666ca 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Stripe's key insight: *tool curation matters more than tool quantity.* Open SWE | `fetch_url` | Fetch web pages as markdown | | `http_request` | API calls (GET, POST, etc.) | | `linear_comment` | Post updates to Linear tickets | +| `slack_add_reaction` | React to Slack messages | | `slack_thread_reply` | Reply in Slack threads | GitHub operations are performed with `GH_TOKEN=dummy gh` inside the sandbox, backed by the LangSmith proxy. Plus the built-in Deep Agents tools: `read_file`, `write_file`, `edit_file`, `ls`, `glob`, `grep`, `write_todos`, and `task` (subagent spawning). diff --git a/agent/middleware/refresh_slack_status.py b/agent/middleware/refresh_slack_status.py index 6c52cc88..cfcf1dd0 100644 --- a/agent/middleware/refresh_slack_status.py +++ b/agent/middleware/refresh_slack_status.py @@ -55,6 +55,7 @@ _TOOL_STATUS: dict[str, str] = { "fetch_url": "fetching a URL...", "http_request": "making an HTTP request...", "request_pr_review": "requesting a PR review...", + "slack_add_reaction": "reacting in Slack...", "slack_read_thread_messages": "reading Slack history...", "slack_thread_reply": "drafting a Slack reply...", "linear_comment": "commenting on Linear...", diff --git a/agent/prompt.py b/agent/prompt.py index 7eea8341..83f3c2ea 100644 --- a/agent/prompt.py +++ b/agent/prompt.py @@ -84,6 +84,7 @@ OPEN_SWE_SHARED_BASE = """You are **Open SWE**, an open-source agent built on La - Focus on the substance and keep summaries brief. Use light markdown (`###`/`####` headings, bold, code) — avoid `#`/`##` titles. - In Slack, when a user asks to “break out,” “split out,” or “start a separate thread” for part of the work, summarize the requested aspect and relevant context into self-contained instructions, then call `slack_start_new_thread` instead of only replying in the current thread. +- In Slack, when acknowledging a user follow-up while you continue working, prefer `slack_add_reaction` with the default `eyes` reaction over posting a perfunctory “Updating…” / “I’ll check…” confirmation reply. - When you post to Slack with `slack_thread_reply`, do not repeat that text in a later assistant message; the user can already see the Slack message. - When delegated work to a subagent: the calling agent only sees your final message, so make it the complete answer. diff --git a/agent/server.py b/agent/server.py index c16d36ab..bfdf485f 100644 --- a/agent/server.py +++ b/agent/server.py @@ -85,6 +85,7 @@ from .tools import ( request_pr_review, save_plan, schedule_thread_wakeup, + slack_add_reaction, slack_read_thread_messages, slack_start_new_thread, slack_thread_reply, @@ -959,6 +960,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: open_pull_request, request_pr_review, schedule_thread_wakeup, + slack_add_reaction, slack_read_thread_messages, slack_start_new_thread, slack_thread_reply, diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py index 532324d2..96aa85d0 100644 --- a/agent/tools/__init__.py +++ b/agent/tools/__init__.py @@ -20,6 +20,7 @@ from .resolve_finding_thread import resolve_finding_thread from .save_plan import save_plan from .schedule_thread_wakeup import schedule_thread_wakeup from .search_repo_code import search_repo_code +from .slack_add_reaction import slack_add_reaction from .slack_read_thread_messages import slack_read_thread_messages from .slack_start_new_thread import slack_start_new_thread from .slack_thread_reply import slack_thread_reply @@ -49,6 +50,7 @@ __all__ = [ "save_plan", "schedule_thread_wakeup", "search_repo_code", + "slack_add_reaction", "slack_read_thread_messages", "slack_start_new_thread", "slack_thread_reply", diff --git a/agent/tools/slack_add_reaction.py b/agent/tools/slack_add_reaction.py new file mode 100644 index 00000000..b518c8bb --- /dev/null +++ b/agent/tools/slack_add_reaction.py @@ -0,0 +1,46 @@ +from typing import Any + +from langgraph.config import get_config + +from ..utils.slack import add_slack_reaction + + +async def slack_add_reaction( + emoji: str = "eyes", + message_ts: str | None = None, +) -> dict[str, Any]: + """Add a reaction to a Slack message in the current Slack thread. + + Use this with the default `eyes` reaction to acknowledge Slack user follow-up + requests while you continue working, instead of posting a perfunctory + confirmation reply. If `message_ts` is omitted, this reacts to the latest + message that triggered the run. Pass emoji names without surrounding colons. + """ + config = get_config() + configurable = config.get("configurable", {}) + slack_thread = configurable.get("slack_thread", {}) + + channel_id = slack_thread.get("channel_id") + if not channel_id: + return {"success": False, "error": "Missing slack_thread.channel_id in config"} + + target_ts = (message_ts or slack_thread.get("triggering_event_ts") or "").strip() + if not target_ts: + return { + "success": False, + "error": "Missing message_ts and slack_thread.triggering_event_ts in config", + } + + reaction = emoji.strip().strip(":") + if not reaction: + return {"success": False, "error": "emoji is required"} + if any(char.isspace() for char in reaction): + return { + "success": False, + "error": "emoji must be a Slack reaction name without whitespace", + } + + success = await add_slack_reaction(channel_id, target_ts, reaction) + if not success: + return {"success": False, "error": "Could not add Slack reaction"} + return {"success": True} diff --git a/agent/webhooks/slack.py b/agent/webhooks/slack.py index 96b8e47f..45dfec65 100644 --- a/agent/webhooks/slack.py +++ b/agent/webhooks/slack.py @@ -142,8 +142,10 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st f"## Latest Mention Request\n{clean_text}\n\n" + (f"{resolved_links_section}\n\n" if resolved_links_section else "") + "Use `slack_thread_reply` to communicate in this Slack thread for clarifications, " - "status updates, and final summaries. Use `slack_read_thread_messages` to read any " - "Slack messages by providing channel_id and message_ts." + "substantive updates, and final summaries. Use `slack_add_reaction` with :eyes: " + "instead of posting perfunctory confirmation replies to user follow-up requests. " + "Use `slack_read_thread_messages` to read any Slack messages by providing channel_id " + "and message_ts." ) content_blocks: list[dict[str, Any]] = [create_text_block(prompt)] diff --git a/tests/test_slack_add_reaction_tool.py b/tests/test_slack_add_reaction_tool.py new file mode 100644 index 00000000..41cd3855 --- /dev/null +++ b/tests/test_slack_add_reaction_tool.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import importlib +from typing import Any + +import pytest + +slack_reaction_tool = importlib.import_module("agent.tools.slack_add_reaction") + + +def _config() -> dict[str, Any]: + return { + "configurable": { + "slack_thread": { + "channel_id": "C1", + "thread_ts": "1.0", + "triggering_event_ts": "1.1", + } + } + } + + +async def test_slack_add_reaction_defaults_to_triggering_event( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, str] = {} + + async def fake_add_slack_reaction( + channel_id: str, message_ts: str, emoji: str = "eyes" + ) -> bool: + captured.update({"channel_id": channel_id, "message_ts": message_ts, "emoji": emoji}) + return True + + monkeypatch.setattr(slack_reaction_tool, "get_config", _config) + monkeypatch.setattr(slack_reaction_tool, "add_slack_reaction", fake_add_slack_reaction) + + result = await slack_reaction_tool.slack_add_reaction() + + assert result == {"success": True} + assert captured == {"channel_id": "C1", "message_ts": "1.1", "emoji": "eyes"} + + +async def test_slack_add_reaction_accepts_explicit_message_and_normalizes_emoji( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, str] = {} + + async def fake_add_slack_reaction( + channel_id: str, message_ts: str, emoji: str = "eyes" + ) -> bool: + captured.update({"channel_id": channel_id, "message_ts": message_ts, "emoji": emoji}) + return True + + monkeypatch.setattr(slack_reaction_tool, "get_config", _config) + monkeypatch.setattr(slack_reaction_tool, "add_slack_reaction", fake_add_slack_reaction) + + result = await slack_reaction_tool.slack_add_reaction( + emoji=":white_check_mark:", message_ts="1.2" + ) + + assert result == {"success": True} + assert captured == {"channel_id": "C1", "message_ts": "1.2", "emoji": "white_check_mark"} + + +async def test_slack_add_reaction_requires_slack_channel(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(slack_reaction_tool, "get_config", lambda: {"configurable": {}}) + + result = await slack_reaction_tool.slack_add_reaction() + + assert result == {"success": False, "error": "Missing slack_thread.channel_id in config"} + + +async def test_slack_add_reaction_rejects_empty_emoji(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(slack_reaction_tool, "get_config", _config) + + result = await slack_reaction_tool.slack_add_reaction(emoji="::") + + assert result == {"success": False, "error": "emoji is required"}