mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-07 16:19:09 +00:00
Port five upstream PRs onto dev: - #1621 / #1658: durable dispatch with loopback webhook defense, create_durable_run helper, _config_with_prepare_run_id, degradation to None for relative/loopback completion webhook URLs - #1696: run-level completion webhook deduplication (replace claim-then-post with post-then-flag per run_id), DeferredErrorModel for graph-factory resilience, ToolRetryMiddleware for task subagents, TimeoutWrapupMiddleware for all three graphs - #1697: lazy-load __init__.py for agent.middleware, agent.tools, agent.dashboard (PEP 562); defer heavy imports (exa_py in web_search, agent.webapp in request_pr_review, deepagents in sandbox.py); add ttl_cache.py with stale-while-revalidate for tool loaders Refs: #137
179 lines
6.6 KiB
Python
179 lines
6.6 KiB
Python
"""Single durable dispatch contract behind every agent/reviewer run trigger.
|
|
|
|
Replaces the per-site ``runs.create`` calls (plus the ``is_thread_active``
|
|
busy-check and the custom store-queue) with one function that always uses:
|
|
|
|
- ``multitask_strategy="interrupt"`` — a follow-up halts the active run
|
|
(progress preserved by the sync checkpoint) and resumes the agent with full
|
|
history + the new message; on an idle thread it just starts. This is the
|
|
platform-native, cross-process replacement for the racy busy-check + queue.
|
|
- ``durability="sync"`` — checkpoint before each step so a crash/recycle
|
|
resumes from the last checkpoint instead of losing all work.
|
|
- ``webhook=COMPLETION_WEBHOOK_URL`` — the platform calls us on completion or
|
|
failure so every run ends with a signal even if the agent died.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import uuid
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
from langgraph_sdk import get_client
|
|
from langgraph_sdk.client import LangGraphClient
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ContentBlocks = str | list[dict[str, Any]]
|
|
RunInput = dict[str, Any]
|
|
RunConfig = dict[str, Any]
|
|
|
|
# FastAPI route the platform POSTs run completion/failure to. The platform
|
|
# rejects loopback webhooks (relative URLs / localhost) — they bypass auth via
|
|
# the in-process ASGI transport — so a loopback URL would 422 *every* run at
|
|
# create time. COMPLETION_WEBHOOK_URL must therefore be the deployment's
|
|
# absolute https URL (…/webhooks/run-complete). The route is fail-closed on
|
|
# RUN_COMPLETE_WEBHOOK_SECRET, so we only attach the webhook when the secret is
|
|
# set, appending it as ?token= so the route can verify the call came from us
|
|
# (completion.verify_run_complete_token). Secret unset, or URL relative/loopback
|
|
# → no webhook attached (the completion reply is best-effort; it must never
|
|
# break run creation).
|
|
_COMPLETION_WEBHOOK_BASE = os.environ.get("COMPLETION_WEBHOOK_URL") or "/webhooks/run-complete"
|
|
_RUN_COMPLETE_SECRET = os.environ.get("RUN_COMPLETE_WEBHOOK_SECRET")
|
|
|
|
|
|
def _is_loopback_webhook(url: str) -> bool:
|
|
"""Whether a webhook URL is relative or points at localhost (platform-rejected)."""
|
|
parsed = urlparse(url)
|
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
return True # relative / schemeless
|
|
return (parsed.hostname or "").lower() in {"localhost", "127.0.0.1", "::1"}
|
|
|
|
|
|
def _resolve_completion_webhook_url(base: str, secret: str | None) -> str | None:
|
|
"""Resolve the completion webhook URL, or None to attach no webhook.
|
|
|
|
Degrades to None (with a warning) for a relative/loopback URL rather than
|
|
letting a rejected webhook poison every ``runs.create``.
|
|
"""
|
|
if not secret:
|
|
return None
|
|
if _is_loopback_webhook(base):
|
|
logger.warning(
|
|
"RUN_COMPLETE_WEBHOOK_SECRET is set but COMPLETION_WEBHOOK_URL (%r) is relative "
|
|
"or loopback; the platform rejects such webhooks, so run-completion replies are "
|
|
"disabled. Set COMPLETION_WEBHOOK_URL to the deployment's absolute https URL "
|
|
"ending in /webhooks/run-complete to enable them.",
|
|
base,
|
|
)
|
|
return None
|
|
if "?" in base:
|
|
return base
|
|
return f"{base}?token={secret}"
|
|
|
|
|
|
COMPLETION_WEBHOOK_URL: str | None = _resolve_completion_webhook_url(
|
|
_COMPLETION_WEBHOOK_BASE, _RUN_COMPLETE_SECRET
|
|
)
|
|
|
|
|
|
def _langgraph_url() -> str:
|
|
return os.environ.get("LANGGRAPH_URL") or os.environ.get(
|
|
"LANGGRAPH_URL_PROD", "http://localhost:2024"
|
|
)
|
|
|
|
|
|
def dispatch_client() -> LangGraphClient:
|
|
return get_client(url=_langgraph_url())
|
|
|
|
|
|
def _config_with_prepare_run_id(
|
|
config: RunConfig | None,
|
|
metadata: dict[str, Any] | None,
|
|
) -> RunConfig:
|
|
run_config = dict(config or {})
|
|
configurable = run_config.get("configurable")
|
|
configurable = dict(configurable) if isinstance(configurable, dict) else {}
|
|
configurable.setdefault("prepare_run_id", str(uuid.uuid4()))
|
|
run_config["configurable"] = configurable
|
|
if metadata is not None:
|
|
run_config["metadata"] = metadata
|
|
return run_config
|
|
|
|
|
|
async def create_durable_run(
|
|
thread_id: str,
|
|
assistant_id: str,
|
|
*,
|
|
input: RunInput,
|
|
source: str,
|
|
config: RunConfig | None = None,
|
|
metadata: dict[str, Any] | None = None,
|
|
client: LangGraphClient | None = None,
|
|
multitask_strategy: str = "interrupt",
|
|
durability: str = "sync",
|
|
if_not_exists: str = "create",
|
|
stream_mode: Any | None = None,
|
|
stream_resumable: bool | None = None,
|
|
after_seconds: int | float | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Create a run with Open SWE's durable LangGraph defaults."""
|
|
client = client or dispatch_client()
|
|
create_kwargs: dict[str, Any] = {
|
|
"input": input,
|
|
"config": _config_with_prepare_run_id(config, metadata),
|
|
"multitask_strategy": multitask_strategy,
|
|
"durability": durability,
|
|
"if_not_exists": if_not_exists,
|
|
}
|
|
if COMPLETION_WEBHOOK_URL:
|
|
create_kwargs["webhook"] = COMPLETION_WEBHOOK_URL
|
|
if stream_mode is not None:
|
|
create_kwargs["stream_mode"] = stream_mode
|
|
if stream_resumable is not None:
|
|
create_kwargs["stream_resumable"] = stream_resumable
|
|
if after_seconds is not None:
|
|
create_kwargs["after_seconds"] = after_seconds
|
|
|
|
run = await client.runs.create(thread_id, assistant_id, **create_kwargs)
|
|
logger.info(
|
|
"Dispatched %s run on thread %s (source=%s, run=%s)",
|
|
assistant_id,
|
|
thread_id,
|
|
source,
|
|
run.get("run_id") if isinstance(run, dict) else None,
|
|
)
|
|
return run
|
|
|
|
|
|
async def dispatch_agent_run(
|
|
thread_id: str,
|
|
content: ContentBlocks,
|
|
configurable: dict[str, Any],
|
|
*,
|
|
source: str,
|
|
assistant_id: str = "agent",
|
|
metadata: dict[str, Any] | None = None,
|
|
client: LangGraphClient | None = None,
|
|
multitask_strategy: str = "interrupt",
|
|
) -> dict[str, Any]:
|
|
"""Create (or interrupt-and-resume) a run for ``thread_id``.
|
|
|
|
Routes every Slack / Linear / GitHub / dashboard trigger through one
|
|
contract. ``source`` is for logging/metadata only; ``assistant_id`` selects
|
|
the graph (``"agent"`` or ``"reviewer"``). ``multitask_strategy`` defaults to
|
|
``"interrupt"`` (human follow-ups halt + resume); autofix passes ``"reject"``
|
|
so a burst of concurrent CI events for one head SHA can't interrupt each other.
|
|
"""
|
|
return await create_durable_run(
|
|
thread_id,
|
|
assistant_id,
|
|
input={"messages": [{"role": "user", "content": content}]},
|
|
config={"configurable": configurable},
|
|
metadata=metadata or {},
|
|
source=source,
|
|
client=client or dispatch_client(),
|
|
multitask_strategy=multitask_strategy,
|
|
)
|