open-swe/agent/middleware/check_message_queue.py
Johannes du Plessis 209132d355
refactor: durable interrupt dispatch + completion webhook (#1621)
* wip(rebuild): core reliability spine

- remove PR-babysitting (ci_autofix + ci_monitor graph + webhook wiring)
- dispatch core: agent/dispatch.py with multitask_strategy=interrupt +
  durability=sync + completion webhook; reroute all webhook + plan triggers;
  drop the racy in-process lock + is_thread_active busy-check
- completion webhook: agent/completion.py + /webhooks/run-complete loopback
  route for failure/timeout replies (idempotent)

Co-authored-by: open-swe[bot]

* feat(rebuild): async tools, reconcile, shared http timeouts, assembly tuning

Parallel batch on top of the reliability spine:
- async-ify all 24 tools (drop asyncio.run; requests->httpx); re-implement the
  http_request/fetch_url SSRF + DNS-rebinding defense httpx-natively and harden
  the IP check to 'not is_global' (+ IPv4-mapped unwrap)
- reconcile.py: stale pending-run sweep (threads.search -> per-thread runs.list
  -> cancel_many), wired into the scheduler graph via task='reconcile'
- shared DEFAULT_HTTP_TIMEOUT (agent/utils/http.py) on every bare
  httpx.AsyncClient() across utils/dashboard/webapp/middleware
- run budget: MODEL_CALL_RECURSION_LIMIT 5000->250
- fix stale OpenAI->Anthropic fallback id (claude-opus-4-5 -> 4-8)
- drop redundant custom repair middleware (deepagents auto-adds PatchToolCalls)
- confirm tool-result eviction + summarization auto-wired via backend
- slim system prompt ~8% (full harness-profile rewrite deferred)

Co-authored-by: open-swe[bot]

* feat(rebuild): harness-profile prompt + split webhooks out of webapp

- prompt.py: own the system prompt via a registered harness profile
  (OPEN_SWE_SHARED_BASE, kept neutral so the read-only reviewer/analyzer that
  share it stay safe), registered across all 4 providers; per-thread values
  stay in construct_system_prompt. Assembled main-agent prompt ~6.8k -> ~3.1k
  tokens (~55% smaller); de-duped PR/commit/suite/force-push guidance; dropped
  ALL-CAPS markers.
- webapp.py 3325 -> 1890 LOC: moved 14 per-source handlers into
  agent/webhooks/{linear,slack,github}.py; webapp re-exports them for the
  routes + tests; moved handlers reach shared helpers via the webapp namespace
  to preserve the test suite's monkeypatch targets.

Full suite: 1168 passing, lint clean.

Co-authored-by: open-swe[bot]

* Restore MODEL_CALL_RECURSION_LIMIT to 5000 for long-running tasks

Reverts the 250 cap from the run-budget change — long-running tasks legitimately
need many model calls. The notify_step_limit_reached safety net still fires if a
run does hit the cap, so runs end with a signal either way.

Co-authored-by: open-swe[bot]

* fix: address PR review (auth, SSRF, interrupted status, redirect headers)

- completion.py: drop `interrupted` from failure statuses — with
  multitask_strategy=interrupt a follow-up ends the prior run as interrupted,
  which is healthy, not a failure to report. [open-swe]
- /webhooks/run-complete: shared-secret auth — dispatch appends ?token= when
  RUN_COMPLETE_WEBHOOK_SECRET is set; route verifies via hmac.compare_digest.
  [corridor-security]
- SSRF: extract the URL validator to agent/utils/url_safety.py and apply it
  before server-side image fetches in multimodal.fetch_image_block.
  [corridor-security]
- http_request: preserve caller headers/extensions across redirect hops instead
  of dropping them on the first hop. [open-swe]

Co-authored-by: open-swe[bot]

* chore: remove REBUILD_PLAN.md (planning doc, not needed in the repo)

Co-authored-by: open-swe[bot]

* fix: fail closed on run-complete webhook auth when secret unset

Corridor follow-up: verify_run_complete_token returns False (not True) when
RUN_COMPLETE_WEBHOOK_SECRET is unset, so the public route is never
unauthenticated. Logs a startup warning when the secret is absent, and dispatch
skips registering the webhook when there's no secret (no rejected callbacks).

Co-authored-by: open-swe[bot]

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-26 13:48:38 -07:00

232 lines
8.7 KiB
Python

"""Before-model middleware that injects queued messages into state.
Checks the LangGraph store for pending messages (e.g. follow-up Linear
comments that arrived while the agent was busy) and injects them as new
human messages before the next model call.
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from langchain.agents.middleware import AgentState, before_model
from langgraph.config import get_config, get_store
from langgraph.runtime import Runtime
from langgraph.store.base import BaseStore
from langgraph_sdk import get_client
from ..dashboard.options import model_supports_images
from ..utils.http import DEFAULT_HTTP_TIMEOUT
from ..utils.multimodal import fetch_image_block, vision_not_supported_warning
logger = logging.getLogger(__name__)
DASHBOARD_HANDOFF_MARKER = "[Open SWE Web handoff]"
DASHBOARD_HANDOFF_INSTRUCTION = (
f"{DASHBOARD_HANDOFF_MARKER} This follow-up was sent from Web. "
"The conversation has moved to Web, so answer in the dashboard stream with a normal "
"assistant message. Do not call slack_thread_reply unless a later Slack message explicitly "
"moves the conversation back to Slack."
)
class LinearNotifyState(AgentState):
"""Extended agent state for tracking Linear notifications."""
linear_messages_sent_count: int
async def _resolve_thread_model_id(thread_id: str) -> str | None:
"""Read the resolved model from thread metadata (set by ``get_agent``)."""
try:
client = get_client()
thread = await client.threads.get(thread_id)
metadata = thread.get("metadata") if isinstance(thread, dict) else None
if not isinstance(metadata, dict):
return None
model = metadata.get("model")
return model if isinstance(model, str) and model else None
except Exception:
logger.debug("Could not read thread metadata for model resolution", exc_info=True)
return None
async def _build_blocks_from_payload(
payload: dict[str, Any],
*,
model_id: str | None = None,
) -> list[dict[str, Any]]:
text = payload.get("text", "")
image_urls = payload.get("image_urls", []) or []
images = payload.get("images", []) or []
blocks: list[dict[str, Any]] = []
if text:
blocks.append({"type": "text", "text": text})
if isinstance(images, list):
blocks.extend(image for image in images if isinstance(image, dict))
if not image_urls:
return blocks
if model_id and not model_supports_images(model_id):
logger.warning(
"Skipping %d queued image(s): model %s does not support images",
len(image_urls),
model_id,
)
if text:
blocks[0] = {
"type": "text",
"text": text + vision_not_supported_warning(model_id, len(image_urls)),
}
return blocks
async with httpx.AsyncClient(timeout=DEFAULT_HTTP_TIMEOUT) as client:
for image_url in image_urls:
image_block = await fetch_image_block(image_url, client)
if image_block:
blocks.append(image_block)
return blocks
def _is_dashboard_queued_message(content: object) -> bool:
return isinstance(content, dict) and content.get("source") == "dashboard"
def _message_update(content_blocks: list[dict[str, Any]], thread_id: str) -> dict[str, Any] | None:
if not content_blocks:
return None
logger.info(
"Injected %d queued message block(s) into state for thread %s",
len(content_blocks),
thread_id,
)
return {"messages": [{"role": "user", "content": content_blocks}]}
async def _consume_pending_autofix_event(store: BaseStore, thread_id: str) -> str | None:
"""Pull and clear a batched PR-babysitting event from the store (no thread fetch)."""
namespace = ("autofix", thread_id)
try:
item = await store.aget(namespace, "pending_event")
except Exception: # noqa: BLE001
logger.debug(
"Could not read pending auto-fix event for thread %s", thread_id, exc_info=True
)
return None
if item is None or not item.value.get("reason"):
return None
try:
await store.adelete(namespace, "pending_event")
except Exception: # noqa: BLE001
logger.debug(
"Could not clear pending auto-fix event for thread %s", thread_id, exc_info=True
)
message = (
"A PR babysitting event arrived while you were already working on this PR. "
"Do not start a separate run for that event. Before finishing, re-check the "
"PR's latest CI status and review comments, then address any newly failed "
"checks or actionable comments that are clear and deterministic."
)
details = item.value.get("details")
if isinstance(details, list):
joined = "\n\n".join(d for d in details if isinstance(d, str) and d)
if joined:
message += "\n\nNewly arrived feedback to address:\n" + joined
return message
@before_model(state_schema=LinearNotifyState)
async def check_message_queue_before_model( # noqa: PLR0911
state: LinearNotifyState, # noqa: ARG001
runtime: Runtime, # noqa: ARG001
) -> dict[str, Any] | None:
"""Middleware that checks for queued messages before each model call.
If messages are found in the queue for this thread, it extracts all messages,
adds them to the conversation state as new human messages, and clears the queue.
Messages are processed in FIFO order (oldest first).
This enables handling of follow-up comments that arrive while the agent is busy.
The agent will see the new messages and can incorporate them into its response.
"""
try:
config = get_config()
configurable = config.get("configurable", {})
thread_id = configurable.get("thread_id")
if not thread_id:
return None
try:
store = get_store()
except Exception as e: # noqa: BLE001
logger.debug("Could not get store from context: %s", e)
return None
if store is None:
return None
content_blocks: list[dict[str, Any]] = []
pending_autofix = await _consume_pending_autofix_event(store, thread_id)
if pending_autofix:
content_blocks.append({"type": "text", "text": pending_autofix})
namespace = ("queue", thread_id)
try:
queued_item = await store.aget(namespace, "pending_messages")
except Exception as e: # noqa: BLE001
logger.warning("Failed to get queued item: %s", e)
return _message_update(content_blocks, thread_id)
if queued_item is None:
return _message_update(content_blocks, thread_id)
queued_value = queued_item.value
queued_messages = queued_value.get("messages", [])
# Delete early to prevent duplicate processing if middleware runs again
await store.adelete(namespace, "pending_messages")
if not queued_messages:
return _message_update(content_blocks, thread_id)
logger.info(
"Found %d queued message(s) for thread %s, injecting into state",
len(queued_messages),
thread_id,
)
has_images = any(
isinstance(msg.get("content"), dict)
and (msg["content"].get("image_urls") or msg["content"].get("images"))
for msg in queued_messages
)
resolved_model_id: str | None = None
if has_images:
resolved_model_id = await _resolve_thread_model_id(thread_id)
for msg in queued_messages:
content = msg.get("content")
if _is_dashboard_queued_message(content):
content_blocks.append({"type": "text", "text": DASHBOARD_HANDOFF_INSTRUCTION})
if isinstance(content, dict) and (
"text" in content or "image_urls" in content or "images" in content
):
logger.debug("Queued message contains text + image URLs")
blocks = await _build_blocks_from_payload(content, model_id=resolved_model_id)
content_blocks.extend(blocks)
continue
if isinstance(content, list):
logger.debug("Queued message contains %d content block(s)", len(content))
content_blocks.extend(content)
continue
if isinstance(content, str) and content:
logger.debug("Queued message contains text content")
content_blocks.append({"type": "text", "text": content})
return _message_update(content_blocks, thread_id) # noqa: TRY300
except Exception:
logger.exception("Error in check_message_queue_before_model")
return None