From bec99b106ea97164ed2dd6bccac993a31ad41955 Mon Sep 17 00:00:00 2001 From: aran-yogesh Date: Thu, 19 Feb 2026 14:06:06 -0800 Subject: [PATCH 01/15] feat: add multimodal image support for Linear comments and queued run --- .../agent/middleware/check_message_queue.py | 58 ++++++++- apps/agent/agent/utils/multimodal.py | 86 +++++++++++++ apps/agent/agent/webapp.py | 115 ++++++++++++++++-- 3 files changed, 242 insertions(+), 17 deletions(-) create mode 100644 apps/agent/agent/utils/multimodal.py diff --git a/apps/agent/agent/middleware/check_message_queue.py b/apps/agent/agent/middleware/check_message_queue.py index b85248fd..89cc49a3 100644 --- a/apps/agent/agent/middleware/check_message_queue.py +++ b/apps/agent/agent/middleware/check_message_queue.py @@ -8,12 +8,16 @@ human messages before the next model call. from __future__ import annotations import logging +import os from typing import Any +import httpx from langchain.agents.middleware import AgentState, before_model from langgraph.config import get_config, get_store from langgraph.runtime import Runtime +from ..utils.multimodal import fetch_image_block + logger = logging.getLogger(__name__) @@ -80,11 +84,55 @@ async def check_message_queue_before_model( # noqa: PLR0911 thread_id, ) - content_blocks = [ - {"type": "text", "text": msg.get("content", "")} - for msg in queued_messages - if msg.get("content") - ] + async def _build_blocks_from_payload( + payload: dict[str, Any], + ) -> list[dict[str, Any]]: + text = payload.get("text", "") + image_urls = payload.get("image_urls", []) or [] + blocks: list[dict[str, Any]] = [] + if text: + blocks.append({"type": "text", "text": text}) + + if not image_urls: + return blocks + linear_api_key = os.environ.get("LINEAR_API_KEY", "") + async with httpx.AsyncClient() as client: + for image_url in image_urls: + headers = None + if "uploads.linear.app" in image_url: + if linear_api_key: + headers = {"Authorization": linear_api_key} + else: + logger.warning( + "LINEAR_API_KEY not set; cannot authenticate image fetch for %s", + image_url, + ) + image_block = await fetch_image_block( + image_url, client, headers=headers + ) + if image_block: + blocks.append(image_block) + return blocks + + content_blocks: list[dict[str, Any]] = [] + for msg in queued_messages: + content = msg.get("content") + if isinstance(content, dict) and ( + "text" in content or "image_urls" in content + ): + logger.debug("Queued message contains text + image URLs") + blocks = await _build_blocks_from_payload(content) + content_blocks.extend(blocks) + continue + if isinstance(content, list): + logger.debug( + "Queued message contains %d content block(s)", len(content) + ) + content_blocks.extend(content) + continue + if isinstance(content, str) and content: + logger.debug("Queued message contains text content") + content_blocks.append({"type": "text", "text": content}) if not content_blocks: return None diff --git a/apps/agent/agent/utils/multimodal.py b/apps/agent/agent/utils/multimodal.py new file mode 100644 index 00000000..a8cb05cb --- /dev/null +++ b/apps/agent/agent/utils/multimodal.py @@ -0,0 +1,86 @@ +"""Utilities for building multimodal content blocks.""" + +from __future__ import annotations + +import base64 +import logging +import mimetypes +import re +from typing import Any + +import httpx +from langchain_core.messages.content import create_image_block + +logger = logging.getLogger(__name__) + +IMAGE_MARKDOWN_RE = re.compile(r"!\[[^\]]*\]\((https?://[^\s)]+)\)") +IMAGE_URL_RE = re.compile( + r"(https?://[^\s)]+\.(?:png|jpe?g|gif|webp|bmp|tiff)(?:\?[^\s)]+)?)", + re.IGNORECASE, +) + + +def extract_image_urls(text: str) -> list[str]: + """Extract image URLs from markdown image syntax and direct image links.""" + if not text: + return [] + + urls: list[str] = [] + urls.extend(IMAGE_MARKDOWN_RE.findall(text)) + urls.extend(IMAGE_URL_RE.findall(text)) + + deduped = _dedupe_urls(urls) + if deduped: + logger.debug("Extracted %d image URL(s)", len(deduped)) + return deduped + + +def strip_image_urls(text: str, image_urls: list[str]) -> str: + """Remove markdown image links and direct image URLs from text.""" + if not text or not image_urls: + return text + + logger.debug("Stripping %d image URL(s) from text", len(image_urls)) + text = IMAGE_MARKDOWN_RE.sub("", text) + for url in image_urls: + text = text.replace(url, "") + return text + + +async def fetch_image_block( + image_url: str, + client: httpx.AsyncClient, + headers: dict[str, str] | None = None, +) -> dict[str, Any] | None: + """Fetch image bytes and build an image content block.""" + try: + logger.debug("Fetching image from %s", image_url) + response = await client.get(image_url, headers=headers) + response.raise_for_status() + content_type = response.headers.get("Content-Type", "").split(";")[0].strip() + if not content_type: + guessed, _ = mimetypes.guess_type(image_url) + content_type = guessed or "application/octet-stream" + + encoded = base64.b64encode(response.content).decode("ascii") + logger.info( + "Fetched image %s (%s, %d bytes)", + image_url, + content_type, + len(response.content), + ) + return create_image_block(base64=encoded, mime_type=content_type) + except Exception: + logger.exception("Failed to fetch image from %s", image_url) + return None + + +def _dedupe_urls(urls: list[str]) -> list[str]: + seen: set[str] = set() + deduped: list[str] = [] + for url in urls: + if url in seen: + continue + seen.add(url) + deduped.append(url) + return deduped diff --git a/apps/agent/agent/webapp.py b/apps/agent/agent/webapp.py index de40a6da..c3b308c5 100644 --- a/apps/agent/agent/webapp.py +++ b/apps/agent/agent/webapp.py @@ -15,6 +15,7 @@ from langgraph_sdk import get_client # Local import for encryption from .encryption import encrypt_token +from .utils.multimodal import extract_image_urls, fetch_image_block, strip_image_urls logger = logging.getLogger(__name__) @@ -36,6 +37,7 @@ LINEAR_API_KEY = os.environ.get("LINEAR_API_KEY", "") X_SERVICE_AUTH_JWT_SECRET = os.environ.get("X_SERVICE_AUTH_JWT_SECRET", "") + def get_service_jwt_token_for_user( user_id: str, tenant_id: str, expiration_seconds: int = 300 ) -> str: @@ -131,6 +133,8 @@ def get_repo_config_from_team_mapping( return {"owner": "langchain-ai", "name": "langchainplus"} + + async def get_ls_user_id_from_email(email: str) -> dict[str, str | None]: """Get the LangSmith user ID and tenant ID from a user's email. @@ -426,7 +430,9 @@ async def is_thread_active(thread_id: str) -> bool: return status == "busy" -async def queue_message_for_thread(thread_id: str, message_content: str) -> bool: +async def queue_message_for_thread( + thread_id: str, message_content: str | list[dict[str, Any]] | dict[str, Any] +) -> bool: """Queue a message for a thread that is currently active. Stores the message in the langgraph store, namespaced to the thread. @@ -435,7 +441,7 @@ async def queue_message_for_thread(thread_id: str, message_content: str) -> bool Args: thread_id: The LangGraph thread ID - message_content: The message content to queue + message_content: The message content to queue (text or content blocks) Returns: True if successfully queued, False otherwise @@ -571,9 +577,20 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 title = full_issue.get("title", "No title") description = full_issue.get("description") or "No description" + image_urls: list[str] = [] + description_image_urls = extract_image_urls(description) + if description_image_urls: + image_urls.extend(description_image_urls) + description = strip_image_urls(description, description_image_urls) + logger.debug( + "Found %d image URL(s) in issue description", + len(description_image_urls), + ) comments = full_issue.get("comments", {}).get("nodes", []) comments_text = "" + triggering_comment = issue_data.get("triggering_comment", "") + triggering_comment_id = issue_data.get("triggering_comment_id", "") bot_message_prefixes = ( "🔐 **GitHub Authentication Required**", @@ -585,32 +602,77 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 "❌ **Agent Error**", ) + comment_ids: set[str] = set() + comment_id_to_index: dict[str, int] = {} if comments: last_bot_comment_idx = -1 for i, comment in enumerate(comments): + comment_id = comment.get("id", "") + if comment_id: + comment_ids.add(comment_id) + comment_id_to_index[comment_id] = i body = comment.get("body", "") if any(body.startswith(prefix) for prefix in bot_message_prefixes): last_bot_comment_idx = i relevant_comments = [] - for i, comment in enumerate(comments): - if i <= last_bot_comment_idx: - continue - body = comment.get("body", "") - if "@openswe" in body.lower(): - relevant_comments.append(comment) - relevant_comments.extend(comments[i + 1 :]) - break + trigger_index = None + if triggering_comment_id: + trigger_index = comment_id_to_index.get(triggering_comment_id) + if trigger_index is not None: + relevant_comments = comments[trigger_index:] + logger.debug( + "Using triggering comment index %d to build relevant comments", + trigger_index, + ) + else: + for i, comment in enumerate(comments): + if i <= last_bot_comment_idx: + continue + body = comment.get("body", "") + if "@openswe" in body.lower(): + relevant_comments.append(comment) + relevant_comments.extend(comments[i + 1 :]) + break if relevant_comments: comments_text = "\n\n## Comments:\n" for comment in relevant_comments: author = comment.get("user", {}).get("name", "Unknown") body = comment.get("body", "") + body_image_urls = extract_image_urls(body) + if body_image_urls: + image_urls.extend(body_image_urls) + body = strip_image_urls(body, body_image_urls) + logger.debug( + "Found %d image URL(s) in comment by %s", + len(body_image_urls), + author, + ) if any(body.startswith(prefix) for prefix in bot_message_prefixes): continue comments_text += f"\n**{author}:** {body}\n" + if triggering_comment and triggering_comment_id not in comment_ids: + if not comments_text: + comments_text = "\n\n## Comments:\n" + trigger_author = comment_author.get("name", "Unknown") + trigger_body = triggering_comment + trigger_image_urls = extract_image_urls(trigger_body) + if trigger_image_urls: + image_urls.extend(trigger_image_urls) + trigger_body = strip_image_urls(trigger_body, trigger_image_urls) + logger.debug( + "Found %d image URL(s) in triggering comment by %s", + len(trigger_image_urls), + trigger_author, + ) + comments_text += f"\n**{trigger_author}:** {trigger_body}\n" + logger.debug( + "Appended triggering comment %s not present in issue comments list", + triggering_comment_id or "", + ) + prompt = ( f"Please work on the following issue:\n\n" f"## Title: {title}\n\n" @@ -619,6 +681,34 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 "Please analyze this issue and implement the necessary changes. " "When you're done, commit and push your changes." ) + content_blocks: list[dict[str, Any]] = [{"type": "text", "text": prompt}] + if image_urls: + seen_urls: set[str] = set() + deduped_urls: list[str] = [] + for url in image_urls: + if url in seen_urls: + continue + seen_urls.add(url) + deduped_urls.append(url) + image_urls = deduped_urls + logger.info("Preparing %d image(s) for multimodal content", len(image_urls)) + logger.debug("Image URLs: %s", image_urls) + + async with httpx.AsyncClient() as client: + for image_url in image_urls: + headers = None + if "uploads.linear.app" in image_url: + if LINEAR_API_KEY: + headers = {"Authorization": LINEAR_API_KEY} + else: + logger.warning( + "LINEAR_API_KEY not set; cannot authenticate image fetch for %s", + image_url, + ) + image_block = await fetch_image_block(image_url, client, headers=headers) + if image_block: + 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 = "" @@ -652,9 +742,10 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 thread_id, ) + queued_payload = {"text": prompt, "image_urls": image_urls} queued = await queue_message_for_thread( thread_id=thread_id, - message_content=prompt, + message_content=queued_payload, ) if queued: @@ -669,7 +760,7 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 await langgraph_client.runs.create( thread_id, "agent", - input={"messages": [{"role": "user", "content": prompt}]}, + input={"messages": [{"role": "user", "content": content_blocks}]}, config={"configurable": configurable}, if_not_exists="create", ) From 446b659c543d3c966bb4dc670f3d322857f4a7cd Mon Sep 17 00:00:00 2001 From: aran-yogesh Date: Sun, 22 Feb 2026 18:57:49 -0800 Subject: [PATCH 02/15] refactor: keep image URLs in text and centralize image auth --- .../agent/middleware/check_message_queue.py | 52 ++++++++----------- apps/agent/agent/utils/multimodal.py | 26 +++++----- apps/agent/agent/webapp.py | 18 ++----- 3 files changed, 40 insertions(+), 56 deletions(-) diff --git a/apps/agent/agent/middleware/check_message_queue.py b/apps/agent/agent/middleware/check_message_queue.py index 89cc49a3..66178f83 100644 --- a/apps/agent/agent/middleware/check_message_queue.py +++ b/apps/agent/agent/middleware/check_message_queue.py @@ -27,6 +27,28 @@ class LinearNotifyState(AgentState): linear_messages_sent_count: int +async def _build_blocks_from_payload( + payload: dict[str, Any], +) -> list[dict[str, Any]]: + text = payload.get("text", "") + image_urls = payload.get("image_urls", []) or [] + blocks: list[dict[str, Any]] = [] + if text: + blocks.append({"type": "text", "text": text}) + + if not image_urls: + return blocks + linear_api_key = os.environ.get("LINEAR_API_KEY", "") + async with httpx.AsyncClient() as client: + for image_url in image_urls: + image_block = await fetch_image_block( + image_url, client, linear_api_key=linear_api_key + ) + if image_block: + blocks.append(image_block) + return blocks + + @before_model(state_schema=LinearNotifyState) async def check_message_queue_before_model( # noqa: PLR0911 state: LinearNotifyState, # noqa: ARG001 @@ -84,36 +106,6 @@ async def check_message_queue_before_model( # noqa: PLR0911 thread_id, ) - async def _build_blocks_from_payload( - payload: dict[str, Any], - ) -> list[dict[str, Any]]: - text = payload.get("text", "") - image_urls = payload.get("image_urls", []) or [] - blocks: list[dict[str, Any]] = [] - if text: - blocks.append({"type": "text", "text": text}) - - if not image_urls: - return blocks - linear_api_key = os.environ.get("LINEAR_API_KEY", "") - async with httpx.AsyncClient() as client: - for image_url in image_urls: - headers = None - if "uploads.linear.app" in image_url: - if linear_api_key: - headers = {"Authorization": linear_api_key} - else: - logger.warning( - "LINEAR_API_KEY not set; cannot authenticate image fetch for %s", - image_url, - ) - image_block = await fetch_image_block( - image_url, client, headers=headers - ) - if image_block: - blocks.append(image_block) - return blocks - content_blocks: list[dict[str, Any]] = [] for msg in queued_messages: content = msg.get("content") diff --git a/apps/agent/agent/utils/multimodal.py b/apps/agent/agent/utils/multimodal.py index a8cb05cb..4443cc78 100644 --- a/apps/agent/agent/utils/multimodal.py +++ b/apps/agent/agent/utils/multimodal.py @@ -5,6 +5,7 @@ from __future__ import annotations import base64 import logging import mimetypes +import os import re from typing import Any @@ -35,26 +36,27 @@ def extract_image_urls(text: str) -> list[str]: return deduped -def strip_image_urls(text: str, image_urls: list[str]) -> str: - """Remove markdown image links and direct image URLs from text.""" - if not text or not image_urls: - return text - - logger.debug("Stripping %d image URL(s) from text", len(image_urls)) - text = IMAGE_MARKDOWN_RE.sub("", text) - for url in image_urls: - text = text.replace(url, "") - return text - async def fetch_image_block( image_url: str, client: httpx.AsyncClient, - headers: dict[str, str] | None = None, + *, + linear_api_key: str | None = None, ) -> dict[str, Any] | None: """Fetch image bytes and build an image content block.""" try: logger.debug("Fetching image from %s", image_url) + headers = None + if "uploads.linear.app" in image_url: + if linear_api_key is None: + linear_api_key = os.environ.get("LINEAR_API_KEY", "") + if linear_api_key: + headers = {"Authorization": linear_api_key} + else: + logger.warning( + "LINEAR_API_KEY not set; cannot authenticate image fetch for %s", + image_url, + ) response = await client.get(image_url, headers=headers) response.raise_for_status() content_type = response.headers.get("Content-Type", "").split(";")[0].strip() diff --git a/apps/agent/agent/webapp.py b/apps/agent/agent/webapp.py index c3b308c5..1b6c0270 100644 --- a/apps/agent/agent/webapp.py +++ b/apps/agent/agent/webapp.py @@ -15,7 +15,7 @@ from langgraph_sdk import get_client # Local import for encryption from .encryption import encrypt_token -from .utils.multimodal import extract_image_urls, fetch_image_block, strip_image_urls +from .utils.multimodal import extract_image_urls, fetch_image_block logger = logging.getLogger(__name__) @@ -581,7 +581,6 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 description_image_urls = extract_image_urls(description) if description_image_urls: image_urls.extend(description_image_urls) - description = strip_image_urls(description, description_image_urls) logger.debug( "Found %d image URL(s) in issue description", len(description_image_urls), @@ -643,7 +642,6 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 body_image_urls = extract_image_urls(body) if body_image_urls: image_urls.extend(body_image_urls) - body = strip_image_urls(body, body_image_urls) logger.debug( "Found %d image URL(s) in comment by %s", len(body_image_urls), @@ -661,7 +659,6 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 trigger_image_urls = extract_image_urls(trigger_body) if trigger_image_urls: image_urls.extend(trigger_image_urls) - trigger_body = strip_image_urls(trigger_body, trigger_image_urls) logger.debug( "Found %d image URL(s) in triggering comment by %s", len(trigger_image_urls), @@ -696,16 +693,9 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 async with httpx.AsyncClient() as client: for image_url in image_urls: - headers = None - if "uploads.linear.app" in image_url: - if LINEAR_API_KEY: - headers = {"Authorization": LINEAR_API_KEY} - else: - logger.warning( - "LINEAR_API_KEY not set; cannot authenticate image fetch for %s", - image_url, - ) - image_block = await fetch_image_block(image_url, client, headers=headers) + image_block = await fetch_image_block( + image_url, client, linear_api_key=LINEAR_API_KEY + ) if image_block: content_blocks.append(image_block) logger.info("Built %d content block(s) for prompt", len(content_blocks)) From 2d5da34b3d3412f35c228a551e0ed6b2720d4327 Mon Sep 17 00:00:00 2001 From: aran-yogesh Date: Sun, 22 Feb 2026 22:01:21 -0800 Subject: [PATCH 03/15] feat: inject AGENTS.md into system prompt --- apps/agent/agent/prompt.py | 12 ++++++++++++ apps/agent/agent/server.py | 9 +++++++-- apps/agent/agent/utils/agents_md.py | 30 +++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 apps/agent/agent/utils/agents_md.py diff --git a/apps/agent/agent/prompt.py b/apps/agent/agent/prompt.py index 0e356ebd..ad94bfbf 100644 --- a/apps/agent/agent/prompt.py +++ b/apps/agent/agent/prompt.py @@ -4,6 +4,7 @@ You are operating in a **remote Linux sandbox** at `{working_dir}`. All code execution and file operations happen in this sandbox environment. +{agents_md_section} **Important:** - Use `{working_dir}` as your working directory for all operations @@ -72,9 +73,20 @@ def construct_system_prompt( working_dir: str, linear_project_id: str = "", linear_issue_number: str = "", + agents_md: str | None = None, ) -> str: + agents_md_section = "" + if agents_md: + agents_md_section = ( + "\nThe following text is pulled from the repository's AGENTS.md file. " + "It may contain specific instructions and guidelines for the agent.\n" + "\n" + f"{agents_md}\n" + "\n" + ) return SYSTEM_PROMPT.format( working_dir=working_dir, linear_project_id=linear_project_id or "", linear_issue_number=linear_issue_number or "", + agents_md_section=agents_md_section, ) diff --git a/apps/agent/agent/server.py b/apps/agent/agent/server.py index c5c97898..37007cc8 100644 --- a/apps/agent/agent/server.py +++ b/apps/agent/agent/server.py @@ -42,6 +42,7 @@ SANDBOX_CREATING = "__creating__" SANDBOX_CREATION_TIMEOUT = 180 SANDBOX_POLL_INTERVAL = 1.0 +from .utils.agents_md import read_agents_md_in_sandbox from .utils.github import ( git_has_uncommitted_changes, is_valid_git_repo, @@ -136,8 +137,10 @@ async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915 logger.exception("Failed to restore clean remote URL") raise - logger.info("Repo updated at %s", repo_dir) - return repo_dir + logger.info("Repo updated at %s", repo_dir) + return repo_dir + + logger.info("Cloning repo %s/%s to %s", owner, repo, repo_dir) try: @@ -327,6 +330,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 linear_issue = config["configurable"].get("linear_issue", {}) linear_project_id = linear_issue.get("linear_project_id", "") linear_issue_number = linear_issue.get("linear_issue_number", "") + agents_md = await read_agents_md_in_sandbox(sandbox_backend, repo_dir) logger.info("Returning agent with sandbox for thread %s", thread_id) return create_deep_agent( @@ -335,6 +339,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 repo_dir, linear_project_id=linear_project_id, linear_issue_number=linear_issue_number, + agents_md=agents_md, ), tools=[http_request, fetch_url, commit_and_open_pr], backend=sandbox_backend, diff --git a/apps/agent/agent/utils/agents_md.py b/apps/agent/agent/utils/agents_md.py new file mode 100644 index 00000000..3d7ef45b --- /dev/null +++ b/apps/agent/agent/utils/agents_md.py @@ -0,0 +1,30 @@ +"""Helpers for reading agent instructions from AGENTS.md.""" + +from __future__ import annotations + +import asyncio +import shlex + +from deepagents.backends.protocol import SandboxBackendProtocol + + +async def read_agents_md_in_sandbox( + sandbox_backend: SandboxBackendProtocol, + repo_dir: str | None, +) -> str | None: + """Read AGENTS.md from the repo root if it exists.""" + if not repo_dir: + return None + + safe_agents_path = shlex.quote(f"{repo_dir}/AGENTS.md") + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + sandbox_backend.execute, + f"test -f {safe_agents_path} && cat {safe_agents_path}", + ) + if result.exit_code != 0: + return None + content = result.output or "" + content = content.strip() + return content or None From 66bd62b69bc873ea4e1f51f8ef936f4de07e02a5 Mon Sep 17 00:00:00 2001 From: aran-yogesh Date: Mon, 23 Feb 2026 13:13:54 -0800 Subject: [PATCH 04/15] feat: add AGENTS.md support and fix repo cloning --- apps/agent/agent/server.py | 8 +++----- apps/agent/agent/utils/agents_md.py | 6 ++++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/agent/agent/server.py b/apps/agent/agent/server.py index 37007cc8..1dad6586 100644 --- a/apps/agent/agent/server.py +++ b/apps/agent/agent/server.py @@ -137,11 +137,10 @@ async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915 logger.exception("Failed to restore clean remote URL") raise - logger.info("Repo updated at %s", repo_dir) - return repo_dir - - + logger.info("Repo updated at %s", repo_dir) + return repo_dir + # If we reach here, repo doesn't exist or is invalid - clone it logger.info("Cloning repo %s/%s to %s", owner, repo, repo_dir) try: result = await loop.run_in_executor( @@ -218,7 +217,6 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 encrypted_token = config["configurable"].get("github_token_encrypted") if encrypted_token: github_token = decrypt_token(encrypted_token) - logger.debug("Decrypted GitHub token") if thread_id is None or not graph_loaded_for_execution(config): logger.info("No thread_id or not for execution, returning agent without sandbox") diff --git a/apps/agent/agent/utils/agents_md.py b/apps/agent/agent/utils/agents_md.py index 3d7ef45b..715de9f9 100644 --- a/apps/agent/agent/utils/agents_md.py +++ b/apps/agent/agent/utils/agents_md.py @@ -3,10 +3,13 @@ from __future__ import annotations import asyncio +import logging import shlex from deepagents.backends.protocol import SandboxBackendProtocol +logger = logging.getLogger(__name__) + async def read_agents_md_in_sandbox( sandbox_backend: SandboxBackendProtocol, @@ -24,7 +27,10 @@ async def read_agents_md_in_sandbox( f"test -f {safe_agents_path} && cat {safe_agents_path}", ) if result.exit_code != 0: + logger.debug("AGENTS.md not found at %s", safe_agents_path) return None content = result.output or "" content = content.strip() + if content: + logger.info("Loaded AGENTS.md from %s", safe_agents_path) return content or None From 69e1eeb36f1f5f0406c3db6224553ad64c9369fb Mon Sep 17 00:00:00 2001 From: aran-yogesh Date: Mon, 23 Feb 2026 14:04:39 -0800 Subject: [PATCH 05/15] test: add unit tests for extract_image_urls function --- apps/agent/tests/test_multimodal.py | 101 ++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 apps/agent/tests/test_multimodal.py diff --git a/apps/agent/tests/test_multimodal.py b/apps/agent/tests/test_multimodal.py new file mode 100644 index 00000000..66cc6129 --- /dev/null +++ b/apps/agent/tests/test_multimodal.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from agent.utils.multimodal import extract_image_urls + + +def test_extract_image_urls_empty() -> None: + assert extract_image_urls("") == [] + + +def test_extract_image_urls_markdown_and_direct_dedupes() -> None: + text = ( + "Here is an image ![alt](https://example.com/a.png) and another " + "![https://example.com/b.JPG?size=large plus a repeat https://example.com/a.png" + ) + + assert extract_image_urls(text) == [ + "https://example.com/a.png", + "https://example.com/b.JPG?size=large", + ] + + +def test_extract_image_urls_ignores_non_images() -> None: + text = "Not images: https://example.com/file.pdf and https://example.com/noext" + + assert extract_image_urls(text) == [] + + +def test_extract_image_urls_markdown_syntax() -> None: + text = "Check out this screenshot: ![Screenshot](https://example.com/screenshot.png)" + + assert extract_image_urls(text) == ["https://example.com/screenshot.png"] + + +def test_extract_image_urls_direct_links() -> None: + text = "Direct link: https://example.com/photo.jpg and another https://example.com/image.gif" + + assert extract_image_urls(text) == [ + "https://example.com/photo.jpg", + "https://example.com/image.gif", + ] + + +def test_extract_image_urls_various_formats() -> None: + text = ( + "Multiple formats: " + "https://example.com/image.png " + "https://example.com/photo.jpeg " + "https://example.com/pic.gif " + "https://example.com/img.webp " + "https://example.com/bitmap.bmp " + "https://example.com/scan.tiff" + ) + + assert extract_image_urls(text) == [ + "https://example.com/image.png", + "https://example.com/photo.jpeg", + "https://example.com/pic.gif", + "https://example.com/img.webp", + "https://example.com/bitmap.bmp", + "https://example.com/scan.tiff", + ] + + +def test_extract_image_urls_with_query_params() -> None: + text = "Image with params: https://cdn.example.com/image.png?width=800&height=600" + + assert extract_image_urls(text) == ["https://cdn.example.com/image.png?width=800&height=600"] + + +def test_extract_image_urls_case_insensitive() -> None: + text = "Mixed case: https://example.com/Image.PNG and https://example.com/photo.JpEg" + + assert extract_image_urls(text) == [ + "https://example.com/Image.PNG", + "https://example.com/photo.JpEg", + ] + + +def test_extract_image_urls_deduplication() -> None: + text = ( + "Same URL twice: https://example.com/image.png " + "and again https://example.com/image.png" + ) + + assert extract_image_urls(text) == ["https://example.com/image.png"] + + +def test_extract_image_urls_mixed_markdown_and_direct() -> None: + text = ( + "Markdown: ![alt text](https://example.com/markdown.png) " + "and direct: https://example.com/direct.jpg " + "and another markdown ![](https://example.com/another.gif)" + ) + + result = extract_image_urls(text) + assert set(result) == { + "https://example.com/markdown.png", + "https://example.com/direct.jpg", + "https://example.com/another.gif", + } + assert len(result) == 3 From b8f5b735e2b61c3c7753ae2a740e41631f10573d Mon Sep 17 00:00:00 2001 From: aran-yogesh Date: Mon, 23 Feb 2026 16:52:15 -0800 Subject: [PATCH 06/15] address review comments on AGENTS.md injection --- apps/agent/agent/prompt.py | 2 +- apps/agent/agent/server.py | 5 ++++- apps/agent/agent/utils/agents_md.py | 2 -- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/agent/agent/prompt.py b/apps/agent/agent/prompt.py index ad94bfbf..85adab26 100644 --- a/apps/agent/agent/prompt.py +++ b/apps/agent/agent/prompt.py @@ -73,7 +73,7 @@ def construct_system_prompt( working_dir: str, linear_project_id: str = "", linear_issue_number: str = "", - agents_md: str | None = None, + agents_md: str = "", ) -> str: agents_md_section = "" if agents_md: diff --git a/apps/agent/agent/server.py b/apps/agent/agent/server.py index 1dad6586..eb297579 100644 --- a/apps/agent/agent/server.py +++ b/apps/agent/agent/server.py @@ -140,7 +140,6 @@ async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915 logger.info("Repo updated at %s", repo_dir) return repo_dir - # If we reach here, repo doesn't exist or is invalid - clone it logger.info("Cloning repo %s/%s to %s", owner, repo, repo_dir) try: result = await loop.run_in_executor( @@ -325,6 +324,10 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 SANDBOX_BACKENDS[thread_id] = sandbox_backend + if not repo_dir: + msg = "repo_dir is not defined; cannot operate without a cloned repository" + raise RuntimeError(msg) + linear_issue = config["configurable"].get("linear_issue", {}) linear_project_id = linear_issue.get("linear_project_id", "") linear_issue_number = linear_issue.get("linear_issue_number", "") diff --git a/apps/agent/agent/utils/agents_md.py b/apps/agent/agent/utils/agents_md.py index 715de9f9..6afd63e7 100644 --- a/apps/agent/agent/utils/agents_md.py +++ b/apps/agent/agent/utils/agents_md.py @@ -31,6 +31,4 @@ async def read_agents_md_in_sandbox( return None content = result.output or "" content = content.strip() - if content: - logger.info("Loaded AGENTS.md from %s", safe_agents_path) return content or None From 31adfd0bd45385bb1074c6ec4379d5157a8e5573 Mon Sep 17 00:00:00 2001 From: aran-yogesh Date: Mon, 23 Feb 2026 17:16:30 -0800 Subject: [PATCH 07/15] update error message --- apps/agent/agent/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/agent/agent/server.py b/apps/agent/agent/server.py index eb297579..ea0bca67 100644 --- a/apps/agent/agent/server.py +++ b/apps/agent/agent/server.py @@ -325,7 +325,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 SANDBOX_BACKENDS[thread_id] = sandbox_backend if not repo_dir: - msg = "repo_dir is not defined; cannot operate without a cloned repository" + msg = "Cannot proceed: no repo was cloned. Set 'repo.owner' and 'repo.name' in the configurable config" raise RuntimeError(msg) linear_issue = config["configurable"].get("linear_issue", {}) From e7ed8601142152e62dd5db96c723da05306bec42 Mon Sep 17 00:00:00 2001 From: Aran Yogesh Date: Mon, 23 Feb 2026 19:09:38 -0800 Subject: [PATCH 08/15] feat: enforce timeouts on execute tool commands (#955) * feat: enforce timeouts on execute tool commands * refactor: dedupe execute-timeout middleware logic * cr * move info to debug --------- Co-authored-by: bracesproul --- apps/agent/agent/middleware/__init__.py | 2 + .../agent/middleware/timeout_execute_tool.py | 83 +++++++++++++++++++ apps/agent/agent/prompt.py | 2 + apps/agent/agent/server.py | 2 + 4 files changed, 89 insertions(+) create mode 100644 apps/agent/agent/middleware/timeout_execute_tool.py diff --git a/apps/agent/agent/middleware/__init__.py b/apps/agent/agent/middleware/__init__.py index 250091d4..3e8253ac 100644 --- a/apps/agent/agent/middleware/__init__.py +++ b/apps/agent/agent/middleware/__init__.py @@ -1,9 +1,11 @@ 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 .timeout_execute_tool import TimeoutExecuteToolMiddleware from .tool_error_handler import ToolErrorMiddleware __all__ = [ + "TimeoutExecuteToolMiddleware", "ToolErrorMiddleware", "check_message_queue_before_model", "open_pr_if_needed", diff --git a/apps/agent/agent/middleware/timeout_execute_tool.py b/apps/agent/agent/middleware/timeout_execute_tool.py new file mode 100644 index 00000000..b51f597a --- /dev/null +++ b/apps/agent/agent/middleware/timeout_execute_tool.py @@ -0,0 +1,83 @@ +"""Tool middleware that wraps execute commands with a timeout.""" + +from __future__ import annotations + +import logging +import re +import shlex +from collections.abc import Awaitable, Callable + +from langchain.agents.middleware.types import AgentMiddleware, AgentState +from langchain_core.messages import ToolMessage +from langgraph.prebuilt.tool_node import ToolCallRequest +from langgraph.types import Command + +logger = logging.getLogger(__name__) + +DEFAULT_TIMEOUT_SECONDS = 300 +TIMEOUT_REGEX = re.compile(r"\btimeout\s+\d+(?:\.\d+)?\s*[smhd]?\b", re.IGNORECASE) + + +def _get_tool_name(request: ToolCallRequest) -> str | None: + tool_call = request.tool_call + if isinstance(tool_call, dict): + return tool_call.get("name") + return None + + +def _get_command_arg(request: ToolCallRequest) -> str | None: + tool_call = request.tool_call + if not isinstance(tool_call, dict): + return None + args = tool_call.get("args") + if not isinstance(args, dict): + return None + command = args.get("command") + return command if isinstance(command, str) else None + + +def _wrap_command(command: str) -> str: + if TIMEOUT_REGEX.search(command): + return command + quoted = shlex.quote(command) + return f"timeout {DEFAULT_TIMEOUT_SECONDS}s sh -c {quoted}" + + +def _overwrite_request_if_needed(request: ToolCallRequest) -> ToolCallRequest: + if _get_tool_name(request) != "execute": + return request + + command = _get_command_arg(request) + if not command: + return request + + wrapped = _wrap_command(command) + if wrapped == command: + return request + + tool_call = dict(request.tool_call) + args = dict(tool_call.get("args", {})) + args["command"] = wrapped + tool_call["args"] = args + logger.debug("Wrapped execute command with timeout") + return request.override(tool_call=tool_call) + + +class TimeoutExecuteToolMiddleware(AgentMiddleware): + """Ensure execute tool calls are wrapped with a timeout.""" + + state_schema = AgentState + + def wrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + return handler(_overwrite_request_if_needed(request)) + + async def awrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]], + ) -> ToolMessage | Command: + return await handler(_overwrite_request_if_needed(request)) diff --git a/apps/agent/agent/prompt.py b/apps/agent/agent/prompt.py index 85adab26..7f80740e 100644 --- a/apps/agent/agent/prompt.py +++ b/apps/agent/agent/prompt.py @@ -8,6 +8,8 @@ All code execution and file operations happen in this sandbox environment. **Important:** - Use `{working_dir}` as your working directory for all operations +- The `execute` tool enforces a 5-minute timeout by default (`timeout 300s`) +- If a command times out and needs longer, rerun it by explicitly appending `timeout Ns` --- diff --git a/apps/agent/agent/server.py b/apps/agent/agent/server.py index ea0bca67..ebb4a891 100644 --- a/apps/agent/agent/server.py +++ b/apps/agent/agent/server.py @@ -32,6 +32,7 @@ from .middleware import ( check_message_queue_before_model, open_pr_if_needed, post_to_linear_after_model, + TimeoutExecuteToolMiddleware, ) from .prompt import construct_system_prompt from .tools import commit_and_open_pr, fetch_url, http_request @@ -345,6 +346,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 tools=[http_request, fetch_url, commit_and_open_pr], backend=sandbox_backend, middleware=[ + TimeoutExecuteToolMiddleware(), ToolErrorMiddleware(), check_message_queue_before_model, post_to_linear_after_model, From 4a851a05ea37e9c13fe7831c96fdb2463fd5aacf Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Mon, 23 Feb 2026 19:11:41 -0800 Subject: [PATCH 09/15] ci: Add CI actions (#972) * ci: Add CI actions * cr * cr * cr --- .github/workflows/agent-ci.yml | 58 ++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 10 ++++++ .github/workflows/unit-tests.yml | 10 ++++++ apps/agent/Makefile | 17 ++++++++-- apps/agent/agent/server.py | 2 +- 5 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/agent-ci.yml diff --git a/.github/workflows/agent-ci.yml b/.github/workflows/agent-ci.yml new file mode 100644 index 00000000..21e9edd4 --- /dev/null +++ b/.github/workflows/agent-ci.yml @@ -0,0 +1,58 @@ +name: Agent CI + +on: + push: + branches: ["main"] + paths: + - "apps/agent/**" + pull_request: + paths: + - "apps/agent/**" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Agent lint + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/agent + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + - name: Install dependencies + run: uv sync --locked --extra dev + - name: Run lint + run: make lint + + format: + name: Agent format check + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/agent + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + - name: Install dependencies + run: uv sync --locked --extra dev + - name: Run format check + run: make format-check + + unit-tests: + name: Agent unit tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/agent + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + - name: Install dependencies + run: uv sync --locked --extra dev + - name: Run unit tests + run: make test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ea1b54b..27bc2309 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,17 @@ name: CI on: push: branches: ["main"] + paths: + - "apps/web/**" + - "packages/shared/**" + - "*" + - ".*" pull_request: + paths: + - "apps/web/**" + - "packages/shared/**" + - "*" + - ".*" workflow_dispatch: # Allows triggering the workflow manually in GitHub UI # If another push to the same PR or branch happens while this workflow is still running, diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index d8bff89a..5cd56537 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -6,7 +6,17 @@ permissions: on: push: branches: ["main"] + paths: + - "apps/web/**" + - "packages/shared/**" + - "*" + - ".*" pull_request: + paths: + - "apps/web/**" + - "packages/shared/**" + - "*" + - ".*" workflow_dispatch: # Allows triggering the workflow manually in GitHub UI # If another push to the same PR or branch happens while this workflow is still running, diff --git a/apps/agent/Makefile b/apps/agent/Makefile index 788e203f..666594e1 100644 --- a/apps/agent/Makefile +++ b/apps/agent/Makefile @@ -1,4 +1,4 @@ -.PHONY: all format lint test tests integration_tests help run dev +.PHONY: all format format-check lint test tests integration_tests help run dev # Default target executed when no arguments are given to make. all: help @@ -23,10 +23,18 @@ install: TEST_FILE ?= tests/ test tests: - uv run pytest -vvv $(TEST_FILE) + @if [ -d "$(TEST_FILE)" ] || [ -f "$(TEST_FILE)" ]; then \ + uv run pytest -vvv $(TEST_FILE); \ + else \ + echo "Skipping tests: path not found: $(TEST_FILE)"; \ + fi integration_tests: - uv run pytest -vvv tests/integration_tests/ + @if [ -d "tests/integration_tests/" ] || [ -f "tests/integration_tests/" ]; then \ + uv run pytest -vvv tests/integration_tests/; \ + else \ + echo "Skipping integration tests: path not found: tests/integration_tests/"; \ + fi ###################### # LINTING AND FORMATTING @@ -42,6 +50,9 @@ format: uv run ruff format $(PYTHON_FILES) uv run ruff check --fix $(PYTHON_FILES) +format-check: + uv run ruff format $(PYTHON_FILES) --check + ###################### # HELP ###################### diff --git a/apps/agent/agent/server.py b/apps/agent/agent/server.py index ebb4a891..56d726af 100644 --- a/apps/agent/agent/server.py +++ b/apps/agent/agent/server.py @@ -28,11 +28,11 @@ from langchain_openai import ChatOpenAI from .encryption import decrypt_token from .integrations.langsmith import _create_langsmith_sandbox from .middleware import ( + TimeoutExecuteToolMiddleware, ToolErrorMiddleware, check_message_queue_before_model, open_pr_if_needed, post_to_linear_after_model, - TimeoutExecuteToolMiddleware, ) from .prompt import construct_system_prompt from .tools import commit_and_open_pr, fetch_url, http_request From ad4452032fc5da868a654054a9026f9b8d88d1c2 Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Tue, 24 Feb 2026 07:52:47 -0800 Subject: [PATCH 10/15] fix: Security fixes for ci (#974) --- .github/workflows/agent-ci.yml | 3 +++ .github/workflows/ci.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/agent-ci.yml b/.github/workflows/agent-ci.yml index 21e9edd4..948fa8a1 100644 --- a/.github/workflows/agent-ci.yml +++ b/.github/workflows/agent-ci.yml @@ -1,5 +1,8 @@ name: Agent CI +permissions: + contents: read + on: push: branches: ["main"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27bc2309..3ff4de76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,9 @@ name: CI +permissions: + contents: read + on: push: branches: ["main"] From ff7c52e6494c7ffec8b116ef958c58e9687b97af Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Tue, 24 Feb 2026 07:56:31 -0800 Subject: [PATCH 11/15] chore: Open PRs as draft by default (#975) --- apps/agent/agent/utils/github.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/agent/agent/utils/github.py b/apps/agent/agent/utils/github.py index b09c6325..3fe2dd37 100644 --- a/apps/agent/agent/utils/github.py +++ b/apps/agent/agent/utils/github.py @@ -134,7 +134,7 @@ async def create_github_pr( base_branch: str, body: str, ) -> tuple[str | None, int | None, bool]: - """Create a GitHub pull request via the API. + """Create a draft GitHub pull request via the API. Args: repo_owner: Repository owner (e.g., "langchain-ai") @@ -153,6 +153,7 @@ async def create_github_pr( "head": head_branch, "base": base_branch, "body": body, + "draft": True, } logger.info( From 7972ebf48108608f24514fc540960757f1a97361 Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Tue, 24 Feb 2026 08:14:31 -0800 Subject: [PATCH 12/15] refactor: Replace timeout wrapper with tool call arg (#976) --- apps/agent/agent/integrations/langsmith.py | 21 +++-- apps/agent/agent/middleware/__init__.py | 2 - .../agent/middleware/timeout_execute_tool.py | 83 ------------------- apps/agent/agent/prompt.py | 4 +- apps/agent/agent/server.py | 10 +-- apps/agent/agent/utils/sandbox_state.py | 4 +- apps/agent/pyproject.toml | 10 +-- apps/agent/uv.lock | 20 ++--- 8 files changed, 35 insertions(+), 119 deletions(-) delete mode 100644 apps/agent/agent/middleware/timeout_execute_tool.py diff --git a/apps/agent/agent/integrations/langsmith.py b/apps/agent/agent/integrations/langsmith.py index b1679f51..2c7c63d7 100644 --- a/apps/agent/agent/integrations/langsmith.py +++ b/apps/agent/agent/integrations/langsmith.py @@ -43,7 +43,7 @@ def _get_sandbox_template_config() -> tuple[str | None, str | None]: return template_name, template_image -def _create_langsmith_sandbox( +def create_langsmith_sandbox( sandbox_id: str | None = None, ) -> SandboxBackendProtocol: """Create or connect to a LangSmith sandbox without automatic cleanup. @@ -108,17 +108,28 @@ class LangSmithBackend(BaseSandbox): def __init__(self, sandbox: Sandbox) -> None: self._sandbox = sandbox - self._timeout: int = 30 * 60 # 30 mins default + self._default_timeout: int = 30 * 5 # 5 minute default @property def id(self) -> str: """Unique identifier for the sandbox backend.""" return self._sandbox.name - def execute(self, command: str) -> ExecuteResponse: - """Execute a command in the sandbox and return ExecuteResponse.""" - result = self._sandbox.run(command, timeout=self._timeout) + def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse: + """Execute a command in the sandbox and return ExecuteResponse. + Args: + command: Full shell command string to execute. + timeout: Maximum time in seconds to wait for the command to complete. + If None, uses the default timeout of 5 minutes. + + Returns: + ExecuteResponse with combined output, exit code, and truncation flag. + """ + effective_timeout = timeout if timeout is not None else self._default_timeout + result = self._sandbox.run(command, timeout=effective_timeout) + + # Combine stdout and stderr (matching other backends' approach) output = result.stdout or "" if result.stderr: output += "\n" + result.stderr if output else result.stderr diff --git a/apps/agent/agent/middleware/__init__.py b/apps/agent/agent/middleware/__init__.py index 3e8253ac..250091d4 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 .timeout_execute_tool import TimeoutExecuteToolMiddleware from .tool_error_handler import ToolErrorMiddleware __all__ = [ - "TimeoutExecuteToolMiddleware", "ToolErrorMiddleware", "check_message_queue_before_model", "open_pr_if_needed", diff --git a/apps/agent/agent/middleware/timeout_execute_tool.py b/apps/agent/agent/middleware/timeout_execute_tool.py deleted file mode 100644 index b51f597a..00000000 --- a/apps/agent/agent/middleware/timeout_execute_tool.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Tool middleware that wraps execute commands with a timeout.""" - -from __future__ import annotations - -import logging -import re -import shlex -from collections.abc import Awaitable, Callable - -from langchain.agents.middleware.types import AgentMiddleware, AgentState -from langchain_core.messages import ToolMessage -from langgraph.prebuilt.tool_node import ToolCallRequest -from langgraph.types import Command - -logger = logging.getLogger(__name__) - -DEFAULT_TIMEOUT_SECONDS = 300 -TIMEOUT_REGEX = re.compile(r"\btimeout\s+\d+(?:\.\d+)?\s*[smhd]?\b", re.IGNORECASE) - - -def _get_tool_name(request: ToolCallRequest) -> str | None: - tool_call = request.tool_call - if isinstance(tool_call, dict): - return tool_call.get("name") - return None - - -def _get_command_arg(request: ToolCallRequest) -> str | None: - tool_call = request.tool_call - if not isinstance(tool_call, dict): - return None - args = tool_call.get("args") - if not isinstance(args, dict): - return None - command = args.get("command") - return command if isinstance(command, str) else None - - -def _wrap_command(command: str) -> str: - if TIMEOUT_REGEX.search(command): - return command - quoted = shlex.quote(command) - return f"timeout {DEFAULT_TIMEOUT_SECONDS}s sh -c {quoted}" - - -def _overwrite_request_if_needed(request: ToolCallRequest) -> ToolCallRequest: - if _get_tool_name(request) != "execute": - return request - - command = _get_command_arg(request) - if not command: - return request - - wrapped = _wrap_command(command) - if wrapped == command: - return request - - tool_call = dict(request.tool_call) - args = dict(tool_call.get("args", {})) - args["command"] = wrapped - tool_call["args"] = args - logger.debug("Wrapped execute command with timeout") - return request.override(tool_call=tool_call) - - -class TimeoutExecuteToolMiddleware(AgentMiddleware): - """Ensure execute tool calls are wrapped with a timeout.""" - - state_schema = AgentState - - def wrap_tool_call( - self, - request: ToolCallRequest, - handler: Callable[[ToolCallRequest], ToolMessage | Command], - ) -> ToolMessage | Command: - return handler(_overwrite_request_if_needed(request)) - - async def awrap_tool_call( - self, - request: ToolCallRequest, - handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]], - ) -> ToolMessage | Command: - return await handler(_overwrite_request_if_needed(request)) diff --git a/apps/agent/agent/prompt.py b/apps/agent/agent/prompt.py index 7f80740e..3208ac05 100644 --- a/apps/agent/agent/prompt.py +++ b/apps/agent/agent/prompt.py @@ -8,8 +8,8 @@ All code execution and file operations happen in this sandbox environment. **Important:** - Use `{working_dir}` as your working directory for all operations -- The `execute` tool enforces a 5-minute timeout by default (`timeout 300s`) -- If a command times out and needs longer, rerun it by explicitly appending `timeout Ns` +- The `execute` tool enforces a 5-minute timeout by default +- If a command times out and needs longer, rerun it explicitly passing the `timeout` argument to the `execute` tool with a higher value in seconds. --- diff --git a/apps/agent/agent/server.py b/apps/agent/agent/server.py index 56d726af..337ce9f4 100644 --- a/apps/agent/agent/server.py +++ b/apps/agent/agent/server.py @@ -26,9 +26,8 @@ from deepagents.backends.protocol import SandboxBackendProtocol from langchain_openai import ChatOpenAI from .encryption import decrypt_token -from .integrations.langsmith import _create_langsmith_sandbox +from .integrations.langsmith import create_langsmith_sandbox from .middleware import ( - TimeoutExecuteToolMiddleware, ToolErrorMiddleware, check_message_queue_before_model, open_pr_if_needed, @@ -253,7 +252,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 try: # Create sandbox without context manager cleanup (sandbox persists) - sandbox_backend = await asyncio.to_thread(_create_langsmith_sandbox) + sandbox_backend = await asyncio.to_thread(create_langsmith_sandbox) logger.info("Sandbox created: %s", sandbox_backend.id) # Update metadata immediately after sandbox creation so other callers @@ -287,7 +286,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 logger.info("Connecting to existing sandbox %s", sandbox_id) try: # Connect to existing sandbox without context manager cleanup - sandbox_backend = await asyncio.to_thread(_create_langsmith_sandbox, sandbox_id) + sandbox_backend = await asyncio.to_thread(create_langsmith_sandbox, sandbox_id) logger.info("Connected to existing sandbox %s", sandbox_id) except Exception: logger.warning("Failed to connect to existing sandbox %s, creating new one", sandbox_id) @@ -298,7 +297,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 ) try: - sandbox_backend = await asyncio.to_thread(_create_langsmith_sandbox) + sandbox_backend = await asyncio.to_thread(create_langsmith_sandbox) logger.info("New sandbox created: %s", sandbox_backend.id) await client.threads.update( @@ -346,7 +345,6 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 tools=[http_request, fetch_url, commit_and_open_pr], backend=sandbox_backend, middleware=[ - TimeoutExecuteToolMiddleware(), ToolErrorMiddleware(), check_message_queue_before_model, post_to_linear_after_model, diff --git a/apps/agent/agent/utils/sandbox_state.py b/apps/agent/agent/utils/sandbox_state.py index 98f97ff0..08aa35d7 100644 --- a/apps/agent/agent/utils/sandbox_state.py +++ b/apps/agent/agent/utils/sandbox_state.py @@ -8,7 +8,7 @@ from typing import Any from langgraph.config import get_config -from ..integrations.langsmith import _create_langsmith_sandbox +from ..integrations.langsmith import create_langsmith_sandbox logger = logging.getLogger(__name__) @@ -36,7 +36,7 @@ async def get_sandbox_backend(thread_id: str) -> Any | None: if not sandbox_id: raise ValueError(f"Missing sandbox_id in thread metadata for {thread_id}") - sandbox_backend = await asyncio.to_thread(_create_langsmith_sandbox, sandbox_id) + sandbox_backend = await asyncio.to_thread(create_langsmith_sandbox, sandbox_id) SANDBOX_BACKENDS[thread_id] = sandbox_backend return sandbox_backend diff --git a/apps/agent/pyproject.toml b/apps/agent/pyproject.toml index 5e103584..9b2e9b9b 100644 --- a/apps/agent/pyproject.toml +++ b/apps/agent/pyproject.toml @@ -6,26 +6,18 @@ readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } dependencies = [ - # Core deepagents library - "deepagents>=0.4.0", - # FastAPI for webhook handling + "deepagents>=0.4.3", "fastapi>=0.104.0", "uvicorn>=0.24.0", - # HTTP client "httpx>=0.25.0", - # JWT for service authentication "PyJWT>=2.8.0", - # Encryption "cryptography>=41.0.0", - # LangGraph SDK for thread management "langgraph-sdk>=0.1.0", - # LangChain dependencies (will be pulled in by deepagents but listing for clarity) "langchain>=1.2.9", "langgraph>=1.0.8", "markdownify>=1.2.2", "langchain-anthropic>1.1.0", "langgraph-cli[inmem]>=0.4.12", - # LangSmith SDK for sandbox management "langsmith>=0.7.1", "langchain-openai==1.1.10", ] diff --git a/apps/agent/uv.lock b/apps/agent/uv.lock index edf2784e..7c1428a4 100644 --- a/apps/agent/uv.lock +++ b/apps/agent/uv.lock @@ -345,7 +345,7 @@ wheels = [ [[package]] name = "deepagents" -version = "0.4.0" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain" }, @@ -354,9 +354,9 @@ dependencies = [ { name = "langchain-google-genai" }, { name = "wcmatch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b2/ac/895c5efe77ee64f38af64146509b97220867fc149a9a376b2c74f266fd45/deepagents-0.4.0.tar.gz", hash = "sha256:ccfbb2394d2c50a3cf6f61457c5c1d3868354beee2177a57b8497c72c38f8e9b", size = 77614 } +sdist = { url = "https://files.pythonhosted.org/packages/b4/30/5bba09d1c196a9e6e2e3a3406cd131bdf01e84ec67c4b6233f68a903978f/deepagents-0.4.3.tar.gz", hash = "sha256:88033c616c5ea481f2620dbb2d05533bc8fdcd48f376d713f9dba49a8157b6f8", size = 83210 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/c8/cbedac42e011889f151047cf22dbecef9a38330c2e90e517c2bd1b1e636d/deepagents-0.4.0-py3-none-any.whl", hash = "sha256:475af99429c7b6abe4c53d476d6b48f01960dc1bcaec7fa21093189acdfc2864", size = 87831 }, + { url = "https://files.pythonhosted.org/packages/58/f8/c076a841b68cc13d89c395cc97965b37751ed008691a304119efa0f5717e/deepagents-0.4.3-py3-none-any.whl", hash = "sha256:298d19c5c0b4c6fc6a74b68049a7bfea0ba481aece7201ab21e7172b71ee61b9", size = 94882 }, ] [[package]] @@ -787,21 +787,21 @@ wheels = [ [[package]] name = "langchain-anthropic" -version = "1.3.2" +version = "1.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic" }, { name = "langchain-core" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/dd/c5e094079bdd748ca3f0bd0a09189ed2fa46bba56b5a8351198dc7c19e1f/langchain_anthropic-1.3.2.tar.gz", hash = "sha256:e551726a6ebf20229bde06022b5149d33bd48d28e34bd002a744953667b8ad48", size = 686239 } +sdist = { url = "https://files.pythonhosted.org/packages/30/4e/7c1ffac126f5e62b0b9066f331f91ae69361e73476fd3ca1b19f8d8a3cc3/langchain_anthropic-1.3.4.tar.gz", hash = "sha256:000ed4c2d6fb8842b4ffeed22a74a3e84f9e9bcb63638e4abbb4a1d8ffa07211", size = 671858 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/6b/2da16c32308f79bb4588cec7095edbc770722ae4b3c3a1c135e05b0bdc2e/langchain_anthropic-1.3.2-py3-none-any.whl", hash = "sha256:35bc30862696a493680b898eb76bd6c866841f8e48a57d5eca1420a4fd807ac0", size = 46751 }, + { url = "https://files.pythonhosted.org/packages/9b/cf/b7c7b7270efbb3db2edbf14b09ba9110a41628f3a85a11cae9527a35641c/langchain_anthropic-1.3.4-py3-none-any.whl", hash = "sha256:cd112dcc8049aef09f58b3c4338b2c9db5ee98105e08664954a4e40d8bf120b9", size = 47454 }, ] [[package]] name = "langchain-core" -version = "1.2.14" +version = "1.2.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -813,9 +813,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/ff/c5e3da8eca8a18719b300ef6c29e28208ee4e9da7f9749022b96292b6541/langchain_core-1.2.14.tar.gz", hash = "sha256:09549d838a2672781da3a9502f3b9c300863284b77b27e2a6dac4e6e650acfed", size = 833399 } +sdist = { url = "https://files.pythonhosted.org/packages/cc/db/693d81b6c229aceb7c6e6809939c6ab1b023554227a25de438f00c0389c6/langchain_core-1.2.15.tar.gz", hash = "sha256:7d5f5d2daa8ddbe4054a96101dc5d509926f831b9914808c24640987d499758c", size = 835280 } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/41/fe6ae9065b866b1397adbfc98db5e1648e8dcd78126b8e1266fcbe2d6395/langchain_core-1.2.14-py3-none-any.whl", hash = "sha256:b349ca28c057ac1f9b5280ea091bddb057db24d0f1c3c89bbb590713e1715838", size = 501411 }, + { url = "https://files.pythonhosted.org/packages/48/e0/a6a83dde94400b43d9b091ecbb41a50d6f86c4fecacb81b13d8452a7712b/langchain_core-1.2.15-py3-none-any.whl", hash = "sha256:8d920d8a31d8c223966a3993d8c79fd6093b9665f2222fc878812f3a52072ab7", size = 502213 }, ] [[package]] @@ -1050,7 +1050,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "cryptography", specifier = ">=41.0.0" }, - { name = "deepagents", specifier = ">=0.4.0" }, + { name = "deepagents", specifier = ">=0.4.3" }, { name = "fastapi", specifier = ">=0.104.0" }, { name = "httpx", specifier = ">=0.25.0" }, { name = "langchain", specifier = ">=1.2.9" }, From fb23ef6c159ccbcd83749e7ae3cbe0e65844a81a Mon Sep 17 00:00:00 2001 From: Aran Yogesh Date: Tue, 24 Feb 2026 11:21:32 -0800 Subject: [PATCH 13/15] Apply suggestion from @bracesproul Co-authored-by: Brace Sproul --- apps/agent/agent/webapp.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/agent/agent/webapp.py b/apps/agent/agent/webapp.py index 1b6c0270..84c8eee4 100644 --- a/apps/agent/agent/webapp.py +++ b/apps/agent/agent/webapp.py @@ -133,8 +133,6 @@ def get_repo_config_from_team_mapping( return {"owner": "langchain-ai", "name": "langchainplus"} - - async def get_ls_user_id_from_email(email: str) -> dict[str, str | None]: """Get the LangSmith user ID and tenant ID from a user's email. From f82408cd612bc1cba21fe53599cb6cf872faf4ba Mon Sep 17 00:00:00 2001 From: aran-yogesh Date: Tue, 24 Feb 2026 12:11:24 -0800 Subject: [PATCH 14/15] refactor: use content block helpers and shared url dedupe; run format and lint --- apps/agent/agent/integrations/langsmith.py | 12 ++------ .../agent/middleware/check_message_queue.py | 14 ++------- apps/agent/agent/middleware/open_pr.py | 8 ++--- apps/agent/agent/server.py | 10 ++----- apps/agent/agent/tools/commit_and_open_pr.py | 4 +-- apps/agent/agent/utils/github.py | 24 ++++----------- apps/agent/agent/utils/multimodal.py | 28 ++++++++---------- apps/agent/agent/webapp.py | 29 +++++++------------ apps/agent/tests/test_multimodal.py | 5 +--- 9 files changed, 41 insertions(+), 93 deletions(-) diff --git a/apps/agent/agent/integrations/langsmith.py b/apps/agent/agent/integrations/langsmith.py index 3bf23afd..b1679f51 100644 --- a/apps/agent/agent/integrations/langsmith.py +++ b/apps/agent/agent/integrations/langsmith.py @@ -9,7 +9,7 @@ import contextlib import os import time from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any +from typing import Any from deepagents.backends.protocol import ( ExecuteResponse, @@ -19,7 +19,6 @@ from deepagents.backends.protocol import ( WriteResult, ) from deepagents.backends.sandbox import BaseSandbox - from langsmith.sandbox import Sandbox, SandboxClient, SandboxTemplate @@ -148,9 +147,7 @@ class LangSmithBackend(BaseSandbox): responses: list[FileDownloadResponse] = [] for path in paths: content = self._sandbox.read(path) - responses.append( - FileDownloadResponse(path=path, content=content, error=None) - ) + responses.append(FileDownloadResponse(path=path, content=content, error=None)) return responses def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: @@ -209,10 +206,7 @@ class LangSmithProvider(SandboxProvider): template_name=resolved_template_name, timeout=timeout ) except Exception as e: - msg = ( - f"Failed to create sandbox from template " - f"'{resolved_template_name}': {e}" - ) + msg = f"Failed to create sandbox from template '{resolved_template_name}': {e}" raise RuntimeError(msg) from e # Verify sandbox is ready by polling diff --git a/apps/agent/agent/middleware/check_message_queue.py b/apps/agent/agent/middleware/check_message_queue.py index 66178f83..a8825761 100644 --- a/apps/agent/agent/middleware/check_message_queue.py +++ b/apps/agent/agent/middleware/check_message_queue.py @@ -8,7 +8,6 @@ human messages before the next model call. from __future__ import annotations import logging -import os from typing import Any import httpx @@ -38,12 +37,9 @@ async def _build_blocks_from_payload( if not image_urls: return blocks - linear_api_key = os.environ.get("LINEAR_API_KEY", "") async with httpx.AsyncClient() as client: for image_url in image_urls: - image_block = await fetch_image_block( - image_url, client, linear_api_key=linear_api_key - ) + image_block = await fetch_image_block(image_url, client) if image_block: blocks.append(image_block) return blocks @@ -109,17 +105,13 @@ async def check_message_queue_before_model( # noqa: PLR0911 content_blocks: list[dict[str, Any]] = [] for msg in queued_messages: content = msg.get("content") - if isinstance(content, dict) and ( - "text" in content or "image_urls" in content - ): + if isinstance(content, dict) and ("text" in content or "image_urls" in content): logger.debug("Queued message contains text + image URLs") blocks = await _build_blocks_from_payload(content) content_blocks.extend(blocks) continue if isinstance(content, list): - logger.debug( - "Queued message contains %d content block(s)", len(content) - ) + logger.debug("Queued message contains %d content block(s)", len(content)) content_blocks.extend(content) continue if isinstance(content, str) and content: diff --git a/apps/agent/agent/middleware/open_pr.py b/apps/agent/agent/middleware/open_pr.py index 426bed6d..0a4b05d5 100644 --- a/apps/agent/agent/middleware/open_pr.py +++ b/apps/agent/agent/middleware/open_pr.py @@ -181,16 +181,12 @@ I've {action} pull request to address this issue: logger.info("Changes detected, preparing PR for thread %s", thread_id) - current_branch = await asyncio.to_thread( - git_current_branch, sandbox_backend, repo_dir - ) + 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: - await asyncio.to_thread( - git_checkout_branch, sandbox_backend, repo_dir, target_branch - ) + await asyncio.to_thread(git_checkout_branch, sandbox_backend, repo_dir, target_branch) await asyncio.to_thread( git_config_user, diff --git a/apps/agent/agent/server.py b/apps/agent/agent/server.py index c08746a9..c162642f 100644 --- a/apps/agent/agent/server.py +++ b/apps/agent/agent/server.py @@ -4,7 +4,6 @@ # Suppress deprecation warnings from langchain_core (e.g., Pydantic V1 on Python 3.14+) # ruff: noqa: E402 import logging -import os import warnings logger = logging.getLogger(__name__) @@ -29,6 +28,7 @@ from deepagents.backends.protocol import SandboxBackendProtocol from langchain_anthropic import ChatAnthropic from .encryption import decrypt_token +from .integrations.langsmith import _create_langsmith_sandbox from .middleware import ( ToolErrorMiddleware, check_message_queue_before_model, @@ -37,8 +37,6 @@ from .middleware import ( ) from .prompt import construct_system_prompt from .tools import commit_and_open_pr, fetch_url, http_request -from .integrations.langsmith import _create_langsmith_sandbox - client = get_client() @@ -46,12 +44,12 @@ SANDBOX_CREATING = "__creating__" SANDBOX_CREATION_TIMEOUT = 180 SANDBOX_POLL_INTERVAL = 1.0 -from .utils.sandbox_state import SANDBOX_BACKENDS, get_sandbox_id_from_metadata from .utils.github import ( git_has_uncommitted_changes, is_valid_git_repo, remove_directory, ) +from .utils.sandbox_state import SANDBOX_BACKENDS, get_sandbox_id_from_metadata async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915 @@ -87,9 +85,7 @@ async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915 is_git_repo = await loop.run_in_executor(None, is_valid_git_repo, sandbox_backend, repo_dir) if not is_git_repo: - logger.warning( - "Repo directory missing or not a valid git repo at %s, removing", repo_dir - ) + logger.warning("Repo directory missing or not a valid git repo at %s, removing", repo_dir) try: removed = await loop.run_in_executor(None, remove_directory, sandbox_backend, repo_dir) if not removed: diff --git a/apps/agent/agent/tools/commit_and_open_pr.py b/apps/agent/agent/tools/commit_and_open_pr.py index 2afc3615..78a8c1e3 100644 --- a/apps/agent/agent/tools/commit_and_open_pr.py +++ b/apps/agent/agent/tools/commit_and_open_pr.py @@ -181,9 +181,7 @@ def commit_and_open_pr( "pr_url": None, } - base_branch = asyncio.run( - get_github_default_branch(repo_owner, repo_name, github_token) - ) + base_branch = asyncio.run(get_github_default_branch(repo_owner, repo_name, github_token)) pr_url, _pr_number, pr_existing = asyncio.run( create_github_pr( repo_owner=repo_owner, diff --git a/apps/agent/agent/utils/github.py b/apps/agent/agent/utils/github.py index d966aa5c..b09c6325 100644 --- a/apps/agent/agent/utils/github.py +++ b/apps/agent/agent/utils/github.py @@ -37,24 +37,18 @@ def remove_directory(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> return result.exit_code == 0 -def git_has_uncommitted_changes( - sandbox_backend: SandboxBackendProtocol, repo_dir: str -) -> bool: +def git_has_uncommitted_changes(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> bool: """Check whether the repo has uncommitted changes.""" result = _run_git(sandbox_backend, repo_dir, "git status --porcelain") return result.exit_code == 0 and bool(result.output.strip()) -def git_fetch_origin( - sandbox_backend: SandboxBackendProtocol, repo_dir: str -) -> ExecuteResponse: +def git_fetch_origin(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> ExecuteResponse: """Fetch latest from origin (best-effort).""" return _run_git(sandbox_backend, repo_dir, "git fetch origin 2>/dev/null || true") -def git_has_unpushed_commits( - sandbox_backend: SandboxBackendProtocol, repo_dir: str -) -> bool: +def git_has_unpushed_commits(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> bool: """Check whether there are commits not pushed to upstream.""" git_log_cmd = ( "git log --oneline @{upstream}..HEAD 2>/dev/null " @@ -75,9 +69,7 @@ def git_checkout_branch( ) -> bool: """Checkout branch, creating it if needed.""" safe_branch = shlex.quote(branch) - checkout_result = _run_git( - sandbox_backend, repo_dir, f"git checkout -b {safe_branch}" - ) + checkout_result = _run_git(sandbox_backend, repo_dir, f"git checkout -b {safe_branch}") if checkout_result.exit_code == 0: return True fallback = _run_git(sandbox_backend, repo_dir, f"git checkout {safe_branch}") @@ -97,9 +89,7 @@ def git_config_user( _run_git(sandbox_backend, repo_dir, f"git config user.email {safe_email}") -def git_add_all( - sandbox_backend: SandboxBackendProtocol, repo_dir: str -) -> ExecuteResponse: +def git_add_all(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> ExecuteResponse: """Stage all changes.""" return _run_git(sandbox_backend, repo_dir, "git add -A") @@ -112,9 +102,7 @@ def git_commit( return _run_git(sandbox_backend, repo_dir, f"git commit -m {safe_message}") -def git_get_remote_url( - sandbox_backend: SandboxBackendProtocol, repo_dir: str -) -> str | None: +def git_get_remote_url(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> str | None: """Get the origin remote URL.""" result = _run_git(sandbox_backend, repo_dir, "git remote get-url origin") if result.exit_code != 0: diff --git a/apps/agent/agent/utils/multimodal.py b/apps/agent/agent/utils/multimodal.py index 4443cc78..a7e83cd5 100644 --- a/apps/agent/agent/utils/multimodal.py +++ b/apps/agent/agent/utils/multimodal.py @@ -30,26 +30,22 @@ def extract_image_urls(text: str) -> list[str]: urls.extend(IMAGE_MARKDOWN_RE.findall(text)) urls.extend(IMAGE_URL_RE.findall(text)) - deduped = _dedupe_urls(urls) + deduped = dedupe_urls(urls) if deduped: logger.debug("Extracted %d image URL(s)", len(deduped)) return deduped - async def fetch_image_block( image_url: str, client: httpx.AsyncClient, - *, - linear_api_key: str | None = None, ) -> dict[str, Any] | None: """Fetch image bytes and build an image content block.""" try: logger.debug("Fetching image from %s", image_url) headers = None if "uploads.linear.app" in image_url: - if linear_api_key is None: - linear_api_key = os.environ.get("LINEAR_API_KEY", "") + linear_api_key = os.environ.get("LINEAR_API_KEY", "") if linear_api_key: headers = {"Authorization": linear_api_key} else: @@ -62,7 +58,13 @@ async def fetch_image_block( content_type = response.headers.get("Content-Type", "").split(";")[0].strip() if not content_type: guessed, _ = mimetypes.guess_type(image_url) - content_type = guessed or "application/octet-stream" + if not guessed: + logger.warning( + "Could not determine content type for %s; skipping image", + image_url, + ) + return None + content_type = guessed encoded = base64.b64encode(response.content).decode("ascii") logger.info( @@ -77,12 +79,6 @@ async def fetch_image_block( return None -def _dedupe_urls(urls: list[str]) -> list[str]: - seen: set[str] = set() - deduped: list[str] = [] - for url in urls: - if url in seen: - continue - seen.add(url) - deduped.append(url) - return deduped +def dedupe_urls(urls: list[str]) -> list[str]: + deduped: set[str] = set(urls) + return list(deduped) diff --git a/apps/agent/agent/webapp.py b/apps/agent/agent/webapp.py index 84c8eee4..1949b0e6 100644 --- a/apps/agent/agent/webapp.py +++ b/apps/agent/agent/webapp.py @@ -11,11 +11,12 @@ from typing import Any import httpx import jwt from fastapi import BackgroundTasks, FastAPI, HTTPException, Request +from langchain_core.messages.content import create_text_block from langgraph_sdk import get_client # Local import for encryption from .encryption import encrypt_token -from .utils.multimodal import extract_image_urls, fetch_image_block +from .utils.multimodal import dedupe_urls, extract_image_urls, fetch_image_block logger = logging.getLogger(__name__) @@ -37,7 +38,6 @@ LINEAR_API_KEY = os.environ.get("LINEAR_API_KEY", "") X_SERVICE_AUTH_JWT_SECRET = os.environ.get("X_SERVICE_AUTH_JWT_SECRET", "") - def get_service_jwt_token_for_user( user_id: str, tenant_id: str, expiration_seconds: int = 300 ) -> str: @@ -78,9 +78,11 @@ LINEAR_TEAM_TO_REPO: dict[str, dict[str, Any] | dict[str, str]] = { "open-swe-v3-test": {"owner": "aran-yogesh", "name": "nimedge"}, "open-swe-dev-test": {"owner": "aran-yogesh", "name": "TalkBack"}, }, - "default": {"owner": "aran-yogesh", "name": "TalkBack"} # Fallback for issues without project + "default": { + "owner": "aran-yogesh", + "name": "TalkBack", + }, # Fallback for issues without project }, - "LangChain OSS": { "projects": { "deepagents": {"owner": "langchain-ai", "name": "deepagents"}, @@ -93,9 +95,7 @@ LINEAR_TEAM_TO_REPO: dict[str, dict[str, Any] | dict[str, str]] = { }, "default": {"owner": "langchain-ai", "name": "ai-sdr"}, }, - "Docs": { - "default": {"owner": "langchain-ai", "name": "docs"} - }, + "Docs": {"default": {"owner": "langchain-ai", "name": "docs"}}, } @@ -676,24 +676,15 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 "Please analyze this issue and implement the necessary changes. " "When you're done, commit and push your changes." ) - content_blocks: list[dict[str, Any]] = [{"type": "text", "text": prompt}] + content_blocks: list[dict[str, Any]] = [create_text_block(prompt)] if image_urls: - seen_urls: set[str] = set() - deduped_urls: list[str] = [] - for url in image_urls: - if url in seen_urls: - continue - seen_urls.add(url) - deduped_urls.append(url) - image_urls = deduped_urls + image_urls = dedupe_urls(image_urls) logger.info("Preparing %d image(s) for multimodal content", len(image_urls)) logger.debug("Image URLs: %s", image_urls) async with httpx.AsyncClient() as client: for image_url in image_urls: - image_block = await fetch_image_block( - image_url, client, linear_api_key=LINEAR_API_KEY - ) + image_block = await fetch_image_block(image_url, client) if image_block: content_blocks.append(image_block) logger.info("Built %d content block(s) for prompt", len(content_blocks)) diff --git a/apps/agent/tests/test_multimodal.py b/apps/agent/tests/test_multimodal.py index 66cc6129..5dca4d39 100644 --- a/apps/agent/tests/test_multimodal.py +++ b/apps/agent/tests/test_multimodal.py @@ -77,10 +77,7 @@ def test_extract_image_urls_case_insensitive() -> None: def test_extract_image_urls_deduplication() -> None: - text = ( - "Same URL twice: https://example.com/image.png " - "and again https://example.com/image.png" - ) + text = "Same URL twice: https://example.com/image.png and again https://example.com/image.png" assert extract_image_urls(text) == ["https://example.com/image.png"] From 4d114d2e29c3d5388168dcd96bed8a1c0f62b173 Mon Sep 17 00:00:00 2001 From: aran-yogesh Date: Tue, 24 Feb 2026 12:36:09 -0800 Subject: [PATCH 15/15] fix: preserve image url order when deduping --- apps/agent/agent/utils/multimodal.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/agent/agent/utils/multimodal.py b/apps/agent/agent/utils/multimodal.py index a7e83cd5..bc2b7ff6 100644 --- a/apps/agent/agent/utils/multimodal.py +++ b/apps/agent/agent/utils/multimodal.py @@ -80,5 +80,4 @@ async def fetch_image_block( def dedupe_urls(urls: list[str]) -> list[str]: - deduped: set[str] = set(urls) - return list(deduped) + return list(dict.fromkeys(urls))