From 5bced68e72131be905a7bfb07e9533bd965de459 Mon Sep 17 00:00:00 2001 From: Aran Yogesh Date: Tue, 3 Mar 2026 14:34:29 -0800 Subject: [PATCH] feat: add linear_comment tool, remove auto-comment middleware (#994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add linear_comment tool, remove auto-comment middleware * ⏺ feat: pass triggering user name to linear_comment tool for @mentions * linting * Apply suggestion from @bracesproul Co-authored-by: Brace Sproul * Apply suggestion from @bracesproul Co-authored-by: Brace Sproul * feat: add ticket_id arg to linear_comment tool and include it in agent prompt * Apply suggestions from code review --------- Co-authored-by: Brace Sproul --- apps/agent/agent/middleware/__init__.py | 2 - apps/agent/agent/middleware/open_pr.py | 125 ++---------------- apps/agent/agent/middleware/post_to_linear.py | 117 ---------------- apps/agent/agent/prompt.py | 33 ++++- apps/agent/agent/server.py | 6 +- apps/agent/agent/tools/__init__.py | 3 +- apps/agent/agent/tools/linear_comment.py | 26 ++++ apps/agent/agent/webapp.py | 16 ++- 8 files changed, 80 insertions(+), 248 deletions(-) delete mode 100644 apps/agent/agent/middleware/post_to_linear.py create mode 100644 apps/agent/agent/tools/linear_comment.py diff --git a/apps/agent/agent/middleware/__init__.py b/apps/agent/agent/middleware/__init__.py index 250091d4..5a9f1379 100644 --- a/apps/agent/agent/middleware/__init__.py +++ b/apps/agent/agent/middleware/__init__.py @@ -1,11 +1,9 @@ from .check_message_queue import check_message_queue_before_model from .open_pr import open_pr_if_needed -from .post_to_linear import post_to_linear_after_model from .tool_error_handler import ToolErrorMiddleware __all__ = [ "ToolErrorMiddleware", "check_message_queue_before_model", "open_pr_if_needed", - "post_to_linear_after_model", ] diff --git a/apps/agent/agent/middleware/open_pr.py b/apps/agent/agent/middleware/open_pr.py index 6ea657b9..16fb545a 100644 --- a/apps/agent/agent/middleware/open_pr.py +++ b/apps/agent/agent/middleware/open_pr.py @@ -1,9 +1,8 @@ -"""After-agent middleware that creates a GitHub PR and comments on Linear. +"""After-agent middleware that creates a GitHub PR if needed. -Runs once after the agent finishes. If the agent called the -``commit_and_open_pr`` tool, this middleware commits any remaining changes, -pushes to a feature branch, opens a GitHub PR, and posts a summary comment -back to the originating Linear issue. +Runs once after the agent finishes as a safety net. If the agent called +``commit_and_open_pr`` and it already succeeded, this is a no-op. Otherwise it +commits any remaining changes, pushes to a feature branch, and opens a GitHub PR. """ from __future__ import annotations @@ -31,8 +30,6 @@ from ..utils.github import ( git_has_unpushed_commits, git_push, ) -from ..utils.linear import comment_on_linear_issue -from ..utils.messages import extract_text_content from ..utils.sandbox_state import get_sandbox_backend logger = logging.getLogger(__name__) @@ -63,10 +60,8 @@ async def open_pr_if_needed( state: AgentState, runtime: Runtime, ) -> dict[str, Any] | None: - """Middleware that commits/pushes changes and comments on Linear after agent runs.""" + """Middleware that commits/pushes changes after agent runs if `commit_and_open_pr` tool didn't.""" logger.info("After-agent middleware started") - pr_url = None - pr_number = None try: config = get_config() @@ -74,62 +69,15 @@ async def open_pr_if_needed( thread_id = configurable.get("thread_id") logger.debug("Middleware running for thread %s", thread_id) - last_message_content = "" messages = state.get("messages", []) - if messages: - last_message = messages[-1] - if isinstance(last_message, dict): - last_message_content = extract_text_content(last_message.get("content", "")) - elif hasattr(last_message, "content"): - last_message_content = extract_text_content(last_message.content) - - linear_issue = configurable.get("linear_issue", {}) - linear_issue_id = linear_issue.get("id") - pr_payload = _extract_pr_params_from_messages(messages) if not pr_payload: logger.info("No commit_and_open_pr tool call found, skipping PR creation") - if linear_issue_id and last_message_content: - comment = f""" **Agent Response** - -{last_message_content}""" - await comment_on_linear_issue(linear_issue_id, comment) return None if "success" in pr_payload: - pr_url = pr_payload.get("pr_url") - pr_existing = bool(pr_payload.get("pr_existing", False)) - error = pr_payload.get("error") - if linear_issue_id and last_message_content: - if pr_url: - header = "Pull Request Updated" if pr_existing else "Pull Request Created" - action = "updated the existing" if pr_existing else "created a" - comment = f"""**{header}** - -I've {action} pull request to address this issue: - -{pr_url} - ---- - **Agent Response** - -{last_message_content}""" - elif error: - comment = f"""**Pull Request Error** - -{error} - ---- - -**Agent Response** - -{last_message_content}""" - else: - comment = f""" **Agent Response** - -{last_message_content}""" - await comment_on_linear_issue(linear_issue_id, comment) + # Tool already handled commit/push/PR creation return None pr_title = pr_payload.get("title", "feat: Open SWE PR") @@ -137,11 +85,6 @@ I've {action} pull request to address this issue: commit_message = pr_payload.get("commit_message", pr_title) if not thread_id: - if linear_issue_id and last_message_content: - comment = f"""🤖 **Agent Response** - -{last_message_content}""" - await comment_on_linear_issue(linear_issue_id, comment) raise ValueError("No thread_id found in config") repo_config = configurable.get("repo", {}) @@ -149,15 +92,9 @@ I've {action} pull request to address this issue: repo_name = repo_config.get("name") sandbox_backend = await get_sandbox_backend(thread_id) - repo_dir = f"/workspace/{repo_name}" if not sandbox_backend or not repo_dir: - if linear_issue_id and last_message_content: - comment = f"""🤖 **Agent Response** - -{last_message_content}""" - await comment_on_linear_issue(linear_issue_id, comment) return None has_uncommitted_changes = await asyncio.to_thread( @@ -173,17 +110,11 @@ I've {action} pull request to address this issue: if not has_changes: logger.info("No changes detected, skipping PR creation") - if linear_issue_id and last_message_content: - comment = f"""🤖 **Agent Response** - -{last_message_content}""" - await comment_on_linear_issue(linear_issue_id, comment) return None logger.info("Changes detected, preparing PR for thread %s", thread_id) current_branch = await asyncio.to_thread(git_current_branch, sandbox_backend, repo_dir) - target_branch = f"open-swe/{thread_id}" if current_branch != target_branch: @@ -212,7 +143,7 @@ I've {action} pull request to address this issue: base_branch = await get_github_default_branch(repo_owner, repo_name, github_token) logger.info("Using base branch: %s", base_branch) - pr_url, pr_number, pr_existing = await create_github_pr( + await create_github_pr( repo_owner=repo_owner, repo_name=repo_name, github_token=github_token, @@ -222,48 +153,8 @@ I've {action} pull request to address this issue: body=pr_body, ) - linear_issue = configurable.get("linear_issue", {}) - linear_issue_id = linear_issue.get("id") - - if linear_issue_id and last_message_content: - if pr_url: - header = "Pull Request Updated" if pr_existing else "Pull Request Created" - action = "updated the existing" if pr_existing else "created a" - comment = f"""**{header}** - -I've {action} pull request to address this issue: - -**[PR #{pr_number}: {pr_title}]({pr_url})** - ---- - -🤖 **Agent Response** - -{last_message_content}""" - else: - comment = f""" **Agent Response** - -{last_message_content}""" - await comment_on_linear_issue(linear_issue_id, comment) - logger.info("After-agent middleware completed successfully") - except Exception as e: + except Exception: logger.exception("Error in after-agent middleware") - try: - config = get_config() - configurable = config.get("configurable", {}) - linear_issue = configurable.get("linear_issue", {}) - linear_issue_id = linear_issue.get("id") - if linear_issue_id: - error_comment = f""" **Agent Error** - -An error occurred while processing this issue: - -``` -{type(e).__name__}: {e} -```""" - await comment_on_linear_issue(linear_issue_id, error_comment) - except Exception: - logger.exception("Failed to post error comment to Linear") return None diff --git a/apps/agent/agent/middleware/post_to_linear.py b/apps/agent/agent/middleware/post_to_linear.py deleted file mode 100644 index 760691ed..00000000 --- a/apps/agent/agent/middleware/post_to_linear.py +++ /dev/null @@ -1,117 +0,0 @@ -"""After-model middleware that posts AI responses to Linear. - -Posts the first AI text response back to the originating Linear issue so -stakeholders can see progress without leaving Linear. -""" - -from __future__ import annotations - -import logging -from typing import Any - -from langchain.agents.middleware import after_model -from langgraph.config import get_config -from langgraph.runtime import Runtime - -from ..utils.linear import comment_on_linear_issue -from ..utils.messages import extract_text_content -from .check_message_queue import LinearNotifyState - -logger = logging.getLogger(__name__) - -MIN_MESSAGES_FOR_PREV_CHECK = 2 - - -@after_model(state_schema=LinearNotifyState) -async def post_to_linear_after_model( # noqa: PLR0911, PLR0912 - state: LinearNotifyState, - runtime: Runtime, # noqa: ARG001 -) -> dict[str, Any] | None: - """Middleware that posts AI responses to Linear after each model call. - - Only posts if: - - This is a Linear-triggered conversation (has linear_issue in config) - - There's exactly 1 human message (initial request) - - The previous message was from human (not a tool result) - - The AI response has text content (not just tool calls) - - The message hasn't already been sent (tracked via linear_messages_sent_count) - """ - try: - config = get_config() - configurable = config.get("configurable", {}) - - linear_issue = configurable.get("linear_issue", {}) - linear_issue_id = linear_issue.get("id") - - if not linear_issue_id: - return None - - messages = state.get("messages", []) - if not messages: - return None - - sent_count = state.get("linear_messages_sent_count", 0) - - human_message_count = 0 - for msg in messages: - if isinstance(msg, dict): - role = msg.get("role", "") - else: - role = getattr(msg, "type", "") or getattr(msg, "role", "") - if role in ("human", "user"): - human_message_count += 1 - - if human_message_count != 1: - return None - - last_message = messages[-1] - if isinstance(last_message, dict): - role = last_message.get("role", "") - content = last_message.get("content", "") - else: - role = getattr(last_message, "type", "") or getattr(last_message, "role", "") - content = getattr(last_message, "content", "") - - if role not in ("ai", "assistant"): - return None - - ai_message_count = 0 - for msg in messages: - if isinstance(msg, dict): - r = msg.get("role", "") - else: - r = getattr(msg, "type", "") or getattr(msg, "role", "") - if r in ("ai", "assistant"): - ai_message_count += 1 - - if ai_message_count <= sent_count: - return None - - if len(messages) >= MIN_MESSAGES_FOR_PREV_CHECK: - prev_message = messages[-2] - if isinstance(prev_message, dict): - prev_role = prev_message.get("role", "") - else: - prev_role = getattr(prev_message, "type", "") or getattr(prev_message, "role", "") - - if prev_role not in ("human", "user"): - return None - - content_text = extract_text_content(content) - if not content_text: - return None - - comment = f"""🤖 **Agent Response** - -{content_text}""" - logger.info("Posting AI response to Linear issue %s", linear_issue_id) - success = await comment_on_linear_issue(linear_issue_id, comment) - - if success: - logger.info("Successfully posted to Linear") - return {"linear_messages_sent_count": ai_message_count} - logger.warning("Failed to post to Linear") - - except Exception: - logger.exception("Error in post_to_linear_after_model") - return None diff --git a/apps/agent/agent/prompt.py b/apps/agent/agent/prompt.py index be3bd4db..0fe9c1d6 100644 --- a/apps/agent/agent/prompt.py +++ b/apps/agent/agent/prompt.py @@ -37,12 +37,20 @@ TASK_EXECUTION_SECTION = """--- ### Task Execution -When working on a task, follow this order: +If you make changes, call `linear_comment` to notify the user of the changes. For questions or status updates, call `linear_comment` with your answer. + +For tasks that require code changes, follow this order: 1. **Understand** — Read the issue/task carefully. Explore relevant files before making any changes. 2. **Implement** — Make focused, minimal changes. Do not modify code outside the scope of the task. 3. **Verify** — Run tests and linters to confirm correctness before submitting. -4. **Submit** — Call `commit_and_open_pr` as the final step.""" +4. **Submit** — Call `commit_and_open_pr`. +5. **Comment** — Call `linear_comment` with a summary and the PR link. + +For questions or status checks (no code changes needed): + +1. **Answer** — Gather the information needed to respond. +2. **Comment** — Call `linear_comment` with your answer. Never leave a question unanswered.""" TOOL_USAGE_SECTION = """--- @@ -59,7 +67,10 @@ Fetches a URL and converts HTML to markdown. Use for web pages. Synthesize the c Make HTTP requests (GET, POST, PUT, DELETE, etc.) to APIs. Use this for API calls with custom headers, methods, params, or request bodies — not for fetching web pages. #### `commit_and_open_pr` -Commits all changes, pushes to a branch, and opens a **draft** GitHub PR. If a PR already exists for the branch, it is updated instead of recreated. Always the final step.""" +Commits all changes, pushes to a branch, and opens a **draft** GitHub PR. If a PR already exists for the branch, it is updated instead of recreated. + +#### `linear_comment` +Posts a comment to a Linear ticket given a `ticket_id`. Call this **after** `commit_and_open_pr` to notify stakeholders that the work is done and include the PR link. You can tag Linear users with `@username` (their Linear display name). Example: "I've completed the implementation and opened a PR: . Hey @username, let me know if you have any feedback!".""" TOOL_BEST_PRACTICES_SECTION = """--- @@ -189,7 +200,21 @@ When you have completed your implementation, follow these steps in order: **Commit message**: Concise, focusing on the "why" rather than the "what". If not provided, the PR title is used. -Always call `commit_and_open_pr` as the final step once implementation is complete and code quality checks pass.""" +4. **Comment on the Linear ticket** via `linear_comment` immediately after `commit_and_open_pr` succeeds. Include: + - A brief summary of what was done + - The PR link returned by `commit_and_open_pr` + - An `@mention` of the user who triggered the task by their Linear display name + + Example comment: + ``` + @username, I've completed the implementation and opened a PR: + + Here's a summary of the changes: + - + - + ``` + +Always call `commit_and_open_pr` followed by `linear_comment` once implementation is complete and code quality checks pass.""" SYSTEM_PROMPT = ( diff --git a/apps/agent/agent/server.py b/apps/agent/agent/server.py index b453fe07..78577467 100644 --- a/apps/agent/agent/server.py +++ b/apps/agent/agent/server.py @@ -31,10 +31,9 @@ from .middleware import ( ToolErrorMiddleware, check_message_queue_before_model, open_pr_if_needed, - post_to_linear_after_model, ) from .prompt import construct_system_prompt -from .tools import commit_and_open_pr, fetch_url, http_request +from .tools import commit_and_open_pr, fetch_url, http_request, linear_comment from .utils.model import make_model client = get_client() @@ -368,12 +367,11 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 linear_issue_number=linear_issue_number, agents_md=agents_md, ), - tools=[http_request, fetch_url, commit_and_open_pr], + tools=[http_request, fetch_url, commit_and_open_pr, linear_comment], backend=sandbox_backend, middleware=[ ToolErrorMiddleware(), check_message_queue_before_model, - post_to_linear_after_model, open_pr_if_needed, ], ).with_config(config) diff --git a/apps/agent/agent/tools/__init__.py b/apps/agent/agent/tools/__init__.py index dc2b1f2a..92f20643 100644 --- a/apps/agent/agent/tools/__init__.py +++ b/apps/agent/agent/tools/__init__.py @@ -1,5 +1,6 @@ from .commit_and_open_pr import commit_and_open_pr from .fetch_url import fetch_url from .http_request import http_request +from .linear_comment import linear_comment -__all__ = ["commit_and_open_pr", "fetch_url", "http_request"] +__all__ = ["commit_and_open_pr", "fetch_url", "http_request", "linear_comment"] diff --git a/apps/agent/agent/tools/linear_comment.py b/apps/agent/agent/tools/linear_comment.py new file mode 100644 index 00000000..ef8b7f46 --- /dev/null +++ b/apps/agent/agent/tools/linear_comment.py @@ -0,0 +1,26 @@ +import asyncio +from typing import Any + +from ..utils.linear import comment_on_linear_issue + + +def linear_comment(comment_body: str, ticket_id: str) -> dict[str, Any]: + """Post a comment to a Linear issue. + + Use this tool to communicate progress and completion to stakeholders on Linear. + + **When to use:** + - After calling `commit_and_open_pr`, post a comment on the Linear ticket to let + stakeholders know the task is complete and include the PR link. For example: + "I've completed the implementation and opened a PR: " + - When answering a question or sharing an update (no code changes needed). + + Args: + comment_body: Markdown-formatted comment text to post to the Linear issue. + ticket_id: The Linear issue UUID to post the comment to. + + Returns: + Dictionary with 'success' (bool) key. + """ + success = asyncio.run(comment_on_linear_issue(ticket_id, comment_body)) + return {"success": success} diff --git a/apps/agent/agent/webapp.py b/apps/agent/agent/webapp.py index e4860802..c6d41a58 100644 --- a/apps/agent/agent/webapp.py +++ b/apps/agent/agent/webapp.py @@ -663,13 +663,23 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 triggering_comment_id or "", ) + identifier = full_issue.get("identifier", "") or issue_data.get("identifier", "") + + triggered_by_line = f"## Triggered by: {user_name}\n\n" if user_name else "" + tag_instruction = ( + f"When calling linear_comment, tag @{user_name} if you are asking them a question, need their input, or are notifying them of something important (e.g. a completed PR). For simple answers, tagging is not required." + if user_name + else "" + ) prompt = ( f"Please work on the following issue:\n\n" f"## Title: {title}\n\n" + f"{triggered_by_line}" + f"## Linear Ticket: {identifier} - Ticket ID: {issue_id}\n\n" f"## Description:\n{description}\n" f"{comments_text}\n\n" - "Please analyze this issue and implement the necessary changes. " - "When you're done, commit and push your changes." + f"Please analyze this issue and implement the necessary changes. " + f"When you're done, commit and push your changes. {tag_instruction}" ) content_blocks: list[dict[str, Any]] = [create_text_block(prompt)] if image_urls: @@ -684,7 +694,6 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 content_blocks.append(image_block) logger.info("Built %d content block(s) for prompt", len(content_blocks)) - identifier = full_issue.get("identifier", "") or issue_data.get("identifier", "") linear_project_id = "" linear_issue_number = "" if identifier and "-" in identifier: @@ -701,6 +710,7 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 "identifier": identifier, "linear_project_id": linear_project_id, "linear_issue_number": linear_issue_number, + "triggering_user_name": user_name or "", }, } if github_token: