mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-05 03:42:13 +00:00
Merge pull request #206 from Sea-Haven-Industries/feature/port-upstream-clean-batch
feat: (port upstream) UI labels, git panel, Linear search, sandbox config
This commit is contained in:
commit
a3f243433b
33 changed files with 944 additions and 124 deletions
|
|
@ -88,7 +88,7 @@ There is intentionally no after-agent safety net that opens a PR for the agent.
|
||||||
All tools live in `agent/tools/` and are flat-imported via `agent/tools/__init__.py`. The set is intentionally small and curated — see README "Tools — Curated, Not Accumulated".
|
All tools live in `agent/tools/` and are flat-imported via `agent/tools/__init__.py`. The set is intentionally small and curated — see README "Tools — Curated, Not Accumulated".
|
||||||
|
|
||||||
Wired into `get_agent`:
|
Wired into `get_agent`:
|
||||||
`http_request`, `fetch_url`, `web_search`, `linear_comment`, `linear_create_issue`, `linear_delete_issue`, `linear_get_issue`, `linear_get_issue_comments`, `linear_list_teams`, `linear_update_issue`, `jira_comment`, `jira_create_issue`, `jira_get_issue`, `jira_get_issue_comments`, `jira_list_projects`, `jira_update_issue`, `confluence_get_page`, `confluence_create_page`, `confluence_update_page`, `confluence_comment`, `confluence_search`, `request_pr_review`, `schedule_thread_wakeup`, `slack_add_reaction`, `slack_read_thread_messages`, `slack_thread_reply`.
|
`http_request`, `fetch_url`, `web_search`, `linear_comment`, `linear_create_issue`, `linear_delete_issue`, `linear_get_issue`, `linear_get_issue_comments`, `linear_list_teams`, `linear_search_issues`, `linear_update_issue`, `jira_comment`, `jira_create_issue`, `jira_get_issue`, `jira_get_issue_comments`, `jira_list_projects`, `jira_update_issue`, `confluence_get_page`, `confluence_create_page`, `confluence_update_page`, `confluence_comment`, `confluence_search`, `request_pr_review`, `schedule_thread_wakeup`, `slack_add_reaction`, `slack_read_thread_messages`, `slack_thread_reply`.
|
||||||
|
|
||||||
Reviewer-only tools (in `agent/reviewer.py`): `add_finding`, `update_finding`, `list_findings`, `publish_review`. The review-style analyzer uses `save_review_style` (exported as `save_review_style_prompt`).
|
Reviewer-only tools (in `agent/reviewer.py`): `add_finding`, `update_finding`, `list_findings`, `publish_review`. The review-style analyzer uses `save_review_style` (exported as `save_review_style_prompt`).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ There is intentionally no after-agent safety net that opens a PR for the agent.
|
||||||
All tools live in `agent/tools/` and are flat-imported via `agent/tools/__init__.py`. The set is intentionally small and curated — see README "Tools — Curated, Not Accumulated".
|
All tools live in `agent/tools/` and are flat-imported via `agent/tools/__init__.py`. The set is intentionally small and curated — see README "Tools — Curated, Not Accumulated".
|
||||||
|
|
||||||
Wired into `get_agent`:
|
Wired into `get_agent`:
|
||||||
`http_request`, `fetch_url`, `web_search`, `linear_comment`, `linear_create_issue`, `linear_delete_issue`, `linear_get_issue`, `linear_get_issue_comments`, `linear_list_teams`, `linear_update_issue`, `jira_comment`, `jira_create_issue`, `jira_get_issue`, `jira_get_issue_comments`, `jira_list_projects`, `jira_update_issue`, `confluence_get_page`, `confluence_create_page`, `confluence_update_page`, `confluence_comment`, `confluence_search`, `request_pr_review`, `schedule_thread_wakeup`, `slack_add_reaction`, `slack_read_thread_messages`, `slack_thread_reply`.
|
`http_request`, `fetch_url`, `web_search`, `linear_comment`, `linear_create_issue`, `linear_delete_issue`, `linear_get_issue`, `linear_get_issue_comments`, `linear_list_teams`, `linear_search_issues`, `linear_update_issue`, `jira_comment`, `jira_create_issue`, `jira_get_issue`, `jira_get_issue_comments`, `jira_list_projects`, `jira_update_issue`, `confluence_get_page`, `confluence_create_page`, `confluence_update_page`, `confluence_comment`, `confluence_search`, `request_pr_review`, `schedule_thread_wakeup`, `slack_add_reaction`, `slack_read_thread_messages`, `slack_thread_reply`.
|
||||||
|
|
||||||
Jira uses a service-account REST client (`agent/utils/jira.py`, Basic auth) with ADF↔markdown conversion (`agent/utils/adf.py`); Confluence likewise (`agent/utils/confluence.py`, XHTML storage-format). Both are dark-safe: unset env returns a clean error.
|
Jira uses a service-account REST client (`agent/utils/jira.py`, Basic auth) with ADF↔markdown conversion (`agent/utils/adf.py`); Confluence likewise (`agent/utils/confluence.py`, XHTML storage-format). Both are dark-safe: unset env returns a clean error.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ Stripe's key insight: *tool curation matters more than tool quantity.* Open SWE
|
||||||
| `fetch_url` | Fetch web pages as markdown |
|
| `fetch_url` | Fetch web pages as markdown |
|
||||||
| `http_request` | API calls (GET, POST, etc.) |
|
| `http_request` | API calls (GET, POST, etc.) |
|
||||||
| `linear_comment` | Post updates to Linear tickets |
|
| `linear_comment` | Post updates to Linear tickets |
|
||||||
|
| `linear_search_issues` | Search Linear issues by free text |
|
||||||
| `jira_*` | Read/comment/create/update Jira issues |
|
| `jira_*` | Read/comment/create/update Jira issues |
|
||||||
| `confluence_*` | Read/write Confluence pages + comments |
|
| `confluence_*` | Read/write Confluence pages + comments |
|
||||||
| `slack_add_reaction` | React to Slack messages |
|
| `slack_add_reaction` | React to Slack messages |
|
||||||
|
|
|
||||||
|
|
@ -115,8 +115,10 @@ async def _resolve_chat_model(configurable: dict) -> tuple[str, str]:
|
||||||
|
|
||||||
async def get_chat_agent(config: RunnableConfig) -> Pregel:
|
async def get_chat_agent(config: RunnableConfig) -> Pregel:
|
||||||
"""Get a read-only PR chat agent. No sandbox; PR context comes via config."""
|
"""Get a read-only PR chat agent. No sandbox; PR context comes via config."""
|
||||||
|
config = config.copy()
|
||||||
|
config["configurable"] = config["configurable"].copy()
|
||||||
|
config.setdefault("recursion_limit", DEFAULT_RECURSION_LIMIT)
|
||||||
thread_id = config["configurable"].get("thread_id")
|
thread_id = config["configurable"].get("thread_id")
|
||||||
config["recursion_limit"] = DEFAULT_RECURSION_LIMIT
|
|
||||||
|
|
||||||
if thread_id is None or not graph_loaded_for_execution(config):
|
if thread_id is None or not graph_loaded_for_execution(config):
|
||||||
return create_deep_agent(system_prompt="", tools=[]).with_config(config)
|
return create_deep_agent(system_prompt="", tools=[]).with_config(config)
|
||||||
|
|
|
||||||
|
|
@ -342,9 +342,9 @@ def _build_snapshot_sync(record: dict[str, Any], snapshot_name: str) -> tuple[st
|
||||||
"""
|
"""
|
||||||
from langsmith.sandbox import SandboxClient
|
from langsmith.sandbox import SandboxClient
|
||||||
|
|
||||||
from agent.integrations.langsmith import _get_langsmith_api_key
|
from agent.integrations.langsmith import _get_sandbox_api_endpoint, _get_sandbox_api_key
|
||||||
|
|
||||||
api_key = _get_langsmith_api_key()
|
api_key = _get_sandbox_api_key()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise RuntimeError("LANGSMITH_API_KEY is not configured")
|
raise RuntimeError("LANGSMITH_API_KEY is not configured")
|
||||||
|
|
||||||
|
|
@ -356,7 +356,7 @@ def _build_snapshot_sync(record: dict[str, Any], snapshot_name: str) -> tuple[st
|
||||||
timeout = int(
|
timeout = int(
|
||||||
os.environ.get("REPO_SNAPSHOT_BUILD_TIMEOUT_SECONDS", DEFAULT_BUILD_TIMEOUT_SECONDS)
|
os.environ.get("REPO_SNAPSHOT_BUILD_TIMEOUT_SECONDS", DEFAULT_BUILD_TIMEOUT_SECONDS)
|
||||||
)
|
)
|
||||||
client = SandboxClient(api_key=api_key)
|
client = SandboxClient(api_key=api_key, api_endpoint=_get_sandbox_api_endpoint())
|
||||||
try:
|
try:
|
||||||
with tempfile.TemporaryDirectory(prefix="openswe-snapshot-") as context_dir:
|
with tempfile.TemporaryDirectory(prefix="openswe-snapshot-") as context_dir:
|
||||||
dockerfile_path = Path(context_dir) / "Dockerfile"
|
dockerfile_path = Path(context_dir) / "Dockerfile"
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from concurrent.futures import TimeoutError as FuturesTimeout
|
from concurrent.futures import TimeoutError as FuturesTimeout
|
||||||
|
|
@ -44,6 +46,69 @@ def _get_langsmith_api_key() -> str | None:
|
||||||
return os.environ.get("LANGSMITH_API_KEY") or os.environ.get("LANGSMITH_API_KEY_PROD")
|
return os.environ.get("LANGSMITH_API_KEY") or os.environ.get("LANGSMITH_API_KEY_PROD")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sandbox_api_key() -> str | None:
|
||||||
|
"""LangSmith API key for sandbox operations.
|
||||||
|
|
||||||
|
``SANDBOX_LANGSMITH_API_KEY`` lets sandboxes run against a different
|
||||||
|
LangSmith workspace than the one used for tracing/other API calls; falls
|
||||||
|
back to the standard key.
|
||||||
|
"""
|
||||||
|
return os.environ.get("SANDBOX_LANGSMITH_API_KEY") or _get_langsmith_api_key()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sandbox_endpoint() -> str:
|
||||||
|
"""LangSmith API **root** for sandbox operations.
|
||||||
|
|
||||||
|
Overridable via ``SANDBOX_LANGSMITH_ENDPOINT`` to pair with
|
||||||
|
``SANDBOX_LANGSMITH_API_KEY``; falls back to ``LANGSMITH_ENDPOINT``. This is
|
||||||
|
the bare root (e.g. ``https://api.smith.langchain.com``) used to build the
|
||||||
|
proxy-config URL; the SDK clients take :func:`_get_sandbox_api_endpoint`.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
os.environ.get("SANDBOX_LANGSMITH_ENDPOINT")
|
||||||
|
or os.environ.get("LANGSMITH_ENDPOINT")
|
||||||
|
or "https://api.smith.langchain.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sandbox_api_endpoint() -> str:
|
||||||
|
"""Sandbox API base URL for the langsmith SDK clients.
|
||||||
|
|
||||||
|
The SDK's ``api_endpoint`` is the sandbox base (root + ``/v2/sandboxes``),
|
||||||
|
not the API root, and its methods append ``/boxes``, ``/snapshots``, etc.
|
||||||
|
"""
|
||||||
|
root = _get_sandbox_endpoint().rstrip("/")
|
||||||
|
suffix = "/v2/sandboxes"
|
||||||
|
return root if root.endswith(suffix) else f"{root}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _current_thread_id() -> str | None:
|
||||||
|
"""The LangGraph thread id for the active run, if any."""
|
||||||
|
try:
|
||||||
|
from langgraph.config import get_config
|
||||||
|
|
||||||
|
return get_config().get("configurable", {}).get("thread_id")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sandbox_name_for_thread(thread_id: str | None) -> str | None:
|
||||||
|
"""Deterministic, thread-traceable sandbox name: ``openswe-<b32(thread uuid)>``.
|
||||||
|
|
||||||
|
The thread id (a UUID) is base32-encoded lowercase without padding so the
|
||||||
|
name is a compact, hyphen-free token that maps back to the thread. Returns
|
||||||
|
None when the thread id is missing or not a UUID, leaving the name unset.
|
||||||
|
"""
|
||||||
|
if not thread_id:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw = uuid.UUID(thread_id).bytes
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
encoded = base64.b32encode(raw).decode("ascii").rstrip("=").lower()
|
||||||
|
return f"openswe-{encoded}"
|
||||||
|
|
||||||
|
|
||||||
def _parse_optional_int(name: str, default: int) -> int:
|
def _parse_optional_int(name: str, default: int) -> int:
|
||||||
raw = os.environ.get(name)
|
raw = os.environ.get(name)
|
||||||
if not raw:
|
if not raw:
|
||||||
|
|
@ -87,6 +152,43 @@ def _get_sandbox_snapshot_config() -> tuple[str | None, int, int, int, int, int]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sandbox_create_extra_fields() -> dict[str, Any]:
|
||||||
|
"""Parse SANDBOX_CREATE_EXTRA_JSON into extra fields merged into the
|
||||||
|
sandbox-create request body, e.g. ``{"_internal_runtime": "v2"}``."""
|
||||||
|
raw = os.environ.get("SANDBOX_CREATE_EXTRA_JSON")
|
||||||
|
if not raw or not raw.strip():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
msg = f"SANDBOX_CREATE_EXTRA_JSON must be valid JSON, got {raw!r}"
|
||||||
|
raise ValueError(msg) from e
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
msg = f"SANDBOX_CREATE_EXTRA_JSON must be a JSON object, got {type(parsed).__name__}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _install_create_extra_fields(client: SandboxClient, extra: dict[str, Any]) -> None:
|
||||||
|
"""Merge ``extra`` into the JSON body of the sandbox-create request.
|
||||||
|
|
||||||
|
The SDK's ``create_sandbox`` builds a fixed payload with no passthrough, so
|
||||||
|
wrap the HTTP client's ``post`` to inject the fields on the ``POST /boxes``
|
||||||
|
request only (other endpoints post to ``/boxes/{name}/...``).
|
||||||
|
"""
|
||||||
|
if not extra:
|
||||||
|
return
|
||||||
|
original_post = client._http.post
|
||||||
|
|
||||||
|
def post_with_extra(url: Any, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
payload = kwargs.get("json")
|
||||||
|
if str(url).endswith("/boxes") and isinstance(payload, dict):
|
||||||
|
kwargs["json"] = {**payload, **extra}
|
||||||
|
return original_post(url, *args, **kwargs)
|
||||||
|
|
||||||
|
client._http.post = post_with_extra
|
||||||
|
|
||||||
|
|
||||||
def _github_proxy_rules(github_token: str) -> list[dict[str, Any]]:
|
def _github_proxy_rules(github_token: str) -> list[dict[str, Any]]:
|
||||||
basic_auth = base64.b64encode(f"x-access-token:{github_token}".encode()).decode()
|
basic_auth = base64.b64encode(f"x-access-token:{github_token}".encode()).decode()
|
||||||
return [
|
return [
|
||||||
|
|
@ -134,6 +236,22 @@ def _is_retryable_proxy_config_error(exc: BaseException) -> bool:
|
||||||
return isinstance(exc, httpx.TransportError)
|
return isinstance(exc, httpx.TransportError)
|
||||||
|
|
||||||
|
|
||||||
|
def _release_sandbox_name(client: SandboxClient, name: str | None) -> None:
|
||||||
|
"""Best-effort delete of any existing sandbox holding ``name``.
|
||||||
|
|
||||||
|
Sandbox names are unique in LangSmith and thread-deterministic, so the only
|
||||||
|
box that can hold this name is this thread's own — typically a dead one
|
||||||
|
(idle-stopped past its TTL) we're recreating. Provisioning is serialized per
|
||||||
|
thread, so this never races a live box. Without this, recreate would 409.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
client.delete_sandbox(name)
|
||||||
|
except Exception as exc: # noqa: BLE001 - name is free if nothing to delete
|
||||||
|
logger.debug("No pre-existing sandbox %s to release (%s)", name, type(exc).__name__)
|
||||||
|
|
||||||
|
|
||||||
def _configure_github_proxy(sandbox_name: str, github_token: str) -> None:
|
def _configure_github_proxy(sandbox_name: str, github_token: str) -> None:
|
||||||
"""Configure sandbox proxy to inject GitHub auth for GitHub traffic.
|
"""Configure sandbox proxy to inject GitHub auth for GitHub traffic.
|
||||||
|
|
||||||
|
|
@ -145,11 +263,11 @@ def _configure_github_proxy(sandbox_name: str, github_token: str) -> None:
|
||||||
sandbox_name: The sandbox name/ID returned by the LangSmith API.
|
sandbox_name: The sandbox name/ID returned by the LangSmith API.
|
||||||
github_token: GitHub token to inject as Authorization header.
|
github_token: GitHub token to inject as Authorization header.
|
||||||
"""
|
"""
|
||||||
api_key = _get_langsmith_api_key()
|
api_key = _get_sandbox_api_key()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
logger.warning("No LangSmith API key found, skipping GitHub proxy configuration")
|
logger.warning("No LangSmith API key found, skipping GitHub proxy configuration")
|
||||||
return
|
return
|
||||||
langsmith_endpoint = os.environ.get("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com")
|
langsmith_endpoint = _get_sandbox_endpoint()
|
||||||
url = f"{langsmith_endpoint}/v2/sandboxes/boxes/{sandbox_name}"
|
url = f"{langsmith_endpoint}/v2/sandboxes/boxes/{sandbox_name}"
|
||||||
payload = {"proxy_config": {"rules": _github_proxy_rules(github_token)}}
|
payload = {"proxy_config": {"rules": _github_proxy_rules(github_token)}}
|
||||||
with httpx.Client(timeout=PROXY_CONFIG_TIMEOUT_SECONDS) as client:
|
with httpx.Client(timeout=PROXY_CONFIG_TIMEOUT_SECONDS) as client:
|
||||||
|
|
@ -211,7 +329,7 @@ def create_langsmith_sandbox(
|
||||||
Returns:
|
Returns:
|
||||||
SandboxBackendProtocol instance
|
SandboxBackendProtocol instance
|
||||||
"""
|
"""
|
||||||
api_key = _get_langsmith_api_key()
|
api_key = _get_sandbox_api_key()
|
||||||
(
|
(
|
||||||
default_snapshot_id,
|
default_snapshot_id,
|
||||||
fs_capacity_bytes,
|
fs_capacity_bytes,
|
||||||
|
|
@ -227,6 +345,7 @@ def create_langsmith_sandbox(
|
||||||
backend = provider.get_or_create(
|
backend = provider.get_or_create(
|
||||||
sandbox_id=sandbox_id,
|
sandbox_id=sandbox_id,
|
||||||
snapshot_id=effective_snapshot_id,
|
snapshot_id=effective_snapshot_id,
|
||||||
|
name=_sandbox_name_for_thread(_current_thread_id()),
|
||||||
fs_capacity_bytes=fs_capacity_bytes,
|
fs_capacity_bytes=fs_capacity_bytes,
|
||||||
vcpus=vcpus,
|
vcpus=vcpus,
|
||||||
mem_bytes=mem_bytes,
|
mem_bytes=mem_bytes,
|
||||||
|
|
@ -246,11 +365,9 @@ def _update_thread_sandbox_metadata(sandbox_id: str) -> None:
|
||||||
try:
|
try:
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from langgraph.config import get_config
|
|
||||||
from langgraph_sdk import get_client
|
from langgraph_sdk import get_client
|
||||||
|
|
||||||
config = get_config()
|
thread_id = _current_thread_id()
|
||||||
thread_id = config.get("configurable", {}).get("thread_id")
|
|
||||||
if not thread_id:
|
if not thread_id:
|
||||||
return
|
return
|
||||||
client = get_client()
|
client = get_client()
|
||||||
|
|
@ -416,11 +533,14 @@ class LangSmithProvider(SandboxProvider):
|
||||||
def __init__(self, api_key: str | None = None) -> None:
|
def __init__(self, api_key: str | None = None) -> None:
|
||||||
from langsmith import sandbox
|
from langsmith import sandbox
|
||||||
|
|
||||||
self._api_key = api_key or _get_langsmith_api_key()
|
self._api_key = api_key or _get_sandbox_api_key()
|
||||||
|
self._api_endpoint = _get_sandbox_api_endpoint()
|
||||||
if not self._api_key:
|
if not self._api_key:
|
||||||
msg = "LANGSMITH_API_KEY (or LANGSMITH_API_KEY_PROD) not set"
|
msg = "LANGSMITH_API_KEY (or LANGSMITH_API_KEY_PROD) not set"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
self._client: SandboxClient = sandbox.SandboxClient(api_key=self._api_key)
|
self._client: SandboxClient = sandbox.SandboxClient(
|
||||||
|
api_key=self._api_key, api_endpoint=self._api_endpoint
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_startup_config(cls) -> None:
|
def validate_startup_config(cls) -> None:
|
||||||
|
|
@ -453,6 +573,7 @@ class LangSmithProvider(SandboxProvider):
|
||||||
):
|
):
|
||||||
msg = f"{name} must be >= 0, got {value}"
|
msg = f"{name} must be >= 0, got {value}"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
_get_sandbox_create_extra_fields()
|
||||||
|
|
||||||
def get_or_create(
|
def get_or_create(
|
||||||
self,
|
self,
|
||||||
|
|
@ -460,6 +581,7 @@ class LangSmithProvider(SandboxProvider):
|
||||||
sandbox_id: str | None = None,
|
sandbox_id: str | None = None,
|
||||||
timeout: int = 180,
|
timeout: int = 180,
|
||||||
snapshot_id: str | None = None,
|
snapshot_id: str | None = None,
|
||||||
|
name: str | None = None,
|
||||||
fs_capacity_bytes: int | None = None,
|
fs_capacity_bytes: int | None = None,
|
||||||
vcpus: int | None = None,
|
vcpus: int | None = None,
|
||||||
mem_bytes: int | None = None,
|
mem_bytes: int | None = None,
|
||||||
|
|
@ -483,9 +605,13 @@ class LangSmithProvider(SandboxProvider):
|
||||||
msg = "DEFAULT_SANDBOX_SNAPSHOT_ID must be set when SANDBOX_TYPE=langsmith"
|
msg = "DEFAULT_SANDBOX_SNAPSHOT_ID must be set when SANDBOX_TYPE=langsmith"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
_install_create_extra_fields(self._client, _get_sandbox_create_extra_fields())
|
||||||
|
_release_sandbox_name(self._client, name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sandbox = self._client.create_sandbox(
|
sandbox = self._client.create_sandbox(
|
||||||
snapshot_id=snapshot_id,
|
snapshot_id=snapshot_id,
|
||||||
|
name=name,
|
||||||
fs_capacity_bytes=fs_capacity_bytes,
|
fs_capacity_bytes=fs_capacity_bytes,
|
||||||
vcpus=vcpus,
|
vcpus=vcpus,
|
||||||
mem_bytes=mem_bytes,
|
mem_bytes=mem_bytes,
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ _TOOL_STATUS: dict[str, str] = {
|
||||||
"linear_get_issue": "checking Linear...",
|
"linear_get_issue": "checking Linear...",
|
||||||
"linear_get_issue_comments": "checking Linear...",
|
"linear_get_issue_comments": "checking Linear...",
|
||||||
"linear_list_teams": "checking Linear...",
|
"linear_list_teams": "checking Linear...",
|
||||||
|
"linear_search_issues": "searching Linear...",
|
||||||
"linear_update_issue": "updating Linear...",
|
"linear_update_issue": "updating Linear...",
|
||||||
"linear_delete_issue": "updating Linear...",
|
"linear_delete_issue": "updating Linear...",
|
||||||
"add_finding": "recording review findings...",
|
"add_finding": "recording review findings...",
|
||||||
|
|
|
||||||
|
|
@ -854,10 +854,11 @@ async def _resolve_grouping_model(
|
||||||
|
|
||||||
async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
||||||
"""Get or create a reviewer agent with a sandbox + prepped repo."""
|
"""Get or create a reviewer agent with a sandbox + prepped repo."""
|
||||||
|
config = config.copy()
|
||||||
|
config["configurable"] = config["configurable"].copy()
|
||||||
|
config.setdefault("recursion_limit", DEFAULT_RECURSION_LIMIT)
|
||||||
thread_id = config["configurable"].get("thread_id", None)
|
thread_id = config["configurable"].get("thread_id", None)
|
||||||
|
|
||||||
config["recursion_limit"] = DEFAULT_RECURSION_LIMIT
|
|
||||||
|
|
||||||
if thread_id is None or not graph_loaded_for_execution(config):
|
if thread_id is None or not graph_loaded_for_execution(config):
|
||||||
logger.info("No thread_id or not for execution, returning reviewer agent without sandbox")
|
logger.info("No thread_id or not for execution, returning reviewer agent without sandbox")
|
||||||
return create_deep_agent(system_prompt="", tools=[]).with_config(config)
|
return create_deep_agent(system_prompt="", tools=[]).with_config(config)
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ from .tools import (
|
||||||
linear_get_issue,
|
linear_get_issue,
|
||||||
linear_get_issue_comments,
|
linear_get_issue_comments,
|
||||||
linear_list_teams,
|
linear_list_teams,
|
||||||
|
linear_search_issues,
|
||||||
linear_update_issue,
|
linear_update_issue,
|
||||||
open_pull_request,
|
open_pull_request,
|
||||||
report_platform_issue,
|
report_platform_issue,
|
||||||
|
|
@ -1019,6 +1020,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
||||||
linear_get_issue,
|
linear_get_issue,
|
||||||
linear_get_issue_comments,
|
linear_get_issue_comments,
|
||||||
linear_list_teams,
|
linear_list_teams,
|
||||||
|
linear_search_issues,
|
||||||
linear_update_issue,
|
linear_update_issue,
|
||||||
jira_comment,
|
jira_comment,
|
||||||
jira_create_issue,
|
jira_create_issue,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ _TOOL_MODULES = {
|
||||||
"linear_get_issue": ".linear_get_issue",
|
"linear_get_issue": ".linear_get_issue",
|
||||||
"linear_get_issue_comments": ".linear_get_issue_comments",
|
"linear_get_issue_comments": ".linear_get_issue_comments",
|
||||||
"linear_list_teams": ".linear_list_teams",
|
"linear_list_teams": ".linear_list_teams",
|
||||||
|
"linear_search_issues": ".linear_search_issues",
|
||||||
"linear_update_issue": ".linear_update_issue",
|
"linear_update_issue": ".linear_update_issue",
|
||||||
"list_findings": ".list_findings",
|
"list_findings": ".list_findings",
|
||||||
"list_review_findings": ".list_review_findings",
|
"list_review_findings": ".list_review_findings",
|
||||||
|
|
@ -67,6 +68,7 @@ __all__ = [
|
||||||
"linear_get_issue",
|
"linear_get_issue",
|
||||||
"linear_get_issue_comments",
|
"linear_get_issue_comments",
|
||||||
"linear_list_teams",
|
"linear_list_teams",
|
||||||
|
"linear_search_issues",
|
||||||
"linear_update_issue",
|
"linear_update_issue",
|
||||||
"list_findings",
|
"list_findings",
|
||||||
"list_review_findings",
|
"list_review_findings",
|
||||||
|
|
@ -110,6 +112,7 @@ if TYPE_CHECKING:
|
||||||
from .linear_get_issue import linear_get_issue
|
from .linear_get_issue import linear_get_issue
|
||||||
from .linear_get_issue_comments import linear_get_issue_comments
|
from .linear_get_issue_comments import linear_get_issue_comments
|
||||||
from .linear_list_teams import linear_list_teams
|
from .linear_list_teams import linear_list_teams
|
||||||
|
from .linear_search_issues import linear_search_issues
|
||||||
from .linear_update_issue import linear_update_issue
|
from .linear_update_issue import linear_update_issue
|
||||||
from .list_findings import list_findings
|
from .list_findings import list_findings
|
||||||
from .list_review_findings import list_review_findings
|
from .list_review_findings import list_review_findings
|
||||||
|
|
|
||||||
34
agent/tools/linear_search_issues.py
Normal file
34
agent/tools/linear_search_issues.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..utils.linear import search_issues
|
||||||
|
|
||||||
|
|
||||||
|
async def linear_search_issues(
|
||||||
|
query: str,
|
||||||
|
team_id: str | None = None,
|
||||||
|
limit: int = 10,
|
||||||
|
include_archived: bool = False,
|
||||||
|
include_comments: bool = False,
|
||||||
|
after: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Search Linear issues by title, description, and optionally comments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Free-text search query.
|
||||||
|
team_id: Optional team UUID used to restrict matches to that team.
|
||||||
|
limit: Maximum results to return, from 1 to 50.
|
||||||
|
include_archived: Whether to include archived issues.
|
||||||
|
include_comments: Whether to search issue comments in addition to issue content.
|
||||||
|
after: Optional pagination cursor from a previous result's page_info.endCursor.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Matching issues plus total_count and page_info for pagination.
|
||||||
|
"""
|
||||||
|
return await search_issues(
|
||||||
|
query=query,
|
||||||
|
team_id=team_id,
|
||||||
|
limit=limit,
|
||||||
|
include_archived=include_archived,
|
||||||
|
include_comments=include_comments,
|
||||||
|
after=after,
|
||||||
|
)
|
||||||
|
|
@ -132,6 +132,84 @@ async def get_issue(issue_id: str) -> dict[str, Any]:
|
||||||
return {"issue": result.get("issue")}
|
return {"issue": result.get("issue")}
|
||||||
|
|
||||||
|
|
||||||
|
async def search_issues(
|
||||||
|
query: str,
|
||||||
|
team_id: str | None = None,
|
||||||
|
limit: int = 10,
|
||||||
|
include_archived: bool = False,
|
||||||
|
include_comments: bool = False,
|
||||||
|
after: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Search Linear issues by free-text query."""
|
||||||
|
query = query.strip()
|
||||||
|
if not query:
|
||||||
|
return {"error": "Search query must not be empty"}
|
||||||
|
if not 1 <= limit <= 50:
|
||||||
|
return {"error": "Search limit must be between 1 and 50"}
|
||||||
|
|
||||||
|
search_query = """
|
||||||
|
query SearchIssues(
|
||||||
|
$query: String!
|
||||||
|
$filter: IssueFilter
|
||||||
|
$limit: Int!
|
||||||
|
$includeArchived: Boolean
|
||||||
|
$includeComments: Boolean
|
||||||
|
$after: String
|
||||||
|
) {
|
||||||
|
searchIssues(
|
||||||
|
term: $query
|
||||||
|
filter: $filter
|
||||||
|
first: $limit
|
||||||
|
includeArchived: $includeArchived
|
||||||
|
includeComments: $includeComments
|
||||||
|
after: $after
|
||||||
|
) {
|
||||||
|
totalCount
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
identifier
|
||||||
|
title
|
||||||
|
priority
|
||||||
|
priorityLabel
|
||||||
|
state { id name type }
|
||||||
|
assignee { id name email }
|
||||||
|
team { id name key }
|
||||||
|
project { id name }
|
||||||
|
labels { nodes { id name } }
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
archivedAt
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = await _graphql_request(
|
||||||
|
search_query,
|
||||||
|
{
|
||||||
|
"query": query,
|
||||||
|
"filter": {"team": {"id": {"eq": team_id}}} if team_id else None,
|
||||||
|
"limit": limit,
|
||||||
|
"includeArchived": include_archived,
|
||||||
|
"includeComments": include_comments,
|
||||||
|
"after": after,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if "error" in result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
search_results = result.get("searchIssues", {})
|
||||||
|
return {
|
||||||
|
"issues": search_results.get("nodes", []),
|
||||||
|
"total_count": search_results.get("totalCount", 0),
|
||||||
|
"page_info": search_results.get("pageInfo", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def create_issue(
|
async def create_issue(
|
||||||
team_id: str,
|
team_id: str,
|
||||||
title: str,
|
title: str,
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,8 @@ Set the `SANDBOX_TYPE` environment variable to switch providers. Each provider h
|
||||||
|
|
||||||
> **Warning**: `local` runs commands directly on your host with no sandboxing. Only use for local development with human-in-the-loop enabled.
|
> **Warning**: `local` runs commands directly on your host with no sandboxing. Only use for local development with human-in-the-loop enabled.
|
||||||
|
|
||||||
|
For `langsmith`, sandboxes default to the same LangSmith credentials as tracing. To run sandboxes against a **different** LangSmith workspace, set `SANDBOX_LANGSMITH_API_KEY` (falls back to `LANGSMITH_API_KEY` / `LANGSMITH_API_KEY_PROD`) and optionally `SANDBOX_LANGSMITH_ENDPOINT` (falls back to `LANGSMITH_ENDPOINT`). These apply to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds — the `DEFAULT_SANDBOX_SNAPSHOT_ID` must exist in whichever workspace these credentials point at.
|
||||||
|
|
||||||
### Adding a new sandbox provider
|
### Adding a new sandbox provider
|
||||||
|
|
||||||
1. **Create an integration file** at `agent/integrations/my_provider.py` with a factory function matching this signature:
|
1. **Create an integration file** at `agent/integrations/my_provider.py` with a factory function matching this signature:
|
||||||
|
|
|
||||||
122
tests/reviewer/test_factory_config_isolation.py
Normal file
122
tests/reviewer/test_factory_config_isolation.py
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
"""Tests that get_reviewer_agent and get_chat_agent do not mutate the caller's config."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from langgraph.graph.state import RunnableConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _make_config(recursion_limit: int = 25) -> RunnableConfig:
|
||||||
|
return {
|
||||||
|
"configurable": {"thread_id": None},
|
||||||
|
"recursion_limit": recursion_limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_reviewer_agent_does_not_mutate_caller_config() -> None:
|
||||||
|
"""get_reviewer_agent must not overwrite the caller's recursion_limit."""
|
||||||
|
from agent import reviewer
|
||||||
|
|
||||||
|
config = _make_config(recursion_limit=25)
|
||||||
|
original_limit = config["recursion_limit"]
|
||||||
|
|
||||||
|
fake_pregel = MagicMock()
|
||||||
|
fake_pregel.with_config = MagicMock(return_value=fake_pregel)
|
||||||
|
|
||||||
|
with patch("agent.reviewer.create_deep_agent", return_value=fake_pregel):
|
||||||
|
await reviewer.get_reviewer_agent(config)
|
||||||
|
|
||||||
|
assert config["recursion_limit"] == original_limit, (
|
||||||
|
f"get_reviewer_agent mutated caller's recursion_limit: "
|
||||||
|
f"expected {original_limit}, got {config['recursion_limit']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_reviewer_agent_applies_default_when_limit_unset() -> None:
|
||||||
|
"""get_reviewer_agent should apply DEFAULT_RECURSION_LIMIT when the caller didn't set one."""
|
||||||
|
from agent import reviewer
|
||||||
|
|
||||||
|
config: RunnableConfig = {"configurable": {"thread_id": None}}
|
||||||
|
|
||||||
|
fake_pregel = MagicMock()
|
||||||
|
fake_pregel.with_config = MagicMock(return_value=fake_pregel)
|
||||||
|
|
||||||
|
with patch("agent.reviewer.create_deep_agent", return_value=fake_pregel):
|
||||||
|
await reviewer.get_reviewer_agent(config)
|
||||||
|
|
||||||
|
assert "recursion_limit" not in config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_chat_agent_does_not_mutate_caller_config() -> None:
|
||||||
|
"""get_chat_agent must not overwrite the caller's recursion_limit."""
|
||||||
|
from agent import chat
|
||||||
|
|
||||||
|
config = _make_config(recursion_limit=50)
|
||||||
|
original_limit = config["recursion_limit"]
|
||||||
|
|
||||||
|
fake_pregel = MagicMock()
|
||||||
|
fake_pregel.with_config = MagicMock(return_value=fake_pregel)
|
||||||
|
|
||||||
|
with patch("agent.chat.create_deep_agent", return_value=fake_pregel):
|
||||||
|
await chat.get_chat_agent(config)
|
||||||
|
|
||||||
|
assert config["recursion_limit"] == original_limit, (
|
||||||
|
f"get_chat_agent mutated caller's recursion_limit: "
|
||||||
|
f"expected {original_limit}, got {config['recursion_limit']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_chat_agent_applies_default_when_limit_unset() -> None:
|
||||||
|
"""get_chat_agent should apply DEFAULT_RECURSION_LIMIT when the caller didn't set one."""
|
||||||
|
from agent import chat
|
||||||
|
|
||||||
|
config: RunnableConfig = {"configurable": {"thread_id": None}}
|
||||||
|
|
||||||
|
fake_pregel = MagicMock()
|
||||||
|
fake_pregel.with_config = MagicMock(return_value=fake_pregel)
|
||||||
|
|
||||||
|
with patch("agent.chat.create_deep_agent", return_value=fake_pregel):
|
||||||
|
await chat.get_chat_agent(config)
|
||||||
|
|
||||||
|
assert "recursion_limit" not in config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("module_name", "factory_name"),
|
||||||
|
[("agent.reviewer", "get_reviewer_agent"), ("agent.chat", "get_chat_agent")],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_factory_copies_config_dicts_but_preserves_runtime_objects(
|
||||||
|
module_name: str, factory_name: str
|
||||||
|
) -> None:
|
||||||
|
"""Factory config isolation must preserve callback and configurable value identities."""
|
||||||
|
module = __import__(module_name, fromlist=[factory_name])
|
||||||
|
factory = getattr(module, factory_name)
|
||||||
|
callback = object()
|
||||||
|
configurable_value = object()
|
||||||
|
callbacks = [callback]
|
||||||
|
config: RunnableConfig = {
|
||||||
|
"configurable": {"thread_id": None, "custom_key": configurable_value},
|
||||||
|
"callbacks": callbacks,
|
||||||
|
}
|
||||||
|
|
||||||
|
fake_pregel = MagicMock()
|
||||||
|
fake_pregel.with_config = MagicMock(return_value=fake_pregel)
|
||||||
|
|
||||||
|
with patch(f"{module_name}.create_deep_agent", return_value=fake_pregel):
|
||||||
|
await factory(config)
|
||||||
|
|
||||||
|
bound_config = fake_pregel.with_config.call_args.args[0]
|
||||||
|
assert bound_config is not config
|
||||||
|
assert bound_config["configurable"] is not config["configurable"]
|
||||||
|
assert bound_config["configurable"]["custom_key"] is configurable_value
|
||||||
|
assert bound_config["callbacks"] is callbacks
|
||||||
|
assert bound_config["callbacks"][0] is callback
|
||||||
|
assert "recursion_limit" not in config
|
||||||
|
assert config["configurable"] == {"thread_id": None, "custom_key": configurable_value}
|
||||||
|
|
@ -1288,13 +1288,17 @@ async def test_reviewer_populates_diff_line_set_from_github_api() -> None:
|
||||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||||
patch("agent.reviewer.create_deep_agent", side_effect=fake_create_deep_agent),
|
patch("agent.reviewer.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||||
):
|
):
|
||||||
await reviewer.get_reviewer_agent(config)
|
agent = await reviewer.get_reviewer_agent(config)
|
||||||
|
|
||||||
mock_fetch_diff.assert_awaited_once_with(
|
mock_fetch_diff.assert_awaited_once_with(
|
||||||
owner="acme", repo="repo", pr_number=42, token="gh-token"
|
owner="acme", repo="repo", pr_number=42, token="gh-token"
|
||||||
)
|
)
|
||||||
assert config["configurable"]["diff_text"] == pr_diff
|
# get_reviewer_agent copies the caller's config before mutating it (it must not
|
||||||
assert config["configurable"]["diff_line_set"] == {"in_diff.py": {"RIGHT": {10}, "LEFT": {1}}}
|
# mutate the caller's dict in place), so assert against the config actually
|
||||||
|
# bound to the returned agent rather than the original input dict.
|
||||||
|
bound_configurable = agent.config["configurable"]
|
||||||
|
assert bound_configurable["diff_text"] == pr_diff
|
||||||
|
assert bound_configurable["diff_line_set"] == {"in_diff.py": {"RIGHT": {10}, "LEFT": {1}}}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -1353,10 +1357,14 @@ async def test_reviewer_leaves_validation_disabled_when_diff_fetch_fails() -> No
|
||||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||||
patch("agent.reviewer.create_deep_agent", side_effect=fake_create_deep_agent),
|
patch("agent.reviewer.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||||
):
|
):
|
||||||
await reviewer.get_reviewer_agent(config)
|
agent = await reviewer.get_reviewer_agent(config)
|
||||||
|
|
||||||
assert config["configurable"]["diff_text"] == ""
|
# get_reviewer_agent copies the caller's config before mutating it (it must not
|
||||||
assert config["configurable"]["diff_line_set"] is None
|
# mutate the caller's dict in place), so assert against the config actually
|
||||||
|
# bound to the returned agent rather than the original input dict.
|
||||||
|
bound_configurable = agent.config["configurable"]
|
||||||
|
assert bound_configurable["diff_text"] == ""
|
||||||
|
assert bound_configurable["diff_line_set"] is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
"""Tests for LangSmith sandbox env-var configuration parsing."""
|
"""Tests for LangSmith sandbox env-var configuration parsing."""
|
||||||
|
|
||||||
from unittest.mock import patch
|
import base64
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -11,10 +13,62 @@ from agent.integrations.langsmith import (
|
||||||
DEFAULT_SANDBOX_VCPUS,
|
DEFAULT_SANDBOX_VCPUS,
|
||||||
DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES,
|
DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES,
|
||||||
LangSmithProvider,
|
LangSmithProvider,
|
||||||
|
_get_sandbox_api_endpoint,
|
||||||
|
_get_sandbox_create_extra_fields,
|
||||||
_get_sandbox_snapshot_config,
|
_get_sandbox_snapshot_config,
|
||||||
|
_install_create_extra_fields,
|
||||||
|
_release_sandbox_name,
|
||||||
|
_sandbox_name_for_thread,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sandbox_api_endpoint_appends_v2_sandboxes() -> None:
|
||||||
|
with patch.dict("os.environ", {"LANGSMITH_ENDPOINT": "https://eu.smith.langchain.com"}):
|
||||||
|
assert _get_sandbox_api_endpoint() == "https://eu.smith.langchain.com/v2/sandboxes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sandbox_api_endpoint_no_double_suffix() -> None:
|
||||||
|
with patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{"SANDBOX_LANGSMITH_ENDPOINT": "https://x.smith.langchain.com/v2/sandboxes"},
|
||||||
|
):
|
||||||
|
assert _get_sandbox_api_endpoint() == "https://x.smith.langchain.com/v2/sandboxes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sandbox_name_for_thread_encodes_uuid() -> None:
|
||||||
|
thread_id = "12345678-1234-5678-1234-567812345678"
|
||||||
|
name = _sandbox_name_for_thread(thread_id)
|
||||||
|
assert name is not None
|
||||||
|
prefix, _, encoded = name.partition("-")
|
||||||
|
assert prefix == "openswe"
|
||||||
|
assert encoded == encoded.lower()
|
||||||
|
assert "=" not in encoded and "-" not in encoded
|
||||||
|
# Round-trips back to the original UUID.
|
||||||
|
padded = encoded.upper() + "=" * (-len(encoded) % 8)
|
||||||
|
assert uuid.UUID(bytes=base64.b32decode(padded)) == uuid.UUID(thread_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sandbox_name_for_thread_none_or_invalid() -> None:
|
||||||
|
assert _sandbox_name_for_thread(None) is None
|
||||||
|
assert _sandbox_name_for_thread("not-a-uuid") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_sandbox_name_deletes_stale_box() -> None:
|
||||||
|
client = MagicMock()
|
||||||
|
_release_sandbox_name(client, "openswe-abc")
|
||||||
|
client.delete_sandbox.assert_called_once_with("openswe-abc")
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_sandbox_name_swallows_missing_and_skips_none() -> None:
|
||||||
|
client = MagicMock()
|
||||||
|
client.delete_sandbox.side_effect = RuntimeError("not found")
|
||||||
|
_release_sandbox_name(client, "openswe-abc") # must not raise
|
||||||
|
|
||||||
|
client.delete_sandbox.reset_mock(side_effect=True)
|
||||||
|
_release_sandbox_name(client, None)
|
||||||
|
client.delete_sandbox.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_defaults_when_env_unset() -> None:
|
def test_defaults_when_env_unset() -> None:
|
||||||
with patch.dict(
|
with patch.dict(
|
||||||
"os.environ",
|
"os.environ",
|
||||||
|
|
@ -97,3 +151,67 @@ def test_validate_startup_accepts_valid_config() -> None:
|
||||||
clear=True,
|
clear=True,
|
||||||
):
|
):
|
||||||
LangSmithProvider.validate_startup_config()
|
LangSmithProvider.validate_startup_config()
|
||||||
|
|
||||||
|
|
||||||
|
def test_extra_fields_unset_is_empty() -> None:
|
||||||
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
|
assert _get_sandbox_create_extra_fields() == {}
|
||||||
|
with patch.dict("os.environ", {"SANDBOX_CREATE_EXTRA_JSON": " "}, clear=True):
|
||||||
|
assert _get_sandbox_create_extra_fields() == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_extra_fields_parsed() -> None:
|
||||||
|
with patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{"SANDBOX_CREATE_EXTRA_JSON": '{"_internal_runtime": "v2"}'},
|
||||||
|
clear=True,
|
||||||
|
):
|
||||||
|
assert _get_sandbox_create_extra_fields() == {"_internal_runtime": "v2"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_extra_fields_rejects_invalid_json() -> None:
|
||||||
|
with patch.dict("os.environ", {"SANDBOX_CREATE_EXTRA_JSON": "{not json"}, clear=True):
|
||||||
|
with pytest.raises(ValueError, match="valid JSON"):
|
||||||
|
_get_sandbox_create_extra_fields()
|
||||||
|
|
||||||
|
|
||||||
|
def test_extra_fields_rejects_non_object() -> None:
|
||||||
|
with patch.dict("os.environ", {"SANDBOX_CREATE_EXTRA_JSON": "[1, 2]"}, clear=True):
|
||||||
|
with pytest.raises(ValueError, match="JSON object"):
|
||||||
|
_get_sandbox_create_extra_fields()
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_create_extra_fields_merges_only_boxes_post() -> None:
|
||||||
|
calls: list[tuple[str, dict]] = []
|
||||||
|
|
||||||
|
class _FakeHttp:
|
||||||
|
def post(self, url, **kwargs): # noqa: ANN001, ANN003
|
||||||
|
calls.append((url, kwargs.get("json")))
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._http = _FakeHttp()
|
||||||
|
|
||||||
|
client = _FakeClient()
|
||||||
|
_install_create_extra_fields(client, {"_internal_runtime": "v2"})
|
||||||
|
|
||||||
|
client._http.post("https://api/v2/sandboxes/boxes", json={"snapshot_id": "s"})
|
||||||
|
client._http.post("https://api/v2/sandboxes/boxes/abc/start", json={"foo": "bar"})
|
||||||
|
|
||||||
|
assert calls[0][1] == {"snapshot_id": "s", "_internal_runtime": "v2"}
|
||||||
|
assert calls[1][1] == {"foo": "bar"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_create_extra_fields_noop_when_empty() -> None:
|
||||||
|
class _FakeHttp:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.post = "sentinel"
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._http = _FakeHttp()
|
||||||
|
|
||||||
|
client = _FakeClient()
|
||||||
|
_install_create_extra_fields(client, {})
|
||||||
|
assert client._http.post == "sentinel"
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,35 @@ class TestConfigureGithubProxy:
|
||||||
headers = mock_client.patch.call_args.kwargs["headers"]
|
headers = mock_client.patch.call_args.kwargs["headers"]
|
||||||
assert headers == {"X-API-Key": "my-api-key"}
|
assert headers == {"X-API-Key": "my-api-key"}
|
||||||
|
|
||||||
|
def test_sandbox_overrides_take_precedence(self) -> None:
|
||||||
|
"""SANDBOX_LANGSMITH_* override the shared key/endpoint for the proxy call."""
|
||||||
|
with (
|
||||||
|
patch("agent.integrations.langsmith.httpx.Client") as mock_client_cls,
|
||||||
|
patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{
|
||||||
|
"LANGSMITH_API_KEY": "shared-key",
|
||||||
|
"LANGSMITH_ENDPOINT": "https://shared.smith.langchain.com",
|
||||||
|
"SANDBOX_LANGSMITH_API_KEY": "sandbox-key",
|
||||||
|
"SANDBOX_LANGSMITH_ENDPOINT": "https://sandbox.smith.langchain.com",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_client.patch.return_value = mock_response
|
||||||
|
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||||
|
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
_configure_github_proxy("sandbox-abc", "token")
|
||||||
|
|
||||||
|
assert (
|
||||||
|
mock_client.patch.call_args.args[0]
|
||||||
|
== "https://sandbox.smith.langchain.com/v2/sandboxes/boxes/sandbox-abc"
|
||||||
|
)
|
||||||
|
assert mock_client.patch.call_args.kwargs["headers"] == {"X-API-Key": "sandbox-key"}
|
||||||
|
|
||||||
def test_retries_transient_http_error(self) -> None:
|
def test_retries_transient_http_error(self) -> None:
|
||||||
"""Transient proxy API errors should be retried on the same sandbox."""
|
"""Transient proxy API errors should be retried on the same sandbox."""
|
||||||
request = httpx.Request(
|
request = httpx.Request(
|
||||||
|
|
|
||||||
131
tests/tools/test_linear_search_issues.py
Normal file
131
tests/tools/test_linear_search_issues.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agent.utils import linear
|
||||||
|
|
||||||
|
linear_search_tool = importlib.import_module("agent.tools.linear_search_issues")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_issues_returns_results_and_pagination(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
|
async def fake_graphql_request(
|
||||||
|
query: str, variables: dict[str, Any] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
captured.update({"query": query, "variables": variables})
|
||||||
|
return {
|
||||||
|
"searchIssues": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "issue-id",
|
||||||
|
"identifier": "DCD-20",
|
||||||
|
"title": "User-message styling improvement",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalCount": 12,
|
||||||
|
"pageInfo": {"hasNextPage": True, "endCursor": "next-page"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(linear, "_graphql_request", fake_graphql_request)
|
||||||
|
|
||||||
|
result = await linear.search_issues(
|
||||||
|
" user message styling ",
|
||||||
|
team_id="team-id",
|
||||||
|
limit=5,
|
||||||
|
include_archived=True,
|
||||||
|
include_comments=True,
|
||||||
|
after="current-page",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "searchIssues" in captured["query"]
|
||||||
|
assert captured["variables"] == {
|
||||||
|
"query": "user message styling",
|
||||||
|
"filter": {"team": {"id": {"eq": "team-id"}}},
|
||||||
|
"limit": 5,
|
||||||
|
"includeArchived": True,
|
||||||
|
"includeComments": True,
|
||||||
|
"after": "current-page",
|
||||||
|
}
|
||||||
|
assert result == {
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"id": "issue-id",
|
||||||
|
"identifier": "DCD-20",
|
||||||
|
"title": "User-message styling improvement",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_count": 12,
|
||||||
|
"page_info": {"hasNextPage": True, "endCursor": "next-page"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_issues_rejects_blank_query(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
async def unexpected_request(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
|
||||||
|
pytest.fail("GraphQL request should not be made")
|
||||||
|
|
||||||
|
monkeypatch.setattr(linear, "_graphql_request", unexpected_request)
|
||||||
|
|
||||||
|
result = await linear.search_issues(" ")
|
||||||
|
|
||||||
|
assert result == {"error": "Search query must not be empty"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("limit", [0, 51])
|
||||||
|
async def test_search_issues_rejects_invalid_limit(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, limit: int
|
||||||
|
) -> None:
|
||||||
|
async def unexpected_request(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
|
||||||
|
pytest.fail("GraphQL request should not be made")
|
||||||
|
|
||||||
|
monkeypatch.setattr(linear, "_graphql_request", unexpected_request)
|
||||||
|
|
||||||
|
result = await linear.search_issues("styling", limit=limit)
|
||||||
|
|
||||||
|
assert result == {"error": "Search limit must be between 1 and 50"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_issues_propagates_graphql_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
async def fake_graphql_request(
|
||||||
|
_query: str, _variables: dict[str, Any] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {"error": "rate limited"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(linear, "_graphql_request", fake_graphql_request)
|
||||||
|
|
||||||
|
assert await linear.search_issues("styling") == {"error": "rate limited"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_linear_search_issues_tool_delegates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
|
async def fake_search_issues(**kwargs: Any) -> dict[str, Any]:
|
||||||
|
captured.update(kwargs)
|
||||||
|
return {"issues": []}
|
||||||
|
|
||||||
|
monkeypatch.setattr(linear_search_tool, "search_issues", fake_search_issues)
|
||||||
|
|
||||||
|
result = await linear_search_tool.linear_search_issues(
|
||||||
|
"styling",
|
||||||
|
team_id="team-id",
|
||||||
|
limit=20,
|
||||||
|
include_archived=True,
|
||||||
|
include_comments=True,
|
||||||
|
after="cursor",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"issues": []}
|
||||||
|
assert captured == {
|
||||||
|
"query": "styling",
|
||||||
|
"team_id": "team-id",
|
||||||
|
"limit": 20,
|
||||||
|
"include_archived": True,
|
||||||
|
"include_comments": True,
|
||||||
|
"after": "cursor",
|
||||||
|
}
|
||||||
|
|
@ -88,9 +88,6 @@ function commonDirPrefix(paths: Array<string>): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
const PANEL_STORAGE_WIDTH = "open-swe.gitpanel.width"
|
const PANEL_STORAGE_WIDTH = "open-swe.gitpanel.width"
|
||||||
const PANEL_STORAGE_COLLAPSED = "open-swe.gitpanel.collapsed"
|
|
||||||
const COLLAPSED_STATE_TRUE = "1"
|
|
||||||
const COLLAPSED_STATE_FALSE = "0"
|
|
||||||
const PANEL_DEFAULT_WIDTH = 420
|
const PANEL_DEFAULT_WIDTH = 420
|
||||||
const PANEL_MIN_WIDTH = 320
|
const PANEL_MIN_WIDTH = 320
|
||||||
// Keep at least this much room for the chat so the panel can grow to nearly the
|
// Keep at least this much room for the chat so the panel can grow to nearly the
|
||||||
|
|
@ -119,23 +116,6 @@ function readStoredPanelWidth(): number {
|
||||||
return clampPanelWidth(parsed)
|
return clampPanelWidth(parsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readStoredPanelCollapsed(): boolean {
|
|
||||||
if (typeof window === "undefined") return true
|
|
||||||
// Default to collapsed until the user opens it once.
|
|
||||||
return (
|
|
||||||
window.localStorage.getItem(PANEL_STORAGE_COLLAPSED) !==
|
|
||||||
COLLAPSED_STATE_FALSE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeStoredPanelCollapsed(collapsed: boolean): void {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
window.localStorage.setItem(
|
|
||||||
PANEL_STORAGE_COLLAPSED,
|
|
||||||
collapsed ? COLLAPSED_STATE_TRUE : COLLAPSED_STATE_FALSE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PanelResizeHandle({
|
function PanelResizeHandle({
|
||||||
width,
|
width,
|
||||||
onResize,
|
onResize,
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,12 @@ import type { ModelSelection } from "@/features/agents/lib/provider/useModelOpti
|
||||||
import {
|
import {
|
||||||
AgentGitPanel,
|
AgentGitPanel,
|
||||||
PANEL_MIN_CHAT_WIDTH,
|
PANEL_MIN_CHAT_WIDTH,
|
||||||
readStoredPanelCollapsed,
|
|
||||||
writeStoredPanelCollapsed,
|
|
||||||
} from "@/features/agents/components/AgentGitPanel"
|
} from "@/features/agents/components/AgentGitPanel"
|
||||||
import { AgentPromptBar } from "@/features/agents/components/AgentPromptBar"
|
import { AgentPromptBar } from "@/features/agents/components/AgentPromptBar"
|
||||||
|
import {
|
||||||
|
readStoredPanelCollapsed,
|
||||||
|
writeStoredPanelCollapsed,
|
||||||
|
} from "@/features/agents/lib/gitPanelPreferences"
|
||||||
import { Messages } from "@/features/agents/components/messages"
|
import { Messages } from "@/features/agents/components/messages"
|
||||||
import { streamMessagesToUi } from "@/features/agents/lib/streamMessagesToUi"
|
import { streamMessagesToUi } from "@/features/agents/lib/streamMessagesToUi"
|
||||||
import { messageArrivalTimestamp } from "@/features/agents/lib/messageTimestamps"
|
import { messageArrivalTimestamp } from "@/features/agents/lib/messageTimestamps"
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,10 @@ function triToBool(value: TriState): boolean | undefined {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function displayStatus(status: AgentStatus): string {
|
||||||
|
return STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status
|
||||||
|
}
|
||||||
|
|
||||||
export function AgentsThreadsPage({
|
export function AgentsThreadsPage({
|
||||||
filters,
|
filters,
|
||||||
onFiltersChange,
|
onFiltersChange,
|
||||||
|
|
@ -279,8 +283,8 @@ function ThreadListItem({ thread }: { thread: AgentThread }) {
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm text-[var(--ui-text)]">{thread.title}</p>
|
<p className="truncate text-sm text-[var(--ui-text)]">{thread.title}</p>
|
||||||
<p className="truncate text-[11px] text-[var(--ui-text-dim)]">
|
<p className="truncate text-[11px] text-[var(--ui-text-dim)]">
|
||||||
{thread.repoFullName || "no repo"} · {thread.status}
|
{thread.repoFullName || "No repo"} · {displayStatus(thread.status)}
|
||||||
{isResolved ? " · resolved" : ""}
|
{isResolved ? " · Resolved" : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -75,11 +75,11 @@ export function WorkflowApprovalCard({
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[var(--ui-text-dim)]">
|
<p className="text-xs text-[var(--ui-text-dim)]">
|
||||||
{approval.repo || "Repository"} on{" "}
|
{approval.repo || "Repository"} on{" "}
|
||||||
{approval.branch || "current branch"} ·{" "}
|
{approval.branch || "Current branch"} ·{" "}
|
||||||
{shortSha(approval.baseSha)} → {shortSha(approval.headSha)}
|
{shortSha(approval.baseSha)} → {shortSha(approval.headSha)}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-[0.68rem] break-all text-[var(--ui-text-dim)]">
|
<p className="font-mono text-[0.68rem] break-all text-[var(--ui-text-dim)]">
|
||||||
fingerprint: {approval.fingerprint}
|
Fingerprint: {approval.fingerprint}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-wrap gap-2">
|
<div className="flex shrink-0 flex-wrap gap-2">
|
||||||
|
|
|
||||||
|
|
@ -393,7 +393,7 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`data:${image.mimeType};base64,${image.base64}`}
|
src={`data:${image.mimeType};base64,${image.base64}`}
|
||||||
alt={image.fileName || "pending image"}
|
alt={image.fileName || "Pending image"}
|
||||||
className="size-16 rounded-lg border border-[var(--ui-border)] object-cover"
|
className="size-16 rounded-lg border border-[var(--ui-border)] object-cover"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
49
ui/src/features/agents/components/chat/ToolExecution.test.ts
Normal file
49
ui/src/features/agents/components/chat/ToolExecution.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { formatToolDisplay } from "./toolExecutionDisplay";
|
||||||
|
|
||||||
|
describe("formatToolDisplay", () => {
|
||||||
|
const projectPath = "/workspace/open-swe";
|
||||||
|
|
||||||
|
it("renders read_file with the file_path alias consistently", () => {
|
||||||
|
expect(
|
||||||
|
formatToolDisplay(
|
||||||
|
"read_file /workspace/open-swe/AGENTS.md",
|
||||||
|
"read",
|
||||||
|
{ file_path: "/workspace/open-swe/AGENTS.md" },
|
||||||
|
projectPath,
|
||||||
|
),
|
||||||
|
).toBe("Read AGENTS.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders ls as a list operation with a relative path", () => {
|
||||||
|
expect(
|
||||||
|
formatToolDisplay(
|
||||||
|
"ls /workspace/open-swe/ui",
|
||||||
|
"read",
|
||||||
|
{ path: "/workspace/open-swe/ui" },
|
||||||
|
projectPath,
|
||||||
|
),
|
||||||
|
).toBe("List ui");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders search tools with their pattern", () => {
|
||||||
|
expect(
|
||||||
|
formatToolDisplay("grep", "search", { pattern: "tool_calls" }, projectPath),
|
||||||
|
).toBe('Search "tool_calls"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes write_todos", () => {
|
||||||
|
expect(formatToolDisplay("write todos", "other", {}, projectPath)).toBe("Update todos");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sentence-cases raw tool names", () => {
|
||||||
|
expect(formatToolDisplay("enter_plan_mode", "other", {}, projectPath)).toBe(
|
||||||
|
"Enter plan mode",
|
||||||
|
);
|
||||||
|
expect(formatToolDisplay("save_plan", "other", {}, projectPath)).toBe("Save plan");
|
||||||
|
expect(formatToolDisplay("slack_thread_reply", "other", {}, projectPath)).toBe(
|
||||||
|
"Slack thread reply",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { MultiFileDiff } from "@pierre/diffs/react";
|
import { MultiFileDiff } from "@pierre/diffs/react";
|
||||||
import { DiffView } from "./DiffView";
|
import { DiffView } from "./DiffView";
|
||||||
import type { AcpToolKind, ToolExecutionChunk } from "@/features/agents/lib/types";
|
import { formatToolDisplay } from "./toolExecutionDisplay";
|
||||||
|
import type { ToolExecutionChunk } from "@/features/agents/lib/types";
|
||||||
import { useDiffOptions } from "@/features/agents/utils/diffUtils";
|
import { useDiffOptions } from "@/features/agents/utils/diffUtils";
|
||||||
import { countLineChanges } from "@/features/agents/utils/diffStats";
|
import { countLineChanges } from "@/features/agents/utils/diffStats";
|
||||||
|
|
||||||
|
|
@ -27,60 +28,6 @@ function getFileName(path: string): string {
|
||||||
return parts[parts.length - 1] || path;
|
return parts[parts.length - 1] || path;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatToolDisplay(
|
|
||||||
title: string,
|
|
||||||
toolKind: AcpToolKind,
|
|
||||||
input: Record<string, unknown> | undefined,
|
|
||||||
projectPath?: string,
|
|
||||||
): string {
|
|
||||||
const path = input?.path as string | undefined;
|
|
||||||
const pattern = input?.pattern as string | undefined;
|
|
||||||
const query = input?.query as string | undefined;
|
|
||||||
const url = input?.url as string | undefined;
|
|
||||||
const command = input?.command as string | undefined;
|
|
||||||
|
|
||||||
switch (toolKind) {
|
|
||||||
case "read": {
|
|
||||||
if (path) {
|
|
||||||
const displayPath = stripProjectPath(path, projectPath);
|
|
||||||
return `Read(${displayPath})`;
|
|
||||||
}
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
case "search": {
|
|
||||||
if (pattern) {
|
|
||||||
const truncated = pattern.length > 40 ? pattern.slice(0, 40) + "..." : pattern;
|
|
||||||
return `Search("${truncated}")`;
|
|
||||||
}
|
|
||||||
if (query) {
|
|
||||||
return `Search("${query.slice(0, 40)}${query.length > 40 ? "..." : ""}")`;
|
|
||||||
}
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
case "fetch": {
|
|
||||||
if (url) {
|
|
||||||
return `Fetch(${url.slice(0, 50)}${url.length > 50 ? "..." : ""})`;
|
|
||||||
}
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
case "execute": {
|
|
||||||
if (command) {
|
|
||||||
const truncated = command.length > 60 ? command.slice(0, 60) + "..." : command;
|
|
||||||
return `Shell(${truncated})`;
|
|
||||||
}
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
case "edit":
|
|
||||||
case "delete":
|
|
||||||
case "move":
|
|
||||||
return title;
|
|
||||||
case "think":
|
|
||||||
return "Thinking...";
|
|
||||||
default:
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const InlineDiffCollapsible = memo(function InlineDiffCollapsible({
|
const InlineDiffCollapsible = memo(function InlineDiffCollapsible({
|
||||||
filePath,
|
filePath,
|
||||||
fileName,
|
fileName,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { humanizeToolName } from "@/features/agents/lib/toolNames";
|
||||||
|
import type { AcpToolKind } from "@/features/agents/lib/types";
|
||||||
|
|
||||||
|
function stripProjectPath(path: string, projectPath?: string): string {
|
||||||
|
if (!projectPath || !path.startsWith(projectPath)) return path;
|
||||||
|
const relative = path.slice(projectPath.length);
|
||||||
|
return relative.replace(/^\/+/, "") || ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstStringArg(
|
||||||
|
input: Record<string, unknown> | undefined,
|
||||||
|
keys: Array<string>,
|
||||||
|
): string | undefined {
|
||||||
|
if (!input) return undefined;
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = input[key];
|
||||||
|
if (typeof value === "string" && value.trim()) return value.trim();
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateMiddle(value: string, maxLength: number): string {
|
||||||
|
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedToolName(title: string): string {
|
||||||
|
return title.trim().split(/\s+/, 1)[0]?.toLowerCase() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanizeToolTitle(title: string): string {
|
||||||
|
const trimmed = title.trim();
|
||||||
|
if (!trimmed) return "Tool";
|
||||||
|
|
||||||
|
const [name, ...rest] = trimmed.split(/\s+/);
|
||||||
|
const suffix = rest.join(" ");
|
||||||
|
if (name && suffix && /^(?:[./~]|[a-z]+:\/\/)/i.test(suffix)) {
|
||||||
|
return `${humanizeToolName(name)} ${suffix}`;
|
||||||
|
}
|
||||||
|
return humanizeToolName(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatToolDisplay(
|
||||||
|
title: string,
|
||||||
|
toolKind: AcpToolKind,
|
||||||
|
input: Record<string, unknown> | undefined,
|
||||||
|
projectPath?: string,
|
||||||
|
): string {
|
||||||
|
const toolName = normalizedToolName(title);
|
||||||
|
const path = firstStringArg(input, ["path", "file_path", "target_file"]);
|
||||||
|
const pattern = firstStringArg(input, ["pattern"]);
|
||||||
|
const query = firstStringArg(input, ["query"]);
|
||||||
|
const url = firstStringArg(input, ["url"]);
|
||||||
|
const command = firstStringArg(input, ["command"]);
|
||||||
|
|
||||||
|
switch (toolKind) {
|
||||||
|
case "read": {
|
||||||
|
if (path) {
|
||||||
|
const displayPath = stripProjectPath(path, projectPath);
|
||||||
|
return toolName === "ls" ? `List ${displayPath}` : `Read ${displayPath}`;
|
||||||
|
}
|
||||||
|
return humanizeToolTitle(title);
|
||||||
|
}
|
||||||
|
case "search": {
|
||||||
|
if (pattern) return `Search "${truncateMiddle(pattern, 40)}"`;
|
||||||
|
if (query) return `Search "${truncateMiddle(query, 40)}"`;
|
||||||
|
if (path) return `Search ${stripProjectPath(path, projectPath)}`;
|
||||||
|
return humanizeToolTitle(title);
|
||||||
|
}
|
||||||
|
case "fetch": {
|
||||||
|
if (url) return `Fetch ${truncateMiddle(url, 50)}`;
|
||||||
|
return humanizeToolTitle(title);
|
||||||
|
}
|
||||||
|
case "execute": {
|
||||||
|
if (command) return `Shell ${truncateMiddle(command, 60)}`;
|
||||||
|
return humanizeToolTitle(title);
|
||||||
|
}
|
||||||
|
case "edit":
|
||||||
|
case "delete":
|
||||||
|
case "move":
|
||||||
|
return humanizeToolTitle(title);
|
||||||
|
case "think":
|
||||||
|
return "Thinking...";
|
||||||
|
default: {
|
||||||
|
if (toolName === "write_todos" || title.toLowerCase().startsWith("write todos")) {
|
||||||
|
return "Update todos";
|
||||||
|
}
|
||||||
|
if (toolName === "ls" && path) return `List ${stripProjectPath(path, projectPath)}`;
|
||||||
|
return humanizeToolTitle(title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,17 +3,17 @@ import { useEffect, useRef, useState } from "react";
|
||||||
import { formatElapsed } from "@/lib/utils";
|
import { formatElapsed } from "@/lib/utils";
|
||||||
|
|
||||||
const BUSY_TEXTS: Array<{ present: string; past: string }> = [
|
const BUSY_TEXTS: Array<{ present: string; past: string }> = [
|
||||||
{ present: "vibing...", past: "Vibed" },
|
{ present: "Vibing...", past: "Vibed" },
|
||||||
{ present: "noodling...", past: "Noodled" },
|
{ present: "Noodling...", past: "Noodled" },
|
||||||
{ present: "pondering...", past: "Pondered" },
|
{ present: "Pondering...", past: "Pondered" },
|
||||||
{ present: "thinking really hard...", past: "Thought really hard" },
|
{ present: "Thinking really hard...", past: "Thought really hard" },
|
||||||
{ present: "spinning up...", past: "Spun up" },
|
{ present: "Spinning up...", past: "Spun up" },
|
||||||
{ present: "connecting the dots...", past: "Connected the dots" },
|
{ present: "Connecting the dots...", past: "Connected the dots" },
|
||||||
{ present: "brewing ideas...", past: "Brewed ideas" },
|
{ present: "Brewing ideas...", past: "Brewed ideas" },
|
||||||
{ present: "cooking...", past: "Cooked" },
|
{ present: "Cooking...", past: "Cooked" },
|
||||||
{ present: "crunching...", past: "Crunched" },
|
{ present: "Crunching...", past: "Crunched" },
|
||||||
{ present: "scheming...", past: "Schemed" },
|
{ present: "Scheming...", past: "Schemed" },
|
||||||
{ present: "processing...", past: "Processed" },
|
{ present: "Processing...", past: "Processed" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const THINKING_SETTLE_MS = 300;
|
const THINKING_SETTLE_MS = 300;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { useStreamContext as useAgentThreadStream, useToolCalls } from "@langchain/react"
|
import { useStreamContext as useAgentThreadStream, useToolCalls } from "@langchain/react"
|
||||||
import { Check, Loader2, X } from "lucide-react"
|
import { Check, Loader2, X } from "lucide-react"
|
||||||
|
|
||||||
function humanizeToolName(name: string): string {
|
import { humanizeToolName } from "@/features/agents/lib/toolNames"
|
||||||
return name.replace(/_/g, " ").trim() || "tool"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live status for a single subagent, read straight from the SDK's scoped
|
* Live status for a single subagent, read straight from the SDK's scoped
|
||||||
|
|
|
||||||
|
|
@ -528,7 +528,7 @@ export const PromptBar = memo(function PromptBar({
|
||||||
<div key={i} className="relative group">
|
<div key={i} className="relative group">
|
||||||
<img
|
<img
|
||||||
src={`data:${img.mimeType};base64,${img.base64}`}
|
src={`data:${img.mimeType};base64,${img.base64}`}
|
||||||
alt={img.fileName || "pending image"}
|
alt={img.fileName || "Pending image"}
|
||||||
className="w-16 h-16 object-cover rounded border border-gray-600"
|
className="w-16 h-16 object-cover rounded border border-gray-600"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
58
ui/src/features/agents/lib/gitPanelPreferences.test.ts
Normal file
58
ui/src/features/agents/lib/gitPanelPreferences.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
/** @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
import {
|
||||||
|
readStoredPanelCollapsed,
|
||||||
|
writeStoredPanelCollapsed,
|
||||||
|
} from "./gitPanelPreferences"
|
||||||
|
|
||||||
|
function mockViewport(matches: boolean): void {
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear()
|
||||||
|
mockViewport(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("git panel collapsed preference", () => {
|
||||||
|
it("defaults to collapsed before really wide screens", () => {
|
||||||
|
mockViewport(false)
|
||||||
|
|
||||||
|
expect(readStoredPanelCollapsed()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("defaults to expanded on really wide screens", () => {
|
||||||
|
mockViewport(true)
|
||||||
|
|
||||||
|
expect(readStoredPanelCollapsed()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps an explicit collapsed preference on really wide screens", () => {
|
||||||
|
mockViewport(true)
|
||||||
|
writeStoredPanelCollapsed(true)
|
||||||
|
|
||||||
|
expect(readStoredPanelCollapsed()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps an explicit expanded preference before really wide screens", () => {
|
||||||
|
mockViewport(false)
|
||||||
|
writeStoredPanelCollapsed(false)
|
||||||
|
|
||||||
|
expect(readStoredPanelCollapsed()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
20
ui/src/features/agents/lib/gitPanelPreferences.ts
Normal file
20
ui/src/features/agents/lib/gitPanelPreferences.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
const PANEL_STORAGE_COLLAPSED = "open-swe.gitpanel.collapsed"
|
||||||
|
const COLLAPSED_STATE_TRUE = "1"
|
||||||
|
const COLLAPSED_STATE_FALSE = "0"
|
||||||
|
const PANEL_DEFAULT_EXPANDED_MEDIA_QUERY = "(min-width: 1536px)"
|
||||||
|
|
||||||
|
export function readStoredPanelCollapsed(): boolean {
|
||||||
|
if (typeof window === "undefined") return true
|
||||||
|
const stored = window.localStorage.getItem(PANEL_STORAGE_COLLAPSED)
|
||||||
|
if (stored === COLLAPSED_STATE_TRUE) return true
|
||||||
|
if (stored === COLLAPSED_STATE_FALSE) return false
|
||||||
|
return !window.matchMedia(PANEL_DEFAULT_EXPANDED_MEDIA_QUERY).matches
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeStoredPanelCollapsed(collapsed: boolean): void {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.localStorage.setItem(
|
||||||
|
PANEL_STORAGE_COLLAPSED,
|
||||||
|
collapsed ? COLLAPSED_STATE_TRUE : COLLAPSED_STATE_FALSE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import { AIMessage, HumanMessage, ToolMessage } from "@langchain/core/messages";
|
import { AIMessage, HumanMessage, ToolMessage } from "@langchain/core/messages";
|
||||||
import { messageArrivalTimestamp } from "./messageTimestamps";
|
import { messageArrivalTimestamp } from "./messageTimestamps";
|
||||||
|
import { humanizeToolName } from "./toolNames";
|
||||||
import type { BaseMessage, ContentBlock } from "@langchain/core/messages";
|
import type { BaseMessage, ContentBlock } from "@langchain/core/messages";
|
||||||
import type { AssembledToolCall, SubagentDiscoverySnapshot } from "@langchain/react";
|
import type { AssembledToolCall, SubagentDiscoverySnapshot } from "@langchain/react";
|
||||||
|
|
||||||
import type { Chunk, DiffData, Message, ToolExecutionChunk } from "./types";
|
import type { Chunk, DiffData, Message, ToolExecutionChunk } from "./types";
|
||||||
|
|
||||||
const READ_TOOLS = new Set(["read_file", "read", "glob", "grep"]);
|
const READ_TOOLS = new Set(["read_file", "read", "ls"]);
|
||||||
const EDIT_TOOLS = new Set(["write_file", "edit_file", "str_replace", "write", "edit", "patch"]);
|
const EDIT_TOOLS = new Set(["write_file", "edit_file", "str_replace", "write", "edit", "patch"]);
|
||||||
const EXECUTE_TOOLS = new Set(["execute", "bash", "shell", "run_terminal_cmd"]);
|
const EXECUTE_TOOLS = new Set(["execute", "bash", "shell", "run_terminal_cmd"]);
|
||||||
const SEARCH_TOOLS = new Set(["glob", "grep", "web_search", "fetch_url", "search"]);
|
const SEARCH_TOOLS = new Set(["glob", "grep", "web_search", "search"]);
|
||||||
|
const FETCH_TOOLS = new Set(["fetch", "fetch_url", "http_request"]);
|
||||||
const INTERNAL_TOOLS = new Set(["confirming_completion", "no_op"]);
|
const INTERNAL_TOOLS = new Set(["confirming_completion", "no_op"]);
|
||||||
|
|
||||||
type ToolKind = ToolExecutionChunk["toolKind"];
|
type ToolKind = ToolExecutionChunk["toolKind"];
|
||||||
|
|
@ -19,14 +21,15 @@ function toolKind(name: string): ToolKind {
|
||||||
if (lowered === "task") return "task";
|
if (lowered === "task") return "task";
|
||||||
if (lowered === "slack_thread_reply") return "slack";
|
if (lowered === "slack_thread_reply") return "slack";
|
||||||
if (lowered === "linear_comment") return "linear";
|
if (lowered === "linear_comment") return "linear";
|
||||||
|
if (lowered === "write_todos") return "other";
|
||||||
if (EDIT_TOOLS.has(lowered) || ["edit", "write", "replace"].some((t) => lowered.includes(t))) {
|
if (EDIT_TOOLS.has(lowered) || ["edit", "write", "replace"].some((t) => lowered.includes(t))) {
|
||||||
return "edit";
|
return "edit";
|
||||||
}
|
}
|
||||||
if (EXECUTE_TOOLS.has(lowered)) return "execute";
|
if (EXECUTE_TOOLS.has(lowered)) return "execute";
|
||||||
|
if (FETCH_TOOLS.has(lowered)) return "fetch";
|
||||||
if (SEARCH_TOOLS.has(lowered)) return "search";
|
if (SEARCH_TOOLS.has(lowered)) return "search";
|
||||||
if (READ_TOOLS.has(lowered) || lowered.includes("read")) return "read";
|
if (READ_TOOLS.has(lowered) || lowered.includes("read")) return "read";
|
||||||
if (lowered === "think") return "think";
|
if (lowered === "think") return "think";
|
||||||
if (["fetch", "fetch_url", "http_request"].includes(lowered)) return "fetch";
|
|
||||||
return "other";
|
return "other";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,7 +40,7 @@ function toolTitle(name: string, args: Record<string, unknown>): string {
|
||||||
if (typeof command === "string" && command.trim()) {
|
if (typeof command === "string" && command.trim()) {
|
||||||
return command.trim().split("\n")[0]?.slice(0, 120) ?? "";
|
return command.trim().split("\n")[0]?.slice(0, 120) ?? "";
|
||||||
}
|
}
|
||||||
return name.replace(/_/g, " ").trim() || "Tool";
|
return humanizeToolName(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseToolArgs(raw: unknown): Record<string, unknown> {
|
function parseToolArgs(raw: unknown): Record<string, unknown> {
|
||||||
|
|
|
||||||
10
ui/src/features/agents/lib/toolNames.ts
Normal file
10
ui/src/features/agents/lib/toolNames.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
export function humanizeToolName(name: string, fallback = "Tool"): string {
|
||||||
|
const normalized = name
|
||||||
|
.replace(/[_-]+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.toLowerCase()
|
||||||
|
|
||||||
|
if (!normalized) return fallback
|
||||||
|
return `${normalized.charAt(0).toUpperCase()}${normalized.slice(1)}`
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue