open-swe/agent/server.py
open-swe[bot] 96f97710ad
feat: add optional Slack Assistants API typing status indicator (#1269)
* feat: add optional Slack Assistants API typing status indicator

Mirrors OpenClaw's pragmatic approach: instead of rebuilding around
assistant_thread_started events, just opt into assistants.threads.setStatus
to show 'is thinking…' while the agent is working, and clear it when
post_slack_thread_reply lands. Gated behind SLACK_ASSISTANTS_API_ENABLED so
it can be toggled without touching code.

* fix(slack): drop redundant clear, add status heartbeat across model calls

- Slack auto-clears the typing indicator on bot post; remove the explicit
  assistants.threads.setStatus("") call from post_slack_thread_reply.
- The indicator expires after ~2 minutes; add a before_model middleware
  that refreshes it on every model tick so it stays visible across long
  agent runs. Reuses the existing slack_thread.{channel_id,thread_ts}
  configurable already plumbed for notify_step_limit.
- chat:write is sufficient on the bot token (assistant:write is on the
  way out per Slack docs); no scope or app-config change required.

* feat(slack): contextual status text + rotating loading_messages

- set_slack_assistant_status now accepts an optional loading_messages list
  (capped at 10 per Slack's API), surfaced via the assistants.threads.setStatus
  payload so Slack rotates through them client-side.
- The heartbeat middleware derives a contextual status from the last
  assistant message's tool calls (e.g. "searching the codebase…" after
  grep, "running commands…" after execute), falling back to the default
  "is thinking…" when no tool calls or unknown tool name.
- Adds a curated DEFAULT_LOADING_MESSAGES list passed alongside the
  contextual status on each refresh.

* fix slack assistant status lifecycle

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Johannes du Plessis <johannes@langchain.dev>
2026-05-08 10:21:55 -07:00

379 lines
14 KiB
Python

"""Main entry point and CLI loop for Open SWE agent."""
# ruff: noqa: E402
# Suppress deprecation warnings from langchain_core (e.g., Pydantic V1 on Python 3.14+)
# ruff: noqa: E402
import logging
import os
import warnings
logger = logging.getLogger(__name__)
from langgraph.graph.state import RunnableConfig
from langgraph.pregel import Pregel
from langgraph_sdk import get_client
warnings.filterwarnings("ignore", module="langchain_core._api.deprecation")
import asyncio
# Suppress Pydantic v1 compatibility warnings from langchain on Python 3.14+
warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarning)
# Now safe to import agent (which imports LangChain modules)
from deepagents import create_deep_agent
from deepagents.backends import LangSmithSandbox
from deepagents.backends.protocol import SandboxBackendProtocol
from langchain.agents.middleware import ModelCallLimitMiddleware
from langsmith.sandbox import SandboxClientError
from .integrations.langsmith import _configure_github_proxy
from .middleware import (
SanitizeToolInputsMiddleware,
SlackAssistantStatusMiddleware,
ToolErrorMiddleware,
check_message_queue_before_model,
ensure_no_empty_msg,
notify_step_limit_reached,
)
from .prompt import construct_system_prompt
from .tools import (
fetch_url,
http_request,
linear_comment,
linear_create_issue,
linear_delete_issue,
linear_get_issue,
linear_get_issue_comments,
linear_list_teams,
linear_update_issue,
request_pr_review,
slack_read_thread_messages,
slack_thread_reply,
web_search,
)
from .utils.auth import resolve_github_token
from .utils.github_app import get_github_app_installation_token
from .utils.model import ModelKwargs, OpenAIReasoning, make_model
from .utils.sandbox import create_sandbox
from .utils.sandbox_paths import aresolve_sandbox_work_dir
client = get_client()
SANDBOX_CREATING = "__creating__"
SANDBOX_CREATION_TIMEOUT = 180
SANDBOX_POLL_INTERVAL = 1.0
from .utils.sandbox_state import SANDBOX_BACKENDS, get_sandbox_id_from_metadata
async def _start_langsmith_sandbox_if_needed(sandbox_backend: SandboxBackendProtocol) -> None:
"""Start a LangSmith sandbox before operations that require it to be running."""
if os.getenv("SANDBOX_TYPE", "langsmith") != "langsmith":
return
if not isinstance(sandbox_backend, LangSmithSandbox):
return
sandbox = sandbox_backend._sandbox # noqa: SLF001
status = await asyncio.to_thread(sandbox._client.get_sandbox_status, sandbox.name) # noqa: SLF001
status_name = getattr(status, "status", status)
status_name = getattr(status_name, "value", status_name)
status_text = str(status_name or "").lower()
if status_text in {"running", "ready"}:
return
logger.info(
"Starting LangSmith sandbox %s before proxy refresh (status=%s)",
sandbox_backend.id,
status_text or "unknown",
)
await asyncio.to_thread(sandbox.start)
async def _create_sandbox_with_proxy() -> SandboxBackendProtocol:
"""Create a new sandbox with GitHub proxy auth configured.
Uses create_sandbox (generic factory) so non-langsmith providers still work.
For langsmith sandboxes, configures the proxy with the installation token.
"""
sandbox_backend = await asyncio.to_thread(create_sandbox)
sandbox_type = os.getenv("SANDBOX_TYPE", "langsmith")
if sandbox_type == "langsmith":
installation_token = await get_github_app_installation_token()
if not installation_token:
msg = "Cannot configure proxy: GitHub App installation token is unavailable"
logger.error(msg)
raise ValueError(msg)
await _start_langsmith_sandbox_if_needed(sandbox_backend)
await asyncio.to_thread(_configure_github_proxy, sandbox_backend.id, installation_token)
return sandbox_backend
async def _refresh_github_proxy(
sandbox_backend: SandboxBackendProtocol,
) -> None:
"""Refresh GitHub proxy credentials for reused LangSmith sandboxes."""
if os.getenv("SANDBOX_TYPE", "langsmith") != "langsmith":
return
installation_token = await get_github_app_installation_token()
if not installation_token:
logger.warning(
"Skipping GitHub proxy refresh for sandbox %s: installation token unavailable",
sandbox_backend.id,
)
return
await _start_langsmith_sandbox_if_needed(sandbox_backend)
await asyncio.to_thread(_configure_github_proxy, sandbox_backend.id, installation_token)
async def _refresh_github_proxy_or_recreate(
sandbox_backend: SandboxBackendProtocol,
thread_id: str,
) -> SandboxBackendProtocol:
"""Refresh proxy credentials, recreating stale LangSmith sandboxes on failure."""
try:
await _refresh_github_proxy(sandbox_backend)
except Exception: # noqa: BLE001
logger.warning(
"Failed to refresh GitHub proxy for sandbox %s on thread %s, recreating sandbox",
sandbox_backend.id,
thread_id,
exc_info=True,
)
return await _recreate_sandbox(thread_id)
return sandbox_backend
async def _recreate_sandbox(thread_id: str) -> SandboxBackendProtocol:
"""Recreate a sandbox after a connection failure.
Clears the stale cache entry, sets the SANDBOX_CREATING sentinel,
and creates a fresh sandbox (with proxy auth configured).
The agent is responsible for cloning repos via tools.
"""
SANDBOX_BACKENDS.pop(thread_id, None)
await client.threads.update(
thread_id=thread_id,
metadata={"sandbox_id": SANDBOX_CREATING},
)
try:
sandbox_backend = await _create_sandbox_with_proxy()
except Exception:
logger.exception("Failed to recreate sandbox after connection failure")
await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": None})
raise
return sandbox_backend
async def check_or_recreate_sandbox(
sandbox_backend: SandboxBackendProtocol, thread_id: str
) -> SandboxBackendProtocol:
"""Check if a cached sandbox is reachable; recreate it if not.
Pings the sandbox with a lightweight command. If the sandbox is
unreachable (SandboxClientError), it is torn down and a fresh one
is created via _recreate_sandbox.
Returns the original backend if healthy, or a new one if recreated.
"""
try:
await asyncio.to_thread(sandbox_backend.execute, "echo ok")
except SandboxClientError:
logger.warning(
"Cached sandbox is no longer reachable for thread %s, recreating",
thread_id,
)
sandbox_backend = await _recreate_sandbox(thread_id)
return sandbox_backend
async def _wait_for_sandbox_id(thread_id: str) -> str:
"""Wait for sandbox_id to be set in thread metadata.
Polls thread metadata until sandbox_id is set to a real value
(not the creating sentinel).
Raises:
TimeoutError: If sandbox creation takes too long
"""
elapsed = 0.0
while elapsed < SANDBOX_CREATION_TIMEOUT:
sandbox_id = await get_sandbox_id_from_metadata(thread_id)
if sandbox_id is not None and sandbox_id != SANDBOX_CREATING:
return sandbox_id
await asyncio.sleep(SANDBOX_POLL_INTERVAL)
elapsed += SANDBOX_POLL_INTERVAL
msg = f"Timeout waiting for sandbox creation for thread {thread_id}"
raise TimeoutError(msg)
def graph_loaded_for_execution(config: RunnableConfig) -> bool:
"""Check if the graph is loaded for actual execution vs introspection."""
return (
config["configurable"].get("__is_for_execution__", False)
if "configurable" in config
else False
)
async def ensure_sandbox_for_thread(thread_id: str) -> SandboxBackendProtocol:
"""Get-or-create a healthy sandbox bound to ``thread_id``.
Implements the four-state lifecycle described in AGENTS.md:
1. Cached in memory → ping; recreate on ``SandboxClientError``.
2. Metadata says ``__creating__`` and no cache → poll until ready.
3. No sandbox at all → create one and persist the id.
4. Metadata has an id but no cache → reconnect; recreate on failure.
For LangSmith sandboxes, also refreshes the GitHub App proxy auth.
Persists the resulting ``sandbox_id`` to thread metadata, and on the
first creation/reconnect for this thread initializes git identity.
"""
sandbox_backend = SANDBOX_BACKENDS.get(thread_id)
sandbox_id = await get_sandbox_id_from_metadata(thread_id)
if sandbox_id == SANDBOX_CREATING and not sandbox_backend:
logger.info("Sandbox creation in progress for thread %s, waiting...", thread_id)
sandbox_id = await _wait_for_sandbox_id(thread_id)
if sandbox_backend:
logger.info("Using cached sandbox backend for thread %s", thread_id)
original_sandbox_id = sandbox_backend.id
sandbox_backend = await check_or_recreate_sandbox(sandbox_backend, thread_id)
if sandbox_backend.id == original_sandbox_id:
sandbox_backend = await _refresh_github_proxy_or_recreate(sandbox_backend, thread_id)
elif sandbox_id is None:
logger.info("Creating new sandbox for thread %s", thread_id)
await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": SANDBOX_CREATING})
try:
sandbox_backend = await _create_sandbox_with_proxy()
logger.info("Sandbox created: %s", sandbox_backend.id)
except Exception:
logger.exception("Failed to create sandbox")
try:
await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": None})
except Exception:
logger.exception("Failed to reset sandbox_id metadata")
raise
else:
logger.info("Connecting to existing sandbox %s", sandbox_id)
created_replacement_sandbox = False
try:
sandbox_backend = await asyncio.to_thread(create_sandbox, sandbox_id)
except Exception:
logger.warning("Failed to connect to existing sandbox %s, creating new one", sandbox_id)
await client.threads.update(
thread_id=thread_id, metadata={"sandbox_id": SANDBOX_CREATING}
)
try:
sandbox_backend = await _create_sandbox_with_proxy()
created_replacement_sandbox = True
except Exception:
logger.exception("Failed to create replacement sandbox")
await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": None})
raise
if not created_replacement_sandbox:
original_sandbox_id = sandbox_backend.id
sandbox_backend = await check_or_recreate_sandbox(sandbox_backend, thread_id)
if sandbox_backend.id == original_sandbox_id:
sandbox_backend = await _refresh_github_proxy_or_recreate(
sandbox_backend, thread_id
)
SANDBOX_BACKENDS[thread_id] = sandbox_backend
if sandbox_id != sandbox_backend.id:
await client.threads.update(
thread_id=thread_id, metadata={"sandbox_id": sandbox_backend.id}
)
# Re-apply git identity every run: cached/reconnected sandboxes may have
# lost their `--global` config (or had it overwritten), and Vercel preview
# deploys reject commits whose author email can't be resolved to a GitHub
# account.
await asyncio.to_thread(
sandbox_backend.execute,
"git config --global user.name 'open-swe[bot]' && "
"git config --global user.email 'open-swe@users.noreply.github.com'",
)
return sandbox_backend
DEFAULT_LLM_MODEL_ID = "openai:gpt-5.5"
DEFAULT_LLM_REASONING: OpenAIReasoning = {"effort": "medium"}
DEFAULT_LLM_MAX_TOKENS = 64_000
DEFAULT_RECURSION_LIMIT = 9_999
MODEL_CALL_RECURSION_LIMIT = 5_000 # ~half the recursion limit to account for tool calls
async def get_agent(config: RunnableConfig) -> Pregel:
"""Get or create an agent with a sandbox for the given thread."""
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):
logger.info("No thread_id or not for execution, returning agent without sandbox")
return create_deep_agent(
system_prompt="",
tools=[],
).with_config(config)
github_token, new_encrypted = await resolve_github_token(config, thread_id)
config["metadata"]["github_token_encrypted"] = new_encrypted
del github_token
sandbox_backend = await ensure_sandbox_for_thread(thread_id)
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", "")
work_dir = await aresolve_sandbox_work_dir(sandbox_backend)
model_id = os.environ.get("LLM_MODEL_ID", DEFAULT_LLM_MODEL_ID)
model_kwargs: ModelKwargs = {"max_tokens": DEFAULT_LLM_MAX_TOKENS}
if model_id == DEFAULT_LLM_MODEL_ID:
model_kwargs["reasoning"] = DEFAULT_LLM_REASONING
logger.info("Returning agent with sandbox for thread %s", thread_id)
return create_deep_agent(
model=make_model(model_id, **model_kwargs),
system_prompt=construct_system_prompt(
working_dir=work_dir,
linear_project_id=linear_project_id,
linear_issue_number=linear_issue_number,
),
tools=[
http_request,
fetch_url,
web_search,
linear_comment,
linear_create_issue,
linear_delete_issue,
linear_get_issue,
linear_get_issue_comments,
linear_list_teams,
linear_update_issue,
request_pr_review,
slack_read_thread_messages,
slack_thread_reply,
],
backend=sandbox_backend,
middleware=[
SanitizeToolInputsMiddleware(),
ModelCallLimitMiddleware(run_limit=MODEL_CALL_RECURSION_LIMIT, exit_behavior="end"),
ToolErrorMiddleware(),
check_message_queue_before_model,
SlackAssistantStatusMiddleware(),
ensure_no_empty_msg,
notify_step_limit_reached,
],
).with_config(config)