Merge branch 'open-swe-v3' into yogesh/multimodal-inputs

This commit is contained in:
Aran Yogesh 2026-02-24 12:24:18 -08:00 • committed by GitHub
commit 1bdcb6de8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1489 additions and 1119 deletions

61
.github/workflows/agent-ci.yml vendored Normal file
View file

@ -0,0 +1,61 @@
name: Agent CI
permissions:
contents: read
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

View file

@ -2,10 +2,23 @@
name: CI
permissions:
contents: read
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,

View file

@ -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,

View file

@ -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
######################

View file

@ -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

View file

@ -32,6 +32,7 @@ from ..utils.github import (
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__)
@ -78,9 +79,9 @@ async def open_pr_if_needed(
if messages:
last_message = messages[-1]
if isinstance(last_message, dict):
last_message_content = last_message.get("content", "")
last_message_content = extract_text_content(last_message.get("content", ""))
elif hasattr(last_message, "content"):
last_message_content = 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")
@ -192,8 +193,8 @@ I've {action} pull request to address this issue:
git_config_user,
sandbox_backend,
repo_dir,
"Open SWE[bot]",
"Open SWE@users.noreply.github.com",
"open-swe[bot]",
"open-swe@users.noreply.github.com",
)
await asyncio.to_thread(git_add_all, sandbox_backend, repo_dir)
await asyncio.to_thread(git_commit, sandbox_backend, repo_dir, commit_message)

View file

@ -14,6 +14,7 @@ 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__)
@ -96,12 +97,13 @@ async def post_to_linear_after_model( # noqa: PLR0911, PLR0912
if prev_role not in ("human", "user"):
return None
if not content or not isinstance(content, str):
content_text = extract_text_content(content)
if not content_text:
return None
comment = f"""🤖 **Agent Response**
{content}"""
{content_text}"""
logger.info("Posting AI response to Linear issue %s", linear_issue_id)
success = await comment_on_linear_issue(linear_issue_id, comment)

View file

@ -4,9 +4,12 @@ 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
- 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.
---
@ -72,9 +75,20 @@ def construct_system_prompt(
working_dir: str,
linear_project_id: str = "",
linear_issue_number: str = "",
agents_md: str = "",
) -> 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"
"<agents_md>\n"
f"{agents_md}\n"
"</agents_md>\n"
)
return SYSTEM_PROMPT.format(
working_dir=working_dir,
linear_project_id=linear_project_id or "<PROJECT_ID>",
linear_issue_number=linear_issue_number or "<ISSUE_NUMBER>",
agents_md_section=agents_md_section,
)

View file

@ -23,12 +23,10 @@ warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarnin
# Now safe to import agent (which imports LangChain modules)
from deepagents import create_deep_agent
from deepagents.backends.protocol import SandboxBackendProtocol
# Local import for encryption
from langchain_anthropic import ChatAnthropic
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 (
ToolErrorMiddleware,
check_message_queue_before_model,
@ -44,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,
@ -217,7 +216,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")
@ -254,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
@ -288,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)
@ -299,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(
@ -326,17 +324,23 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
SANDBOX_BACKENDS[thread_id] = sandbox_backend
if not repo_dir:
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", {})
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(
model=ChatAnthropic(model="claude-opus-4-6", max_tokens=20_000),
model=ChatOpenAI(model="gpt-5.2-codex", temperature=0, max_tokens=20_000),
system_prompt=construct_system_prompt(
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,

View file

@ -153,8 +153,8 @@ def commit_and_open_pr(
git_config_user(
sandbox_backend,
repo_dir,
"Open SWE[bot]",
"Open SWE@users.noreply.github.com",
"open-swe[bot]",
"open-swe@users.noreply.github.com",
)
git_add_all(sandbox_backend, repo_dir)

View file

@ -0,0 +1,34 @@
"""Helpers for reading agent instructions from AGENTS.md."""
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,
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:
logger.debug("AGENTS.md not found at %s", safe_agents_path)
return None
content = result.output or ""
content = content.strip()
return content or None

View file

@ -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(

View file

@ -0,0 +1,28 @@
"""Helpers for normalizing message content across model providers."""
from __future__ import annotations
from langchain_core.messages import ContentBlock
def extract_text_content(content: str | list[ContentBlock]) -> str:
"""Extract human-readable text from model message content.
Supports:
- Plain strings
- OpenAI-style content blocks (list of {"type": "text", "text": ...})
- Dict wrappers with nested "content" or "text"
"""
if isinstance(content, str):
return content.strip()
if not isinstance(content, list):
return ""
text = ""
for item in content:
if isinstance(item, dict) and "text" in item:
text += item["text"]
return text.strip()

View file

@ -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

View file

@ -6,27 +6,20 @@ 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",
]
[project.optional-dependencies]

2351
apps/agent/uv.lock generated

File diff suppressed because it is too large Load diff