mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-07 16:19:09 +00:00
Compare commits
8 commits
50dbe05c3b
...
be9f44213e
| Author | SHA1 | Date | |
|---|---|---|---|
| be9f44213e | |||
|
|
e444ead202 | ||
|
|
ddae41542c | ||
|
|
efeb6b1245 | ||
|
|
e3c7c03c92 | ||
|
|
0546085672 | ||
|
|
f87847baa4 | ||
|
|
c2bc7720cd |
61 changed files with 3060 additions and 636 deletions
|
|
@ -193,6 +193,25 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
return create_deep_agent(model=model, ...)
|
||||
```
|
||||
|
||||
### Routing through the LangSmith LLM Gateway
|
||||
|
||||
Model calls can be proxied through the [LangSmith LLM Gateway](https://docs.langchain.com/langsmith/llm-gateway) (private beta) instead of hitting providers directly. The gateway authenticates with a **LangSmith API key** that has the `gateway:invoke` permission and resolves the real provider key from workspace Provider Secrets, so no provider API keys are needed at runtime — and it adds central spend limits, PII/secrets redaction, and tracing. Your org must have the gateway enabled with Provider Secrets configured.
|
||||
|
||||
Routing is opt-in and off by default. Enable it either way:
|
||||
|
||||
| Env var | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `LANGSMITH_GATEWAY_ENABLED` | `false` | Deployment-level default for gateway routing. |
|
||||
| `LANGSMITH_GATEWAY_API_KEY` | unset | Optional dedicated LangSmith key for Gateway calls. Prefer this in LangGraph Cloud if the platform-provided `LANGSMITH_API_KEY` lacks `gateway:invoke`. Falls back to `LANGSMITH_API_KEY_PROD`, then `LANGSMITH_API_KEY`. |
|
||||
| `LANGSMITH_GATEWAY_BASE_URL` | `https://gateway.smith.langchain.com` | Override for a regional or self-hosted gateway host. |
|
||||
| `LANGSMITH_GATEWAY_OPENAI_USE_RESPONSES` | `true` | Use the OpenAI Responses API through the gateway. Set to `false` only to force Chat Completions for OpenAI models. |
|
||||
|
||||
The admin panel (**Admin → LLM Gateway**) exposes a per-workspace toggle stored in team settings; when set it overrides the `LANGSMITH_GATEWAY_ENABLED` env default (a `None`/unset team value inherits the env default).
|
||||
|
||||
Routing is applied centrally in `make_model` (`agent/utils/model.py`), which resolves the effective on/off and delegates URL/key wiring to `agent/utils/gateway.py`. **OpenAI, Anthropic, Fireworks, and Google Gemini** are routed (their LangChain integrations accept `base_url` + `api_key`); Google Vertex (service-account auth) and any other provider call the provider directly with a logged warning.
|
||||
|
||||
**Caveat — OpenAI endpoint:** open-swe uses the OpenAI Responses API by default because OpenAI reasoning models with function tools reject `reasoning_effort` on Chat Completions. Direct OpenAI calls use a `wss://` base URL; gateway-routed OpenAI uses the HTTPS gateway base URL with Responses enabled. Set `LANGSMITH_GATEWAY_OPENAI_USE_RESPONSES=false` only if you need to force Chat Completions. Anthropic and Fireworks are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tools
|
||||
|
|
|
|||
|
|
@ -29,8 +29,13 @@ from deepagents.backends.protocol import SandboxBackendProtocol
|
|||
from deepagents.backends.state import StateBackend
|
||||
from langchain.agents.middleware import ModelCallLimitMiddleware
|
||||
|
||||
from .dashboard.team_settings import get_effective_gateway_enabled
|
||||
from .integrations.langsmith import _configure_github_proxy
|
||||
from .middleware import SanitizeToolInputsMiddleware, ToolErrorMiddleware
|
||||
from .middleware import (
|
||||
SanitizeToolInputsMiddleware,
|
||||
TimeoutWrapupMiddleware,
|
||||
ToolErrorMiddleware,
|
||||
)
|
||||
from .review_style_guidance import REVIEWER_STYLE_THEMES
|
||||
from .server import (
|
||||
DEFAULT_LLM_MAX_TOKENS,
|
||||
|
|
@ -42,8 +47,9 @@ from .server import (
|
|||
from .tools.read_finding_outcomes import read_finding_outcomes
|
||||
from .tools.save_review_style import save_review_style_prompt
|
||||
from .utils.analyzer_skills import SKILLS_ROUTE, skill_path_for_mode
|
||||
from .utils.deferred_model import make_model_or_defer
|
||||
from .utils.github_app import get_github_app_installation_token
|
||||
from .utils.model import DEFAULT_LLM_REASONING, make_model, provider_model_kwargs
|
||||
from .utils.model import DEFAULT_LLM_REASONING, provider_model_kwargs
|
||||
from .utils.sandbox_paths import aresolve_sandbox_work_dir
|
||||
from .utils.sandbox_state import unwrap_sandbox_backend
|
||||
from .utils.tracing import REVIEW_TRACING_PROJECT, traced_graph_factory
|
||||
|
|
@ -116,6 +122,7 @@ async def get_analyzer(config: RunnableConfig) -> Pregel:
|
|||
backend = CompositeBackend(default=sandbox_backend, routes={SKILLS_ROUTE: StateBackend()})
|
||||
|
||||
model_id = DEFAULT_LLM_MODEL_ID
|
||||
use_gateway = await get_effective_gateway_enabled()
|
||||
model_kwargs = provider_model_kwargs(
|
||||
model_id,
|
||||
None,
|
||||
|
|
@ -135,7 +142,7 @@ async def get_analyzer(config: RunnableConfig) -> Pregel:
|
|||
system_prompt = f"{system_prompt}\n\n{user_context}"
|
||||
|
||||
return create_deep_agent(
|
||||
model=make_model(model_id, **model_kwargs),
|
||||
model=make_model_or_defer(model_id, use_gateway=use_gateway, **model_kwargs),
|
||||
system_prompt=system_prompt,
|
||||
tools=[save_review_style_prompt, read_finding_outcomes],
|
||||
backend=backend,
|
||||
|
|
@ -147,6 +154,7 @@ async def get_analyzer(config: RunnableConfig) -> Pregel:
|
|||
exit_behavior="end",
|
||||
),
|
||||
ToolErrorMiddleware(),
|
||||
TimeoutWrapupMiddleware(),
|
||||
],
|
||||
).with_config(config)
|
||||
|
||||
|
|
|
|||
|
|
@ -29,9 +29,11 @@ from deepagents import create_deep_agent
|
|||
from langchain.agents.middleware import ModelCallLimitMiddleware
|
||||
|
||||
from .dashboard.options import SUPPORTED_MODEL_IDS, model_supports_effort
|
||||
from .dashboard.team_settings import get_team_default_model
|
||||
from .dashboard.team_settings import get_effective_gateway_enabled, get_team_default_model
|
||||
from .middleware import (
|
||||
ExcludeToolsMiddleware,
|
||||
SanitizeFireworksMessagesMiddleware,
|
||||
SanitizeOpenAIResponsesMiddleware,
|
||||
SanitizeThinkingBlocksMiddleware,
|
||||
SanitizeToolInputsMiddleware,
|
||||
ToolErrorMiddleware,
|
||||
|
|
@ -123,6 +125,7 @@ async def get_chat_agent(config: RunnableConfig) -> Pregel:
|
|||
configurable["chat_github_token"] = token
|
||||
|
||||
model_id, effort = await _resolve_chat_model(configurable)
|
||||
use_gateway = await get_effective_gateway_enabled()
|
||||
model_kwargs = provider_model_kwargs(
|
||||
model_id,
|
||||
effort,
|
||||
|
|
@ -137,7 +140,7 @@ async def get_chat_agent(config: RunnableConfig) -> Pregel:
|
|||
)
|
||||
|
||||
return create_deep_agent(
|
||||
model=make_model(model_id, **model_kwargs),
|
||||
model=make_model(model_id, use_gateway=use_gateway, **model_kwargs),
|
||||
system_prompt=system_prompt,
|
||||
tools=[
|
||||
read_repo_file,
|
||||
|
|
@ -151,6 +154,8 @@ async def get_chat_agent(config: RunnableConfig) -> Pregel:
|
|||
ModelCallLimitMiddleware(run_limit=CHAT_MODEL_CALL_LIMIT, exit_behavior="end"),
|
||||
ToolErrorMiddleware(),
|
||||
ExcludeToolsMiddleware(excluded=_EXCLUDED_TOOLS),
|
||||
SanitizeOpenAIResponsesMiddleware(),
|
||||
SanitizeFireworksMessagesMiddleware(),
|
||||
SanitizeThinkingBlocksMiddleware(),
|
||||
],
|
||||
).with_config(config)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,17 @@
|
|||
|
||||
The platform POSTs a run-completion payload to ``/webhooks/run-complete`` (wired
|
||||
as the ``webhook`` on every dispatched run, see ``agent.dispatch``). When a run
|
||||
ends in a failure state (``error`` / ``timeout`` / ``interrupted``) we post a
|
||||
ends in a failure state (``error`` / ``timeout``) we post a
|
||||
short failure reply to the originating channel, so a run that died on a server
|
||||
recycle or hit a limit never leaves the user in silence.
|
||||
|
||||
This decouples "the user gets an answer" from "the agent remembered to reply."
|
||||
The reply is idempotent: a per-thread metadata flag prevents double-posting when
|
||||
the platform retries the webhook or a checkpoint replays.
|
||||
The reply is idempotent per run: the run id (or, for manual payloads without
|
||||
one, a run-distinguishing ``updated_at``/``created_at`` marker) is claimed in a
|
||||
bounded per-thread list *before* the post (claim-then-post), so a retried or
|
||||
concurrent completion webhook can't double-post. A payload that carries nothing
|
||||
to distinguish one run from another posts without deduping — a rare duplicate is
|
||||
preferred over silencing a later, genuinely different failed run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -33,11 +37,13 @@ logger = logging.getLogger(__name__)
|
|||
# follow-up halts the prior run (status "interrupted") while its replacement
|
||||
# carries on — that's healthy, not a failure worth a "couldn't finish" reply.
|
||||
_TERMINAL_FAILURE_STATUSES = frozenset({"error", "timeout"})
|
||||
_FAILURE_REPLY_FLAG = "failure_reply_posted"
|
||||
_FAILURE_REPLY_RUN_ID = "failure_reply_posted_run_id"
|
||||
_FAILURE_REPLY_RUN_IDS = "failure_reply_posted_run_ids"
|
||||
_MAX_FAILURE_REPLY_RUN_IDS = 20
|
||||
|
||||
|
||||
class _ClaimFailed(Exception):
|
||||
"""Raised when the dedup flag couldn't be claimed, so we skip the post."""
|
||||
"""Raised when the dedup key couldn't be claimed, so we skip the post."""
|
||||
|
||||
|
||||
# Shared-secret bearer token proving a /webhooks/run-complete call came from our
|
||||
|
|
@ -65,9 +71,14 @@ def verify_run_complete_token(token: str | None) -> bool:
|
|||
|
||||
|
||||
def _failure_text(status: str, dashboard_url: str | None = None) -> str:
|
||||
reason = "timed out" if status == "timeout" else "hit an unexpected error"
|
||||
if status == "timeout":
|
||||
reason = "timed out"
|
||||
elif status == "interrupted":
|
||||
reason = "was interrupted before it could finish"
|
||||
else:
|
||||
reason = "hit an unexpected error"
|
||||
text = (
|
||||
f"⚠️ I wasn't able to finish that — the run {reason}. "
|
||||
f"\u26a0\ufe0f I wasn't able to finish that \u2014 the run {reason}. "
|
||||
"Send another message and I'll pick it back up."
|
||||
)
|
||||
if dashboard_url:
|
||||
|
|
@ -86,7 +97,7 @@ async def _post_failure_reply(
|
|||
|
||||
``claim`` is awaited immediately before the network post (claim-then-post),
|
||||
only on a branch that actually delivers, so a retried/concurrent webhook
|
||||
can't double-post and threads with no channel never burn the flag.
|
||||
can't double-post and threads with no reply channel never burn the claim.
|
||||
"""
|
||||
source = metadata.get("source")
|
||||
ctx = metadata.get("source_context")
|
||||
|
|
@ -131,6 +142,43 @@ async def _post_failure_reply(
|
|||
return False
|
||||
|
||||
|
||||
def _dedup_key(payload: dict[str, Any]) -> str | None:
|
||||
"""A run-distinguishing key for dedupe, or None when nothing distinguishes runs.
|
||||
|
||||
Prefers the platform's ``run_id`` (always present on real completion
|
||||
webhooks). Manual/legacy payloads without one fall back to a marker built
|
||||
from ``updated_at``/``created_at`` so a retry of the *same* run still dedupes
|
||||
while a *different* failed run on the same thread still gets its reply — the
|
||||
old sticky per-thread boolean silenced every later run forever (SR160-03).
|
||||
"""
|
||||
raw = payload.get("run_id")
|
||||
if isinstance(raw, str) and raw:
|
||||
return raw
|
||||
for marker in ("updated_at", "created_at"):
|
||||
value = payload.get(marker)
|
||||
if isinstance(value, str) and value:
|
||||
return f"{marker}:{value}"
|
||||
return None
|
||||
|
||||
|
||||
def _posted_failure_run_ids(metadata: dict[str, Any]) -> list[str]:
|
||||
raw = metadata.get(_FAILURE_REPLY_RUN_IDS)
|
||||
ids = [item for item in raw if isinstance(item, str) and item] if isinstance(raw, list) else []
|
||||
latest = metadata.get(_FAILURE_REPLY_RUN_ID)
|
||||
if isinstance(latest, str) and latest and latest not in ids:
|
||||
ids.append(latest)
|
||||
return ids
|
||||
|
||||
|
||||
def _failure_reply_metadata(metadata: dict[str, Any], key: str) -> dict[str, Any]:
|
||||
ids = [item for item in _posted_failure_run_ids(metadata) if item != key]
|
||||
ids.append(key)
|
||||
return {
|
||||
_FAILURE_REPLY_RUN_ID: key,
|
||||
_FAILURE_REPLY_RUN_IDS: ids[-_MAX_FAILURE_REPLY_RUN_IDS:],
|
||||
}
|
||||
|
||||
|
||||
async def handle_run_completion(payload: dict[str, Any]) -> dict[str, str]:
|
||||
"""Handle a platform run-completion webhook POST.
|
||||
|
||||
|
|
@ -153,17 +201,26 @@ async def handle_run_completion(payload: dict[str, Any]) -> dict[str, str]:
|
|||
|
||||
metadata = thread.get("metadata") if isinstance(thread, dict) else None
|
||||
metadata = metadata if isinstance(metadata, dict) else {}
|
||||
if metadata.get(_FAILURE_REPLY_FLAG):
|
||||
return {"status": "ignored", "reason": "failure reply already posted"}
|
||||
key = _dedup_key(payload)
|
||||
if key is not None and key in _posted_failure_run_ids(metadata):
|
||||
return {"status": "ignored", "reason": "failure reply already posted for run"}
|
||||
|
||||
# Claim-then-post: set the dedup flag immediately before the actual post (via
|
||||
# the claim callback) so a retried/concurrent completion webhook can't
|
||||
# double-post. The flag is only claimed on a branch that delivers, so a
|
||||
# thread with no reply channel never burns it. If the claim itself fails we
|
||||
# skip the post, leaving the flag unset so a later retry can try again.
|
||||
# Claim-then-post: record the dedup key immediately before the actual post
|
||||
# (via the claim callback) so a retried/concurrent completion webhook can't
|
||||
# double-post (SR160-02). The key is only claimed on a branch that delivers,
|
||||
# so a thread with no reply channel never burns it. If the claim itself fails
|
||||
# we raise so we skip the post, leaving the key unclaimed for a later retry
|
||||
# rather than reporting a clean success that invites a duplicate. A payload
|
||||
# with no distinguishing key can't be recorded; it posts un-deduped rather
|
||||
# than being permanently suppressed.
|
||||
async def _claim() -> None:
|
||||
if key is None:
|
||||
return
|
||||
try:
|
||||
await client.threads.update(thread_id=thread_id, metadata={_FAILURE_REPLY_FLAG: True})
|
||||
await client.threads.update(
|
||||
thread_id=thread_id,
|
||||
metadata=_failure_reply_metadata(metadata, key),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("run-complete: could not flag thread %s", thread_id, exc_info=True)
|
||||
raise _ClaimFailed from exc
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
"""Dashboard backend: OAuth, profiles, and admin endpoints for the open-swe UI."""
|
||||
"""Dashboard backend: OAuth, profiles, and admin endpoints for the open-swe UI.
|
||||
|
||||
from .routes import router
|
||||
``router`` is loaded lazily (PEP 562): importing any dashboard submodule
|
||||
(e.g. ``agent.dashboard.options`` from middleware) executes this __init__,
|
||||
and it must NOT drag in routes.py + FastAPI + every API/job module. Only the
|
||||
webapp, which actually mounts the router, pays that cost.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name == "router":
|
||||
from .routes import router
|
||||
|
||||
globals()[name] = router
|
||||
return router
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
|
|
|||
|
|
@ -80,21 +80,36 @@ def _origin_of(url: str) -> str:
|
|||
return f"{scheme}://{host}:{port}"
|
||||
|
||||
|
||||
def sanitize_redirect_to(redirect_to: str | None) -> str:
|
||||
"""Return a safe post-login redirect URL.
|
||||
def _is_blocked_redirect_path(path: str) -> bool:
|
||||
return path in {"/login", "/dashboard/api", "/_serverFn"} or path.startswith(
|
||||
("/login/", "/login?", "/login#", "/dashboard/api/", "/_serverFn/")
|
||||
)
|
||||
|
||||
Falls back to DASHBOARD_BASE_URL when the supplied URL's origin isn't
|
||||
explicitly allowed. This blocks the open-redirect / phishing primitive
|
||||
where an attacker drops their own URL into `?redirect_to=`.
|
||||
"""
|
||||
|
||||
def sanitize_redirect_to(redirect_to: str | None) -> str:
|
||||
"""Return a safe post-login redirect URL."""
|
||||
fallback = os.environ.get("DASHBOARD_BASE_URL", "").strip()
|
||||
if not redirect_to:
|
||||
return fallback
|
||||
candidate_origin = _origin_of(redirect_to)
|
||||
trimmed = redirect_to.strip()
|
||||
parsed = urlparse(trimmed)
|
||||
if (
|
||||
trimmed.startswith("/")
|
||||
and not trimmed.startswith("//")
|
||||
and not parsed.scheme
|
||||
and not parsed.netloc
|
||||
and not _is_blocked_redirect_path(parsed.path)
|
||||
):
|
||||
if fallback:
|
||||
return f"{fallback.rstrip('/')}{trimmed}"
|
||||
return trimmed
|
||||
if _is_blocked_redirect_path(parsed.path):
|
||||
return fallback
|
||||
candidate_origin = _origin_of(trimmed)
|
||||
if not candidate_origin:
|
||||
return fallback
|
||||
if candidate_origin in allowed_dashboard_origins():
|
||||
return redirect_to
|
||||
return trimmed
|
||||
logger.warning("Rejected redirect_to=%r — origin not in allowlist", redirect_to)
|
||||
return fallback
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from .plan_store import (
|
|||
PLAN_STATUS_CANCELLED,
|
||||
PLAN_STATUS_READY,
|
||||
PLAN_STATUS_REVISING,
|
||||
PLAN_STATUS_SHARED,
|
||||
add_plan_comment,
|
||||
delete_plan_comment,
|
||||
get_plan_content,
|
||||
|
|
@ -112,6 +113,7 @@ async def update_plan(
|
|||
if not markdown:
|
||||
raise HTTPException(422, "plan markdown cannot be empty")
|
||||
content = await get_plan_content(thread_id) or {}
|
||||
_reject_shared_content(content)
|
||||
status = content.get("status") or metadata.get("plan_status") or "planning"
|
||||
if status in (PLAN_STATUS_APPROVED, PLAN_STATUS_CANCELLED):
|
||||
raise HTTPException(409, f"cannot edit a {status} plan")
|
||||
|
|
@ -139,6 +141,7 @@ async def post_plan_comment(
|
|||
metadata = await _thread_metadata(thread_id)
|
||||
if not _thread_is_readable(metadata):
|
||||
raise HTTPException(404, "thread not found")
|
||||
_reject_shared_content(await get_plan_content(thread_id) or {})
|
||||
text = body.body.strip()
|
||||
if not text:
|
||||
raise HTTPException(422, "comment body cannot be empty")
|
||||
|
|
@ -155,6 +158,7 @@ async def remove_plan_comment(
|
|||
metadata = await _thread_metadata(thread_id)
|
||||
if not _thread_is_readable(metadata):
|
||||
raise HTTPException(404, "thread not found")
|
||||
_reject_shared_content(await get_plan_content(thread_id) or {})
|
||||
comments = await list_plan_comments(thread_id)
|
||||
target = next((c for c in comments if c.get("id") == comment_id), None)
|
||||
if target is None:
|
||||
|
|
@ -175,14 +179,16 @@ async def approve_plan(thread_id: str, session: dict[str, Any] = _SESSION_DEP) -
|
|||
# Read the published plan + comments BEFORE mutating state: a store failure
|
||||
# here aborts the decision (500) rather than dispatching without them. The
|
||||
# published markdown may have been edited by the owner, so it is the
|
||||
# source of truth handed to the agent (not its own stale history).
|
||||
content = await get_plan_content(thread_id, raise_on_error=True)
|
||||
status = (content or {}).get("status") or metadata.get("plan_status") or "planning"
|
||||
# source of truth handed to the agent (not its own stale history) — read it
|
||||
# strictly so a transient failure can't silently drop the edit.
|
||||
content = await get_plan_content(thread_id, raise_on_error=True) or {}
|
||||
_reject_shared_content(content)
|
||||
status = content.get("status") or metadata.get("plan_status") or "planning"
|
||||
if status == PLAN_STATUS_APPROVED:
|
||||
# Idempotent: a repeat approve (double-click / retry) must not dispatch a
|
||||
# second implementation run or post a duplicate Slack notice.
|
||||
raise HTTPException(409, "plan is already approved")
|
||||
plan_markdown = (content or {}).get("markdown", "")
|
||||
plan_markdown = str(content.get("markdown", "")).strip()
|
||||
comments = await list_plan_comments(thread_id, raise_on_error=True)
|
||||
feedback = _format_comments(comments)
|
||||
if plan_markdown:
|
||||
|
|
@ -215,6 +221,8 @@ async def reject_plan(thread_id: str, session: dict[str, Any] = _SESSION_DEP) ->
|
|||
metadata = await _thread_metadata(thread_id)
|
||||
if not _thread_is_readable(metadata):
|
||||
raise HTTPException(404, "thread not found")
|
||||
content = await get_plan_content(thread_id, raise_on_error=True) or {}
|
||||
_reject_shared_content(content)
|
||||
feedback = _format_comments(await list_plan_comments(thread_id, raise_on_error=True))
|
||||
await set_plan_status(thread_id, PLAN_STATUS_REVISING, plan_mode=True)
|
||||
text = (
|
||||
|
|
@ -227,6 +235,11 @@ async def reject_plan(thread_id: str, session: dict[str, Any] = _SESSION_DEP) ->
|
|||
return {"status": PLAN_STATUS_REVISING}
|
||||
|
||||
|
||||
def _reject_shared_content(content: dict[str, Any]) -> None:
|
||||
if content.get("status") == PLAN_STATUS_SHARED:
|
||||
raise HTTPException(409, "shared content is not an implementation plan")
|
||||
|
||||
|
||||
def _format_comments(comments: list[dict[str, Any]]) -> str:
|
||||
lines: list[str] = []
|
||||
index = 1
|
||||
|
|
|
|||
|
|
@ -27,9 +27,10 @@ PLAN_COMMENTS_NAMESPACE = ["plan", "comments"]
|
|||
# Plans are mirrored into the sandbox outside cloned repositories.
|
||||
PLAN_FILE_DIRECTORY = "/workspace/plans"
|
||||
|
||||
# Plan lifecycle, stored on both the content record and the thread metadata.
|
||||
# Plan/share lifecycle, stored on both the content record and the thread metadata.
|
||||
PLAN_STATUS_PLANNING = "planning"
|
||||
PLAN_STATUS_READY = "ready"
|
||||
PLAN_STATUS_SHARED = "shared"
|
||||
PLAN_STATUS_REVISING = "revising"
|
||||
PLAN_STATUS_APPROVED = "approved"
|
||||
PLAN_STATUS_CANCELLED = "cancelled"
|
||||
|
|
@ -68,8 +69,9 @@ async def save_plan_content(
|
|||
status: str = PLAN_STATUS_READY,
|
||||
clear_comments: bool = True,
|
||||
plan_file_path: str | None = None,
|
||||
plan_mode: bool | None = True,
|
||||
) -> None:
|
||||
"""Publish the plan markdown + status for the dashboard to render.
|
||||
"""Publish markdown + status for the dashboard to render.
|
||||
|
||||
A republished (revised) plan supersedes the prior revision, so comments left
|
||||
on it are cleared — otherwise stale feedback would resurface on the new plan
|
||||
|
|
@ -92,7 +94,10 @@ async def save_plan_content(
|
|||
except Exception:
|
||||
# Best-effort: a failed cleanup must not block publishing the new plan.
|
||||
pass
|
||||
await _merge_thread_metadata(thread_id, {"plan_status": status, "plan_mode": True})
|
||||
metadata: dict[str, Any] = {"plan_status": status}
|
||||
if plan_mode is not None:
|
||||
metadata["plan_mode"] = plan_mode
|
||||
await _merge_thread_metadata(thread_id, metadata)
|
||||
|
||||
|
||||
async def write_plan_to_sandbox(
|
||||
|
|
@ -136,10 +141,16 @@ async def get_plan_content(
|
|||
async def set_plan_status(thread_id: str, status: str, *, plan_mode: bool | None = None) -> None:
|
||||
"""Update the plan lifecycle status on both the content record and metadata."""
|
||||
existing = await get_plan_content(thread_id) or {}
|
||||
entering_plan_after_share = (
|
||||
existing.get("status") == PLAN_STATUS_SHARED and status == PLAN_STATUS_PLANNING
|
||||
)
|
||||
client = _client()
|
||||
record: dict[str, Any] = {"markdown": existing.get("markdown", ""), "status": status}
|
||||
record: dict[str, Any] = {
|
||||
"markdown": "" if entering_plan_after_share else existing.get("markdown", ""),
|
||||
"status": status,
|
||||
}
|
||||
plan_file_path = existing.get("plan_file_path")
|
||||
if isinstance(plan_file_path, str) and plan_file_path:
|
||||
if not entering_plan_after_share and isinstance(plan_file_path, str) and plan_file_path:
|
||||
record["plan_file_path"] = plan_file_path
|
||||
await client.store.put_item(
|
||||
PLAN_CONTENT_NAMESPACE,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from typing import Any, Literal
|
|||
from langgraph_sdk import get_client
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
|
||||
from ..utils.gateway import resolve_gateway_enabled
|
||||
from .options import (
|
||||
SUPPORTED_MODEL_IDS,
|
||||
default_model_pair,
|
||||
|
|
@ -52,6 +53,9 @@ class TeamSettingsUpdate(BaseModel):
|
|||
review_draft_prs: bool = False
|
||||
pr_summaries: bool = True
|
||||
review_trace_links: bool = True
|
||||
# Tri-state LLM Gateway toggle: True/False is authoritative, None inherits the
|
||||
# LANGSMITH_GATEWAY_ENABLED deployment default.
|
||||
gateway_enabled: bool | None = None
|
||||
review_tracing_project: str | None = None
|
||||
org_guidelines: str | None = None
|
||||
default_agent_model: str | None = None
|
||||
|
|
@ -166,6 +170,7 @@ def _default_settings() -> dict[str, Any]:
|
|||
"review_draft_prs": False,
|
||||
"pr_summaries": True,
|
||||
"review_trace_links": True,
|
||||
"gateway_enabled": None,
|
||||
"review_tracing_project": None,
|
||||
"org_guidelines": DEFAULT_ORG_REVIEW_GUIDELINES,
|
||||
"default_agent_model": fallback_model,
|
||||
|
|
@ -220,6 +225,7 @@ async def upsert_team_settings(update: TeamSettingsUpdate) -> dict[str, Any]:
|
|||
"review_draft_prs": update.review_draft_prs,
|
||||
"pr_summaries": update.pr_summaries,
|
||||
"review_trace_links": update.review_trace_links,
|
||||
"gateway_enabled": update.gateway_enabled,
|
||||
"review_tracing_project": update.review_tracing_project,
|
||||
"org_guidelines": update.org_guidelines,
|
||||
"default_agent_model": update.default_agent_model,
|
||||
|
|
@ -340,6 +346,18 @@ async def get_team_review_trace_links_enabled() -> bool:
|
|||
return bool(settings.get("review_trace_links", True))
|
||||
|
||||
|
||||
async def get_team_gateway_enabled() -> bool | None:
|
||||
"""Return the stored LLM Gateway toggle (``None`` means inherit the env default)."""
|
||||
settings = await get_team_settings()
|
||||
value = settings.get("gateway_enabled")
|
||||
return value if isinstance(value, bool) else None
|
||||
|
||||
|
||||
async def get_effective_gateway_enabled() -> bool:
|
||||
"""Resolve whether LLM Gateway routing is on: team setting, else env default."""
|
||||
return resolve_gateway_enabled(await get_team_gateway_enabled())
|
||||
|
||||
|
||||
async def get_team_review_tracing_project() -> str | None:
|
||||
"""Return the LangSmith tracing project used for PR trace resolution."""
|
||||
settings = await get_team_settings()
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ WORKFLOW_APPROVAL_PENDING = "pending"
|
|||
WORKFLOW_APPROVAL_APPROVED = "approved"
|
||||
WORKFLOW_APPROVAL_REJECTED = "rejected"
|
||||
_MAX_APPROVAL_RECORDS = 20
|
||||
_TERMINAL_STATUSES = {WORKFLOW_APPROVAL_APPROVED, WORKFLOW_APPROVAL_REJECTED}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
|
|
@ -80,25 +81,38 @@ async def ensure_workflow_push_pending(
|
|||
base_sha: str,
|
||||
head_sha: str,
|
||||
files: list[str],
|
||||
diff_stats: Mapping[str, Any] | None = None,
|
||||
diff_preview: str | None = None,
|
||||
diff_preview_truncated: bool = False,
|
||||
approval_url: str | None = None,
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
"""Store a pending approval unless a terminal record already exists."""
|
||||
approvals = await get_workflow_push_approvals(thread_id)
|
||||
existing = approvals.get(fingerprint)
|
||||
if existing and existing.get("status") in {
|
||||
WORKFLOW_APPROVAL_PENDING,
|
||||
WORKFLOW_APPROVAL_APPROVED,
|
||||
WORKFLOW_APPROVAL_REJECTED,
|
||||
}:
|
||||
if existing and existing.get("status") in _TERMINAL_STATUSES:
|
||||
return existing, False
|
||||
|
||||
record = {
|
||||
"fingerprint": fingerprint,
|
||||
"status": WORKFLOW_APPROVAL_PENDING,
|
||||
review_fields = {
|
||||
"repo": repo,
|
||||
"branch": branch,
|
||||
"base_sha": base_sha,
|
||||
"head_sha": head_sha,
|
||||
"files": files,
|
||||
"files": list(files),
|
||||
"diff_stats": _normalize_diff_stats(diff_stats, len(files)),
|
||||
"diff_preview": diff_preview or "",
|
||||
"diff_preview_truncated": diff_preview_truncated,
|
||||
"approval_url": approval_url,
|
||||
}
|
||||
if existing and existing.get("status") == WORKFLOW_APPROVAL_PENDING:
|
||||
record = {**existing, **review_fields}
|
||||
approvals[fingerprint] = record
|
||||
await _save_approvals(thread_id, approvals)
|
||||
return record, False
|
||||
|
||||
record = {
|
||||
"fingerprint": fingerprint,
|
||||
"status": WORKFLOW_APPROVAL_PENDING,
|
||||
**review_fields,
|
||||
"requested_at": _now(),
|
||||
"notified": False,
|
||||
}
|
||||
|
|
@ -107,6 +121,58 @@ async def ensure_workflow_push_pending(
|
|||
return record, True
|
||||
|
||||
|
||||
def _safe_int(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
return max(0, int(value))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_diff_stats(value: Mapping[str, Any] | None, file_count: int) -> dict[str, int]:
|
||||
if not isinstance(value, Mapping):
|
||||
return {"files": file_count, "additions": 0, "deletions": 0}
|
||||
return {
|
||||
"files": _safe_int(value.get("files"), file_count),
|
||||
"additions": _safe_int(value.get("additions")),
|
||||
"deletions": _safe_int(value.get("deletions")),
|
||||
}
|
||||
|
||||
|
||||
def workflow_push_approval_response(record: Mapping[str, Any]) -> dict[str, Any]:
|
||||
files = record.get("files")
|
||||
diff_stats = record.get("diff_stats")
|
||||
requested_at = record.get("requested_at")
|
||||
decided_at = record.get("decided_at")
|
||||
decided_by = record.get("decided_by")
|
||||
approval_url = record.get("approval_url")
|
||||
return {
|
||||
"fingerprint": str(record.get("fingerprint") or ""),
|
||||
"status": str(record.get("status") or WORKFLOW_APPROVAL_PENDING),
|
||||
"repo": str(record.get("repo") or ""),
|
||||
"branch": str(record.get("branch") or ""),
|
||||
"baseSha": str(record.get("base_sha") or ""),
|
||||
"headSha": str(record.get("head_sha") or ""),
|
||||
"files": [str(path) for path in files] if isinstance(files, list) else [],
|
||||
"diffStats": _normalize_diff_stats(
|
||||
diff_stats if isinstance(diff_stats, Mapping) else None,
|
||||
len(files) if isinstance(files, list) else 0,
|
||||
),
|
||||
"diffPreview": str(record.get("diff_preview") or ""),
|
||||
"diffPreviewTruncated": record.get("diff_preview_truncated") is True,
|
||||
"approvalUrl": approval_url if isinstance(approval_url, str) and approval_url else None,
|
||||
"requestedAt": requested_at if isinstance(requested_at, str) else None,
|
||||
"decidedAt": decided_at if isinstance(decided_at, str) else None,
|
||||
"decidedBy": decided_by if isinstance(decided_by, str) else None,
|
||||
}
|
||||
|
||||
|
||||
def workflow_push_approval_responses(
|
||||
approvals: Mapping[str, Mapping[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
ordered = sorted(approvals.values(), key=lambda r: str(r.get("requested_at", "")), reverse=True)
|
||||
return [workflow_push_approval_response(record) for record in ordered]
|
||||
|
||||
|
||||
async def mark_workflow_push_notified(thread_id: str, fingerprint: str) -> None:
|
||||
approvals = await get_workflow_push_approvals(thread_id)
|
||||
record = approvals.get(fingerprint)
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||
|
||||
from .oauth import require_same_origin_for_mutations, require_session
|
||||
from .plan_api import _dispatch_followup, _thread_metadata
|
||||
from .thread_api import _user_owns_thread
|
||||
from .thread_api import _thread_is_readable, _user_owns_thread
|
||||
from .workflow_approval import (
|
||||
WORKFLOW_APPROVAL_PENDING,
|
||||
decide_workflow_push_approval,
|
||||
get_workflow_push_approvals,
|
||||
workflow_push_approval_responses,
|
||||
)
|
||||
|
||||
workflow_approval_router = APIRouter(
|
||||
|
|
@ -23,31 +23,20 @@ workflow_approval_router = APIRouter(
|
|||
_SESSION_DEP = Depends(require_session)
|
||||
|
||||
|
||||
def _approval_record_response(record: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"fingerprint": record.get("fingerprint"),
|
||||
"status": record.get("status"),
|
||||
"repo": record.get("repo"),
|
||||
"branch": record.get("branch"),
|
||||
"files": record.get("files"),
|
||||
"requested_at": record.get("requested_at"),
|
||||
}
|
||||
|
||||
|
||||
@workflow_approval_router.get("/{thread_id}")
|
||||
async def list_workflow_approvals_for_thread(
|
||||
async def list_workflow_push_approvals(
|
||||
thread_id: str, session: dict[str, Any] = _SESSION_DEP
|
||||
) -> dict[str, Any]:
|
||||
metadata = await _thread_metadata(thread_id)
|
||||
if not _user_owns_thread(metadata, session["sub"], session.get("email")):
|
||||
raise HTTPException(403, "only the thread owner can view workflow push approvals")
|
||||
if not _thread_is_readable(metadata):
|
||||
raise HTTPException(404, "thread not found")
|
||||
is_owner = _user_owns_thread(metadata, session["sub"], session.get("email"))
|
||||
approvals = await get_workflow_push_approvals(thread_id)
|
||||
pending = [
|
||||
_approval_record_response(record)
|
||||
for record in approvals.values()
|
||||
if record.get("status") == WORKFLOW_APPROVAL_PENDING
|
||||
]
|
||||
return {"approvals": pending}
|
||||
return {
|
||||
"threadId": thread_id,
|
||||
"isOwner": is_owner,
|
||||
"approvals": workflow_push_approval_responses(approvals),
|
||||
}
|
||||
|
||||
|
||||
@workflow_approval_router.post("/{thread_id}/{fingerprint}/approve")
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ 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
|
||||
|
|
@ -25,22 +27,56 @@ from langgraph_sdk.client import LangGraphClient
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
ContentBlocks = str | list[dict[str, Any]]
|
||||
RunInput = dict[str, Any]
|
||||
RunConfig = dict[str, Any]
|
||||
|
||||
# Same-server FastAPI route the platform POSTs run completion/failure to. A
|
||||
# relative URL loopback-posts into this app (no SSRF/loopback config needed);
|
||||
# override with an absolute URL via env for split deployments. The route is
|
||||
# fail-closed on RUN_COMPLETE_WEBHOOK_SECRET, so only register 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). Unset → no webhook.
|
||||
# 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")
|
||||
COMPLETION_WEBHOOK_URL: str | None
|
||||
if not _RUN_COMPLETE_SECRET:
|
||||
COMPLETION_WEBHOOK_URL = None
|
||||
elif "?" in _COMPLETION_WEBHOOK_BASE:
|
||||
COMPLETION_WEBHOOK_URL = _COMPLETION_WEBHOOK_BASE
|
||||
else:
|
||||
COMPLETION_WEBHOOK_URL = f"{_COMPLETION_WEBHOOK_BASE}?token={_RUN_COMPLETE_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:
|
||||
|
|
@ -53,6 +89,65 @@ 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,
|
||||
|
|
@ -72,22 +167,13 @@ async def dispatch_agent_run(
|
|||
``"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.
|
||||
"""
|
||||
client = client or dispatch_client()
|
||||
run = await client.runs.create(
|
||||
return await create_durable_run(
|
||||
thread_id,
|
||||
assistant_id,
|
||||
input={"messages": [{"role": "user", "content": content}]},
|
||||
config={"configurable": configurable, "metadata": metadata or {}},
|
||||
config={"configurable": configurable},
|
||||
metadata=metadata or {},
|
||||
source=source,
|
||||
client=client or dispatch_client(),
|
||||
multitask_strategy=multitask_strategy,
|
||||
durability="sync",
|
||||
webhook=COMPLETION_WEBHOOK_URL,
|
||||
if_not_exists="create",
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,31 +1,45 @@
|
|||
from .check_message_queue import check_message_queue_before_model
|
||||
from .ensure_no_empty_msg import ensure_no_empty_msg
|
||||
from .exclude_tools import ExcludeToolsMiddleware
|
||||
from .model_fallback import ModelFallbackMiddleware
|
||||
from .notify_step_limit import notify_step_limit_reached
|
||||
from .plan_mode import PlanModeMiddleware
|
||||
from .refresh_github_proxy import refresh_github_proxy_before_model
|
||||
from .refresh_slack_status import SlackAssistantStatusMiddleware
|
||||
from .repair_orphaned_tool_calls import RepairOrphanedToolCallsMiddleware
|
||||
from .sandbox_circuit_breaker import SandboxCircuitBreakerMiddleware
|
||||
from .sanitize_thinking_blocks import SanitizeThinkingBlocksMiddleware
|
||||
from .sanitize_tool_inputs import SanitizeToolInputsMiddleware
|
||||
from .settle_review_check import settle_review_check_on_exit
|
||||
from .subdir_agents import SubdirAgentsReadMiddleware
|
||||
from .tool_artifact import ToolArtifactMiddleware
|
||||
from .tool_error_handler import ToolErrorMiddleware
|
||||
from .workflow_push_guard import WorkflowPushGuardMiddleware
|
||||
import sys
|
||||
from types import ModuleType
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
_MIDDLEWARE_MODULES = {
|
||||
"check_message_queue_before_model": ".check_message_queue",
|
||||
"ensure_no_empty_msg": ".ensure_no_empty_msg",
|
||||
"ExcludeToolsMiddleware": ".exclude_tools",
|
||||
"ModelFallbackMiddleware": ".model_fallback",
|
||||
"notify_step_limit_reached": ".notify_step_limit",
|
||||
"PlanModeMiddleware": ".plan_mode",
|
||||
"refresh_github_proxy_before_model": ".refresh_github_proxy",
|
||||
"RepairOrphanedToolCallsMiddleware": ".repair_orphaned_tool_calls",
|
||||
"SlackAssistantStatusMiddleware": ".refresh_slack_status",
|
||||
"SandboxCircuitBreakerMiddleware": ".sandbox_circuit_breaker",
|
||||
"SanitizeFireworksMessagesMiddleware": ".sanitize_fireworks_messages",
|
||||
"SanitizeOpenAIResponsesMiddleware": ".sanitize_openai_responses",
|
||||
"SanitizeThinkingBlocksMiddleware": ".sanitize_thinking_blocks",
|
||||
"SanitizeToolInputsMiddleware": ".sanitize_tool_inputs",
|
||||
"settle_review_check_on_exit": ".settle_review_check",
|
||||
"SubdirAgentsReadMiddleware": ".subdir_agents",
|
||||
"task_on_failure": ".task_retry",
|
||||
"task_retry_on": ".task_retry",
|
||||
"TimeoutWrapupMiddleware": ".timeout_wrapup",
|
||||
"ToolArtifactMiddleware": ".tool_artifact",
|
||||
"ToolErrorMiddleware": ".tool_error_handler",
|
||||
"WorkflowPushGuardMiddleware": ".workflow_push_guard",
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"ExcludeToolsMiddleware",
|
||||
"ModelFallbackMiddleware",
|
||||
"PlanModeMiddleware",
|
||||
"RepairOrphanedToolCallsMiddleware",
|
||||
"SanitizeFireworksMessagesMiddleware",
|
||||
"SanitizeOpenAIResponsesMiddleware",
|
||||
"SanitizeThinkingBlocksMiddleware",
|
||||
"SanitizeToolInputsMiddleware",
|
||||
"SubdirAgentsReadMiddleware",
|
||||
"ToolArtifactMiddleware",
|
||||
"ToolErrorMiddleware",
|
||||
"TimeoutWrapupMiddleware",
|
||||
"WorkflowPushGuardMiddleware",
|
||||
"SandboxCircuitBreakerMiddleware",
|
||||
"SlackAssistantStatusMiddleware",
|
||||
|
|
@ -34,4 +48,51 @@ __all__ = [
|
|||
"notify_step_limit_reached",
|
||||
"refresh_github_proxy_before_model",
|
||||
"settle_review_check_on_exit",
|
||||
"task_on_failure",
|
||||
"task_retry_on",
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .check_message_queue import check_message_queue_before_model
|
||||
from .ensure_no_empty_msg import ensure_no_empty_msg
|
||||
from .exclude_tools import ExcludeToolsMiddleware
|
||||
from .model_fallback import ModelFallbackMiddleware
|
||||
from .notify_step_limit import notify_step_limit_reached
|
||||
from .plan_mode import PlanModeMiddleware
|
||||
from .refresh_github_proxy import refresh_github_proxy_before_model
|
||||
from .refresh_slack_status import SlackAssistantStatusMiddleware
|
||||
from .repair_orphaned_tool_calls import RepairOrphanedToolCallsMiddleware
|
||||
from .sandbox_circuit_breaker import SandboxCircuitBreakerMiddleware
|
||||
from .sanitize_fireworks_messages import SanitizeFireworksMessagesMiddleware
|
||||
from .sanitize_openai_responses import SanitizeOpenAIResponsesMiddleware
|
||||
from .sanitize_thinking_blocks import SanitizeThinkingBlocksMiddleware
|
||||
from .sanitize_tool_inputs import SanitizeToolInputsMiddleware
|
||||
from .settle_review_check import settle_review_check_on_exit
|
||||
from .subdir_agents import SubdirAgentsReadMiddleware
|
||||
from .task_retry import task_on_failure, task_retry_on
|
||||
from .timeout_wrapup import TimeoutWrapupMiddleware
|
||||
from .tool_artifact import ToolArtifactMiddleware
|
||||
from .tool_error_handler import ToolErrorMiddleware
|
||||
from .workflow_push_guard import WorkflowPushGuardMiddleware
|
||||
|
||||
|
||||
def _load_export(name: str) -> Any:
|
||||
module_name = _MIDDLEWARE_MODULES.get(name)
|
||||
if module_name is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
from importlib import import_module
|
||||
|
||||
value = getattr(import_module(module_name, __name__), name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
class _LazyMiddlewareModule(ModuleType):
|
||||
def __getattribute__(self, name: str) -> Any:
|
||||
module_map = ModuleType.__getattribute__(self, "__dict__").get("_MIDDLEWARE_MODULES", {})
|
||||
if name in module_map:
|
||||
return _load_export(name)
|
||||
return ModuleType.__getattribute__(self, name)
|
||||
|
||||
|
||||
sys.modules[__name__].__class__ = _LazyMiddlewareModule
|
||||
|
|
|
|||
|
|
@ -1,22 +1,40 @@
|
|||
"""Middleware that falls back to a secondary model when the primary fails transiently.
|
||||
"""Middleware that retries model calls across a primary and fallback provider.
|
||||
|
||||
Wraps the model call. When the primary model raises a transient provider error
|
||||
(5xx, 429, connection/timeout), the same request is retried once against the
|
||||
configured fallback model. The fallback is bound to tools by the agent factory
|
||||
on the second call, so swapping ``request.model`` is sufficient.
|
||||
Wraps the model call. When a model raises a transient provider error (5xx,
|
||||
429, connection/timeout), the request is retried, alternating between the
|
||||
primary and the configured fallback model with exponential backoff between
|
||||
attempts. The fallback is bound to tools by the agent factory on each call,
|
||||
so swapping ``request.model`` is sufficient.
|
||||
|
||||
Why alternate with backoff instead of failing over once: both providers can
|
||||
be routed through the same LLM Gateway, so a gateway outage takes out the
|
||||
"cross-provider" fallback too. A single immediate failover cannot ride out
|
||||
even a short shared outage (the gateway's 502 page literally says "try again
|
||||
in 30 seconds"), and an unprotected fallback call crashes the whole run.
|
||||
Alternating with a backoff schedule that reaches past 30s lets a long-running
|
||||
agent run survive multi-minute provider or gateway blips.
|
||||
|
||||
Bidirectional: if the primary is Anthropic the fallback is typically OpenAI,
|
||||
and vice versa. The middleware itself is provider-agnostic — it inspects the
|
||||
exception type/status code to decide whether to fall over.
|
||||
exception type/status code to decide whether an attempt is retryable.
|
||||
|
||||
If every attempt fails, the middleware either raises the last error or (by
|
||||
default) returns a terminal ``AIMessage`` explaining the outage, so the run
|
||||
ends with a visible message in Slack/GitHub instead of an abrupt crash. The
|
||||
turn's progress is checkpointed, so the user can retrigger to continue.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
import random
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
import httpx
|
||||
import openai
|
||||
from botocore.exceptions import ClientError
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
|
|
@ -37,6 +55,7 @@ _TRANSIENT_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
|||
openai.APITimeoutError,
|
||||
openai.RateLimitError,
|
||||
openai.InternalServerError,
|
||||
httpx.TransportError,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -48,6 +67,23 @@ _RETRYABLE_BEDROCK_ERROR_CODES = {
|
|||
}
|
||||
|
||||
|
||||
# Seconds slept before each retry attempt (attempt 0 is the initial call).
|
||||
# The first failover is immediate: a provider-specific outage should not delay
|
||||
# the cross-provider retry. Later delays grow past the ~30s the gateway's 502
|
||||
# page asks for. Each attempt additionally benefits from the SDK's own
|
||||
# ``max_retries`` backoff, so worst-case wall time before giving up is a few
|
||||
# minutes — acceptable for a long-running agent, far better than crashing.
|
||||
DEFAULT_BACKOFF_SCHEDULE: tuple[float, ...] = (0.0, 5.0, 15.0, 30.0, 45.0)
|
||||
|
||||
MODEL_OUTAGE_MESSAGE = (
|
||||
"I wasn't able to reach the language model providers after several retries "
|
||||
"(both the primary and fallback models returned transient errors, e.g. "
|
||||
"502/503/overloaded). This is a temporary provider or gateway outage, not a "
|
||||
"problem with your task. My progress so far has been saved — please retrigger "
|
||||
"the run in a few minutes to continue."
|
||||
)
|
||||
|
||||
|
||||
def _should_fallback(exc: BaseException) -> bool:
|
||||
if isinstance(exc, _TRANSIENT_EXCEPTIONS):
|
||||
return True
|
||||
|
|
@ -117,52 +153,132 @@ def _provider_access_error_message(exc: BaseException) -> str | None:
|
|||
|
||||
|
||||
class ModelFallbackMiddleware(AgentMiddleware):
|
||||
"""Retry the model call against a fallback provider on transient errors."""
|
||||
"""Retry the model call across primary and fallback providers on transient errors.
|
||||
|
||||
def __init__(self, fallback_model: BaseChatModel) -> None:
|
||||
Args:
|
||||
fallback_model: Cross-provider model used on odd-numbered attempts.
|
||||
backoff_schedule: Seconds slept before each retry. ``len(schedule) + 1``
|
||||
is the total number of attempts. Delays get ±25% jitter.
|
||||
surface_outage_message: When all attempts fail, return a terminal
|
||||
``AIMessage`` describing the outage instead of raising, so the run
|
||||
ends gracefully with a user-visible message rather than a crash.
|
||||
Set to ``False`` to re-raise the last error (e.g. if platform-level
|
||||
alerting keys off failed runs).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fallback_model: BaseChatModel,
|
||||
*,
|
||||
backoff_schedule: Sequence[float] = DEFAULT_BACKOFF_SCHEDULE,
|
||||
surface_outage_message: bool = True,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._fallback_model = fallback_model
|
||||
self._backoff_schedule = tuple(backoff_schedule)
|
||||
self._surface_outage_message = surface_outage_message
|
||||
|
||||
def _fallback_name(self) -> str:
|
||||
return (
|
||||
getattr(self._fallback_model, "model_name", None)
|
||||
or getattr(self._fallback_model, "model", None)
|
||||
or "fallback"
|
||||
)
|
||||
|
||||
def _jittered_delay(self, delay: float) -> float:
|
||||
return delay + random.uniform(-delay * 0.25, delay * 0.25) if delay > 0 else 0.0
|
||||
|
||||
def _log_retry(
|
||||
self, exc_type: str, use_fallback: bool, attempt: int, total: int, delay: float
|
||||
) -> None:
|
||||
logger.warning(
|
||||
"Model call failed transiently (%s) on %s model "
|
||||
"(attempt %d/%d); retrying %s model in %.1fs",
|
||||
exc_type,
|
||||
"fallback" if use_fallback else "primary",
|
||||
attempt + 1,
|
||||
total,
|
||||
"primary" if use_fallback else f"fallback ({self._fallback_name()})",
|
||||
delay,
|
||||
)
|
||||
|
||||
def _resolve_request(self, request: ModelRequest, use_fallback: bool) -> ModelRequest:
|
||||
return request.override(model=self._fallback_model) if use_fallback else request
|
||||
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelCallResult:
|
||||
try:
|
||||
return handler(request)
|
||||
except Exception as exc:
|
||||
access_error_message = _provider_access_error_message(exc)
|
||||
if access_error_message is not None:
|
||||
logger.warning("Model access error surfaced to user: %s", type(exc).__name__)
|
||||
return AIMessage(content=access_error_message)
|
||||
if not _should_fallback(exc):
|
||||
raise
|
||||
logger.warning(
|
||||
"Primary model failed (%s); falling back to %s",
|
||||
type(exc).__name__,
|
||||
getattr(self._fallback_model, "model_name", None)
|
||||
or getattr(self._fallback_model, "model", "fallback"),
|
||||
)
|
||||
return handler(request.override(model=self._fallback_model))
|
||||
total_attempts = len(self._backoff_schedule) + 1
|
||||
last_exc: BaseException | None = None
|
||||
|
||||
for attempt in range(total_attempts):
|
||||
use_fallback = attempt % 2 == 1
|
||||
attempt_request = self._resolve_request(request, use_fallback)
|
||||
try:
|
||||
return handler(attempt_request)
|
||||
except Exception as exc:
|
||||
access_error_message = _provider_access_error_message(exc)
|
||||
if access_error_message is not None:
|
||||
logger.warning("Model access error surfaced to user: %s", type(exc).__name__)
|
||||
return AIMessage(content=access_error_message)
|
||||
if not _should_fallback(exc):
|
||||
raise
|
||||
last_exc = exc
|
||||
if attempt + 1 >= total_attempts:
|
||||
break
|
||||
delay = self._jittered_delay(self._backoff_schedule[attempt])
|
||||
self._log_retry(type(exc).__name__, use_fallback, attempt, total_attempts, delay)
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
|
||||
assert last_exc is not None
|
||||
logger.error(
|
||||
"Model call failed after %d attempts across primary and fallback (%s): %s",
|
||||
total_attempts,
|
||||
self._fallback_name(),
|
||||
last_exc,
|
||||
)
|
||||
if self._surface_outage_message:
|
||||
return AIMessage(content=MODEL_OUTAGE_MESSAGE)
|
||||
raise last_exc
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> Any:
|
||||
try:
|
||||
return await handler(request)
|
||||
except Exception as exc:
|
||||
access_error_message = _provider_access_error_message(exc)
|
||||
if access_error_message is not None:
|
||||
logger.warning("Model access error surfaced to user: %s", type(exc).__name__)
|
||||
return AIMessage(content=access_error_message)
|
||||
if not _should_fallback(exc):
|
||||
raise
|
||||
logger.warning(
|
||||
"Primary model failed (%s); falling back to %s",
|
||||
type(exc).__name__,
|
||||
getattr(self._fallback_model, "model_name", None)
|
||||
or getattr(self._fallback_model, "model", "fallback"),
|
||||
)
|
||||
return await handler(request.override(model=self._fallback_model))
|
||||
total_attempts = len(self._backoff_schedule) + 1
|
||||
last_exc: BaseException | None = None
|
||||
|
||||
for attempt in range(total_attempts):
|
||||
use_fallback = attempt % 2 == 1
|
||||
attempt_request = self._resolve_request(request, use_fallback)
|
||||
try:
|
||||
return await handler(attempt_request)
|
||||
except Exception as exc:
|
||||
access_error_message = _provider_access_error_message(exc)
|
||||
if access_error_message is not None:
|
||||
logger.warning("Model access error surfaced to user: %s", type(exc).__name__)
|
||||
return AIMessage(content=access_error_message)
|
||||
if not _should_fallback(exc):
|
||||
raise
|
||||
last_exc = exc
|
||||
if attempt + 1 >= total_attempts:
|
||||
break
|
||||
delay = self._jittered_delay(self._backoff_schedule[attempt])
|
||||
self._log_retry(type(exc).__name__, use_fallback, attempt, total_attempts, delay)
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
assert last_exc is not None
|
||||
logger.error(
|
||||
"Model call failed after %d attempts across primary and fallback (%s): %s",
|
||||
total_attempts,
|
||||
self._fallback_name(),
|
||||
last_exc,
|
||||
)
|
||||
if self._surface_outage_message:
|
||||
return AIMessage(content=MODEL_OUTAGE_MESSAGE)
|
||||
raise last_exc
|
||||
|
|
|
|||
79
agent/middleware/sanitize_fireworks_messages.py
Normal file
79
agent/middleware/sanitize_fireworks_messages.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Strip legacy ``function_call`` fields before Fireworks provider calls.
|
||||
|
||||
The LangSmith LLM Gateway enforces strict request-schema validation and rejects
|
||||
messages carrying the legacy OpenAI ``function_call`` field
|
||||
(``Extra inputs are not permitted, field: 'messages[N].function_call'``).
|
||||
``langchain_fireworks`` forwards ``function_call`` from
|
||||
``AIMessage.additional_kwargs`` whenever it is present — even when the message
|
||||
also carries modern ``tool_calls`` — so a message that was originally produced
|
||||
by (or deserialized from) a provider that populated the legacy field will break
|
||||
every subsequent Fireworks turn once routed through the gateway.
|
||||
|
||||
This middleware drops the redundant legacy field from assistant messages before
|
||||
they reach the Fireworks serializer, mirroring the thinking-block sanitizer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
try:
|
||||
from langchain_fireworks.chat_models import ChatFireworks
|
||||
except ImportError: # pragma: no cover
|
||||
ChatFireworks = None # type: ignore[assignment, misc]
|
||||
|
||||
|
||||
def _is_chat_fireworks(model: object) -> bool:
|
||||
if ChatFireworks is None:
|
||||
return False
|
||||
seen: set[int] = set()
|
||||
current = model
|
||||
for _ in range(10):
|
||||
if isinstance(current, ChatFireworks):
|
||||
return True
|
||||
current_id = id(current)
|
||||
if current_id in seen:
|
||||
return False
|
||||
seen.add(current_id)
|
||||
bound = getattr(current, "bound", None)
|
||||
if bound is None or bound is current:
|
||||
return False
|
||||
current = bound
|
||||
return False
|
||||
|
||||
|
||||
def _sanitize_messages(messages: list[Any]) -> None:
|
||||
for message in messages:
|
||||
if not isinstance(message, AIMessage):
|
||||
continue
|
||||
additional_kwargs = message.additional_kwargs
|
||||
if not isinstance(additional_kwargs, dict) or "function_call" not in additional_kwargs:
|
||||
continue
|
||||
additional_kwargs.pop("function_call", None)
|
||||
|
||||
|
||||
class SanitizeFireworksMessagesMiddleware(AgentMiddleware):
|
||||
"""Drop legacy ``function_call`` fields before Fireworks provider calls."""
|
||||
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelCallResult:
|
||||
if _is_chat_fireworks(request.model):
|
||||
_sanitize_messages(request.messages)
|
||||
return handler(request)
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> Any:
|
||||
if _is_chat_fireworks(request.model):
|
||||
_sanitize_messages(request.messages)
|
||||
return await handler(request)
|
||||
85
agent/middleware/sanitize_openai_responses.py
Normal file
85
agent/middleware/sanitize_openai_responses.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""Middleware that removes stale OpenAI Responses reasoning references."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
try:
|
||||
from langchain_openai import ChatOpenAI
|
||||
except ImportError: # pragma: no cover
|
||||
ChatOpenAI = None # type: ignore[assignment, misc]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_chat_openai(model: object) -> bool:
|
||||
if ChatOpenAI is None:
|
||||
return False
|
||||
seen: set[int] = set()
|
||||
current = model
|
||||
for _ in range(10):
|
||||
if isinstance(current, ChatOpenAI):
|
||||
return True
|
||||
current_id = id(current)
|
||||
if current_id in seen:
|
||||
return False
|
||||
seen.add(current_id)
|
||||
bound = getattr(current, "bound", None)
|
||||
if bound is None or bound is current:
|
||||
return False
|
||||
current = bound
|
||||
return False
|
||||
|
||||
|
||||
def _is_stale_reasoning_reference(block: dict[str, Any]) -> bool:
|
||||
if block.get("type") != "reasoning":
|
||||
return False
|
||||
if block.get("encrypted_content"):
|
||||
return False
|
||||
block_id = block.get("id")
|
||||
return isinstance(block_id, str) and block_id.startswith("rs_")
|
||||
|
||||
|
||||
def _sanitize_messages(messages: list[Any]) -> None:
|
||||
removed = 0
|
||||
for message in messages:
|
||||
if not isinstance(message, AIMessage) or not isinstance(message.content, list):
|
||||
continue
|
||||
content = []
|
||||
for block in message.content:
|
||||
if isinstance(block, dict) and _is_stale_reasoning_reference(block):
|
||||
removed += 1
|
||||
continue
|
||||
content.append(block)
|
||||
if len(content) != len(message.content):
|
||||
message.content = content
|
||||
if removed:
|
||||
logger.warning("Removed %d stale OpenAI Responses reasoning reference(s)", removed)
|
||||
|
||||
|
||||
class SanitizeOpenAIResponsesMiddleware(AgentMiddleware):
|
||||
"""Drop non-replayable OpenAI Responses reasoning item references."""
|
||||
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelCallResult:
|
||||
if _is_chat_openai(request.model):
|
||||
_sanitize_messages(request.messages)
|
||||
return handler(request)
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> Any:
|
||||
if _is_chat_openai(request.model):
|
||||
_sanitize_messages(request.messages)
|
||||
return await handler(request)
|
||||
82
agent/middleware/task_retry.py
Normal file
82
agent/middleware/task_retry.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
_RETURN_TO_MODEL_CODES = frozenset({"invalid_prompt", "context_length_exceeded"})
|
||||
_RETURN_TO_MODEL_STATUS_CODES = frozenset({400, 422})
|
||||
_RETRY_HTTP_STATUS_CODES = frozenset({408, 409, 425, 429, 500, 502, 503, 504, 529})
|
||||
_TRANSIENT_ERROR_NAMES = frozenset(
|
||||
{
|
||||
"APIConnectionError",
|
||||
"APITimeoutError",
|
||||
"ConnectTimeout",
|
||||
"ReadTimeout",
|
||||
"TimeoutException",
|
||||
"TransportError",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _error_body(exc: Exception) -> dict[str, object]:
|
||||
body = getattr(exc, "body", None)
|
||||
if isinstance(body, dict):
|
||||
nested = body.get("error")
|
||||
return nested if isinstance(nested, dict) else body
|
||||
return {}
|
||||
|
||||
|
||||
def _status_code(exc: Exception) -> int | None:
|
||||
status = getattr(exc, "status_code", None)
|
||||
if isinstance(status, int):
|
||||
return status
|
||||
response = getattr(exc, "response", None)
|
||||
status = getattr(response, "status_code", None)
|
||||
return status if isinstance(status, int) else None
|
||||
|
||||
|
||||
def _error_fields(exc: Exception) -> dict[str, object]:
|
||||
body = _error_body(exc)
|
||||
out: dict[str, object] = {}
|
||||
status = _status_code(exc)
|
||||
if status is not None:
|
||||
out["status_code"] = status
|
||||
for key in ("type", "code", "message"):
|
||||
value = body.get(key)
|
||||
if not isinstance(value, str) or not value:
|
||||
value = getattr(exc, key, None)
|
||||
if isinstance(value, str) and value:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def _is_httpx_transport_error(exc: Exception) -> bool:
|
||||
try:
|
||||
import httpx
|
||||
except ImportError: # pragma: no cover - dependency is declared in production
|
||||
return False
|
||||
return isinstance(exc, httpx.TransportError)
|
||||
|
||||
|
||||
def task_retry_on(exc: Exception) -> bool:
|
||||
status = _status_code(exc)
|
||||
if isinstance(status, int) and (status in _RETRY_HTTP_STATUS_CODES or status >= 500):
|
||||
return True
|
||||
return exc.__class__.__name__ in _TRANSIENT_ERROR_NAMES or _is_httpx_transport_error(exc)
|
||||
|
||||
|
||||
def task_on_failure(exc: Exception) -> str:
|
||||
error = _error_fields(exc)
|
||||
code = error.get("code")
|
||||
status = error.get("status_code")
|
||||
returnable = code in _RETURN_TO_MODEL_CODES or (
|
||||
code is None
|
||||
and error.get("type") == "invalid_request_error"
|
||||
and isinstance(status, int)
|
||||
and status in _RETURN_TO_MODEL_STATUS_CODES
|
||||
)
|
||||
if not returnable:
|
||||
raise exc
|
||||
return json.dumps(
|
||||
{"status": "failed", "source": "subagent", "error": error},
|
||||
sort_keys=True,
|
||||
)
|
||||
64
agent/middleware/timeout_wrapup.py
Normal file
64
agent/middleware/timeout_wrapup.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from langchain.agents.middleware.types import AgentMiddleware, ModelRequest, ModelResponse
|
||||
from langchain_core.messages import BaseMessage, SystemMessage
|
||||
|
||||
_DEFAULT_TIMEOUT_SECONDS = 45 * 60
|
||||
_WRAPUP_INSTRUCTION = """
|
||||
<time_limit_warning>
|
||||
You have been running for a long time. Wrap up immediately: finish the current
|
||||
step, save or report useful state, avoid starting new investigations, and end
|
||||
your turn with the best available result.
|
||||
</time_limit_warning>
|
||||
"""
|
||||
|
||||
|
||||
def _configured_timeout_seconds() -> int:
|
||||
raw = os.environ.get("OPEN_SWE_WRAPUP_TIMEOUT_SECONDS")
|
||||
if not raw:
|
||||
return _DEFAULT_TIMEOUT_SECONDS
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
return _DEFAULT_TIMEOUT_SECONDS
|
||||
return value if value > 0 else _DEFAULT_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def _content_with_instruction(message: BaseMessage | None, instruction: str) -> str | list[object]:
|
||||
if message is None:
|
||||
return instruction
|
||||
content = message.content
|
||||
if isinstance(content, list):
|
||||
return [*content, {"type": "text", "text": instruction}]
|
||||
return f"{content}\n\n{instruction}" if content else instruction
|
||||
|
||||
|
||||
class TimeoutWrapupMiddleware(AgentMiddleware):
|
||||
def __init__(self, timeout_seconds: int | None = None) -> None:
|
||||
super().__init__()
|
||||
self._timeout_seconds = timeout_seconds or _configured_timeout_seconds()
|
||||
# Graph construction should create one middleware instance per run; start
|
||||
# lazily so construction-time caching cannot age the run clock.
|
||||
self._start: float | None = None
|
||||
|
||||
def _should_wrapup(self) -> bool:
|
||||
if self._start is None:
|
||||
self._start = time.monotonic()
|
||||
return (time.monotonic() - self._start) >= self._timeout_seconds
|
||||
|
||||
def _apply(self, request: ModelRequest) -> ModelRequest:
|
||||
if not self._should_wrapup():
|
||||
return request
|
||||
content = _content_with_instruction(request.system_message, _WRAPUP_INSTRUCTION)
|
||||
return request.override(system_message=SystemMessage(content=content))
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelResponse:
|
||||
return await handler(self._apply(request))
|
||||
|
|
@ -29,6 +29,7 @@ from ..dashboard.workflow_approval import (
|
|||
workflow_push_rejected,
|
||||
)
|
||||
from ..tools.slack_thread_reply import build_workflow_approval_blocks
|
||||
from ..utils.dashboard_links import dashboard_workflow_approval_url
|
||||
from ..utils.github_app import (
|
||||
BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
|
|
@ -58,6 +59,8 @@ _GIT_WRAPPERS = {"command", "env", "nice", "sudo", "stdbuf", "nohup", "time", "i
|
|||
# Git global options that consume the following token as a separate value; skipped when
|
||||
# locating the git subcommand so `git -c k=v push` is still recognized as a push.
|
||||
_GIT_VALUE_OPTIONS = {"-C", "-c", "--namespace", "--git-dir", "--work-tree", "--exec-path"}
|
||||
_DIFF_PREVIEW_MAX_CHARS = 20_000
|
||||
_DIFF_PREVIEW_MAX_LINES = 400
|
||||
|
||||
|
||||
class _BlockedGitPush:
|
||||
|
|
@ -90,6 +93,9 @@ class WorkflowPushChange:
|
|||
remote_ref: str
|
||||
fixed_command: str
|
||||
base_sha: str = ""
|
||||
diff_stats: dict[str, int] | None = None
|
||||
diff_preview: str = ""
|
||||
diff_preview_truncated: bool = False
|
||||
blocked: bool = False
|
||||
blocked_reason: str = ""
|
||||
|
||||
|
|
@ -445,6 +451,44 @@ def _fingerprint(payload: Mapping[str, Any]) -> str:
|
|||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def _diff_preview(diff: str) -> tuple[str, bool]:
|
||||
if len(diff) <= _DIFF_PREVIEW_MAX_CHARS:
|
||||
lines = diff.splitlines()
|
||||
if len(lines) <= _DIFF_PREVIEW_MAX_LINES:
|
||||
return diff, False
|
||||
preview_lines: list[str] = []
|
||||
char_count = 0
|
||||
truncated = False
|
||||
for line in diff.splitlines():
|
||||
next_count = char_count + len(line) + 1
|
||||
if len(preview_lines) >= _DIFF_PREVIEW_MAX_LINES or next_count > _DIFF_PREVIEW_MAX_CHARS:
|
||||
truncated = True
|
||||
break
|
||||
preview_lines.append(line)
|
||||
char_count = next_count
|
||||
return "\n".join(preview_lines), truncated
|
||||
|
||||
|
||||
def _diff_stats(files: list[str], numstat: str) -> dict[str, int]:
|
||||
additions = 0
|
||||
deletions = 0
|
||||
for line in numstat.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
if parts[0].isdigit():
|
||||
additions += int(parts[0])
|
||||
if parts[1].isdigit():
|
||||
deletions += int(parts[1])
|
||||
return {"files": len(files), "additions": additions, "deletions": deletions}
|
||||
|
||||
|
||||
def _approval_url(thread_id: str | None, fingerprint: str) -> str | None:
|
||||
if not thread_id:
|
||||
return None
|
||||
return dashboard_workflow_approval_url(thread_id, fingerprint)
|
||||
|
||||
|
||||
def _run_coroutine_sync(coro: Awaitable[ToolMessage | Command]) -> ToolMessage | Command:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
|
|
@ -614,6 +658,29 @@ def _workflow_change_for_push(backend: Any, parsed: ParsedGitPush) -> WorkflowPu
|
|||
fixed_args.append("--set-upstream")
|
||||
fixed_args.extend([parsed.remote, fixed_refspec])
|
||||
fixed_command = _git_command(root, " ".join(shlex.quote(arg) for arg in fixed_args))
|
||||
|
||||
# Fetch diff preview for the workflow approval card.
|
||||
diff_range = f"{shlex.quote(base_ref)}...{shlex.quote(head)}" if base_ref else shlex.quote(head)
|
||||
diff = _run_git(backend, root, f"diff --binary --full-index {diff_range} -- .github/workflows")
|
||||
numstat = None
|
||||
diff_preview = ""
|
||||
diff_preview_truncated = False
|
||||
diff_stats_val: dict[str, int] = {"files": len(files), "additions": 0, "deletions": 0}
|
||||
base_sha_actual = base_ref if base_ref else ""
|
||||
if diff.ok and diff.output:
|
||||
diff_preview, diff_preview_truncated = _diff_preview(diff.output)
|
||||
numstat = _run_git(backend, root, f"diff --numstat {diff_range} -- .github/workflows")
|
||||
if numstat.ok:
|
||||
diff_stats_val = _diff_stats(files, numstat.output)
|
||||
elif base_ref and not diff.ok:
|
||||
diff_only_head = _run_git(
|
||||
backend,
|
||||
root,
|
||||
f"diff --binary --full-index --root {shlex.quote(head)} -- .github/workflows",
|
||||
)
|
||||
if diff_only_head.ok and diff_only_head.output:
|
||||
diff_preview, diff_preview_truncated = _diff_preview(diff_only_head.output)
|
||||
|
||||
content_payload = {
|
||||
"repo": repo,
|
||||
"branch": branch_name,
|
||||
|
|
@ -626,16 +693,21 @@ def _workflow_change_for_push(backend: Any, parsed: ParsedGitPush) -> WorkflowPu
|
|||
branch=branch_name,
|
||||
files=files,
|
||||
head_sha=head,
|
||||
base_sha=base_sha_actual,
|
||||
remote=parsed.remote,
|
||||
local_ref=parsed.local_ref,
|
||||
remote_ref=parsed.remote_ref,
|
||||
fixed_command=fixed_command,
|
||||
diff_stats=diff_stats_val,
|
||||
diff_preview=diff_preview,
|
||||
diff_preview_truncated=diff_preview_truncated,
|
||||
)
|
||||
|
||||
|
||||
def _blocked_message(
|
||||
change: WorkflowPushChange,
|
||||
*,
|
||||
approval_url: str | None = None,
|
||||
already_rejected: bool = False,
|
||||
stale: bool = False,
|
||||
blocked: bool = False,
|
||||
|
|
@ -657,7 +729,7 @@ def _blocked_message(
|
|||
error = (
|
||||
"This git push includes GitHub workflow file changes and requires human "
|
||||
"approval before Open SWE can push it. Retry the same standalone git push "
|
||||
"after the thread owner approves the workflow files."
|
||||
"after the thread owner approves the workflow diff in Slack or the web UI."
|
||||
)
|
||||
error_type = "WorkflowPushApprovalRequired"
|
||||
content = {
|
||||
|
|
@ -669,6 +741,11 @@ def _blocked_message(
|
|||
"files": change.files,
|
||||
"repo": change.repo,
|
||||
"branch": change.branch,
|
||||
"base_sha": change.base_sha,
|
||||
"head_sha": change.head_sha,
|
||||
"diff_stats": change.diff_stats,
|
||||
"diff_preview_truncated": change.diff_preview_truncated,
|
||||
"approval_url": approval_url,
|
||||
}
|
||||
return ToolMessage(content=json.dumps(content), tool_call_id="", status="error")
|
||||
|
||||
|
|
@ -687,20 +764,22 @@ def _override_execute_command(request: ToolCallRequest, command: str) -> ToolCal
|
|||
return request.override(tool_call={**dict(tool_call), "args": args})
|
||||
|
||||
|
||||
def _approval_slack_message(change: WorkflowPushChange) -> str:
|
||||
def _approval_slack_message(change: WorkflowPushChange, approval_url: str | None = None) -> str:
|
||||
files = "\n".join(f"• `{path}`" for path in change.files[:10])
|
||||
if len(change.files) > 10:
|
||||
files += f"\n• …and {len(change.files) - 10} more"
|
||||
repo = change.repo or "the repository"
|
||||
branch = change.branch or "the current branch"
|
||||
stats = change.diff_stats or {"files": len(change.files), "additions": 0, "deletions": 0}
|
||||
web_review = f"\n\n*Review diff:* <{approval_url}|Open in Web>" if approval_url else ""
|
||||
return (
|
||||
"*Workflow file approval required*\n"
|
||||
f"Open SWE is trying to push the workflow files below to `{repo}` on `{branch}`.\n\n"
|
||||
f"*Files at the pushed head:*\n{files}\n\n"
|
||||
f"*Fingerprint:* `{change.fingerprint}`\n\n"
|
||||
"Approval covers the exact workflow files and content listed above at the pushed head, "
|
||||
"including future rebases or amends that replay the same workflow tree. If the set of "
|
||||
"workflow files, the branch, or the workflow-file content at the pushed head changes, "
|
||||
f"Open SWE is trying to push changes to GitHub workflow files in `{repo}` on `{branch}`.\n\n"
|
||||
f"*Files:*\n{files}\n\n"
|
||||
f"*Diff stat:* {stats.get('files', len(change.files))} files, "
|
||||
f"+{stats.get('additions', 0)} / -{stats.get('deletions', 0)}\n"
|
||||
f"*Fingerprint:* `{change.fingerprint}`{web_review}\n\n"
|
||||
"Approve only if this exact workflow diff is expected. If the workflow files change, "
|
||||
"a new fingerprint will be required."
|
||||
)
|
||||
|
||||
|
|
@ -718,7 +797,9 @@ async def _post_slack_approval_if_needed(
|
|||
thread_ts = slack_thread.get("thread_ts")
|
||||
if not isinstance(channel_id, str) or not isinstance(thread_ts, str):
|
||||
return
|
||||
message = _approval_slack_message(change)
|
||||
message = _approval_slack_message(
|
||||
change, _approval_url(_thread_id(request), change.fingerprint)
|
||||
)
|
||||
message_ts, error = await post_slack_thread_reply_with_ts(
|
||||
channel_id,
|
||||
thread_ts,
|
||||
|
|
@ -736,6 +817,7 @@ async def _approval_state(request: ToolCallRequest, change: WorkflowPushChange)
|
|||
if not thread_id:
|
||||
return "missing_thread"
|
||||
try:
|
||||
approval_url = _approval_url(thread_id, change.fingerprint)
|
||||
if await workflow_push_approved(thread_id, change.fingerprint):
|
||||
return "approved"
|
||||
if await workflow_push_rejected(thread_id, change.fingerprint):
|
||||
|
|
@ -763,6 +845,10 @@ async def _approval_state(request: ToolCallRequest, change: WorkflowPushChange)
|
|||
base_sha=change.base_sha,
|
||||
head_sha=change.head_sha,
|
||||
files=change.files,
|
||||
diff_stats=change.diff_stats,
|
||||
diff_preview=change.diff_preview,
|
||||
diff_preview_truncated=change.diff_preview_truncated,
|
||||
approval_url=approval_url,
|
||||
)
|
||||
await _post_slack_approval_if_needed(request, change, record)
|
||||
return str(record.get("status") or "pending")
|
||||
|
|
@ -810,7 +896,19 @@ async def _run_with_workflow_token(
|
|||
finally:
|
||||
restored = await refresh_proxy_token(thread_id, permissions=RUNTIME_PROXY_TOKEN_PERMISSIONS)
|
||||
if not restored:
|
||||
await refresh_proxy_token(thread_id, permissions=BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS)
|
||||
logger.error(
|
||||
"SECURITY: failed to downscope proxy token for thread %s after an approved "
|
||||
"workflow push; retrying without actions:read.",
|
||||
thread_id,
|
||||
)
|
||||
if not await refresh_proxy_token(
|
||||
thread_id, permissions=BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
):
|
||||
logger.error(
|
||||
"SECURITY: proxy token downscope fully failed for thread %s; the sandbox "
|
||||
"may retain workflows:write for the remainder of this run.",
|
||||
thread_id,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowPushGuardMiddleware(AgentMiddleware):
|
||||
|
|
@ -859,7 +957,7 @@ class WorkflowPushGuardMiddleware(AgentMiddleware):
|
|||
if state == "approved" and thread_id:
|
||||
safe_request = _override_execute_command(request, change.fixed_command)
|
||||
return await _run_with_workflow_token(thread_id, request, lambda: handler(safe_request))
|
||||
if state == "stale_approval":
|
||||
if state == "stale_approval" and thread_id:
|
||||
record, _created = await ensure_workflow_push_pending(
|
||||
thread_id,
|
||||
fingerprint=change.fingerprint,
|
||||
|
|
@ -868,11 +966,16 @@ class WorkflowPushGuardMiddleware(AgentMiddleware):
|
|||
base_sha=change.base_sha,
|
||||
head_sha=change.head_sha,
|
||||
files=change.files,
|
||||
diff_stats=change.diff_stats,
|
||||
diff_preview=change.diff_preview,
|
||||
diff_preview_truncated=change.diff_preview_truncated,
|
||||
approval_url=_approval_url(thread_id, change.fingerprint),
|
||||
)
|
||||
await _post_slack_approval_if_needed(request, change, record)
|
||||
return _tool_message_for_request(
|
||||
_blocked_message(
|
||||
change,
|
||||
approval_url=_approval_url(thread_id, change.fingerprint),
|
||||
already_rejected=state == "rejected",
|
||||
stale=state == "stale_approval",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -83,9 +83,10 @@ OPEN_SWE_SHARED_BASE = """You are **Open SWE**, an open-source agent built on La
|
|||
### Communication
|
||||
|
||||
- Focus on the substance and keep summaries brief. Use light markdown (`###`/`####` headings, bold, code) — avoid `#`/`##` titles.
|
||||
- In Slack, keep every reply terse — a few sentences at most. Lead with the answer or outcome; skip preamble, restating the request, and step-by-step recaps. Do not paste long output, diffs, file listings, or multi-section write-ups into a Slack reply. When the response would genuinely be long — a detailed report, a design/analysis write-up, a large code or log excerpt — write that content to a Markdown file under `/workspace/plans/` and publish it with the `save_plan` tool, then post a terse Slack reply with a one-line summary and the plan-review link so the user can read the full version there. This non-plan share path does not enter plan mode; use it instead of splitting a long answer across multiple Slack messages.
|
||||
- In Slack, when a user asks to “break out,” “split out,” or “start a separate thread” for part of the work, summarize the requested aspect and relevant context into self-contained instructions, then call `slack_start_new_thread` instead of only replying in the current thread.
|
||||
- In Slack, when acknowledging a user follow-up while you continue working, prefer `slack_add_reaction` with the default `eyes` reaction over posting a perfunctory “Updating…” / “I’ll check…” confirmation reply.
|
||||
- When you post to Slack with `slack_thread_reply`, do not repeat that text in a later assistant message; the user can already see the Slack message.
|
||||
- For Slack-triggered information-only answers, post only a concise summary in the associated Slack thread with `slack_thread_reply`, then provide the complete answer inline in your final assistant response. For other Slack updates, keep thread replies brief and avoid duplicating the same text later.
|
||||
- When delegated work to a subagent: the calling agent only sees your final message, so make it the complete answer.
|
||||
|
||||
IMPORTANT: You must ALWAYS call a tool in EVERY SINGLE TURN. If you don't call a tool, the session will end and you won't be able to resume without the user manually restarting you.
|
||||
|
|
@ -310,7 +311,7 @@ Steps, in order:
|
|||
|
||||
**IMPORTANT: If `git push` or `gh` returns "403", "Permission denied", or another permanent authorization failure, do not retry. Report the error to the user immediately and stop.**
|
||||
|
||||
**IMPORTANT: Workflow files (`.github/workflows/`) may be changed only when explicitly requested. Any push that includes workflow files at the pushed head requires human approval before it can proceed. Approval is keyed to the repo, branch, and the exact workflow files and content present at the pushed head, so rebases or amends that replay the same workflow tree do not require a fresh approval; changing the branch, the set of workflow files, or the workflow-file content at the pushed head does require a new approval. Do not attempt to bypass it.**
|
||||
**IMPORTANT: Workflow files (`.github/workflows/`) may be changed only when explicitly requested. Workflow-file pushes are approved by `WorkflowPushGuardMiddleware`: after committing, run the push as a standalone `git push origin <branch>` (or `git -C <repo> push origin <branch>`), never as part of a compound command. Do not manually ask for freeform fingerprint approval. If the push tool returns `WorkflowPushApprovalRequired`, stop retrying and wait for the generated Slack/Web approval; after approval, retry the same standalone push without changing workflow files. Approval is keyed to the repo, branch, and the exact workflow files and content present at the pushed head, so rebases or amends that replay the same workflow tree do not require a fresh approval; changing the branch, the set of workflow files, or the workflow-file content at the pushed head does require a new approval. Do not attempt to bypass it.**
|
||||
|
||||
4. **Notify the source** immediately after pushing and, when applicable, PR creation/update succeeds. Include a brief summary plus the PR link or branch URL:
|
||||
- Linear-triggered: use `linear_comment` with an `@mention` of the user who triggered the task
|
||||
|
|
|
|||
|
|
@ -34,15 +34,19 @@ from langchain.agents.middleware import ModelCallLimitMiddleware
|
|||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
|
||||
from .dashboard.team_settings import (
|
||||
get_effective_gateway_enabled,
|
||||
get_org_review_guidelines,
|
||||
get_team_default_grouping_model,
|
||||
get_team_default_model_pair,
|
||||
)
|
||||
from .middleware import (
|
||||
RepairOrphanedToolCallsMiddleware,
|
||||
SanitizeFireworksMessagesMiddleware,
|
||||
SanitizeOpenAIResponsesMiddleware,
|
||||
SanitizeThinkingBlocksMiddleware,
|
||||
SanitizeToolInputsMiddleware,
|
||||
SlackAssistantStatusMiddleware,
|
||||
TimeoutWrapupMiddleware,
|
||||
ToolErrorMiddleware,
|
||||
check_message_queue_before_model,
|
||||
refresh_github_proxy_before_model,
|
||||
|
|
@ -82,9 +86,10 @@ from .tools import (
|
|||
)
|
||||
from .utils.agents_md import fetch_agents_md
|
||||
from .utils.api_standards_skill import fetch_api_standards_skill
|
||||
from .utils.deferred_model import make_model_or_defer
|
||||
from .utils.github_app import get_github_app_installation_token_with_expiry
|
||||
from .utils.github_token import cache_github_token_for_thread
|
||||
from .utils.model import DEFAULT_LLM_REASONING, make_model, provider_model_kwargs
|
||||
from .utils.model import DEFAULT_LLM_REASONING, provider_model_kwargs
|
||||
from .utils.repo_prep import materialize_trusted_skills, prepare_review_repo
|
||||
from .utils.sandbox_paths import aresolve_sandbox_work_dir
|
||||
from .utils.tracing import REVIEW_TRACING_PROJECT, traced_graph_factory
|
||||
|
|
@ -801,7 +806,9 @@ def _on_background_task_done(task: asyncio.Task[None]) -> None:
|
|||
logger.warning("Background reviewer task failed: %s", exc)
|
||||
|
||||
|
||||
async def _resolve_grouping_model(configurable: dict[str, object]) -> BaseChatModel:
|
||||
async def _resolve_grouping_model(
|
||||
configurable: dict[str, object], *, use_gateway: bool
|
||||
) -> BaseChatModel:
|
||||
"""Resolve the model for the diff-grouping pass.
|
||||
|
||||
Per-run override (``grouping_model_id``/``grouping_reasoning_effort``) wins;
|
||||
|
|
@ -821,7 +828,7 @@ async def _resolve_grouping_model(configurable: dict[str, object]) -> BaseChatMo
|
|||
max_tokens=DEFAULT_LLM_MAX_TOKENS,
|
||||
openai_reasoning_default=DEFAULT_LLM_REASONING,
|
||||
)
|
||||
return make_model(model_id, **model_kwargs)
|
||||
return make_model_or_defer(model_id, use_gateway=use_gateway, **model_kwargs)
|
||||
|
||||
|
||||
async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
||||
|
|
@ -1156,8 +1163,11 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
|||
if review_context:
|
||||
system_prompt = f"{system_prompt}\n\n{review_context}"
|
||||
|
||||
reviewer_model = make_model(model_id, **model_kwargs)
|
||||
reviewer_subagent_model = make_model(subagent_model_id, **subagent_model_kwargs)
|
||||
use_gateway = await get_effective_gateway_enabled()
|
||||
reviewer_model = make_model_or_defer(model_id, use_gateway=use_gateway, **model_kwargs)
|
||||
reviewer_subagent_model = make_model_or_defer(
|
||||
subagent_model_id, use_gateway=use_gateway, **subagent_model_kwargs
|
||||
)
|
||||
|
||||
# Kick off the AI-sorted diff grouping pass at run start, concurrently with
|
||||
# the review, so it adds ~0 latency. First-review and re-review only — a
|
||||
|
|
@ -1165,7 +1175,9 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
|||
# its own errors and the UI falls back to the folder view when groups are
|
||||
# absent.
|
||||
if reviewer_event != "finding_reply" and pr_diff_text and thread_id:
|
||||
grouping_model = await _resolve_grouping_model(config["configurable"])
|
||||
grouping_model = await _resolve_grouping_model(
|
||||
config["configurable"], use_gateway=use_gateway
|
||||
)
|
||||
grouping_task = asyncio.create_task(
|
||||
maybe_generate_and_store_diff_groups(
|
||||
thread_id=thread_id,
|
||||
|
|
@ -1201,6 +1213,9 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
|||
refresh_github_proxy_before_model,
|
||||
check_message_queue_before_model,
|
||||
SlackAssistantStatusMiddleware(),
|
||||
TimeoutWrapupMiddleware(),
|
||||
SanitizeOpenAIResponsesMiddleware(),
|
||||
SanitizeFireworksMessagesMiddleware(),
|
||||
SanitizeThinkingBlocksMiddleware(),
|
||||
RepairOrphanedToolCallsMiddleware(),
|
||||
settle_review_check_on_exit,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ from deepagents import create_deep_agent
|
|||
from deepagents.backends import LangSmithSandbox
|
||||
from deepagents.backends.protocol import SandboxBackendProtocol
|
||||
from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT, SubAgent
|
||||
from langchain.agents.middleware import ModelCallLimitMiddleware
|
||||
from langchain.agents.middleware import ModelCallLimitMiddleware, ToolRetryMiddleware
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
from langsmith.sandbox import SandboxClientError
|
||||
|
||||
|
|
@ -44,6 +44,7 @@ from .dashboard.agent_usage import record_agent_thread_usage
|
|||
from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort
|
||||
from .dashboard.repo_snapshots import resolve_repo_snapshot_id
|
||||
from .dashboard.team_settings import (
|
||||
get_effective_gateway_enabled,
|
||||
get_team_default_model_pair,
|
||||
get_team_default_repo,
|
||||
)
|
||||
|
|
@ -58,10 +59,13 @@ from .middleware import (
|
|||
ModelFallbackMiddleware,
|
||||
PlanModeMiddleware,
|
||||
SandboxCircuitBreakerMiddleware,
|
||||
SanitizeFireworksMessagesMiddleware,
|
||||
SanitizeOpenAIResponsesMiddleware,
|
||||
SanitizeThinkingBlocksMiddleware,
|
||||
SanitizeToolInputsMiddleware,
|
||||
SlackAssistantStatusMiddleware,
|
||||
SubdirAgentsReadMiddleware,
|
||||
TimeoutWrapupMiddleware,
|
||||
ToolArtifactMiddleware,
|
||||
ToolErrorMiddleware,
|
||||
WorkflowPushGuardMiddleware,
|
||||
|
|
@ -69,6 +73,8 @@ from .middleware import (
|
|||
ensure_no_empty_msg,
|
||||
notify_step_limit_reached,
|
||||
refresh_github_proxy_before_model,
|
||||
task_on_failure,
|
||||
task_retry_on,
|
||||
)
|
||||
from .prompt import construct_system_prompt
|
||||
from .tools import (
|
||||
|
|
@ -100,6 +106,7 @@ from .utils.authorship import (
|
|||
resolve_triggering_user_identity,
|
||||
)
|
||||
from .utils.dashboard_links import dashboard_plan_url, dashboard_thread_url
|
||||
from .utils.deferred_model import make_model_or_defer
|
||||
from .utils.github_app import (
|
||||
BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
|
|
@ -110,7 +117,6 @@ from .utils.github_proxy import record_proxy_token_expiry
|
|||
from .utils.github_token import repo_cache_key
|
||||
from .utils.model import (
|
||||
fallback_model_id_for,
|
||||
make_model,
|
||||
provider_model_kwargs,
|
||||
)
|
||||
from .utils.sandbox import create_sandbox
|
||||
|
|
@ -758,19 +764,31 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
ensure_sandbox_for_thread(thread_id, repo=prompt_default_repo)
|
||||
)
|
||||
team_defaults_task = asyncio.create_task(get_team_default_model_pair("agent"))
|
||||
gateway_task = asyncio.create_task(get_effective_gateway_enabled())
|
||||
profile_task = asyncio.create_task(load_profile(profile_login)) if profile_login else None
|
||||
try:
|
||||
triggering_user_identity, sandbox_backend, team_defaults = await asyncio.gather(
|
||||
(
|
||||
triggering_user_identity,
|
||||
sandbox_backend,
|
||||
team_defaults,
|
||||
use_gateway,
|
||||
) = await asyncio.gather(
|
||||
triggering_user_identity_task,
|
||||
sandbox_task,
|
||||
team_defaults_task,
|
||||
gateway_task,
|
||||
)
|
||||
except SandboxRepoMismatchError as exc:
|
||||
# Repo-binding refusal at the run boundary: log for alarming and surface the
|
||||
# already-sanitized terminal error (no sandbox/token internals) to the caller,
|
||||
# rather than letting an opaque deep-stack exception crash-loop the worker.
|
||||
logger.error("Refusing agent run for thread %s: %s", thread_id, exc)
|
||||
for pending in (triggering_user_identity_task, team_defaults_task, profile_task):
|
||||
for pending in (
|
||||
triggering_user_identity_task,
|
||||
team_defaults_task,
|
||||
gateway_task,
|
||||
profile_task,
|
||||
):
|
||||
if pending is not None and not pending.done():
|
||||
pending.cancel()
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
|
@ -860,7 +878,9 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
fallback_model_id, None, max_tokens=DEFAULT_LLM_MAX_TOKENS
|
||||
)
|
||||
fallback_middleware.append(
|
||||
ModelFallbackMiddleware(make_model(fallback_model_id, **fallback_kwargs))
|
||||
ModelFallbackMiddleware(
|
||||
make_model_or_defer(fallback_model_id, use_gateway=use_gateway, **fallback_kwargs)
|
||||
)
|
||||
)
|
||||
logger.info("Configured model fallback %s -> %s", model_id, fallback_model_id)
|
||||
|
||||
|
|
@ -926,8 +946,10 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
notion_tools = []
|
||||
|
||||
logger.info("Returning agent with sandbox for thread %s", thread_id)
|
||||
main_model = make_model(model_id, **model_kwargs)
|
||||
subagent_model = make_model(subagent_model_id, **subagent_model_kwargs)
|
||||
main_model = make_model_or_defer(model_id, use_gateway=use_gateway, **model_kwargs)
|
||||
subagent_model = make_model_or_defer(
|
||||
subagent_model_id, use_gateway=use_gateway, **subagent_model_kwargs
|
||||
)
|
||||
return create_deep_agent(
|
||||
model=main_model,
|
||||
system_prompt=construct_system_prompt(
|
||||
|
|
@ -977,16 +999,27 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
ModelCallLimitMiddleware(run_limit=MODEL_CALL_RECURSION_LIMIT, exit_behavior="end"),
|
||||
ToolErrorMiddleware(),
|
||||
SubdirAgentsReadMiddleware(),
|
||||
ToolRetryMiddleware(
|
||||
max_retries=2,
|
||||
tools=["task"],
|
||||
retry_on=task_retry_on,
|
||||
on_failure=task_on_failure,
|
||||
initial_delay=1.0,
|
||||
max_delay=10.0,
|
||||
),
|
||||
ToolArtifactMiddleware(),
|
||||
WorkflowPushGuardMiddleware(),
|
||||
refresh_github_proxy_before_model,
|
||||
check_message_queue_before_model,
|
||||
SlackAssistantStatusMiddleware(),
|
||||
TimeoutWrapupMiddleware(),
|
||||
ensure_no_empty_msg,
|
||||
notify_step_limit_reached,
|
||||
SandboxCircuitBreakerMiddleware(),
|
||||
*fallback_middleware,
|
||||
*plan_mode_middleware,
|
||||
SanitizeOpenAIResponsesMiddleware(),
|
||||
SanitizeFireworksMessagesMiddleware(),
|
||||
SanitizeThinkingBlocksMiddleware(),
|
||||
],
|
||||
).with_config(config)
|
||||
|
|
|
|||
|
|
@ -1,32 +1,38 @@
|
|||
from .add_finding import add_finding
|
||||
from .enter_plan_mode import enter_plan_mode
|
||||
from .fetch_url import fetch_url
|
||||
from .http_request import http_request
|
||||
from .linear_comment import linear_comment
|
||||
from .linear_create_issue import linear_create_issue
|
||||
from .linear_delete_issue import linear_delete_issue
|
||||
from .linear_get_issue import linear_get_issue
|
||||
from .linear_get_issue_comments import linear_get_issue_comments
|
||||
from .linear_list_teams import linear_list_teams
|
||||
from .linear_update_issue import linear_update_issue
|
||||
from .list_findings import list_findings
|
||||
from .list_review_findings import list_review_findings
|
||||
from .open_pull_request import open_pull_request
|
||||
from .publish_review import publish_review
|
||||
from .read_repo_file import read_repo_file
|
||||
from .reply_to_finding_thread import reply_to_finding_thread
|
||||
from .report_platform_issue import report_platform_issue
|
||||
from .request_pr_review import request_pr_review
|
||||
from .resolve_finding_thread import resolve_finding_thread
|
||||
from .save_plan import save_plan
|
||||
from .schedule_thread_wakeup import schedule_thread_wakeup
|
||||
from .search_repo_code import search_repo_code
|
||||
from .slack_add_reaction import slack_add_reaction
|
||||
from .slack_read_thread_messages import slack_read_thread_messages
|
||||
from .slack_start_new_thread import slack_start_new_thread
|
||||
from .slack_thread_reply import slack_thread_reply
|
||||
from .update_finding import update_finding
|
||||
from .web_search import web_search
|
||||
import sys
|
||||
from types import ModuleType
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
_TOOL_MODULES = {
|
||||
"add_finding": ".add_finding",
|
||||
"enter_plan_mode": ".enter_plan_mode",
|
||||
"fetch_url": ".fetch_url",
|
||||
"http_request": ".http_request",
|
||||
"linear_comment": ".linear_comment",
|
||||
"linear_create_issue": ".linear_create_issue",
|
||||
"linear_delete_issue": ".linear_delete_issue",
|
||||
"linear_get_issue": ".linear_get_issue",
|
||||
"linear_get_issue_comments": ".linear_get_issue_comments",
|
||||
"linear_list_teams": ".linear_list_teams",
|
||||
"linear_update_issue": ".linear_update_issue",
|
||||
"list_findings": ".list_findings",
|
||||
"list_review_findings": ".list_review_findings",
|
||||
"open_pull_request": ".open_pull_request",
|
||||
"publish_review": ".publish_review",
|
||||
"read_repo_file": ".read_repo_file",
|
||||
"report_platform_issue": ".report_platform_issue",
|
||||
"request_pr_review": ".request_pr_review",
|
||||
"reply_to_finding_thread": ".reply_to_finding_thread",
|
||||
"resolve_finding_thread": ".resolve_finding_thread",
|
||||
"save_plan": ".save_plan",
|
||||
"schedule_thread_wakeup": ".schedule_thread_wakeup",
|
||||
"search_repo_code": ".search_repo_code",
|
||||
"slack_add_reaction": ".slack_add_reaction",
|
||||
"slack_read_thread_messages": ".slack_read_thread_messages",
|
||||
"slack_start_new_thread": ".slack_start_new_thread",
|
||||
"slack_thread_reply": ".slack_thread_reply",
|
||||
"update_finding": ".update_finding",
|
||||
"web_search": ".web_search",
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"add_finding",
|
||||
|
|
@ -59,3 +65,56 @@ __all__ = [
|
|||
"update_finding",
|
||||
"web_search",
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .add_finding import add_finding
|
||||
from .enter_plan_mode import enter_plan_mode
|
||||
from .fetch_url import fetch_url
|
||||
from .http_request import http_request
|
||||
from .linear_comment import linear_comment
|
||||
from .linear_create_issue import linear_create_issue
|
||||
from .linear_delete_issue import linear_delete_issue
|
||||
from .linear_get_issue import linear_get_issue
|
||||
from .linear_get_issue_comments import linear_get_issue_comments
|
||||
from .linear_list_teams import linear_list_teams
|
||||
from .linear_update_issue import linear_update_issue
|
||||
from .list_findings import list_findings
|
||||
from .list_review_findings import list_review_findings
|
||||
from .open_pull_request import open_pull_request
|
||||
from .publish_review import publish_review
|
||||
from .read_repo_file import read_repo_file
|
||||
from .reply_to_finding_thread import reply_to_finding_thread
|
||||
from .report_platform_issue import report_platform_issue
|
||||
from .request_pr_review import request_pr_review
|
||||
from .resolve_finding_thread import resolve_finding_thread
|
||||
from .save_plan import save_plan
|
||||
from .schedule_thread_wakeup import schedule_thread_wakeup
|
||||
from .search_repo_code import search_repo_code
|
||||
from .slack_add_reaction import slack_add_reaction
|
||||
from .slack_read_thread_messages import slack_read_thread_messages
|
||||
from .slack_start_new_thread import slack_start_new_thread
|
||||
from .slack_thread_reply import slack_thread_reply
|
||||
from .update_finding import update_finding
|
||||
from .web_search import web_search
|
||||
|
||||
|
||||
def _load_tool(name: str) -> Any:
|
||||
module_name = _TOOL_MODULES.get(name)
|
||||
if module_name is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
from importlib import import_module
|
||||
|
||||
value = getattr(import_module(module_name, __name__), name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
class _LazyToolsModule(ModuleType):
|
||||
def __getattribute__(self, name: str) -> Any:
|
||||
tool_map = ModuleType.__getattribute__(self, "__dict__").get("_TOOL_MODULES", {})
|
||||
if name in tool_map:
|
||||
return _load_tool(name)
|
||||
return ModuleType.__getattribute__(self, name)
|
||||
|
||||
|
||||
sys.modules[__name__].__class__ = _LazyToolsModule
|
||||
|
|
|
|||
|
|
@ -2,8 +2,28 @@ from typing import Any
|
|||
|
||||
from langgraph.config import get_config
|
||||
|
||||
from agent.utils.slack import parse_github_pr_url
|
||||
from agent.webapp import trigger_pr_review_from_ref
|
||||
from agent.utils.slack import GitHubPrRef, parse_github_pr_url
|
||||
|
||||
|
||||
async def trigger_pr_review_from_ref(
|
||||
pr_ref: GitHubPrRef,
|
||||
*,
|
||||
source: str,
|
||||
github_login: str = "",
|
||||
github_user_id: int | None = None,
|
||||
slack_channel_id: str = "",
|
||||
slack_thread_ts: str = "",
|
||||
) -> dict[str, Any]:
|
||||
from agent.webapp import trigger_pr_review_from_ref as _trigger_pr_review_from_ref
|
||||
|
||||
return await _trigger_pr_review_from_ref(
|
||||
pr_ref,
|
||||
source=source,
|
||||
github_login=github_login,
|
||||
github_user_id=github_user_id,
|
||||
slack_channel_id=slack_channel_id,
|
||||
slack_thread_ts=slack_thread_ts,
|
||||
)
|
||||
|
||||
|
||||
async def request_pr_review(pr_url: str) -> dict[str, Any]:
|
||||
|
|
|
|||
|
|
@ -1,20 +1,25 @@
|
|||
"""Tool: ``save_plan``. Publish the sandbox plan file for review.
|
||||
"""Tool: ``save_plan``. Publish sandbox Markdown for review or sharing.
|
||||
|
||||
Reads the Markdown plan file the agent created in the sandbox and publishes it to
|
||||
the plan-review page, where the user and collaborators read it, comment inline,
|
||||
and approve or request changes. Available in plan mode (it does not modify the
|
||||
repository under review).
|
||||
Reads the Markdown file the agent created in the sandbox and publishes it to the
|
||||
plan-review page. In plan mode it is an approvable implementation plan; outside
|
||||
plan mode it is read-only shared content.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from typing import Annotated, Any
|
||||
|
||||
from langgraph.config import get_config
|
||||
from langgraph.prebuilt import InjectedState
|
||||
|
||||
from ..dashboard.plan_store import PLAN_FILE_DIRECTORY, PLAN_STATUS_READY, save_plan_content
|
||||
from ..dashboard.plan_store import (
|
||||
PLAN_FILE_DIRECTORY,
|
||||
PLAN_STATUS_READY,
|
||||
PLAN_STATUS_SHARED,
|
||||
save_plan_content,
|
||||
)
|
||||
from ..utils.sandbox_state import get_sandbox_backend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -23,20 +28,22 @@ _MAX_PLAN_LINES = 20_000
|
|||
_MARKDOWN_EXTENSIONS = (".md", ".markdown")
|
||||
|
||||
|
||||
async def save_plan(plan_file_path: str) -> dict[str, Any]:
|
||||
async def save_plan(
|
||||
plan_file_path: str,
|
||||
state: Annotated[dict[str, Any] | None, InjectedState] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Publish a Markdown plan file from the sandbox for review.
|
||||
|
||||
Use this in plan mode once your plan is ready. First create a Markdown file
|
||||
under ``/workspace/plans/`` using a dated, descriptive filename, then pass
|
||||
that file path here. The file contents are published to the plan-review page
|
||||
linked in the conversation, where the user (the owner) and any collaborators
|
||||
can read it, leave inline comments, and then approve it or request changes.
|
||||
Call it again to publish a revised file when addressing feedback.
|
||||
Use this in plan mode once your plan is ready. Outside plan mode, use it to
|
||||
share long Slack responses without switching the thread into plan mode. First
|
||||
create a Markdown file under ``/workspace/plans/`` using a dated, descriptive
|
||||
filename, then pass that file path here. The file contents are published to
|
||||
the plan-review page linked in the conversation. In plan mode, the user can
|
||||
comment, approve, or request changes; outside plan mode, the page is read-only
|
||||
shared content.
|
||||
|
||||
Write the plan in standard Markdown — headings, bullet/numbered lists, and
|
||||
fenced code blocks all render. Keep it concise and high level, focusing on
|
||||
approach, decisions/tradeoffs, risks, and verification; avoid file/function
|
||||
details unless they are unusually tricky or controversial.
|
||||
Write the content in standard Markdown — headings, bullet/numbered lists, and
|
||||
fenced code blocks all render.
|
||||
|
||||
Args:
|
||||
plan_file_path: Path to the Markdown plan file in the sandbox.
|
||||
|
|
@ -68,19 +75,29 @@ async def save_plan(plan_file_path: str) -> dict[str, Any]:
|
|||
content = (await _read_plan_file(str(thread_id), path)).strip()
|
||||
if not content:
|
||||
return {"success": False, "error": "plan file cannot be empty"}
|
||||
await _save(str(thread_id), content, path)
|
||||
await _save(str(thread_id), content, path, plan_mode=_active_plan_mode(state, configurable))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("save_plan failed for thread %s", thread_id)
|
||||
return {"success": False, "error": f"failed to save plan: {exc}"}
|
||||
return {"success": True, "path": path}
|
||||
|
||||
|
||||
async def _save(thread_id: str, content: str, path: str) -> None:
|
||||
async def _save(thread_id: str, content: str, path: str, *, plan_mode: bool) -> None:
|
||||
await save_plan_content(
|
||||
thread_id, markdown=content, status=PLAN_STATUS_READY, plan_file_path=path
|
||||
thread_id,
|
||||
markdown=content,
|
||||
status=PLAN_STATUS_READY if plan_mode else PLAN_STATUS_SHARED,
|
||||
plan_file_path=path,
|
||||
plan_mode=plan_mode or None,
|
||||
)
|
||||
|
||||
|
||||
def _active_plan_mode(state: dict[str, Any] | None, configurable: Any) -> bool:
|
||||
if isinstance(state, dict) and state.get("plan_mode") is True:
|
||||
return True
|
||||
return isinstance(configurable, dict) and configurable.get("plan_mode") is True
|
||||
|
||||
|
||||
async def _read_plan_file(thread_id: str, path: str) -> str:
|
||||
backend = await get_sandbox_backend(thread_id)
|
||||
result = await backend.aread(path, offset=0, limit=_MAX_PLAN_LINES)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import logging
|
|||
import os
|
||||
from typing import Any
|
||||
|
||||
from exa_py import Exa
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -38,6 +36,8 @@ async def web_search(
|
|||
}
|
||||
|
||||
async def _search() -> dict[str, Any]:
|
||||
from exa_py import Exa # deferred: heavy import
|
||||
|
||||
client = Exa(api_key=api_key)
|
||||
if include_contents:
|
||||
result = await asyncio.to_thread(
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ def dashboard_plan_url(thread_id: str) -> str | None:
|
|||
return f"{base_url}/agents/{quote(thread_id, safe='')}/plan"
|
||||
|
||||
|
||||
def dashboard_workflow_approval_url(thread_id: str, fingerprint: str) -> str | None:
|
||||
"""Build the dashboard workflow approval URL for a thread/fingerprint."""
|
||||
thread_url = dashboard_thread_url(thread_id)
|
||||
if not thread_url or not fingerprint:
|
||||
return thread_url
|
||||
return f"{thread_url}?workflowApproval={quote(fingerprint, safe='')}"
|
||||
|
||||
|
||||
def dashboard_review_url(owner: str, repo: str, pr_number: int) -> str | None:
|
||||
"""Build the dashboard review-detail URL for a PR."""
|
||||
base_url = _dashboard_base_url()
|
||||
|
|
|
|||
57
agent/utils/deferred_model.py
Normal file
57
agent/utils/deferred_model.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
|
||||
from .model import make_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeferredErrorModel(BaseChatModel):
|
||||
"""Model placeholder that raises a stored setup error on first invocation."""
|
||||
|
||||
error_message: str
|
||||
model_id: str | None = None
|
||||
|
||||
@property
|
||||
def _llm_type(self) -> str:
|
||||
return "deferred-error"
|
||||
|
||||
def _get_ls_params(self, stop: Any = None, **kwargs: Any) -> dict[str, Any]:
|
||||
params = super()._get_ls_params(stop=stop, **kwargs)
|
||||
if self.model_id:
|
||||
params["ls_model_name"] = self.model_id
|
||||
if ":" in self.model_id:
|
||||
params["ls_provider"] = self.model_id.split(":", 1)[0]
|
||||
return params
|
||||
|
||||
def bind_tools(self, tools: Any, **kwargs: Any) -> DeferredErrorModel:
|
||||
return self
|
||||
|
||||
def _generate(self, messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any):
|
||||
raise ValueError(self.error_message)
|
||||
|
||||
|
||||
def make_deferred_error_model(
|
||||
error: BaseException, *, model_id: str | None = None
|
||||
) -> BaseChatModel:
|
||||
return DeferredErrorModel(error_message=f"{type(error).__name__}: {error}", model_id=model_id)
|
||||
|
||||
|
||||
def make_model_or_defer(model_id: str, **kwargs: Any) -> BaseChatModel:
|
||||
"""Call ``make_model`` and wrap any setup error in a ``DeferredErrorModel``.
|
||||
|
||||
This lets graph factories pass a model placeholder into the agent graph on
|
||||
startup without crashing the process. The error is raised later inside the
|
||||
agent run when the model is first invoked, so the caller can recover
|
||||
gracefully (e.g. via fallback middleware or by surfacing the error to the
|
||||
user).
|
||||
"""
|
||||
try:
|
||||
return make_model(model_id, **kwargs)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("Deferring model setup failure for %s", model_id, exc_info=True)
|
||||
return make_deferred_error_model(e, model_id=model_id)
|
||||
126
agent/utils/gateway.py
Normal file
126
agent/utils/gateway.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""LangSmith LLM Gateway routing for model construction.
|
||||
|
||||
The LLM Gateway (https://docs.langchain.com/langsmith/llm-gateway) proxies
|
||||
provider calls through LangSmith: the client authenticates with a LangSmith API
|
||||
key and the gateway resolves the real provider key from workspace Provider
|
||||
Secrets, enforcing spend/PII/secrets policies and tracing every call. Routing is
|
||||
opt-in via ``LANGSMITH_GATEWAY_ENABLED`` (deployment default) or the
|
||||
``gateway_enabled`` team setting, and is applied centrally in
|
||||
:func:`agent.utils.model.make_model`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_GATEWAY_BASE_URL = "https://gateway.smith.langchain.com"
|
||||
|
||||
# Provider prefix -> base-URL suffix appended to the gateway host. Each suffix
|
||||
# matches the SDK's own path handling: the OpenAI SDK appends
|
||||
# ``/chat/completions`` to a ``/v1`` base, Fireworks appends
|
||||
# ``/v1/chat/completions`` to a bare provider host, Anthropic appends
|
||||
# ``/v1/messages`` to a bare host, and google-genai appends
|
||||
# ``/<api_version>/models/...`` to a bare host. Vertex (``google_vertexai``, which
|
||||
# uses service-account auth rather than a bearer key) and any other provider are
|
||||
# not routed and call the provider directly.
|
||||
_GATEWAY_PROVIDER_PATHS: dict[str, str] = {
|
||||
"openai": "/openai/v1",
|
||||
"anthropic": "/anthropic",
|
||||
"fireworks": "/fireworks",
|
||||
"google_genai": "/gemini",
|
||||
}
|
||||
|
||||
|
||||
def _env_bool(value: str | None) -> bool:
|
||||
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _langsmith_api_key() -> str | None:
|
||||
"""LangSmith API key used to authenticate gateway calls.
|
||||
|
||||
Prefer a gateway-specific key, then the prod LangSmith key. LangGraph Cloud
|
||||
may inject ``LANGSMITH_API_KEY`` for tracing/platform APIs, and that key can
|
||||
lack the ``gateway:invoke`` permission required by the LLM Gateway.
|
||||
"""
|
||||
return (
|
||||
os.environ.get("LANGSMITH_GATEWAY_API_KEY")
|
||||
or os.environ.get("LANGSMITH_API_KEY_PROD")
|
||||
or os.environ.get("LANGSMITH_API_KEY")
|
||||
)
|
||||
|
||||
|
||||
def gateway_base_url() -> str:
|
||||
"""Gateway host, overridable via ``LANGSMITH_GATEWAY_BASE_URL`` (regional/self-hosted)."""
|
||||
return (os.environ.get("LANGSMITH_GATEWAY_BASE_URL") or DEFAULT_GATEWAY_BASE_URL).rstrip("/")
|
||||
|
||||
|
||||
def gateway_env_default() -> bool:
|
||||
"""Deployment-level default for gateway routing (``LANGSMITH_GATEWAY_ENABLED``)."""
|
||||
return _env_bool(os.environ.get("LANGSMITH_GATEWAY_ENABLED"))
|
||||
|
||||
|
||||
def gateway_openai_use_responses() -> bool:
|
||||
"""Whether gateway-routed OpenAI keeps the Responses API.
|
||||
|
||||
Defaults to ``True`` because OpenAI reasoning models with tool calls reject
|
||||
``reasoning_effort`` on Chat Completions. Set
|
||||
``LANGSMITH_GATEWAY_OPENAI_USE_RESPONSES=false`` only for deployments that
|
||||
need to force Chat Completions through the gateway.
|
||||
"""
|
||||
raw = os.environ.get("LANGSMITH_GATEWAY_OPENAI_USE_RESPONSES")
|
||||
if raw is None:
|
||||
return True
|
||||
return _env_bool(raw)
|
||||
|
||||
|
||||
def resolve_gateway_enabled(team_value: bool | None) -> bool:
|
||||
"""Combine the team-settings toggle with the env default.
|
||||
|
||||
A team value of ``True``/``False`` is authoritative; ``None`` inherits the
|
||||
``LANGSMITH_GATEWAY_ENABLED`` deployment default.
|
||||
"""
|
||||
if team_value is None:
|
||||
return gateway_env_default()
|
||||
return team_value
|
||||
|
||||
|
||||
def _provider_of(model_id: str) -> str:
|
||||
return model_id.split(":", 1)[0]
|
||||
|
||||
|
||||
def gateway_overrides(model_id: str) -> dict[str, object] | None:
|
||||
"""``init_chat_model`` kwargs that route ``model_id`` through the gateway.
|
||||
|
||||
Returns ``None`` (so the caller keeps talking to the provider directly) when
|
||||
the provider isn't routable through the gateway or no LangSmith API key is
|
||||
available — both cases are logged rather than raised, so a run never fails
|
||||
just because gateway routing couldn't be applied.
|
||||
"""
|
||||
provider = _provider_of(model_id)
|
||||
path = _GATEWAY_PROVIDER_PATHS.get(provider)
|
||||
if path is None:
|
||||
logger.debug(
|
||||
"LangSmith gateway enabled but provider %r is not routed; calling it directly",
|
||||
provider,
|
||||
)
|
||||
return None
|
||||
api_key = _langsmith_api_key()
|
||||
if not api_key:
|
||||
logger.debug(
|
||||
"LangSmith gateway enabled but no LANGSMITH_GATEWAY_API_KEY or "
|
||||
"LANGSMITH_API_KEY(_PROD) is set; "
|
||||
"calling the provider directly"
|
||||
)
|
||||
return None
|
||||
overrides: dict[str, object] = {
|
||||
"base_url": f"{gateway_base_url()}{path}",
|
||||
"api_key": api_key,
|
||||
}
|
||||
if provider == "openai":
|
||||
# Use HTTPS Responses through the gateway by default; tool-calling OpenAI
|
||||
# reasoning models reject reasoning_effort on Chat Completions.
|
||||
overrides["use_responses_api"] = gateway_openai_use_responses()
|
||||
return overrides
|
||||
|
|
@ -4,6 +4,7 @@ from typing import Literal, TypedDict, Unpack
|
|||
from langchain.chat_models import init_chat_model
|
||||
|
||||
from ..dashboard.options import DEFAULT_MODEL_ID
|
||||
from .gateway import gateway_env_default, gateway_overrides
|
||||
|
||||
OPENAI_RESPONSES_WS_BASE_URL = "wss://api.openai.com/v1"
|
||||
|
||||
|
|
@ -39,11 +40,14 @@ class AnthropicThinking(TypedDict, total=False):
|
|||
class ModelKwargs(TypedDict, total=False):
|
||||
max_tokens: int | None
|
||||
reasoning: OpenAIReasoning | None
|
||||
reasoning_effort: OpenAIReasoningEffort | None
|
||||
thinking: AnthropicThinking | None
|
||||
effort: AnthropicEffort | None
|
||||
thinking_level: GoogleThinkingLevel | None
|
||||
temperature: float | None
|
||||
max_retries: int | None
|
||||
store: bool | None
|
||||
include: list[str] | None
|
||||
model_kwargs: dict[str, object] | None
|
||||
additional_model_request_fields: dict[str, object] | None
|
||||
region_name: str | None
|
||||
|
|
@ -52,21 +56,62 @@ class ModelKwargs(TypedDict, total=False):
|
|||
_ANTHROPIC_EFFORTS: set[AnthropicEffort] = {"low", "medium", "high", "xhigh", "max"}
|
||||
|
||||
|
||||
def make_model(model_id: str, **kwargs: Unpack[ModelKwargs]):
|
||||
def _coerce_openai_chat_completions_kwargs(model_kwargs: dict[str, object]) -> None:
|
||||
if model_kwargs.get("use_responses_api") is not False:
|
||||
return
|
||||
reasoning = model_kwargs.pop("reasoning", None)
|
||||
if isinstance(reasoning, dict):
|
||||
effort = reasoning.get("effort")
|
||||
if isinstance(effort, str):
|
||||
model_kwargs.setdefault("reasoning_effort", effort)
|
||||
|
||||
|
||||
def _configure_openai_responses_kwargs(model_kwargs: dict[str, object]) -> None:
|
||||
if model_kwargs.get("use_responses_api") is False:
|
||||
return
|
||||
model_kwargs.setdefault("store", False)
|
||||
include = model_kwargs.get("include")
|
||||
if include is None:
|
||||
model_kwargs["include"] = ["reasoning.encrypted_content"]
|
||||
elif isinstance(include, list) and "reasoning.encrypted_content" not in include:
|
||||
include.append("reasoning.encrypted_content")
|
||||
|
||||
|
||||
def make_model(model_id: str, *, use_gateway: bool | None = None, **kwargs: Unpack[ModelKwargs]):
|
||||
"""Build a chat model, optionally routed through the LangSmith LLM Gateway.
|
||||
|
||||
``use_gateway`` resolves the deployment default (``LANGSMITH_GATEWAY_ENABLED``)
|
||||
when ``None``; async callers pass the team-settings-resolved value. When on,
|
||||
gateway ``base_url``/``api_key``/``use_responses_api`` override the direct
|
||||
provider defaults below (see :mod:`agent.utils.gateway`).
|
||||
"""
|
||||
model_kwargs: dict[str, object] = kwargs.copy()
|
||||
model_kwargs.setdefault("max_retries", DEFAULT_MAX_RETRIES)
|
||||
|
||||
if model_id.startswith("openai:"):
|
||||
# Direct-provider default: Responses API over the OpenAI websocket base.
|
||||
# Gateway routing overrides this below (an HTTP(S) proxy can't carry wss).
|
||||
model_kwargs["base_url"] = OPENAI_RESPONSES_WS_BASE_URL
|
||||
model_kwargs["use_responses_api"] = True
|
||||
elif model_id.startswith("bedrock_converse:"):
|
||||
# Resolve region with the same precedence validate_local_dev_llm_config accepts
|
||||
# (AWS_REGION or AWS_DEFAULT_REGION), so the validated value is the one actually used.
|
||||
# Resolve region with the same precedence validate_local_dev_llm_config
|
||||
# accepts (AWS_REGION or AWS_DEFAULT_REGION), so the validated value
|
||||
# is the one actually used.
|
||||
model_kwargs.setdefault(
|
||||
"region_name",
|
||||
os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or "us-east-1",
|
||||
)
|
||||
|
||||
enabled = gateway_env_default() if use_gateway is None else use_gateway
|
||||
if enabled:
|
||||
overrides = gateway_overrides(model_id)
|
||||
if overrides is not None:
|
||||
model_kwargs.update(overrides)
|
||||
|
||||
if model_id.startswith("openai:"):
|
||||
_configure_openai_responses_kwargs(model_kwargs)
|
||||
_coerce_openai_chat_completions_kwargs(model_kwargs)
|
||||
|
||||
return init_chat_model(model=model_id, **model_kwargs)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import os
|
||||
from collections.abc import Callable
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from deepagents.backends.protocol import SandboxBackendProtocol
|
||||
|
||||
SandboxFactory = Callable[[str | None], SandboxBackendProtocol]
|
||||
SandboxFactory = Callable[..., Any]
|
||||
|
||||
SANDBOX_FACTORIES: dict[str, tuple[str, str]] = {
|
||||
"langsmith": ("agent.integrations.langsmith", "create_langsmith_sandbox"),
|
||||
|
|
@ -31,7 +30,7 @@ def create_sandbox(
|
|||
sandbox_id: str | None = None,
|
||||
*,
|
||||
snapshot_id: str | None = None,
|
||||
) -> SandboxBackendProtocol:
|
||||
) -> Any:
|
||||
"""Create or reconnect to a sandbox using the configured provider.
|
||||
|
||||
The provider is selected via the SANDBOX_TYPE environment variable.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{"_meta": {"last_synced": "fd2541ce", "last_synced_date": "2026-07-08"}}
|
||||
{"_meta": {"last_synced": "71e3b818", "last_synced_date": "2026-07-09"}}
|
||||
{"sha": "0b76afdc", "pr": 1653, "subject": "reviews block agenda, sticky headers, diff scroll", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "7530653b", "pr": 1655, "subject": "ResizeObserver settle for review scroll-to", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "23bd4a63", "pr": 1660, "subject": "top padding to sticky review block header", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
|
|
@ -18,12 +18,12 @@
|
|||
{"sha": "e5a29eca", "pr": 1613, "subject": "plan links in PR descriptions", "disposition": "wont-merge", "reason": "regression — dev has async plan-ref", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "e1d85526", "pr": 1645, "subject": "switch ui to pnpm", "disposition": "wont-merge", "reason": "tooling — fork keeps bun", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "f5670f24", "pr": 1639, "subject": "require bun for ui agent work", "disposition": "landed", "reason": "cherry-picked (-x) in this PR (#132)", "branch": "PR1", "local_sha": null, "updated": "2026-07-08T23:17:34Z"}
|
||||
{"sha": "209132d3", "pr": 1621, "subject": "durable interrupt dispatch + completion webhook", "disposition": "deferred", "reason": "investigate first — may be applied", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "02bb4dfd", "pr": 1658, "subject": "don't attach loopback run-complete webhooks", "disposition": "deferred", "reason": "", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "29015fad", "pr": 1614, "subject": "gate workflow pushes with approval", "disposition": "deferred", "reason": "", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "546042a4", "pr": 1652, "subject": "add workflow approval UI", "disposition": "deferred", "reason": "", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "209132d3", "pr": 1621, "subject": "durable interrupt dispatch + completion webhook", "disposition": "landed", "reason": "investigate first — may be applied", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-09T17:22:52Z"}
|
||||
{"sha": "02bb4dfd", "pr": 1658, "subject": "don't attach loopback run-complete webhooks", "disposition": "landed", "reason": "", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-09T17:22:53Z"}
|
||||
{"sha": "29015fad", "pr": 1614, "subject": "gate workflow pushes with approval", "disposition": "landed", "reason": "", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-09T17:22:53Z"}
|
||||
{"sha": "546042a4", "pr": 1652, "subject": "add workflow approval UI", "disposition": "landed", "reason": "", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
{"sha": "ae04b72b", "pr": 1635, "subject": "publish plans from sandbox files", "disposition": "landed", "reason": "ported (adapted) in #128 (save_plan reads sandbox file)", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-08T22:58:21Z"}
|
||||
{"sha": "c03a6be7", "pr": 1634, "subject": "keep plan guidance high-level", "disposition": "deferred", "reason": "", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "c03a6be7", "pr": 1634, "subject": "keep plan guidance high-level", "disposition": "landed", "reason": "", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
{"sha": "96cceb74", "pr": 1632, "subject": "notify Slack on plan approval", "disposition": "landed", "reason": "ported (adapted) in #128", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-08T22:58:21Z"}
|
||||
{"sha": "2f56d754", "pr": 1618, "subject": "omit plan link when no plan exists", "disposition": "landed", "reason": "already in dev via #81 (upstream-sync); ledger was stale (was: likely regression)", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-08T23:25:48Z"}
|
||||
{"sha": "ee224d3e", "pr": 1650, "subject": "add Slack reaction tool", "disposition": "landed", "reason": "", "branch": "slack-tooling", "local_sha": "3c6077c418e4017acf1f04ebeb81846770f934c9", "updated": "2026-07-03T19:38:56Z"}
|
||||
|
|
@ -32,10 +32,10 @@
|
|||
{"sha": "4cd5fa5c", "pr": 1629, "subject": "avoid recapping Slack replies", "disposition": "deferred", "reason": "", "branch": "slack-tooling", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "92dbf6f9", "pr": 1630, "subject": "update Slack trace reply on web handoff", "disposition": "landed", "reason": "ported in #128 (trace_message_ts on first-mention run mapping)", "branch": "slack-tooling", "local_sha": null, "updated": "2026-07-08T22:58:21Z"}
|
||||
{"sha": "bb36448b", "pr": 1627, "subject": "surface Slack thread errors", "disposition": "landed", "reason": "", "branch": "slack-tooling", "local_sha": "e8b6fb70508861e5742d31aaa722e79f4a9a320a", "updated": "2026-07-03T19:50:30Z"}
|
||||
{"sha": "73b7d1c0", "pr": 1678, "subject": "fix OpenAI Responses reasoning replay", "disposition": "deferred", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "5f7c2f46", "pr": 1674, "subject": "fix Fireworks Gateway base URL", "disposition": "deferred", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "702ef908", "pr": 1673, "subject": "dedicated LangSmith gateway API key", "disposition": "deferred", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "e9dc6e01", "pr": 1671, "subject": "opt-in LangSmith LLM Gateway routing", "disposition": "deferred", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "73b7d1c0", "pr": 1678, "subject": "fix OpenAI Responses reasoning replay", "disposition": "landed", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-09T16:22:05Z"}
|
||||
{"sha": "5f7c2f46", "pr": 1674, "subject": "fix Fireworks Gateway base URL", "disposition": "landed", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-09T16:22:05Z"}
|
||||
{"sha": "702ef908", "pr": 1673, "subject": "dedicated LangSmith gateway API key", "disposition": "landed", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-09T16:22:05Z"}
|
||||
{"sha": "e9dc6e01", "pr": 1671, "subject": "opt-in LangSmith LLM Gateway routing", "disposition": "landed", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-09T16:22:04Z"}
|
||||
{"sha": "289f5e3a", "pr": 1651, "subject": "add Sonnet 5 to model picker", "disposition": "landed", "reason": "already in dev; added Bedrock family fallback fix (c16fb915)", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-03T00:19:50Z"}
|
||||
{"sha": "5da3d0c6", "pr": 1624, "subject": "post reviewer resolution notes verbatim", "disposition": "landed", "reason": "cherry-picked (-x) in #127", "branch": "reviewer-misc", "local_sha": null, "updated": "2026-07-08T22:58:20Z"}
|
||||
{"sha": "69148f54", "pr": 1612, "subject": "add PR trace resolution", "disposition": "landed", "reason": "cherry-picked (-x) in #127", "branch": "reviewer-misc", "local_sha": null, "updated": "2026-07-08T22:58:20Z"}
|
||||
|
|
@ -62,24 +62,28 @@
|
|||
{"sha": "13b40113", "pr": 1666, "subject": "chore(deps): bump cryptography from 48.0.1 to 49.0.0 in the major group (#1666)", "disposition": "wont-merge", "reason": "already in dev — same 48->49 cryptography bump dev did via #105", "branch": "", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "fbc6de85", "pr": 1667, "subject": "chore(deps): bump fireworks-ai from 1.2.0a75 to 1.2.0a86 (#1667)", "disposition": "deferred", "reason": "conflicts on pick — dev diverged to fireworks-ai a85; a86 still wanted, needs manual bump + uv lock", "branch": "deps", "local_sha": null, "updated": "2026-07-08T23:17:35Z"}
|
||||
{"sha": "290d0fee", "pr": 1669, "subject": "chore(deps): update langgraph-cli[inmem] requirement (#1669)", "disposition": "wont-merge", "reason": "already in dev — langgraph-cli[inmem] at 0.4.30", "branch": "", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "c9f6dd86", "pr": 1694, "subject": "fix: fall back on model stream transport errors (#1694)", "disposition": "deferred", "reason": "adds httpx.TransportError to transient set; small conflict w/ dev's diverged Bedrock fallback", "branch": "model-fallback", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "c9a9a7cd", "pr": 1695, "subject": "fix: retry model fallback exhaustion (#1695)", "disposition": "deferred", "reason": "alternating retry+backoff rewrite; reconcile by hand w/ dev's Bedrock + sync wrap path", "branch": "model-fallback", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "e5dbc788", "pr": 1696, "subject": "fix: Harden durable agent runs (#1696)", "disposition": "deferred", "reason": "large durable-run hardening; 3 new modules dev lacks; rewrites fork dispatch/completion", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "c9f6dd86", "pr": 1694, "subject": "fix: fall back on model stream transport errors (#1694)", "disposition": "landed", "reason": "adds httpx.TransportError to transient set; small conflict w/ dev's diverged Bedrock fallback", "branch": "model-fallback", "local_sha": null, "updated": "2026-07-09T18:59:20Z"}
|
||||
{"sha": "c9a9a7cd", "pr": 1695, "subject": "fix: retry model fallback exhaustion (#1695)", "disposition": "landed", "reason": "alternating retry+backoff rewrite; reconcile by hand w/ dev's Bedrock + sync wrap path", "branch": "model-fallback", "local_sha": null, "updated": "2026-07-09T18:59:20Z"}
|
||||
{"sha": "e5dbc788", "pr": 1696, "subject": "fix: Harden durable agent runs (#1696)", "disposition": "landed", "reason": "large durable-run hardening; 3 new modules dev lacks; rewrites fork dispatch/completion", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-09T17:22:53Z"}
|
||||
{"sha": "52fe2916", "pr": 1698, "subject": "feat: add PR review link route (#1698)", "disposition": "landed", "reason": "cherry-picked (-x) in #127 (PR review link route)", "branch": "reviewer-misc", "local_sha": null, "updated": "2026-07-08T22:58:21Z"}
|
||||
{"sha": "5f7f5fbd", "pr": 1697, "subject": "fix: Reduce graph import and loader startup latency (#1697)", "disposition": "deferred", "reason": "import-hygiene refactor; cross-cutting, references many deferred upstream-only modules", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "216cf181", "pr": 1699, "subject": "fix: keep workflow HITL without token downscoping (#1699)", "disposition": "deferred", "reason": "FLAG-HUMAN: removes token downscoping — conflicts w/ fork PR#100 workflow-token elevation (auth surface)", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "5f7f5fbd", "pr": 1697, "subject": "fix: Reduce graph import and loader startup latency (#1697)", "disposition": "landed", "reason": "import-hygiene refactor; cross-cutting, references many deferred upstream-only modules", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-09T17:22:53Z"}
|
||||
{"sha": "216cf181", "pr": 1699, "subject": "fix: keep workflow HITL without token downscoping (#1699)", "disposition": "landed", "reason": "DIVERGES-FROM-UPSTREAM: fork deliberately does NOT adopt #1699's standing-token workflows:write broadening. Security review (#159) BLOCKed it — the standing ALWAYS-ON proxy token carrying workflows:write turns the HITL guard's git-push-parser gaps (obfuscated-expansion push, `gh api` REST contents PUT, cross-branch refspecs) into live unapproved-workflow-push exploits. Fork keeps BASE without workflows:write and restores the transient per-approval elevation (_run_with_workflow_token mints WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS around the approved, guard-normalized fixed_command, then downscopes to RUNTIME then BASE): the token scope is the backstop the parser relies on, so a bypass hits GitHub 403. HITL diff-preview/approval-URL/Slack-card additions from #159 retained; token-model divergence only.", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T20:00:00Z"}
|
||||
{"sha": "67abf5b0", "pr": 1659, "subject": "fix: surface attributed PR creation failures (#1659)", "disposition": "deferred", "reason": "PR-attribution-failure guard (new mw, safe imports); heavy conflict on diverged open_pull_request.py", "branch": "pr-attribution", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "3dbc0282", "pr": 1676, "subject": "fix: preserve plan redirects after login (#1676)", "disposition": "deferred", "reason": "FLAG-HUMAN: follow-on to landed #1668 refining sanitize_redirect_to (open-redirect auth surface); not a dup", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "3dbc0282", "pr": 1676, "subject": "fix: preserve plan redirects after login (#1676)", "disposition": "landed", "reason": "FLAG-HUMAN: follow-on to landed #1668 refining sanitize_redirect_to (open-redirect auth surface); not a dup", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
{"sha": "c75cbb1f", "pr": 1677, "subject": "feat: re-add Fable 5 with an admin toggle to disable it (#1677)", "disposition": "deferred", "reason": "FLAG-HUMAN: re-adds Fable 5 via anthropic: — contradicts dev's deliberate hide (#1483) + Bedrock migration (#62); wont-merge candidate", "branch": "fable-admin-toggle", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "bb104d93", "pr": 1679, "subject": "fix: submit plan comments with cmd enter (#1679)", "disposition": "deferred", "reason": "applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-08T23:17:35Z"}
|
||||
{"sha": "bb104d93", "pr": 1679, "subject": "fix: submit plan comments with cmd enter (#1679)", "disposition": "landed", "reason": "applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
{"sha": "304032fa", "pr": 1680, "subject": "chore: clarify question answering prompt (#1680)", "disposition": "deferred", "reason": "reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt", "branch": "prompt-tweaks", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "5003c953", "pr": 1683, "subject": "feat: open Linear-triggered PRs as the triggering user (#1683)", "disposition": "deferred", "reason": "FLAG-HUMAN: adds linear to resolve_github_token per-user OAuth branch (auth surface); depends on #1626 linear.py", "branch": "linear-pr-as-user", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "feb7ac98", "pr": 1689, "subject": "feat(web): surface thread sandbox ID with touch-friendly menu (#1689)", "disposition": "deferred", "reason": "applies clean but frontend<->backend contract (thread_api->queries->types->sidebar); needs UI build + e2e validation — separate PR", "branch": "dashboard-ui", "local_sha": null, "updated": "2026-07-08T23:17:35Z"}
|
||||
{"sha": "7f7af715", "pr": 1684, "subject": "feat: auto-load scoped AGENTS on reads (#1684)", "disposition": "landed", "reason": "ported in #129 (SubdirAgentsReadMiddleware)", "branch": "subdir-agents", "local_sha": null, "updated": "2026-07-08T22:58:22Z"}
|
||||
{"sha": "88b62322", "pr": 1685, "subject": "feat: add platform issue reporting tool (#1685)", "disposition": "landed", "reason": "ported in #129 (report_platform_issue tool)", "branch": "small-tools", "local_sha": null, "updated": "2026-07-08T22:58:22Z"}
|
||||
{"sha": "90cb6caa", "pr": 1681, "subject": "feat: terse Slack replies, share long content via plan-review page (#1681)", "disposition": "deferred", "reason": "terse Slack + long-content-via-plan-page; conflicts w/ fork prompt + diverged plan stack", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "90cb6caa", "pr": 1681, "subject": "feat: terse Slack replies, share long content via plan-review page (#1681)", "disposition": "landed", "reason": "terse Slack + long-content-via-plan-page; conflicts w/ fork prompt + diverged plan stack", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
{"sha": "f53caff1", "pr": 1701, "subject": "fix: fall back to core GitHub App scope when optional grants missing (#1701)", "disposition": "deferred", "reason": "FLAG-HUMAN: GitHub-App permission-ladder degrade (auth surface); heavy conflict on diverged github_app.py/_resolve_proxy_token", "branch": "github-app-scope", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "73a9e8b5", "pr": 1693, "subject": "chore(deps): bump the minor-and-patch group across 1 directory with 19 updates (#1693)", "disposition": "wont-merge", "reason": "dev at-or-ahead on 17/19; group fights dev's pinned langsmith==0.9.7 (#115) and carries an upstream plan-route test", "branch": "", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "9cd7e464", "pr": 1700, "subject": "Fix workflow approval visibility (#1700)", "disposition": "wont-merge", "reason": "superseded — dev's list_workflow_approvals_for_thread already enforces owner-only 403", "branch": "", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "22e024cb", "pr": 1704, "subject": "fix: link issue PRs and prompt repo conventions (#1704)", "disposition": "deferred", "reason": "issue/PR linking + repo-convention prompt; clean but prompt-conflict risk vs #113", "branch": "webhook-issue-linking", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "fd2541ce", "pr": 1705, "subject": "fix: drop orphaned function_call items with stale OpenAI reasoning (#1705)", "disposition": "wont-merge", "reason": "N/A — edits sanitize_openai_responses.py which dev deleted in the Bedrock/Fireworks migration (#62)", "branch": "", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "27b0ddeb", "pr": 1708, "subject": "feat: add GPT-5.6 OpenAI models (#1708)", "disposition": "deferred", "reason": "FLAG-HUMAN: adds OpenAI GPT-5.6 to the model picker; fork's picker is Bedrock/Fireworks-only — needs a product decision before adopting OpenAI models. Gateway (#155) can route OpenAI if adopted.", "branch": "model-picker", "local_sha": null, "updated": "2026-07-09T22:20:06Z"}
|
||||
{"sha": "62e0ca2d", "pr": 1709, "subject": "fix: stale admin model defaults after model upgrades (#1709)", "disposition": "deferred", "reason": "stale admin model-default cleanup in team_settings after model upgrades; applies to fork's default-model resolution.", "branch": "model-picker", "local_sha": null, "updated": "2026-07-09T22:20:06Z"}
|
||||
{"sha": "138ab9ec", "pr": 1710, "subject": "fix: bump langchain-fireworks to 1.4.4 (#1710)", "disposition": "deferred", "reason": "langchain-fireworks 1.4.4 bump; fork uses Fireworks as a primary provider — adopt with a lockfile refresh.", "branch": "deps", "local_sha": null, "updated": "2026-07-09T22:20:07Z"}
|
||||
{"sha": "71e3b818", "pr": 1713, "subject": "fix: align reviewer eval with published findings (#1713)", "disposition": "deferred", "reason": "reviewer-eval/judge alignment; touches reviewer.py + add_finding/publish_review which are fork-diverged — reconcile against fork's reviewer before porting.", "branch": "reviewer-eval", "local_sha": null, "updated": "2026-07-09T22:20:07Z"}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Commits on `upstream/main` (langchain-ai/open-swe) not yet in `dev`, and the dec
|
|||
Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred rows are provisional
|
||||
— re-inspect before picking. See the fork-maintenance runbook in `CLAUDE.md`.
|
||||
|
||||
**Last synced `upstream/main`:** `fd2541ce` (2026-07-08)
|
||||
**Last synced `upstream/main`:** `71e3b818` (2026-07-09)
|
||||
|
||||
| sha | pr | subject | decision | why | branch |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
@ -21,7 +21,12 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
|
|||
| `6575c327` | #1654 | disable React StrictMode | Landed | kept fork's `PwaUpdateProvider` | cherry-pick-upstream |
|
||||
| `00906401` | #1610 | editable plan mode | Landed | re-implemented in fork via #130 (editable plan mode) | |
|
||||
| `f5670f24` | #1639 | require bun for ui agent work | Landed | cherry-picked (-x) in this PR (#132) | PR1 |
|
||||
| `209132d3` | #1621 | durable interrupt dispatch + completion webhook | Landed | investigate first — may be applied | durable-dispatch |
|
||||
| `02bb4dfd` | #1658 | don't attach loopback run-complete webhooks | Landed | | durable-dispatch |
|
||||
| `29015fad` | #1614 | gate workflow pushes with approval | Landed | | durable-dispatch |
|
||||
| `546042a4` | #1652 | add workflow approval UI | Landed | | plan-approval |
|
||||
| `ae04b72b` | #1635 | publish plans from sandbox files | Landed | ported (adapted) in #128 (save_plan reads sandbox file) | plan-approval |
|
||||
| `c03a6be7` | #1634 | keep plan guidance high-level | Landed | | plan-approval |
|
||||
| `96cceb74` | #1632 | notify Slack on plan approval | Landed | ported (adapted) in #128 | plan-approval |
|
||||
| `2f56d754` | #1618 | omit plan link when no plan exists | Landed | already in dev via #81 (upstream-sync); ledger was stale (was: likely regression) | plan-approval |
|
||||
| `ee224d3e` | #1650 | add Slack reaction tool | Landed | | slack-tooling |
|
||||
|
|
@ -29,6 +34,10 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
|
|||
| `27d90ef1` | #1633 | include Slack channel context in prompts | Landed | | slack-tooling |
|
||||
| `92dbf6f9` | #1630 | update Slack trace reply on web handoff | Landed | ported in #128 (trace_message_ts on first-mention run mapping) | slack-tooling |
|
||||
| `bb36448b` | #1627 | surface Slack thread errors | Landed | | slack-tooling |
|
||||
| `73b7d1c0` | #1678 | fix OpenAI Responses reasoning replay | Landed | | gateway-routing |
|
||||
| `5f7c2f46` | #1674 | fix Fireworks Gateway base URL | Landed | | gateway-routing |
|
||||
| `702ef908` | #1673 | dedicated LangSmith gateway API key | Landed | | gateway-routing |
|
||||
| `e9dc6e01` | #1671 | opt-in LangSmith LLM Gateway routing | Landed | | gateway-routing |
|
||||
| `289f5e3a` | #1651 | add Sonnet 5 to model picker | Landed | already in dev; added Bedrock family fallback fix (c16fb915) | gateway-routing |
|
||||
| `5da3d0c6` | #1624 | post reviewer resolution notes verbatim | Landed | cherry-picked (-x) in #127 | reviewer-misc |
|
||||
| `69148f54` | #1612 | add PR trace resolution | Landed | cherry-picked (-x) in #127 | reviewer-misc |
|
||||
|
|
@ -37,9 +46,17 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
|
|||
| `4f913198` | #1647 | widen split review diffs | Landed | already present in dev; empty pick confirmed in #127 | reviewer-misc |
|
||||
| `20f63e8c` | #1646 | install missing deps before verification | Landed | already in dev via #81 (upstream-sync); ledger was stale | prompt-tweaks |
|
||||
| `2f237b53` | #1626 | fall back to vision model for image threads | Landed | ported (adapted to Bedrock/Fireworks vision) in #128 | gateway-routing |
|
||||
| `c9f6dd86` | #1694 | fix: fall back on model stream transport errors (#1694) | Landed | adds httpx.TransportError to transient set; small conflict w/ dev's diverged Bedrock fallback | model-fallback |
|
||||
| `c9a9a7cd` | #1695 | fix: retry model fallback exhaustion (#1695) | Landed | alternating retry+backoff rewrite; reconcile by hand w/ dev's Bedrock + sync wrap path | model-fallback |
|
||||
| `e5dbc788` | #1696 | fix: Harden durable agent runs (#1696) | Landed | large durable-run hardening; 3 new modules dev lacks; rewrites fork dispatch/completion | durable-dispatch |
|
||||
| `52fe2916` | #1698 | feat: add PR review link route (#1698) | Landed | cherry-picked (-x) in #127 (PR review link route) | reviewer-misc |
|
||||
| `5f7f5fbd` | #1697 | fix: Reduce graph import and loader startup latency (#1697) | Landed | import-hygiene refactor; cross-cutting, references many deferred upstream-only modules | durable-dispatch |
|
||||
| `216cf181` | #1699 | fix: keep workflow HITL without token downscoping (#1699) | Landed | DIVERGES-FROM-UPSTREAM: fork deliberately does NOT adopt #1699's standing-token workflows:write broadening. Security review (#159) BLOCKed it — the standing ALWAYS-ON proxy token carrying workflows:write turns the HITL guard's git-push-parser gaps (obfuscated-expansion push, `gh api` REST contents PUT, cross-branch refspecs) into live unapproved-workflow-push exploits. Fork keeps BASE without workflows:write and restores the transient per-approval elevation (_run_with_workflow_token mints WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS around the approved, guard-normalized fixed_command, then downscopes to RUNTIME then BASE): the token scope is the backstop the parser relies on, so a bypass hits GitHub 403. HITL diff-preview/approval-URL/Slack-card additions from #159 retained; token-model divergence only. | plan-approval |
|
||||
| `3dbc0282` | #1676 | fix: preserve plan redirects after login (#1676) | Landed | FLAG-HUMAN: follow-on to landed #1668 refining sanitize_redirect_to (open-redirect auth surface); not a dup | plan-approval |
|
||||
| `bb104d93` | #1679 | fix: submit plan comments with cmd enter (#1679) | Landed | applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR | plan-approval |
|
||||
| `7f7af715` | #1684 | feat: auto-load scoped AGENTS on reads (#1684) | Landed | ported in #129 (SubdirAgentsReadMiddleware) | subdir-agents |
|
||||
| `88b62322` | #1685 | feat: add platform issue reporting tool (#1685) | Landed | ported in #129 (report_platform_issue tool) | small-tools |
|
||||
| `90cb6caa` | #1681 | feat: terse Slack replies, share long content via plan-review page (#1681) | Landed | terse Slack + long-content-via-plan-page; conflicts w/ fork prompt + diverged plan stack | plan-approval |
|
||||
| `c3292d82` | #1611 | bake sfw binary into sandbox image | Won't merge | already in dev | |
|
||||
| `48bf712b` | #1609 | show message timestamps | Won't merge | already in dev | |
|
||||
| `85c0f63e` | #1620 | clickable shared PR header | Won't merge | already in dev | |
|
||||
|
|
@ -57,16 +74,7 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
|
|||
| `73a9e8b5` | #1693 | chore(deps): bump the minor-and-patch group across 1 directory with 19 updates (#1693) | Won't merge | dev at-or-ahead on 17/19; group fights dev's pinned langsmith==0.9.7 (#115) and carries an upstream plan-route test | |
|
||||
| `9cd7e464` | #1700 | Fix workflow approval visibility (#1700) | Won't merge | superseded — dev's list_workflow_approvals_for_thread already enforces owner-only 403 | |
|
||||
| `fd2541ce` | #1705 | fix: drop orphaned function_call items with stale OpenAI reasoning (#1705) | Won't merge | N/A — edits sanitize_openai_responses.py which dev deleted in the Bedrock/Fireworks migration (#62) | |
|
||||
| `209132d3` | #1621 | durable interrupt dispatch + completion webhook | Deferred | investigate first — may be applied | durable-dispatch |
|
||||
| `02bb4dfd` | #1658 | don't attach loopback run-complete webhooks | Deferred | | durable-dispatch |
|
||||
| `29015fad` | #1614 | gate workflow pushes with approval | Deferred | | durable-dispatch |
|
||||
| `546042a4` | #1652 | add workflow approval UI | Deferred | | plan-approval |
|
||||
| `c03a6be7` | #1634 | keep plan guidance high-level | Deferred | | plan-approval |
|
||||
| `4cd5fa5c` | #1629 | avoid recapping Slack replies | Deferred | | slack-tooling |
|
||||
| `73b7d1c0` | #1678 | fix OpenAI Responses reasoning replay | Deferred | | gateway-routing |
|
||||
| `5f7c2f46` | #1674 | fix Fireworks Gateway base URL | Deferred | | gateway-routing |
|
||||
| `702ef908` | #1673 | dedicated LangSmith gateway API key | Deferred | | gateway-routing |
|
||||
| `e9dc6e01` | #1671 | opt-in LangSmith LLM Gateway routing | Deferred | | gateway-routing |
|
||||
| `baf0c248` | #1617 | filter & grouping menu in threads sidebar | Deferred | ~998 LOC | own branch |
|
||||
| `f29868ff` | #1615 | recover thread work as patch | Deferred | ~495 LOC | own branch |
|
||||
| `8e0788dc` | #1631 | show queued dashboard follow-ups | Deferred | | own branch |
|
||||
|
|
@ -79,20 +87,16 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
|
|||
| `4f8bc2dd` | #1692 | refactor: simplify open-swe agent sandbox lifecycle (#1692) | Deferred | FLAG-HUMAN: structural rewrite of ensure_sandbox_for_thread (drops __creating__ 4-case sentinel) | sandbox-refactor |
|
||||
| `48217b68` | #1489 | feat(open-swe): add E2B sandbox provider (#1489) | Deferred | additive E2B provider; separable but ships on the async sandbox.py base | sandbox-refactor |
|
||||
| `fbc6de85` | #1667 | chore(deps): bump fireworks-ai from 1.2.0a75 to 1.2.0a86 (#1667) | Deferred | conflicts on pick — dev diverged to fireworks-ai a85; a86 still wanted, needs manual bump + uv lock | deps |
|
||||
| `c9f6dd86` | #1694 | fix: fall back on model stream transport errors (#1694) | Deferred | adds httpx.TransportError to transient set; small conflict w/ dev's diverged Bedrock fallback | model-fallback |
|
||||
| `c9a9a7cd` | #1695 | fix: retry model fallback exhaustion (#1695) | Deferred | alternating retry+backoff rewrite; reconcile by hand w/ dev's Bedrock + sync wrap path | model-fallback |
|
||||
| `e5dbc788` | #1696 | fix: Harden durable agent runs (#1696) | Deferred | large durable-run hardening; 3 new modules dev lacks; rewrites fork dispatch/completion | durable-dispatch |
|
||||
| `5f7f5fbd` | #1697 | fix: Reduce graph import and loader startup latency (#1697) | Deferred | import-hygiene refactor; cross-cutting, references many deferred upstream-only modules | durable-dispatch |
|
||||
| `216cf181` | #1699 | fix: keep workflow HITL without token downscoping (#1699) | Deferred | FLAG-HUMAN: removes token downscoping — conflicts w/ fork PR#100 workflow-token elevation (auth surface) | plan-approval |
|
||||
| `67abf5b0` | #1659 | fix: surface attributed PR creation failures (#1659) | Deferred | PR-attribution-failure guard (new mw, safe imports); heavy conflict on diverged open_pull_request.py | pr-attribution |
|
||||
| `3dbc0282` | #1676 | fix: preserve plan redirects after login (#1676) | Deferred | FLAG-HUMAN: follow-on to landed #1668 refining sanitize_redirect_to (open-redirect auth surface); not a dup | plan-approval |
|
||||
| `c75cbb1f` | #1677 | feat: re-add Fable 5 with an admin toggle to disable it (#1677) | Deferred | FLAG-HUMAN: re-adds Fable 5 via anthropic: — contradicts dev's deliberate hide (#1483) + Bedrock migration (#62); wont-merge candidate | fable-admin-toggle |
|
||||
| `bb104d93` | #1679 | fix: submit plan comments with cmd enter (#1679) | Deferred | applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR | plan-approval |
|
||||
| `304032fa` | #1680 | chore: clarify question answering prompt (#1680) | Deferred | reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt | prompt-tweaks |
|
||||
| `5003c953` | #1683 | feat: open Linear-triggered PRs as the triggering user (#1683) | Deferred | FLAG-HUMAN: adds linear to resolve_github_token per-user OAuth branch (auth surface); depends on #1626 linear.py | linear-pr-as-user |
|
||||
| `feb7ac98` | #1689 | feat(web): surface thread sandbox ID with touch-friendly menu (#1689) | Deferred | applies clean but frontend<->backend contract (thread_api->queries->types->sidebar); needs UI build + e2e validation — separate PR | dashboard-ui |
|
||||
| `90cb6caa` | #1681 | feat: terse Slack replies, share long content via plan-review page (#1681) | Deferred | terse Slack + long-content-via-plan-page; conflicts w/ fork prompt + diverged plan stack | plan-approval |
|
||||
| `f53caff1` | #1701 | fix: fall back to core GitHub App scope when optional grants missing (#1701) | Deferred | FLAG-HUMAN: GitHub-App permission-ladder degrade (auth surface); heavy conflict on diverged github_app.py/_resolve_proxy_token | github-app-scope |
|
||||
| `22e024cb` | #1704 | fix: link issue PRs and prompt repo conventions (#1704) | Deferred | issue/PR linking + repo-convention prompt; clean but prompt-conflict risk vs #113 | webhook-issue-linking |
|
||||
| `27b0ddeb` | #1708 | feat: add GPT-5.6 OpenAI models (#1708) | Deferred | FLAG-HUMAN: adds OpenAI GPT-5.6 to the model picker; fork's picker is Bedrock/Fireworks-only — needs a product decision before adopting OpenAI models. Gateway (#155) can route OpenAI if adopted. | model-picker |
|
||||
| `62e0ca2d` | #1709 | fix: stale admin model defaults after model upgrades (#1709) | Deferred | stale admin model-default cleanup in team_settings after model upgrades; applies to fork's default-model resolution. | model-picker |
|
||||
| `138ab9ec` | #1710 | fix: bump langchain-fireworks to 1.4.4 (#1710) | Deferred | langchain-fireworks 1.4.4 bump; fork uses Fireworks as a primary provider — adopt with a lockfile refresh. | deps |
|
||||
| `71e3b818` | #1713 | fix: align reviewer eval with published findings (#1713) | Deferred | reviewer-eval/judge alignment; touches reviewer.py + add_finding/publish_review which are fork-diverged — reconcile against fork's reviewer before porting. | reviewer-eval |
|
||||
|
||||
_Maintenance: after a `git sync`, add new `dev..upstream/main` SHAs as **Untriaged** (edit `triage.jsonl`) and bump "Last synced". A successful `git cherry-pick -x` auto-moves the row to **Landed** via the `post-commit` journal + `make triage-reconcile`._
|
||||
|
|
|
|||
|
|
@ -8,27 +8,27 @@ license = { text = "MIT" }
|
|||
dependencies = [
|
||||
"deepagents==0.6.12",
|
||||
"fastapi>=0.139.0",
|
||||
"uvicorn>=0.49.0",
|
||||
"uvicorn>=0.51.0",
|
||||
"httpx>=0.28.1",
|
||||
"PyJWT>=2.13.0",
|
||||
"cryptography>=49.0.0",
|
||||
"langgraph-sdk>=0.4.2",
|
||||
"langchain>=1.3.9",
|
||||
"langgraph>=1.1.10",
|
||||
"langchain>=1.3.12",
|
||||
"langgraph>=1.2.8",
|
||||
"markdownify>=1.2.3",
|
||||
"langchain-anthropic>=1.4.6",
|
||||
"langchain-aws>=0.2.0",
|
||||
"langchain-aws>=1.6.2",
|
||||
"langgraph-cli[inmem]>=0.4.30",
|
||||
"langsmith==0.9.7",
|
||||
"langchain-openai>=1.3.3",
|
||||
"langsmith==0.10.0",
|
||||
"langchain-openai>=1.3.4",
|
||||
"langchain-fireworks>=1.4.3",
|
||||
# langchain-fireworks 1.4.2 pins a pre-release fireworks-ai; opt in explicitly so uv resolves it.
|
||||
"fireworks-ai>=1.2.0a85",
|
||||
"fireworks-ai>=1.2.0a88",
|
||||
"langchain-daytona>=0.0.7",
|
||||
"langchain-modal>=0.0.5",
|
||||
"langchain-runloop>=0.0.6",
|
||||
"exa-py>=2.16.0",
|
||||
"langchain-google-genai>=4.2.4",
|
||||
"langchain-google-genai>=4.2.7",
|
||||
"langchain-mcp-adapters>=0.3.0",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ def apply() -> None:
|
|||
|
||||
import importlib
|
||||
|
||||
from agent import server
|
||||
from agent.utils import auth, authorship
|
||||
from agent.utils import slack as slack_utils
|
||||
|
||||
|
|
@ -52,7 +51,11 @@ def apply() -> None:
|
|||
def _fake_make_model(model_id: str, **kwargs: object): # noqa: ARG001
|
||||
return FakeScriptedChatModel(script=build_script())
|
||||
|
||||
server.make_model = _fake_make_model
|
||||
# ``make_model_or_defer`` (used by server/reviewer/analyzer) resolves
|
||||
# ``make_model`` via the module global at call time, so patch it there.
|
||||
import agent.utils.deferred_model as deferred_model
|
||||
|
||||
deferred_model.make_model = _fake_make_model
|
||||
|
||||
async def _dummy_install_token_with_expiry() -> tuple[str, str | None]:
|
||||
return "dummy-installation-token", None
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ async def _capture_create_deep_agent_kwargs() -> dict[str, object]:
|
|||
),
|
||||
patch("agent.server.load_profile", new_callable=AsyncMock, return_value=None),
|
||||
patch("agent.server.fallback_model_id_for", return_value=None),
|
||||
patch("agent.server.make_model", side_effect=[MagicMock(), MagicMock()]),
|
||||
patch("agent.utils.deferred_model.make_model", side_effect=[MagicMock(), MagicMock()]),
|
||||
patch("agent.server.construct_system_prompt", return_value="prompt"),
|
||||
patch("agent.server.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -66,7 +66,9 @@ async def test_agent_uses_profile_subagent_model_override() -> None:
|
|||
},
|
||||
),
|
||||
patch("agent.server.fallback_model_id_for", return_value=None),
|
||||
patch("agent.server.make_model", side_effect=[main_model, subagent_model]) as make_model,
|
||||
patch(
|
||||
"agent.utils.deferred_model.make_model", side_effect=[main_model, subagent_model]
|
||||
) as make_model,
|
||||
patch("agent.server.construct_system_prompt", return_value="prompt"),
|
||||
patch("agent.server.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
|
|
@ -142,7 +144,9 @@ async def test_agent_subagent_inherits_profile_model_override_without_explicit_p
|
|||
},
|
||||
),
|
||||
patch("agent.server.fallback_model_id_for", return_value=None),
|
||||
patch("agent.server.make_model", side_effect=[main_model, subagent_model]) as make_model,
|
||||
patch(
|
||||
"agent.utils.deferred_model.make_model", side_effect=[main_model, subagent_model]
|
||||
) as make_model,
|
||||
patch("agent.server.construct_system_prompt", return_value="prompt"),
|
||||
patch("agent.server.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -9,20 +9,27 @@ from agent import completion
|
|||
|
||||
|
||||
class _FakeThreads:
|
||||
def __init__(self, metadata: dict[str, Any]) -> None:
|
||||
def __init__(self, metadata: dict[str, Any], *, fail_updates: int = 0) -> None:
|
||||
self._metadata = metadata
|
||||
self.updates: list[dict[str, Any]] = []
|
||||
self._fail_updates = fail_updates
|
||||
|
||||
async def get(self, thread_id: str) -> dict[str, Any]:
|
||||
return {"thread_id": thread_id, "metadata": self._metadata}
|
||||
# Reflect prior claims so a duplicate/retried delivery sees them, exactly
|
||||
# as the platform persists thread metadata across webhook deliveries.
|
||||
return {"thread_id": thread_id, "metadata": dict(self._metadata)}
|
||||
|
||||
async def update(self, *, thread_id: str, metadata: dict[str, Any]) -> None:
|
||||
if self._fail_updates > 0:
|
||||
self._fail_updates -= 1
|
||||
raise RuntimeError("simulated metadata write failure")
|
||||
self.updates.append(metadata)
|
||||
self._metadata.update(metadata)
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, metadata: dict[str, Any]) -> None:
|
||||
self.threads = _FakeThreads(metadata)
|
||||
def __init__(self, metadata: dict[str, Any], *, fail_updates: int = 0) -> None:
|
||||
self.threads = _FakeThreads(metadata, fail_updates=fail_updates)
|
||||
|
||||
|
||||
def _slack_metadata() -> dict[str, Any]:
|
||||
|
|
@ -42,7 +49,9 @@ async def test_error_status_posts_slack_failure_reply(monkeypatch: pytest.Monkey
|
|||
completion, "dashboard_thread_url", lambda thread_id: f"https://ui/{thread_id}"
|
||||
)
|
||||
|
||||
result = await completion.handle_run_completion({"thread_id": "t1", "status": "error"})
|
||||
result = await completion.handle_run_completion(
|
||||
{"thread_id": "t1", "run_id": "run-1", "status": "error"}
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
reply.assert_awaited_once()
|
||||
|
|
@ -50,7 +59,9 @@ async def test_error_status_posts_slack_failure_reply(monkeypatch: pytest.Monkey
|
|||
assert args[0] == "C1"
|
||||
assert args[1] == "123.45"
|
||||
assert "<https://ui/t1|Open SWE Web>" in args[2]
|
||||
assert client.threads.updates == [{"failure_reply_posted": True}]
|
||||
assert client.threads.updates == [
|
||||
{"failure_reply_posted_run_id": "run-1", "failure_reply_posted_run_ids": ["run-1"]}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -60,7 +71,9 @@ async def test_success_status_is_ignored(monkeypatch: pytest.MonkeyPatch) -> Non
|
|||
reply = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(completion, "post_slack_thread_reply", reply)
|
||||
|
||||
result = await completion.handle_run_completion({"thread_id": "t1", "status": "success"})
|
||||
result = await completion.handle_run_completion(
|
||||
{"thread_id": "t1", "run_id": "run-1", "status": "success"}
|
||||
)
|
||||
|
||||
assert result["status"] == "ignored"
|
||||
reply.assert_not_called()
|
||||
|
|
@ -69,19 +82,46 @@ async def test_success_status_is_ignored(monkeypatch: pytest.MonkeyPatch) -> Non
|
|||
@pytest.mark.asyncio
|
||||
async def test_idempotent_when_already_replied(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
metadata = _slack_metadata()
|
||||
metadata["failure_reply_posted"] = True
|
||||
metadata["failure_reply_posted_run_ids"] = ["run-1"]
|
||||
client = _FakeClient(metadata)
|
||||
monkeypatch.setattr(completion, "langgraph_client", lambda: client)
|
||||
reply = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(completion, "post_slack_thread_reply", reply)
|
||||
|
||||
result = await completion.handle_run_completion({"thread_id": "t1", "status": "timeout"})
|
||||
result = await completion.handle_run_completion(
|
||||
{"thread_id": "t1", "run_id": "run-1", "status": "timeout"}
|
||||
)
|
||||
|
||||
assert result["status"] == "ignored"
|
||||
reply.assert_not_called()
|
||||
assert client.threads.updates == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_later_failed_run_posts_even_if_prior_run_replied(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
metadata = _slack_metadata()
|
||||
metadata["failure_reply_posted_run_ids"] = ["run-1"]
|
||||
client = _FakeClient(metadata)
|
||||
monkeypatch.setattr(completion, "langgraph_client", lambda: client)
|
||||
reply = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(completion, "post_slack_thread_reply", reply)
|
||||
|
||||
result = await completion.handle_run_completion(
|
||||
{"thread_id": "t1", "run_id": "run-2", "status": "timeout"}
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
reply.assert_awaited_once()
|
||||
assert client.threads.updates == [
|
||||
{
|
||||
"failure_reply_posted_run_id": "run-2",
|
||||
"failure_reply_posted_run_ids": ["run-1", "run-2"],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_linear_source_comments_on_issue(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _FakeClient({"source": "linear", "source_context": {"linear_issue": {"id": "iss_1"}}})
|
||||
|
|
@ -89,7 +129,9 @@ async def test_linear_source_comments_on_issue(monkeypatch: pytest.MonkeyPatch)
|
|||
comment = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(completion, "comment_on_linear_issue", comment)
|
||||
|
||||
result = await completion.handle_run_completion({"thread_id": "t1", "status": "timeout"})
|
||||
result = await completion.handle_run_completion(
|
||||
{"thread_id": "t1", "run_id": "run-1", "status": "timeout"}
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
comment.assert_awaited_once()
|
||||
|
|
@ -98,46 +140,169 @@ async def test_linear_source_comments_on_issue(monkeypatch: pytest.MonkeyPatch)
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_thread_id_is_ignored() -> None:
|
||||
result = await completion.handle_run_completion({"status": "error"})
|
||||
result = await completion.handle_run_completion({"run_id": "run-1", "status": "error"})
|
||||
assert result["status"] == "ignored"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claims_flag_before_posting(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Claim-then-post: the dedup flag must be set before the reply is posted so a
|
||||
# retried/concurrent webhook can't double-post the canned failure message.
|
||||
async def test_no_run_id_and_no_marker_posts_without_dedupe(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# A payload carrying nothing to distinguish one run from another posts
|
||||
# un-deduped (no sticky flag written) rather than being suppressed. It
|
||||
# prefers a rare duplicate over permanent silence (SR160-03).
|
||||
client = _FakeClient(_slack_metadata())
|
||||
monkeypatch.setattr(completion, "langgraph_client", lambda: client)
|
||||
|
||||
async def _reply(*_args: Any, **_kwargs: Any) -> bool:
|
||||
assert client.threads.updates == [{"failure_reply_posted": True}]
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(completion, "post_slack_thread_reply", AsyncMock(side_effect=_reply))
|
||||
|
||||
result = await completion.handle_run_completion({"thread_id": "t1", "status": "error"})
|
||||
assert result["status"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_post_when_claim_fails(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _FakeClient(_slack_metadata())
|
||||
client.threads.update = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
monkeypatch.setattr(completion, "langgraph_client", lambda: client)
|
||||
reply = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(completion, "post_slack_thread_reply", reply)
|
||||
|
||||
result = await completion.handle_run_completion({"thread_id": "t1", "status": "error"})
|
||||
assert result["status"] == "error"
|
||||
|
||||
assert result["status"] == "ok"
|
||||
reply.assert_awaited_once()
|
||||
assert client.threads.updates == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_sticky_flag_does_not_suppress_new_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# An old per-thread sticky boolean left by a prior code version must not
|
||||
# permanently silence a later failed run (SR160-03).
|
||||
metadata = _slack_metadata()
|
||||
metadata["failure_reply_posted"] = True
|
||||
client = _FakeClient(metadata)
|
||||
monkeypatch.setattr(completion, "langgraph_client", lambda: client)
|
||||
reply = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(completion, "post_slack_thread_reply", reply)
|
||||
|
||||
result = await completion.handle_run_completion(
|
||||
{"thread_id": "t1", "run_id": "run-9", "status": "error"}
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
reply.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_run_id_dedupes_on_updated_at_marker(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Without a run_id the same run's retry (identical updated_at) dedupes...
|
||||
client = _FakeClient(_slack_metadata())
|
||||
monkeypatch.setattr(completion, "langgraph_client", lambda: client)
|
||||
reply = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(completion, "post_slack_thread_reply", reply)
|
||||
|
||||
first = await completion.handle_run_completion(
|
||||
{"thread_id": "t1", "status": "error", "updated_at": "2026-07-09T00:00:00Z"}
|
||||
)
|
||||
retry = await completion.handle_run_completion(
|
||||
{"thread_id": "t1", "status": "error", "updated_at": "2026-07-09T00:00:00Z"}
|
||||
)
|
||||
|
||||
assert first["status"] == "ok"
|
||||
assert retry["status"] == "ignored"
|
||||
reply.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_run_id_different_runs_each_reply(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# ...while two genuinely different failed runs (distinct updated_at) on one
|
||||
# thread each get their reply — the crux of SR160-03.
|
||||
client = _FakeClient(_slack_metadata())
|
||||
monkeypatch.setattr(completion, "langgraph_client", lambda: client)
|
||||
reply = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(completion, "post_slack_thread_reply", reply)
|
||||
|
||||
first = await completion.handle_run_completion(
|
||||
{"thread_id": "t1", "status": "error", "updated_at": "2026-07-09T00:00:00Z"}
|
||||
)
|
||||
second = await completion.handle_run_completion(
|
||||
{"thread_id": "t1", "status": "timeout", "updated_at": "2026-07-09T01:00:00Z"}
|
||||
)
|
||||
|
||||
assert first["status"] == "ok"
|
||||
assert second["status"] == "ok"
|
||||
assert reply.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_written_before_post(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Claim-then-post: the dedup key is recorded before the network reply fires.
|
||||
client = _FakeClient(_slack_metadata())
|
||||
monkeypatch.setattr(completion, "langgraph_client", lambda: client)
|
||||
order: list[str] = []
|
||||
|
||||
async def _update(*, thread_id: str, metadata: dict[str, Any]) -> None:
|
||||
order.append("claim")
|
||||
client.threads.updates.append(metadata)
|
||||
client.threads._metadata.update(metadata)
|
||||
|
||||
async def _reply(*args: Any, **kwargs: Any) -> bool:
|
||||
order.append("post")
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(client.threads, "update", _update)
|
||||
monkeypatch.setattr(completion, "post_slack_thread_reply", _reply)
|
||||
|
||||
result = await completion.handle_run_completion(
|
||||
{"thread_id": "t1", "run_id": "run-1", "status": "error"}
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert order == ["claim", "post"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_write_failure_does_not_post_and_retry_posts_once(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# SR160-02: if the claim write throws, we do NOT post and report an error so
|
||||
# the retry re-attempts — the retry then claims and posts exactly once,
|
||||
# rather than the old flow double-posting on retry.
|
||||
client = _FakeClient(_slack_metadata(), fail_updates=1)
|
||||
monkeypatch.setattr(completion, "langgraph_client", lambda: client)
|
||||
reply = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(completion, "post_slack_thread_reply", reply)
|
||||
|
||||
first = await completion.handle_run_completion(
|
||||
{"thread_id": "t1", "run_id": "run-1", "status": "error"}
|
||||
)
|
||||
assert first["status"] == "error"
|
||||
reply.assert_not_called()
|
||||
|
||||
retry = await completion.handle_run_completion(
|
||||
{"thread_id": "t1", "run_id": "run-1", "status": "error"}
|
||||
)
|
||||
assert retry["status"] == "ok"
|
||||
reply.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_delivery_posts_exactly_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# A retried/duplicate delivery for one run_id posts exactly once because the
|
||||
# first delivery's claim is persisted before the second reads metadata.
|
||||
client = _FakeClient(_slack_metadata())
|
||||
monkeypatch.setattr(completion, "langgraph_client", lambda: client)
|
||||
reply = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(completion, "post_slack_thread_reply", reply)
|
||||
|
||||
payload = {"thread_id": "t1", "run_id": "run-1", "status": "error"}
|
||||
first = await completion.handle_run_completion(dict(payload))
|
||||
second = await completion.handle_run_completion(dict(payload))
|
||||
|
||||
assert first["status"] == "ok"
|
||||
assert second["status"] == "ignored"
|
||||
reply.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_reply_channel_does_not_flag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _FakeClient({"source": "schedule"})
|
||||
monkeypatch.setattr(completion, "langgraph_client", lambda: client)
|
||||
|
||||
result = await completion.handle_run_completion({"thread_id": "t1", "status": "error"})
|
||||
result = await completion.handle_run_completion(
|
||||
{"thread_id": "t1", "run_id": "run-1", "status": "error"}
|
||||
)
|
||||
|
||||
assert result["status"] == "ignored"
|
||||
assert client.threads.updates == []
|
||||
|
|
@ -152,7 +317,9 @@ async def test_interrupted_status_is_ignored(monkeypatch: pytest.MonkeyPatch) ->
|
|||
reply = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(completion, "post_slack_thread_reply", reply)
|
||||
|
||||
result = await completion.handle_run_completion({"thread_id": "t1", "status": "interrupted"})
|
||||
result = await completion.handle_run_completion(
|
||||
{"thread_id": "t1", "run_id": "run-1", "status": "interrupted"}
|
||||
)
|
||||
|
||||
assert result["status"] == "ignored"
|
||||
reply.assert_not_called()
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ async def test_get_agent_passes_corridor_prompt_state() -> None:
|
|||
return_value=(("openai:gpt-5.5", "medium"), ("openai:gpt-5.5", "low")),
|
||||
),
|
||||
patch.object(server, "fallback_model_id_for", return_value=None),
|
||||
patch.object(server, "make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch.object(
|
||||
server, "_load_observability_tools", new_callable=AsyncMock, return_value=[]
|
||||
),
|
||||
|
|
|
|||
124
tests/test_dispatch.py
Normal file
124
tests/test_dispatch.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
dispatch = importlib.import_module("agent.dispatch")
|
||||
|
||||
_ABSOLUTE = "https://open-swe-v3-abc.us.langgraph.app/webhooks/run-complete"
|
||||
|
||||
|
||||
def test_is_loopback_webhook_relative() -> None:
|
||||
assert dispatch._is_loopback_webhook("/webhooks/run-complete") is True
|
||||
|
||||
|
||||
def test_is_loopback_webhook_localhost() -> None:
|
||||
assert dispatch._is_loopback_webhook("http://localhost:2024/webhooks/run-complete") is True
|
||||
assert dispatch._is_loopback_webhook("http://127.0.0.1:8000/webhooks/run-complete") is True
|
||||
|
||||
|
||||
def test_is_loopback_webhook_absolute() -> None:
|
||||
assert dispatch._is_loopback_webhook(_ABSOLUTE) is False
|
||||
|
||||
|
||||
def test_resolve_no_secret_attaches_nothing() -> None:
|
||||
assert dispatch._resolve_completion_webhook_url(_ABSOLUTE, None) is None
|
||||
assert dispatch._resolve_completion_webhook_url(_ABSOLUTE, "") is None
|
||||
|
||||
|
||||
def test_resolve_relative_url_degrades_to_none() -> None:
|
||||
# Secret set but a loopback URL would 422 every run — attach nothing instead.
|
||||
assert dispatch._resolve_completion_webhook_url("/webhooks/run-complete", "s3cret") is None
|
||||
|
||||
|
||||
def test_resolve_localhost_url_degrades_to_none() -> None:
|
||||
assert dispatch._resolve_completion_webhook_url("http://localhost/x", "s3cret") is None
|
||||
|
||||
|
||||
def test_resolve_absolute_url_appends_token() -> None:
|
||||
assert (
|
||||
dispatch._resolve_completion_webhook_url(_ABSOLUTE, "s3cret") == f"{_ABSOLUTE}?token=s3cret"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_absolute_url_with_existing_query_left_as_is() -> None:
|
||||
url = f"{_ABSOLUTE}?token=preset"
|
||||
assert dispatch._resolve_completion_webhook_url(url, "s3cret") == url
|
||||
|
||||
|
||||
class _FakeRuns:
|
||||
def __init__(self) -> None:
|
||||
self.created: list[dict[str, Any]] = []
|
||||
|
||||
async def create(self, thread_id: str, assistant_id: str, **kwargs: Any) -> dict[str, str]:
|
||||
self.created.append({"thread_id": thread_id, "assistant_id": assistant_id, **kwargs})
|
||||
return {"run_id": "run-1"}
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self) -> None:
|
||||
self.runs = _FakeRuns()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_durable_run_applies_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _FakeClient()
|
||||
monkeypatch.setattr(dispatch, "COMPLETION_WEBHOOK_URL", "https://app/webhooks/run-complete")
|
||||
|
||||
run = await dispatch.create_durable_run(
|
||||
"thread-1",
|
||||
"agent",
|
||||
input={"messages": [{"role": "user", "content": "hi"}]},
|
||||
source="test",
|
||||
config={"configurable": {"thread_id": "thread-1"}, "metadata": {"kind": "test"}},
|
||||
client=client,
|
||||
)
|
||||
|
||||
assert run == {"run_id": "run-1"}
|
||||
created = client.runs.created[0]
|
||||
assert created["durability"] == "sync"
|
||||
assert created["multitask_strategy"] == "interrupt"
|
||||
assert created["if_not_exists"] == "create"
|
||||
assert created["webhook"] == "https://app/webhooks/run-complete"
|
||||
assert created["config"]["metadata"] == {"kind": "test"}
|
||||
assert created["config"]["configurable"]["thread_id"] == "thread-1"
|
||||
assert isinstance(created["config"]["configurable"]["prepare_run_id"], str)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_durable_run_no_webhook_when_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _FakeClient()
|
||||
monkeypatch.setattr(dispatch, "COMPLETION_WEBHOOK_URL", None)
|
||||
|
||||
await dispatch.create_durable_run(
|
||||
"thread-1",
|
||||
"agent",
|
||||
input={"messages": []},
|
||||
source="test",
|
||||
client=client,
|
||||
)
|
||||
|
||||
created = client.runs.created[0]
|
||||
assert "webhook" not in created
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_agent_run_preserves_multitask_strategy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client = _FakeClient()
|
||||
monkeypatch.setattr(dispatch, "COMPLETION_WEBHOOK_URL", None)
|
||||
|
||||
await dispatch.dispatch_agent_run(
|
||||
"thread-1",
|
||||
"hi",
|
||||
{"key": "val"},
|
||||
source="test",
|
||||
client=client,
|
||||
multitask_strategy="reject",
|
||||
)
|
||||
|
||||
created = client.runs.created[0]
|
||||
assert created["multitask_strategy"] == "reject"
|
||||
437
tests/test_gateway.py
Normal file
437
tests/test_gateway.py
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
"""Unit tests for LangSmith LLM Gateway routing (agent/utils/gateway.py + make_model)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fireworks import AsyncFireworks
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from agent.utils import gateway, model
|
||||
|
||||
_GATEWAY_ENV_VARS = (
|
||||
"LANGSMITH_API_KEY",
|
||||
"LANGSMITH_API_KEY_PROD",
|
||||
"LANGSMITH_GATEWAY_API_KEY",
|
||||
"LANGSMITH_GATEWAY_ENABLED",
|
||||
"LANGSMITH_GATEWAY_BASE_URL",
|
||||
"LANGSMITH_GATEWAY_OPENAI_USE_RESPONSES",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_gateway_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Start each test from a known env: no key, gateway off, default base URL."""
|
||||
for name in _GATEWAY_ENV_VARS:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
# --- gateway_overrides --------------------------------------------------------
|
||||
|
||||
|
||||
def test_openai_overrides_use_responses_by_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
overrides = gateway.gateway_overrides("openai:gpt-5.5")
|
||||
assert overrides == {
|
||||
"base_url": "https://gateway.smith.langchain.com/openai/v1",
|
||||
"api_key": "ls-key",
|
||||
"use_responses_api": True,
|
||||
}
|
||||
|
||||
|
||||
def test_openai_overrides_chat_completions_optout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
monkeypatch.setenv("LANGSMITH_GATEWAY_OPENAI_USE_RESPONSES", "false")
|
||||
overrides = gateway.gateway_overrides("openai:gpt-5.5")
|
||||
assert overrides is not None
|
||||
assert overrides["use_responses_api"] is False
|
||||
|
||||
|
||||
async def test_openai_sdk_uses_gateway_responses_path() -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"created_at": 0,
|
||||
"status": "completed",
|
||||
"model": "gpt-5.5",
|
||||
"output": [
|
||||
{
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "ok", "annotations": []}],
|
||||
}
|
||||
],
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
||||
},
|
||||
)
|
||||
|
||||
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
chat_model = ChatOpenAI(
|
||||
model="gpt-5.5",
|
||||
api_key="dummy",
|
||||
base_url="https://gateway.smith.langchain.com/openai/v1",
|
||||
use_responses_api=True,
|
||||
http_async_client=http_client,
|
||||
max_retries=0,
|
||||
)
|
||||
await chat_model.ainvoke([HumanMessage(content="hi")])
|
||||
finally:
|
||||
await http_client.aclose()
|
||||
|
||||
assert len(requests) == 1
|
||||
assert requests[0].url.path == "/openai/v1/responses"
|
||||
|
||||
|
||||
def test_anthropic_overrides_have_no_responses_flag(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
overrides = gateway.gateway_overrides("anthropic:claude-opus-4-8")
|
||||
assert overrides == {
|
||||
"base_url": "https://gateway.smith.langchain.com/anthropic",
|
||||
"api_key": "ls-key",
|
||||
}
|
||||
|
||||
|
||||
def test_fireworks_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
overrides = gateway.gateway_overrides("fireworks:accounts/fireworks/models/glm-5p2")
|
||||
assert overrides is not None
|
||||
assert overrides["base_url"] == "https://gateway.smith.langchain.com/fireworks"
|
||||
|
||||
|
||||
async def test_fireworks_sdk_uses_allowlisted_gateway_path() -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "accounts/fireworks/models/glm-5p2",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "ok"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
},
|
||||
)
|
||||
|
||||
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
client = AsyncFireworks(
|
||||
api_key="dummy",
|
||||
base_url="https://gateway.smith.langchain.com/fireworks",
|
||||
http_client=http_client,
|
||||
max_retries=0,
|
||||
)
|
||||
await client.chat.completions.create(
|
||||
model="accounts/fireworks/models/glm-5p2",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
finally:
|
||||
await http_client.aclose()
|
||||
|
||||
assert len(requests) == 1
|
||||
assert requests[0].url.path == "/fireworks/v1/chat/completions"
|
||||
|
||||
|
||||
async def test_fireworks_gateway_strips_legacy_function_call() -> None:
|
||||
"""The serializer must not emit ``function_call`` after sanitization.
|
||||
|
||||
Reproduces the production 400 — ``Extra inputs are not permitted, field:
|
||||
'messages[N].function_call'`` — by routing an ``AIMessage`` that carries the
|
||||
legacy ``function_call`` (alongside modern ``tool_calls``) through the
|
||||
Fireworks serializer toward the gateway. Without the sanitizer middleware
|
||||
the request body contains ``function_call``; with it, only ``tool_calls``
|
||||
survives.
|
||||
"""
|
||||
import json
|
||||
|
||||
from fireworks import AsyncFireworks
|
||||
from langchain_fireworks.chat_models import ChatFireworks
|
||||
|
||||
from agent.middleware.sanitize_fireworks_messages import _sanitize_messages
|
||||
|
||||
captured_bodies: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured_bodies.append(json.loads(request.content))
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "accounts/fireworks/models/glm-5p2",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "ok"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
},
|
||||
)
|
||||
|
||||
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
chat_model = ChatFireworks(
|
||||
model="accounts/fireworks/models/glm-5p2",
|
||||
api_key="dummy",
|
||||
base_url="https://gateway.smith.langchain.com/fireworks",
|
||||
max_retries=0,
|
||||
)
|
||||
# Inject a mock-transport client so no real network call is made.
|
||||
mock_sdk = AsyncFireworks(
|
||||
api_key="dummy",
|
||||
base_url="https://gateway.smith.langchain.com/fireworks",
|
||||
http_client=http_client,
|
||||
max_retries=0,
|
||||
)
|
||||
chat_model._async_sdk_client = mock_sdk # type: ignore[attr-defined]
|
||||
chat_model.async_client = mock_sdk.chat.completions # type: ignore[attr-defined]
|
||||
|
||||
ai_message = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"name": "read_file", "args": {"file_path": "/x"}, "id": "tc1"}],
|
||||
additional_kwargs={"function_call": {"name": "read_file", "arguments": "{}"}},
|
||||
)
|
||||
messages = [HumanMessage(content="hi"), ai_message]
|
||||
|
||||
# Apply the sanitizer the same way the middleware stack does.
|
||||
_sanitize_messages(messages)
|
||||
|
||||
await chat_model.ainvoke(messages)
|
||||
await mock_sdk.close()
|
||||
finally:
|
||||
await http_client.aclose()
|
||||
|
||||
assert len(captured_bodies) == 1
|
||||
body = captured_bodies[0]
|
||||
for msg in body["messages"]:
|
||||
assert "function_call" not in msg, msg
|
||||
# The assistant message still carries tool_calls.
|
||||
assistant_msgs = [m for m in body["messages"] if m["role"] == "assistant"]
|
||||
assert assistant_msgs and "tool_calls" in assistant_msgs[0]
|
||||
|
||||
|
||||
def test_google_genai_routes_to_gemini(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
overrides = gateway.gateway_overrides("google_genai:gemini-3.5-flash")
|
||||
assert overrides == {
|
||||
"base_url": "https://gateway.smith.langchain.com/gemini",
|
||||
"api_key": "ls-key",
|
||||
}
|
||||
|
||||
|
||||
def test_unsupported_provider_passes_through(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
# Vertex authenticates with a service account, not a bearer key, so it isn't routed.
|
||||
assert gateway.gateway_overrides("google_vertexai:gemini-2.5-pro") is None
|
||||
|
||||
|
||||
def test_missing_api_key_passes_through(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert gateway.gateway_overrides("openai:gpt-5.5") is None
|
||||
|
||||
|
||||
def test_prod_key_used_as_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY_PROD", "ls-prod-key")
|
||||
overrides = gateway.gateway_overrides("anthropic:claude-opus-4-8")
|
||||
assert overrides is not None
|
||||
assert overrides["api_key"] == "ls-prod-key"
|
||||
|
||||
|
||||
def test_prod_key_preferred_over_platform_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-platform-key")
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY_PROD", "ls-prod-key")
|
||||
overrides = gateway.gateway_overrides("anthropic:claude-opus-4-8")
|
||||
assert overrides is not None
|
||||
assert overrides["api_key"] == "ls-prod-key"
|
||||
|
||||
|
||||
def test_gateway_key_preferred_over_prod_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-platform-key")
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY_PROD", "ls-prod-key")
|
||||
monkeypatch.setenv("LANGSMITH_GATEWAY_API_KEY", "ls-gateway-key")
|
||||
overrides = gateway.gateway_overrides("anthropic:claude-opus-4-8")
|
||||
assert overrides is not None
|
||||
assert overrides["api_key"] == "ls-gateway-key"
|
||||
|
||||
|
||||
def test_base_url_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
monkeypatch.setenv("LANGSMITH_GATEWAY_BASE_URL", "https://gw.internal.example.com/")
|
||||
overrides = gateway.gateway_overrides("anthropic:claude-opus-4-8")
|
||||
assert overrides is not None
|
||||
# Trailing slash is stripped, then the provider path is appended.
|
||||
assert overrides["base_url"] == "https://gw.internal.example.com/anthropic"
|
||||
|
||||
|
||||
# --- resolve_gateway_enabled --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("team_value", "env_enabled", "expected"),
|
||||
[
|
||||
(True, False, True), # team True wins over env off
|
||||
(False, True, False), # team False wins over env on
|
||||
(None, True, True), # unset inherits env on
|
||||
(None, False, False), # unset inherits env off
|
||||
],
|
||||
)
|
||||
def test_resolve_gateway_enabled_precedence(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
team_value: bool | None,
|
||||
env_enabled: bool,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
if env_enabled:
|
||||
monkeypatch.setenv("LANGSMITH_GATEWAY_ENABLED", "true")
|
||||
assert gateway.resolve_gateway_enabled(team_value) is expected
|
||||
|
||||
|
||||
# --- make_model integration ---------------------------------------------------
|
||||
|
||||
|
||||
def _capture_init_chat_model() -> tuple[dict[str, Any], Any]:
|
||||
"""Patch init_chat_model to record the kwargs make_model builds."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _fake(model: str, **kwargs: Any) -> str:
|
||||
captured["model"] = model
|
||||
captured.update(kwargs)
|
||||
return "MODEL"
|
||||
|
||||
return captured, _fake
|
||||
|
||||
|
||||
def test_make_model_direct_openai_uses_responses_websocket() -> None:
|
||||
captured, fake = _capture_init_chat_model()
|
||||
with patch.object(model, "init_chat_model", fake):
|
||||
model.make_model("openai:gpt-5.5", use_gateway=False)
|
||||
assert captured["base_url"] == model.OPENAI_RESPONSES_WS_BASE_URL
|
||||
assert captured["use_responses_api"] is True
|
||||
assert captured["store"] is False
|
||||
assert captured["include"] == ["reasoning.encrypted_content"]
|
||||
|
||||
|
||||
def test_make_model_gateway_openai_replaces_websocket(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
captured, fake = _capture_init_chat_model()
|
||||
with patch.object(model, "init_chat_model", fake):
|
||||
model.make_model("openai:gpt-5.5", use_gateway=True)
|
||||
assert captured["base_url"] == "https://gateway.smith.langchain.com/openai/v1"
|
||||
assert captured["use_responses_api"] is True
|
||||
assert captured["store"] is False
|
||||
assert captured["include"] == ["reasoning.encrypted_content"]
|
||||
assert captured["api_key"] == "ls-key"
|
||||
|
||||
|
||||
def test_make_model_gateway_openai_chat_completions_optout_converts_reasoning(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
monkeypatch.setenv("LANGSMITH_GATEWAY_OPENAI_USE_RESPONSES", "false")
|
||||
captured, fake = _capture_init_chat_model()
|
||||
with patch.object(model, "init_chat_model", fake):
|
||||
model.make_model(
|
||||
"openai:gpt-5.5",
|
||||
use_gateway=True,
|
||||
reasoning={"effort": "high", "summary": "auto"},
|
||||
)
|
||||
assert captured["use_responses_api"] is False
|
||||
assert captured["reasoning_effort"] == "high"
|
||||
assert "reasoning" not in captured
|
||||
assert "include" not in captured
|
||||
assert "store" not in captured
|
||||
|
||||
|
||||
def test_make_model_gateway_openai_preserves_reasoning_none(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
captured, fake = _capture_init_chat_model()
|
||||
with patch.object(model, "init_chat_model", fake):
|
||||
model.make_model(
|
||||
"openai:gpt-5.5",
|
||||
use_gateway=True,
|
||||
reasoning={"effort": "none"},
|
||||
)
|
||||
assert captured["use_responses_api"] is True
|
||||
assert captured["store"] is False
|
||||
assert captured["include"] == ["reasoning.encrypted_content"]
|
||||
assert captured["reasoning"] == {"effort": "none"}
|
||||
assert "reasoning_effort" not in captured
|
||||
|
||||
|
||||
def test_make_model_gateway_openai_responses_keeps_reasoning(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
reasoning = {"effort": "high", "summary": "auto"}
|
||||
captured, fake = _capture_init_chat_model()
|
||||
with patch.object(model, "init_chat_model", fake):
|
||||
model.make_model("openai:gpt-5.5", use_gateway=True, reasoning=reasoning)
|
||||
assert captured["use_responses_api"] is True
|
||||
assert captured["store"] is False
|
||||
assert captured["include"] == ["reasoning.encrypted_content"]
|
||||
assert captured["reasoning"] == reasoning
|
||||
assert "reasoning_effort" not in captured
|
||||
|
||||
|
||||
def test_make_model_gateway_follows_env_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
monkeypatch.setenv("LANGSMITH_GATEWAY_ENABLED", "true")
|
||||
captured, fake = _capture_init_chat_model()
|
||||
with patch.object(model, "init_chat_model", fake):
|
||||
model.make_model("anthropic:claude-opus-4-8") # use_gateway=None -> env default
|
||||
assert captured["base_url"] == "https://gateway.smith.langchain.com/anthropic"
|
||||
assert captured["api_key"] == "ls-key"
|
||||
|
||||
|
||||
def test_make_model_gateway_google_genai(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
captured, fake = _capture_init_chat_model()
|
||||
with patch.object(model, "init_chat_model", fake):
|
||||
model.make_model("google_genai:gemini-3.5-flash", use_gateway=True)
|
||||
assert captured["base_url"] == "https://gateway.smith.langchain.com/gemini"
|
||||
assert captured["api_key"] == "ls-key"
|
||||
|
||||
|
||||
def test_make_model_gateway_without_key_falls_back_direct(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured, fake = _capture_init_chat_model()
|
||||
with patch.object(model, "init_chat_model", fake):
|
||||
model.make_model("openai:gpt-5.5", use_gateway=True) # no LangSmith key
|
||||
# No key -> overrides skipped -> the direct-provider websocket base stands.
|
||||
assert captured["base_url"] == model.OPENAI_RESPONSES_WS_BASE_URL
|
||||
assert captured["use_responses_api"] is True
|
||||
assert captured["store"] is False
|
||||
assert captured["include"] == ["reasoning.encrypted_content"]
|
||||
assert "api_key" not in captured
|
||||
|
|
@ -73,6 +73,12 @@ class TestShouldFallback:
|
|||
exc = anthropic.BadRequestError("bad", response=response, body={})
|
||||
assert _should_fallback(exc) is False
|
||||
|
||||
def test_httpx_remote_protocol_error_falls_back(self) -> None:
|
||||
exc = httpx.RemoteProtocolError(
|
||||
"peer closed connection without sending complete message body (incomplete chunked read)"
|
||||
)
|
||||
assert _should_fallback(exc) is True
|
||||
|
||||
def test_value_error_does_not_fall_back(self) -> None:
|
||||
assert _should_fallback(ValueError("nope")) is False
|
||||
|
||||
|
|
@ -100,6 +106,31 @@ class TestModelFallbackMiddleware:
|
|||
request.override.assert_called_once_with(model=fallback_model)
|
||||
assert calls[1] is request.override.return_value
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_falls_over_on_stream_transport_error(self) -> None:
|
||||
fallback_model = MagicMock(name="fallback_model")
|
||||
middleware = ModelFallbackMiddleware(fallback_model)
|
||||
|
||||
calls: list[object] = []
|
||||
good_response = MagicMock(result=[AIMessage(content="ok from fallback")])
|
||||
|
||||
async def handler(req: object) -> object:
|
||||
calls.append(req)
|
||||
if len(calls) == 1:
|
||||
raise httpx.RemoteProtocolError(
|
||||
"peer closed connection without sending complete message body "
|
||||
"(incomplete chunked read)"
|
||||
)
|
||||
return good_response
|
||||
|
||||
request = _make_request()
|
||||
result = await middleware.awrap_model_call(request, handler)
|
||||
|
||||
assert result is good_response
|
||||
assert len(calls) == 2
|
||||
request.override.assert_called_once_with(model=fallback_model)
|
||||
assert calls[1] is request.override.return_value
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_propagates_non_transient_error(self) -> None:
|
||||
middleware = ModelFallbackMiddleware(MagicMock())
|
||||
|
|
@ -128,9 +159,50 @@ class TestModelFallbackMiddleware:
|
|||
assert "data retention enabled" in result.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_does_not_double_fall_back(self) -> None:
|
||||
"""If the fallback also fails transiently, the error propagates."""
|
||||
middleware = ModelFallbackMiddleware(MagicMock())
|
||||
async def test_async_retries_primary_after_fallback_failure(self) -> None:
|
||||
"""If the fallback also fails transiently, retry the primary instead of crashing."""
|
||||
fallback_model = MagicMock(name="fallback_model")
|
||||
middleware = ModelFallbackMiddleware(fallback_model, backoff_schedule=(0.0, 0.0, 0.0))
|
||||
calls: list[object] = []
|
||||
good_response = MagicMock(result=[AIMessage(content="ok from primary retry")])
|
||||
|
||||
async def handler(req: object) -> object:
|
||||
calls.append(req)
|
||||
if len(calls) <= 2: # primary fails, then fallback fails
|
||||
raise _openai_5xx()
|
||||
return good_response
|
||||
|
||||
request = _make_request()
|
||||
result = await middleware.awrap_model_call(request, handler)
|
||||
|
||||
assert result is good_response
|
||||
assert len(calls) == 3
|
||||
# Attempts alternate primary -> fallback -> primary.
|
||||
assert calls[0] is request
|
||||
assert calls[1] is request.override.return_value
|
||||
assert calls[2] is request
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_exhaustion_returns_outage_message(self) -> None:
|
||||
"""After exhausting all attempts, the run ends with a visible message, not a crash."""
|
||||
middleware = ModelFallbackMiddleware(MagicMock(), backoff_schedule=(0.0, 0.0))
|
||||
calls: list[object] = []
|
||||
|
||||
async def handler(req: object) -> object:
|
||||
calls.append(req)
|
||||
raise _openai_5xx()
|
||||
|
||||
result = await middleware.awrap_model_call(_make_request(), handler)
|
||||
|
||||
assert len(calls) == 3
|
||||
assert isinstance(result, AIMessage)
|
||||
assert "retrigger" in result.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_exhaustion_raises_when_message_disabled(self) -> None:
|
||||
middleware = ModelFallbackMiddleware(
|
||||
MagicMock(), backoff_schedule=(0.0,), surface_outage_message=False
|
||||
)
|
||||
calls: list[object] = []
|
||||
|
||||
async def handler(req: object) -> object:
|
||||
|
|
|
|||
|
|
@ -152,10 +152,19 @@ async def test_save_plan_reads_markdown_file_from_sandbox(
|
|||
return _Backend()
|
||||
|
||||
async def fake_save_content(
|
||||
thread_id: str, *, markdown: str, status: str, plan_file_path: str | None = None
|
||||
thread_id: str,
|
||||
*,
|
||||
markdown: str,
|
||||
status: str,
|
||||
plan_file_path: str | None = None,
|
||||
plan_mode: bool | None = True,
|
||||
) -> None:
|
||||
saved.update(
|
||||
thread_id=thread_id, markdown=markdown, status=status, plan_file_path=plan_file_path
|
||||
thread_id=thread_id,
|
||||
markdown=markdown,
|
||||
status=status,
|
||||
plan_file_path=plan_file_path,
|
||||
plan_mode=plan_mode,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -175,8 +184,9 @@ async def test_save_plan_reads_markdown_file_from_sandbox(
|
|||
assert saved == {
|
||||
"thread_id": "thread-1",
|
||||
"markdown": "# Plan\n\nDo it.",
|
||||
"status": "ready",
|
||||
"status": "shared",
|
||||
"plan_file_path": "/workspace/plans/2026-07-08-test-plan.md",
|
||||
"plan_mode": None,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -333,7 +333,7 @@ class TestRefreshProxyOnSandboxReuse:
|
|||
new_callable=AsyncMock,
|
||||
return_value=mock_sandbox,
|
||||
),
|
||||
patch("agent.server.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.server.construct_system_prompt", return_value="prompt"),
|
||||
patch("agent.server.create_deep_agent", return_value=_DummyAgent()),
|
||||
patch.dict(
|
||||
|
|
@ -386,7 +386,7 @@ class TestRefreshProxyOnSandboxReuse:
|
|||
new_callable=AsyncMock,
|
||||
return_value="/workspace",
|
||||
),
|
||||
patch("agent.server.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.server.construct_system_prompt", return_value="prompt"),
|
||||
patch("agent.server.create_deep_agent", return_value=_DummyAgent()),
|
||||
patch.dict("agent.server.SANDBOX_BACKENDS", {}, clear=True),
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ async def test_reviewer_resolves_app_installation_token_at_run_start() -> None:
|
|||
new_callable=AsyncMock,
|
||||
return_value="/workspace",
|
||||
),
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.reviewer.create_deep_agent", return_value=dummy_agent) as create_agent,
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -260,7 +260,7 @@ async def test_reviewer_reuses_app_token_for_sandbox_proxy() -> None:
|
|||
new_callable=AsyncMock,
|
||||
),
|
||||
patch("agent.reviewer.fetch_agents_md", new_callable=AsyncMock, return_value=None),
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.reviewer.create_deep_agent", return_value=_DummyAgent()),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -335,7 +335,7 @@ async def test_reviewer_applies_eval_model_and_effort_overrides() -> None:
|
|||
new_callable=AsyncMock,
|
||||
return_value="/workspace",
|
||||
),
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()) as make_model,
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()) as make_model,
|
||||
patch("agent.reviewer.create_deep_agent", return_value=dummy_agent),
|
||||
patch(
|
||||
"agent.reviewer.fetch_agents_md",
|
||||
|
|
@ -383,7 +383,7 @@ async def test_reviewer_subagent_inherits_eval_model_without_explicit_override()
|
|||
new_callable=AsyncMock,
|
||||
return_value="/workspace",
|
||||
),
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()) as make_model,
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()) as make_model,
|
||||
patch("agent.reviewer.create_deep_agent", return_value=dummy_agent),
|
||||
patch(
|
||||
"agent.reviewer.fetch_agents_md",
|
||||
|
|
@ -441,7 +441,7 @@ async def test_reviewer_injects_repo_style_during_eval() -> None:
|
|||
new_callable=AsyncMock,
|
||||
return_value="Flag table rerender regressions.",
|
||||
),
|
||||
patch("agent.reviewer.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.fetch_agents_md",
|
||||
|
|
@ -507,7 +507,7 @@ async def test_reviewer_inlines_org_guidelines_into_system_prompt() -> None:
|
|||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch("agent.reviewer.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),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -572,7 +572,7 @@ async def test_reviewer_inlines_agents_md_into_system_prompt() -> None:
|
|||
new_callable=AsyncMock,
|
||||
return_value="Always use the design system IconButton.",
|
||||
) as mock_fetch_agents_md,
|
||||
patch("agent.reviewer.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),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -624,7 +624,7 @@ async def test_reviewer_inlines_claude_md_when_agents_md_absent() -> None:
|
|||
new_callable=AsyncMock,
|
||||
return_value="# CLAUDE.md\nUse semantic tokens only.",
|
||||
) as mock_fetch_agents_md,
|
||||
patch("agent.reviewer.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),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -989,7 +989,7 @@ async def test_reviewer_injects_pr_review_threads_into_first_review_context() ->
|
|||
new_callable=AsyncMock,
|
||||
return_value=fake_threads,
|
||||
) as mock_fetch_threads,
|
||||
patch("agent.reviewer.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),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -1065,7 +1065,7 @@ async def test_reviewer_injects_pr_review_threads_into_re_review_context() -> No
|
|||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
),
|
||||
patch("agent.reviewer.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),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -1123,7 +1123,7 @@ async def test_reviewer_omits_threads_block_when_fetch_returns_empty() -> None:
|
|||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
),
|
||||
patch("agent.reviewer.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),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -1182,7 +1182,7 @@ async def test_reviewer_continues_when_thread_fetch_raises() -> None:
|
|||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("network down"),
|
||||
),
|
||||
patch("agent.reviewer.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),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -1254,7 +1254,7 @@ async def test_reviewer_populates_diff_line_set_from_github_api() -> None:
|
|||
new_callable=AsyncMock,
|
||||
return_value=pr_diff,
|
||||
) as mock_fetch_diff,
|
||||
patch("agent.reviewer.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),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -1319,7 +1319,7 @@ async def test_reviewer_leaves_validation_disabled_when_diff_fetch_fails() -> No
|
|||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch("agent.reviewer.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),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -1385,7 +1385,7 @@ async def test_reviewer_injects_pr_title_and_body_into_context() -> None:
|
|||
new_callable=AsyncMock,
|
||||
return_value=("Add retry logic for uploads", "Retries flaky uploads up to 3 times."),
|
||||
) as mock_fetch_metadata,
|
||||
patch("agent.reviewer.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),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
|
|||
118
tests/test_sanitize_fireworks_messages.py
Normal file
118
tests/test_sanitize_fireworks_messages.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
|
||||
from agent.middleware.sanitize_fireworks_messages import SanitizeFireworksMessagesMiddleware
|
||||
|
||||
|
||||
def _make_request(messages: list[object], model: object | None = None) -> MagicMock:
|
||||
request = MagicMock()
|
||||
request.model = model
|
||||
request.messages = messages
|
||||
return request
|
||||
|
||||
|
||||
def _fireworks_model() -> MagicMock:
|
||||
"""A mock that satisfies ``_is_chat_fireworks`` via spec'd ``ChatFireworks``."""
|
||||
try:
|
||||
from langchain_fireworks.chat_models import ChatFireworks
|
||||
except ImportError: # pragma: no cover
|
||||
pytest.skip("langchain-fireworks not installed")
|
||||
return MagicMock(spec=ChatFireworks)
|
||||
|
||||
|
||||
class TestSanitizeFireworksMessagesMiddleware:
|
||||
def test_drops_legacy_function_call(self) -> None:
|
||||
message = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"name": "read_file", "args": {"file_path": "/x"}, "id": "tc1"}],
|
||||
additional_kwargs={"function_call": {"name": "read_file", "arguments": "{}"}},
|
||||
)
|
||||
request = _make_request([message], model=_fireworks_model())
|
||||
response = MagicMock()
|
||||
|
||||
def handler(req: object) -> object:
|
||||
assert req is request
|
||||
return response
|
||||
|
||||
result = SanitizeFireworksMessagesMiddleware().wrap_model_call(request, handler)
|
||||
|
||||
assert result is response
|
||||
assert "function_call" not in message.additional_kwargs
|
||||
# tool_calls are untouched
|
||||
assert len(message.tool_calls) == 1
|
||||
|
||||
def test_preserves_message_without_function_call(self) -> None:
|
||||
message = AIMessage(
|
||||
content="ok",
|
||||
tool_calls=[{"name": "read_file", "args": {"file_path": "/x"}, "id": "tc1"}],
|
||||
)
|
||||
request = _make_request([message], model=_fireworks_model())
|
||||
|
||||
SanitizeFireworksMessagesMiddleware().wrap_model_call(request, lambda req: MagicMock())
|
||||
|
||||
assert "function_call" not in message.additional_kwargs
|
||||
assert len(message.tool_calls) == 1
|
||||
|
||||
def test_drops_function_call_with_no_tool_calls(self) -> None:
|
||||
message = AIMessage(
|
||||
content="",
|
||||
additional_kwargs={"function_call": {"name": "search", "arguments": "{}"}},
|
||||
)
|
||||
request = _make_request([message], model=_fireworks_model())
|
||||
|
||||
SanitizeFireworksMessagesMiddleware().wrap_model_call(request, lambda req: MagicMock())
|
||||
|
||||
assert "function_call" not in message.additional_kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_drops_legacy_function_call(self) -> None:
|
||||
tool_result = ToolMessage(content="result", tool_call_id="tc1")
|
||||
message = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"name": "read_file", "args": {"file_path": "/x"}, "id": "tc1"}],
|
||||
additional_kwargs={"function_call": {"name": "read_file", "arguments": "{}"}},
|
||||
)
|
||||
request = _make_request(
|
||||
[HumanMessage(content="hi"), message, tool_result],
|
||||
model=_fireworks_model(),
|
||||
)
|
||||
response = MagicMock()
|
||||
|
||||
async def handler(req: object) -> object:
|
||||
assert req is request
|
||||
return response
|
||||
|
||||
result = await SanitizeFireworksMessagesMiddleware().awrap_model_call(request, handler)
|
||||
|
||||
assert result is response
|
||||
assert "function_call" not in message.additional_kwargs
|
||||
|
||||
def test_ignores_non_fireworks_models(self) -> None:
|
||||
message = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"name": "read_file", "args": {"file_path": "/x"}, "id": "tc1"}],
|
||||
additional_kwargs={"function_call": {"name": "read_file", "arguments": "{}"}},
|
||||
)
|
||||
# Non-Fireworks model (plain MagicMock, no ChatFireworks in its spec chain)
|
||||
request = _make_request([message], model=MagicMock())
|
||||
|
||||
SanitizeFireworksMessagesMiddleware().wrap_model_call(request, lambda req: MagicMock())
|
||||
|
||||
# function_call preserved for non-Fireworks providers
|
||||
assert "function_call" in message.additional_kwargs
|
||||
|
||||
def test_skips_non_ai_messages(self) -> None:
|
||||
messages = [
|
||||
HumanMessage(content="hi"),
|
||||
ToolMessage(content="result", tool_call_id="tc1"),
|
||||
]
|
||||
request = _make_request(messages, model=_fireworks_model())
|
||||
|
||||
SanitizeFireworksMessagesMiddleware().wrap_model_call(request, lambda req: MagicMock())
|
||||
|
||||
# No AIMessages to mutate — handler still called
|
||||
assert all(not isinstance(m, AIMessage) for m in messages)
|
||||
55
tests/test_sanitize_openai_responses.py
Normal file
55
tests/test_sanitize_openai_responses.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from agent.middleware.sanitize_openai_responses import _sanitize_messages
|
||||
|
||||
|
||||
def test_sanitize_messages_drops_stale_reasoning_references() -> None:
|
||||
messages = [
|
||||
HumanMessage(content="review this"),
|
||||
AIMessage(
|
||||
content=[
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "rs_123",
|
||||
"summary": [],
|
||||
"content": [],
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"id": "fc_123",
|
||||
"call_id": "call_123",
|
||||
"name": "read_file",
|
||||
"arguments": "{}",
|
||||
},
|
||||
],
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "read_file",
|
||||
"args": {},
|
||||
"id": "call_123",
|
||||
}
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
_sanitize_messages(messages)
|
||||
|
||||
content = messages[1].content
|
||||
assert isinstance(content, list)
|
||||
assert [block["type"] for block in content] == ["function_call"]
|
||||
assert messages[1].tool_calls[0]["id"] == "call_123"
|
||||
|
||||
|
||||
def test_sanitize_messages_preserves_encrypted_reasoning() -> None:
|
||||
reasoning = {
|
||||
"type": "reasoning",
|
||||
"id": "rs_123",
|
||||
"encrypted_content": "encrypted",
|
||||
"summary": [],
|
||||
"content": [],
|
||||
}
|
||||
messages = [AIMessage(content=[reasoning])]
|
||||
|
||||
_sanitize_messages(messages)
|
||||
|
||||
assert messages[0].content == [reasoning]
|
||||
44
ui/bun.lock
44
ui/bun.lock
|
|
@ -51,7 +51,7 @@
|
|||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.2",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.0",
|
||||
|
|
@ -866,6 +866,46 @@
|
|||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ=="],
|
||||
|
||||
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
|
||||
|
||||
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
|
||||
|
||||
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
|
||||
|
||||
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
|
||||
|
||||
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
|
||||
|
||||
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
|
||||
|
||||
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
|
||||
|
||||
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
|
||||
|
||||
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
|
||||
|
||||
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
|
||||
|
||||
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
|
||||
|
||||
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
|
||||
|
||||
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
|
||||
|
||||
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
|
||||
|
||||
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
|
||||
|
||||
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
|
||||
|
||||
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
|
||||
|
||||
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
|
||||
|
||||
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
|
||||
|
||||
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="],
|
||||
|
||||
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.12.2", "", { "os": "android", "cpu": "arm" }, "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w=="],
|
||||
|
|
@ -2218,7 +2258,7 @@
|
|||
|
||||
"typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.61.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.61.0", "@typescript-eslint/parser": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/utils": "8.61.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -23,11 +23,6 @@ import { useSubmitAgentMessage } from "@/lib/agents/provider/useSubmitAgentMessa
|
|||
import { useModelOptions } from "@/lib/agents/provider/useModelOptions"
|
||||
import { useIsMobile } from "@/lib/useIsMobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
useApproveWorkflowPush,
|
||||
useRejectWorkflowPush,
|
||||
useWorkflowApprovals,
|
||||
} from "@/lib/agents/queries"
|
||||
import { WorkflowApprovalCard } from "@/components/agents/WorkflowApprovalCard"
|
||||
|
||||
interface AgentThreadViewProps {
|
||||
|
|
@ -132,31 +127,6 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
|||
// Show a loading state during that one-time fetch instead of the empty state.
|
||||
const isHydrating = stream.isThreadLoading && !hasMessages
|
||||
|
||||
const approvalsQuery = useWorkflowApprovals(thread.id)
|
||||
const approveMutation = useApproveWorkflowPush(thread.id)
|
||||
const rejectMutation = useRejectWorkflowPush(thread.id)
|
||||
|
||||
const approvals = approvalsQuery.data
|
||||
const isApprovalOwner = thread.isOwner ?? false
|
||||
|
||||
const hasWorkflowApprovalError = useMemo(
|
||||
() =>
|
||||
baseMessages.some((message) =>
|
||||
message.chunks.some(
|
||||
(chunk) =>
|
||||
chunk.kind === "tool-execution" &&
|
||||
chunk.output?.includes("WorkflowPushApprovalRequired")
|
||||
)
|
||||
),
|
||||
[baseMessages]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasWorkflowApprovalError) {
|
||||
void approvalsQuery.refetch()
|
||||
}
|
||||
}, [hasWorkflowApprovalError])
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1">
|
||||
<div
|
||||
|
|
@ -187,35 +157,25 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
|||
<MapIcon className="size-3.5 text-[var(--ui-accent)]" />
|
||||
{thread.planStatus === "ready"
|
||||
? "A plan is ready for your review."
|
||||
: thread.planStatus === "revising"
|
||||
? "The agent is revising the plan."
|
||||
: "The agent is writing a plan."}
|
||||
: thread.planStatus === "shared"
|
||||
? "The agent shared a longer response."
|
||||
: thread.planStatus === "revising"
|
||||
? "The agent is revising the plan."
|
||||
: "The agent is writing a plan."}
|
||||
</span>
|
||||
<span className="font-medium text-[var(--ui-accent)]">
|
||||
Review plan →
|
||||
{thread.planStatus === "shared"
|
||||
? "Open response →"
|
||||
: "Review plan →"}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
<WorkflowApprovalCard
|
||||
threadId={thread.id}
|
||||
pollWhileActive={isStreaming}
|
||||
/>
|
||||
{hasConversation ? (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{approvals && approvals.length > 0 && (
|
||||
<div className="shrink-0 px-4 pt-4">
|
||||
{approvals.map((approval) => (
|
||||
<WorkflowApprovalCard
|
||||
key={approval.fingerprint}
|
||||
approval={approval}
|
||||
isOwner={isApprovalOwner}
|
||||
isPending={
|
||||
approveMutation.isPending || rejectMutation.isPending
|
||||
}
|
||||
onApprove={() =>
|
||||
approveMutation.mutate(approval.fingerprint)
|
||||
}
|
||||
onReject={() => rejectMutation.mutate(approval.fingerprint)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Messages
|
||||
messages={baseMessages}
|
||||
queuedMessages={queuedMessages}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from "react"
|
||||
import { type KeyboardEvent, useCallback, useEffect, useState } from "react"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
|
||||
import type { PlanComment, PlanData } from "@/lib/plan"
|
||||
|
|
@ -59,7 +59,7 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
const [error, setError] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
// Locally track the displayed markdown so a manual edit shows immediately; the
|
||||
// route's query stops polling once a plan exists, so the prop won't refetch.
|
||||
// route's query stops polling once content exists, so the prop won't refetch.
|
||||
const [markdown, setMarkdown] = useState(plan.markdown)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editDraft, setEditDraft] = useState(plan.markdown)
|
||||
|
|
@ -70,8 +70,12 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
if (!editing) setMarkdown(plan.markdown)
|
||||
}, [plan.markdown, editing])
|
||||
|
||||
const isShared = plan.status === "shared"
|
||||
const canEdit =
|
||||
plan.isOwner && plan.status !== "approved" && plan.status !== "cancelled"
|
||||
plan.isOwner &&
|
||||
!isShared &&
|
||||
plan.status !== "approved" &&
|
||||
plan.status !== "cancelled"
|
||||
|
||||
const startEditing = useCallback(() => {
|
||||
setEditDraft(markdown)
|
||||
|
|
@ -114,13 +118,14 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
/* transient; next tick retries */
|
||||
}
|
||||
}
|
||||
if (isShared) return
|
||||
void load()
|
||||
const timer = setInterval(load, POLL_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [plan.threadId])
|
||||
}, [isShared, plan.threadId])
|
||||
|
||||
const submitComment = useCallback(async () => {
|
||||
const body = draft.trim()
|
||||
|
|
@ -138,6 +143,16 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
}
|
||||
}, [draft, plan.threadId])
|
||||
|
||||
const handleCommentKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key !== "Enter" || (!event.metaKey && !event.ctrlKey)) return
|
||||
event.preventDefault()
|
||||
if (posting || !draft.trim()) return
|
||||
void submitComment()
|
||||
},
|
||||
[draft, posting, submitComment]
|
||||
)
|
||||
|
||||
const removeComment = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
|
|
@ -192,10 +207,10 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
<div className="flex flex-col gap-3 border-b border-[var(--ui-border)] px-4 py-3 md:flex-row md:items-center md:justify-between md:gap-4 md:px-6">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-base font-semibold text-[var(--ui-text)]">
|
||||
Implementation plan
|
||||
{isShared ? "Shared response" : "Implementation plan"}
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--ui-text-dim)]">
|
||||
Reviewing as {plan.user.name}
|
||||
{isShared ? "Viewing" : "Reviewing"} as {plan.user.name}
|
||||
{plan.isOwner ? " (owner)" : ""} · status:{" "}
|
||||
<span data-testid="plan-status">{plan.status}</span>
|
||||
</p>
|
||||
|
|
@ -227,7 +242,7 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{plan.isOwner && (
|
||||
{!isShared && plan.isOwner && (
|
||||
<Button
|
||||
data-testid="approve-plan"
|
||||
disabled={busy !== null || decision !== null}
|
||||
|
|
@ -236,6 +251,7 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
Approve
|
||||
</Button>
|
||||
)}
|
||||
{!isShared && (
|
||||
<Button
|
||||
data-testid="reject-plan"
|
||||
variant="secondary"
|
||||
|
|
@ -251,6 +267,7 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
>
|
||||
Request changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -296,7 +313,7 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<aside className="flex shrink-0 flex-col border-t border-[var(--ui-border)] md:w-80 md:border-t-0 md:border-l">
|
||||
{!isShared && (<aside className="flex shrink-0 flex-col border-t border-[var(--ui-border)] md:w-80 md:border-t-0 md:border-l">
|
||||
<div className="border-b border-[var(--ui-border)] px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-[var(--ui-text)]">
|
||||
Comments
|
||||
|
|
@ -347,6 +364,7 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
data-testid="comment-input"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleCommentKeyDown}
|
||||
placeholder="Leave a comment on the plan"
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-md border border-[var(--ui-border)] bg-[var(--ui-bg)] px-2 py-1.5 text-sm text-[var(--ui-text)] outline-none focus:border-[var(--ui-accent)]"
|
||||
|
|
@ -362,7 +380,7 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</aside>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,138 +1,177 @@
|
|||
import { Check, FileCode2, ShieldAlert, X } from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { ShieldCheck } from "lucide-react"
|
||||
|
||||
import type { WorkflowApproval } from "@/lib/agents/api"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { WorkflowPushApproval } from "@/lib/agents/api"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
useWorkflowApprovalDecision,
|
||||
useWorkflowApprovals,
|
||||
} from "@/lib/agents/queries"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type ApprovalStatus = "pending" | "approved" | "rejected" | string
|
||||
|
||||
interface WorkflowApprovalCardProps {
|
||||
approval: WorkflowApproval
|
||||
isOwner: boolean
|
||||
isPending: boolean
|
||||
onApprove: (fingerprint: string) => void
|
||||
onReject: (fingerprint: string) => void
|
||||
function shortSha(value: string): string {
|
||||
return value ? value.slice(0, 7) : "unknown"
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: ApprovalStatus) {
|
||||
switch (status) {
|
||||
case "approved":
|
||||
return "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
case "rejected":
|
||||
return "bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
default:
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400"
|
||||
}
|
||||
function pendingApprovals(
|
||||
approvals: Array<WorkflowPushApproval> | undefined
|
||||
): Array<WorkflowPushApproval> {
|
||||
return (approvals ?? []).filter((approval) => approval.status === "pending")
|
||||
}
|
||||
|
||||
function statusLabel(status: ApprovalStatus) {
|
||||
switch (status) {
|
||||
case "approved":
|
||||
return "Approved"
|
||||
case "rejected":
|
||||
return "Rejected"
|
||||
default:
|
||||
return "Pending approval"
|
||||
}
|
||||
function fileLabel(count: number): string {
|
||||
return count === 1 ? "1 file" : `${count} files`
|
||||
}
|
||||
|
||||
export function WorkflowApprovalCard({
|
||||
approval,
|
||||
isOwner,
|
||||
isPending,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: WorkflowApprovalCardProps) {
|
||||
const actionable = isOwner && approval.status === "pending" && !isPending
|
||||
const decided = approval.status !== "pending"
|
||||
threadId,
|
||||
pollWhileActive = false,
|
||||
}: {
|
||||
threadId: string
|
||||
pollWhileActive?: boolean
|
||||
}) {
|
||||
const query = useWorkflowApprovals(threadId, { pollWhileActive })
|
||||
const decision = useWorkflowApprovalDecision(threadId)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const approvals = useMemo(
|
||||
() => pendingApprovals(query.data?.approvals),
|
||||
[query.data?.approvals]
|
||||
)
|
||||
|
||||
if (approvals.length === 0) return null
|
||||
|
||||
const isOwner = query.data?.isOwner === true
|
||||
const decide = async (
|
||||
approval: WorkflowPushApproval,
|
||||
kind: "approve" | "reject"
|
||||
) => {
|
||||
setError(null)
|
||||
try {
|
||||
await decision.mutateAsync({
|
||||
fingerprint: approval.fingerprint,
|
||||
decision: kind,
|
||||
})
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mx-auto mb-4 w-full max-w-3xl border-amber-500/20 bg-amber-500/5 dark:bg-amber-500/10">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="size-4 text-amber-600 dark:text-amber-400" />
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Workflow push approval required
|
||||
</CardTitle>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase ${statusBadgeClass(
|
||||
approval.status
|
||||
)}`}
|
||||
>
|
||||
{statusLabel(approval.status)}
|
||||
</span>
|
||||
</div>
|
||||
<CardDescription className="text-xs">
|
||||
Open SWE is trying to push GitHub workflow file changes in{" "}
|
||||
<strong>{approval.repo}</strong> on <strong>{approval.branch}</strong>
|
||||
. Approve only if this exact workflow diff is expected.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-[11px] font-medium text-[var(--ui-text-muted)]">
|
||||
<FileCode2 className="size-3.5" />
|
||||
Changed workflow files
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{approval.files.map((file) => (
|
||||
<li
|
||||
key={file}
|
||||
className="font-mono text-[11px] break-all text-[var(--ui-text)]"
|
||||
>
|
||||
{file}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="pt-1 text-[11px] text-[var(--ui-text-muted)]">
|
||||
Fingerprint:{" "}
|
||||
<code className="rounded bg-[var(--ui-panel)] px-1 py-0.5 text-[var(--ui-text)]">
|
||||
{approval.fingerprint}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
{actionable ? (
|
||||
<CardFooter className="gap-2 border-t border-[var(--ui-border)] pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onApprove(approval.fingerprint)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Check className="mr-1 size-3.5" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => onReject(approval.fingerprint)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<X className="mr-1 size-3.5" />
|
||||
Reject
|
||||
</Button>
|
||||
</CardFooter>
|
||||
) : decided ? (
|
||||
<CardFooter className="border-t border-[var(--ui-border)] pt-3 text-[11px] text-[var(--ui-text-muted)]">
|
||||
This workflow push has been {approval.status}. If the workflow files
|
||||
change, a new fingerprint will be required.
|
||||
</CardFooter>
|
||||
) : null}
|
||||
{!isOwner && approval.status === "pending" && (
|
||||
<CardFooter className="border-t border-[var(--ui-border)] pt-3 text-[11px] text-[var(--ui-text-muted)]">
|
||||
Only the thread owner can approve or reject this workflow push.
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
<div className="border-b border-[var(--ui-border)] bg-[var(--ui-panel)] px-4 py-3">
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3">
|
||||
{approvals.map((approval) => {
|
||||
const busy = decision.isPending
|
||||
return (
|
||||
<section
|
||||
key={approval.fingerprint}
|
||||
data-testid="workflow-approval-card"
|
||||
className="rounded-lg border border-[var(--ui-border)] bg-[var(--ui-bg)] p-3 shadow-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--ui-text)]">
|
||||
<ShieldCheck className="size-4 text-[var(--ui-accent)]" />
|
||||
Workflow file approval required
|
||||
</div>
|
||||
<p className="text-xs text-[var(--ui-text-dim)]">
|
||||
{approval.repo || "Repository"} on{" "}
|
||||
{approval.branch || "current branch"} ·{" "}
|
||||
{shortSha(approval.baseSha)} → {shortSha(approval.headSha)}
|
||||
</p>
|
||||
<p className="font-mono text-[0.68rem] break-all text-[var(--ui-text-dim)]">
|
||||
fingerprint: {approval.fingerprint}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{approval.approvalUrl && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
window.location.href = approval.approvalUrl ?? ""
|
||||
}}
|
||||
>
|
||||
Open in Web
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={!isOwner || busy}
|
||||
onClick={() => void decide(approval, "approve")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!isOwner || busy}
|
||||
onClick={() => void decide(approval, "reject")}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isOwner && (
|
||||
<p className="mt-3 text-xs text-[var(--ui-text-dim)]">
|
||||
Only the thread owner can approve or reject this workflow
|
||||
push.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-3 text-xs text-[color:var(--ui-danger)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-[var(--ui-text)]">
|
||||
{fileLabel(approval.files.length)} changed
|
||||
</p>
|
||||
<ul className="mt-1 space-y-1 text-xs text-[var(--ui-text-dim)]">
|
||||
{approval.files.slice(0, 8).map((file) => (
|
||||
<li
|
||||
key={file}
|
||||
className="truncate font-mono"
|
||||
title={file}
|
||||
>
|
||||
{file}
|
||||
</li>
|
||||
))}
|
||||
{approval.files.length > 8 && (
|
||||
<li>…and {approval.files.length - 8} more</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-md border border-[var(--ui-border)] px-3 py-2 text-xs text-[var(--ui-text-dim)]">
|
||||
<span>{approval.diffStats.files} files</span>
|
||||
<span className="mx-2 text-[var(--ui-success)]">
|
||||
+{approval.diffStats.additions}
|
||||
</span>
|
||||
<span className="text-[var(--ui-danger)]">
|
||||
-{approval.diffStats.deletions}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{approval.diffPreview && (
|
||||
<details className="mt-3" open>
|
||||
<summary className="cursor-pointer text-xs font-medium text-[var(--ui-text)]">
|
||||
Diff preview
|
||||
{approval.diffPreviewTruncated ? " (truncated)" : ""}
|
||||
</summary>
|
||||
<pre
|
||||
className={cn(
|
||||
"mt-2 max-h-72 overflow-auto rounded-md border border-[var(--ui-border)]",
|
||||
"bg-[var(--ui-panel)] p-3 text-[0.68rem] leading-relaxed text-[var(--ui-text)]"
|
||||
)}
|
||||
>
|
||||
{approval.diffPreview}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,17 +65,35 @@ export interface ThreadRecoveryPatch {
|
|||
filename: string
|
||||
}
|
||||
|
||||
export interface WorkflowApproval {
|
||||
fingerprint: string
|
||||
status: string
|
||||
repo: string
|
||||
branch: string
|
||||
files: Array<string>
|
||||
requested_at: string
|
||||
export type WorkflowApprovalStatus = "pending" | "approved" | "rejected"
|
||||
|
||||
export interface WorkflowDiffStats {
|
||||
files: number
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
export interface WorkflowApprovalsPayload {
|
||||
approvals: Array<WorkflowApproval>
|
||||
export interface WorkflowPushApproval {
|
||||
fingerprint: string
|
||||
status: WorkflowApprovalStatus
|
||||
repo: string
|
||||
branch: string
|
||||
baseSha: string
|
||||
headSha: string
|
||||
files: Array<string>
|
||||
diffStats: WorkflowDiffStats
|
||||
diffPreview: string
|
||||
diffPreviewTruncated: boolean
|
||||
approvalUrl: string | null
|
||||
requestedAt: string | null
|
||||
decidedAt: string | null
|
||||
decidedBy: string | null
|
||||
}
|
||||
|
||||
export interface WorkflowPushApprovalsResponse {
|
||||
threadId: string
|
||||
isOwner: boolean
|
||||
approvals: Array<WorkflowPushApproval>
|
||||
}
|
||||
|
||||
export interface WorkflowApprovalDecision {
|
||||
|
|
@ -83,6 +101,12 @@ export interface WorkflowApprovalDecision {
|
|||
fingerprint: string
|
||||
}
|
||||
|
||||
// Legacy alias kept for backward-compatible prop typing.
|
||||
export type WorkflowApproval = WorkflowPushApproval
|
||||
export interface WorkflowApprovalsPayload {
|
||||
approvals: Array<WorkflowPushApproval>
|
||||
}
|
||||
|
||||
export interface ThreadsPageParams {
|
||||
limit?: number
|
||||
offset?: number
|
||||
|
|
@ -284,7 +308,7 @@ export const agentsApi = {
|
|||
streamUrl: (threadId: string) =>
|
||||
`${API_BASE}/dashboard/api/threads/${encodeURIComponent(threadId)}/stream`,
|
||||
listWorkflowApprovals: (threadId: string) =>
|
||||
agentsRequest<WorkflowApprovalsPayload>(
|
||||
agentsRequest<WorkflowPushApprovalsResponse>(
|
||||
`/workflow-approval/${encodeURIComponent(threadId)}`
|
||||
),
|
||||
approveWorkflowPush: (threadId: string, fingerprint: string) =>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import type {
|
|||
ScheduleUpdateRequest,
|
||||
SidebarThreads,
|
||||
ThreadsPageParams,
|
||||
WorkflowApproval,
|
||||
WorkflowPushApproval,
|
||||
WorkflowPushApprovalsResponse,
|
||||
} from "./api"
|
||||
import type { AgentThread, Chunk, ImageChunk, Message } from "./types"
|
||||
|
||||
|
|
@ -128,57 +129,43 @@ export function useAgentThreadPrDiff(threadId: string, enabled: boolean) {
|
|||
})
|
||||
}
|
||||
|
||||
export function useWorkflowApprovals(threadId: string) {
|
||||
export function useWorkflowApprovals(
|
||||
threadId: string,
|
||||
options: { pollWhileActive?: boolean } = {}
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: agentThreadKeys.workflowApprovals(threadId),
|
||||
queryFn: async () => {
|
||||
const { approvals } = await agentsApi.listWorkflowApprovals(threadId)
|
||||
return approvals
|
||||
},
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data
|
||||
return data?.some((a) => a.status === "pending") ? 3000 : false
|
||||
},
|
||||
queryFn: () => agentsApi.listWorkflowApprovals(threadId),
|
||||
enabled: Boolean(threadId),
|
||||
refetchInterval: (query) =>
|
||||
options.pollWhileActive ||
|
||||
query.state.data?.approvals?.some(
|
||||
(approval) => approval.status === "pending"
|
||||
)
|
||||
? 3000
|
||||
: false,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function useApproveWorkflowPush(threadId: string) {
|
||||
export function useWorkflowApprovalDecision(threadId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (fingerprint: string) =>
|
||||
agentsApi.approveWorkflowPush(threadId, fingerprint),
|
||||
onSuccess: (_, fingerprint) => {
|
||||
queryClient.setQueryData<Array<WorkflowApproval> | undefined>(
|
||||
agentThreadKeys.workflowApprovals(threadId),
|
||||
(prev) =>
|
||||
prev?.map((record) =>
|
||||
record.fingerprint === fingerprint
|
||||
? { ...record, status: "approved" }
|
||||
: record
|
||||
)
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRejectWorkflowPush(threadId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (fingerprint: string) =>
|
||||
agentsApi.rejectWorkflowPush(threadId, fingerprint),
|
||||
onSuccess: (_, fingerprint) => {
|
||||
queryClient.setQueryData<Array<WorkflowApproval> | undefined>(
|
||||
agentThreadKeys.workflowApprovals(threadId),
|
||||
(prev) =>
|
||||
prev?.map((record) =>
|
||||
record.fingerprint === fingerprint
|
||||
? { ...record, status: "rejected" }
|
||||
: record
|
||||
)
|
||||
)
|
||||
mutationFn: (vars: {
|
||||
fingerprint: string
|
||||
decision: "approve" | "reject"
|
||||
}) =>
|
||||
vars.decision === "approve"
|
||||
? agentsApi.approveWorkflowPush(threadId, vars.fingerprint)
|
||||
: agentsApi.rejectWorkflowPush(threadId, vars.fingerprint),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: agentThreadKeys.workflowApprovals(threadId),
|
||||
})
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: agentThreadKeys.detail(threadId),
|
||||
})
|
||||
invalidateAgentThreadLists(queryClient)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,6 +164,8 @@ export interface TeamSettings {
|
|||
review_draft_prs: boolean
|
||||
pr_summaries: boolean
|
||||
review_trace_links: boolean
|
||||
/** Tri-state LLM Gateway toggle; null inherits the LANGSMITH_GATEWAY_ENABLED default. */
|
||||
gateway_enabled?: boolean | null
|
||||
review_tracing_project?: string | null
|
||||
org_guidelines?: string | null
|
||||
default_agent_model?: string | null
|
||||
|
|
@ -823,7 +825,10 @@ export const api = {
|
|||
|
||||
export function loginUrl(redirectTo?: string): string {
|
||||
const target =
|
||||
redirectTo ?? (typeof window !== "undefined" ? window.location.href : "")
|
||||
redirectTo ??
|
||||
(typeof window !== "undefined"
|
||||
? `${window.location.pathname}${window.location.search}${window.location.hash}`
|
||||
: "")
|
||||
const qs = target ? `?redirect_to=${encodeURIComponent(target)}` : ""
|
||||
return `${API_BASE}/dashboard/api/auth/login${qs}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,12 @@ export function sanitizeAuthRedirect(
|
|||
}
|
||||
|
||||
const path = `${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||
if (!path.startsWith("/") || isBlockedRedirectPath(path)) return fallback
|
||||
// Reject anything that is not a single-leading-slash path. `new URL` can resolve inputs
|
||||
// like `/..//evil.com` to a protocol-relative `//evil.com` that still matches the origin
|
||||
// check above but, when handed to `window.location.replace`, navigates cross-origin.
|
||||
if (path.startsWith("//") || !/^\/[^/]/.test(path) || isBlockedRedirectPath(path)) {
|
||||
return fallback
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,14 @@ describe("auth redirect helpers", () => {
|
|||
)
|
||||
})
|
||||
|
||||
it("rejects inputs that resolve to a protocol-relative path", () => {
|
||||
// These resolve same-origin (passing the origin check) but yield a `//host`
|
||||
// path that would navigate cross-origin via window.location.replace.
|
||||
expect(sanitizeAuthRedirect("/..//evil.com")).toBe(DEFAULT_AUTH_REDIRECT)
|
||||
expect(sanitizeAuthRedirect("/.//evil.com")).toBe(DEFAULT_AUTH_REDIRECT)
|
||||
expect(sanitizeAuthRedirect("//evil.com")).toBe(DEFAULT_AUTH_REDIRECT)
|
||||
})
|
||||
|
||||
it("builds a plan sign-in target for the current plan URL", () => {
|
||||
window.history.pushState({}, "", "/agents/thread-1/plan?from=slack")
|
||||
|
||||
|
|
|
|||
|
|
@ -24,11 +24,7 @@ export interface PlanUser {
|
|||
}
|
||||
|
||||
export type PlanStatus =
|
||||
| "planning"
|
||||
| "ready"
|
||||
| "revising"
|
||||
| "approved"
|
||||
| "cancelled"
|
||||
"planning" | "ready" | "shared" | "revising" | "approved" | "cancelled"
|
||||
|
||||
export interface PlanData {
|
||||
threadId: string
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ function AdminPage() {
|
|||
>
|
||||
<GlobalDefaultsSection models={options.data?.models ?? []} />
|
||||
|
||||
<LLMGatewaySection />
|
||||
|
||||
<TriggerReviewSection />
|
||||
|
||||
<SettingsSection title="Evals">
|
||||
|
|
@ -552,6 +554,77 @@ function ObservabilityCredentialsSection() {
|
|||
)
|
||||
}
|
||||
|
||||
type GatewayMode = "inherit" | "enabled" | "disabled"
|
||||
|
||||
function gatewayMode(value: boolean | null | undefined): GatewayMode {
|
||||
if (value === true) return "enabled"
|
||||
if (value === false) return "disabled"
|
||||
return "inherit"
|
||||
}
|
||||
|
||||
function gatewayModeValue(mode: GatewayMode): boolean | null {
|
||||
if (mode === "enabled") return true
|
||||
if (mode === "disabled") return false
|
||||
return null
|
||||
}
|
||||
|
||||
function LLMGatewaySection() {
|
||||
const qc = useQueryClient()
|
||||
const settings = useQuery({
|
||||
queryKey: ["teamSettings"],
|
||||
queryFn: api.getTeamSettings,
|
||||
})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: TeamSettings) => api.saveTeamSettings(body),
|
||||
onSuccess: (saved) => {
|
||||
qc.setQueryData(["teamSettings"], saved)
|
||||
setError(null)
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
})
|
||||
|
||||
const mode = gatewayMode(settings.data?.gateway_enabled)
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="LLM Gateway"
|
||||
description="Route agent and reviewer LLM calls through the LangSmith LLM Gateway. It authenticates with the workspace LangSmith API key and resolves provider keys from Provider Secrets, so no provider keys are needed at runtime. Requires the gateway (private beta) enabled for your organization."
|
||||
>
|
||||
<div className="divide-y divide-border">
|
||||
<SettingsRow
|
||||
label="Route through the gateway"
|
||||
description="Inherit uses the LANGSMITH_GATEWAY_ENABLED deployment default. OpenAI, Anthropic, Fireworks, and Google Gemini are routed through the gateway. Bedrock (AWS) calls the provider directly and is not routed; other providers not listed also call the provider directly."
|
||||
control={
|
||||
<Select
|
||||
value={mode}
|
||||
onValueChange={(next) =>
|
||||
settings.data &&
|
||||
save.mutate({
|
||||
...settings.data,
|
||||
gateway_enabled: gatewayModeValue(next as GatewayMode),
|
||||
})
|
||||
}
|
||||
disabled={!settings.data || save.isPending}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">Inherit deployment default</SelectItem>
|
||||
<SelectItem value="enabled">Enabled</SelectItem>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
function PRTraceResolutionSection() {
|
||||
const qc = useQueryClient()
|
||||
const settings = useQuery({
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { PlanReview } from "@/components/agents/PlanReview"
|
|||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { loginUrl } from "@/lib/api"
|
||||
import { authRedirectUrl, currentAuthRedirectPath } from "@/lib/auth-redirect"
|
||||
import { currentAuthRedirectPath } from "@/lib/auth-redirect"
|
||||
import { PlanApiError, getPlan } from "@/lib/plan"
|
||||
|
||||
export const Route = createFileRoute("/agents/$threadId_/plan")({
|
||||
|
|
@ -36,7 +36,7 @@ function BackLink({ threadId }: { threadId: string }) {
|
|||
}
|
||||
|
||||
export function planSignInHref(): string {
|
||||
return loginUrl(authRedirectUrl(currentAuthRedirectPath()))
|
||||
return loginUrl(currentAuthRedirectPath())
|
||||
}
|
||||
|
||||
export function PlanSignInButton() {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
import { loginUrl } from "@/lib/api";
|
||||
import {
|
||||
DEFAULT_AUTH_REDIRECT,
|
||||
authRedirectUrl,
|
||||
consumeAuthRedirect,
|
||||
getRememberedAuthRedirect,
|
||||
rememberAuthRedirect,
|
||||
|
|
@ -64,7 +63,7 @@ function Login() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<a
|
||||
href={loginUrl(authRedirectUrl(intendedPath))}
|
||||
href={loginUrl(intendedPath)}
|
||||
className={cn(buttonVariants({ size: "lg" }), "w-full")}
|
||||
>
|
||||
Continue with GitHub
|
||||
|
|
|
|||
70
uv.lock
generated
70
uv.lock
generated
|
|
@ -781,7 +781,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "fireworks-ai"
|
||||
version = "1.2.0a85"
|
||||
version = "1.2.0a88"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
|
@ -793,9 +793,9 @@ dependencies = [
|
|||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/94/fb75cf9c769a09de31e550ba99a279e5f83a05cfcf49cd0cbb8ad586ab35/fireworks_ai-1.2.0a85.tar.gz", hash = "sha256:33abbeddd9a6bd9993bca6cb9e16afe9799b2cd674d6b7c04a601cd4e412f2a7", size = 448217, upload-time = "2026-06-26T08:06:57.286Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/17/f48738655a14d93c98ac34d966caeec7de69843b1e4492e748cc78622169/fireworks_ai-1.2.0a88.tar.gz", hash = "sha256:565db96c0804d9a7901eedad06ae0b656bbbc7514bdf94a1408afba46b40737c", size = 456358, upload-time = "2026-07-09T05:52:58.742Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/5b/0b10d0afb633b45f234f6728cc680851f136b9f75903b4387049537a6cd6/fireworks_ai-1.2.0a85-py3-none-any.whl", hash = "sha256:80a4b13bda8e28ca9064c8339d0afe47cbbdb7c61a88eeebc173ccb58129f19a", size = 528844, upload-time = "2026-06-26T08:06:55.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/8d/ecbdaf71f79d5a29075837300609fc6d0a802025acd4c648e2b6e8f19fc8/fireworks_ai-1.2.0a88-py3-none-any.whl", hash = "sha256:b532ac0a04e9f5c112297732c3f1238d61641b34a6f5843c87f24c879e842d24", size = 537817, upload-time = "2026-07-09T05:52:57.357Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1431,16 +1431,16 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "langchain"
|
||||
version = "1.3.11"
|
||||
version = "1.3.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
{ name = "langgraph" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/a2/91a7197c604a3ce1b774b3c10dd114c3c745c6186a304fc2573b3f94d400/langchain-1.3.11.tar.gz", hash = "sha256:f3cf9cd4d2329b1a03eb8fd92b9d73e4e58a4d52570d67725fc77fbe0f104b32", size = 633374, upload-time = "2026-06-22T23:00:33.44Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/c0/8481c93a3899d7e05ac27f21a4e984dab613b72631f39fe01fe0100e3be0/langchain-1.3.12.tar.gz", hash = "sha256:3321f86824a0c0720004d28797d729a58b9bb8a0ac73c6ea2b856cac38117879", size = 642308, upload-time = "2026-07-08T22:38:12.466Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/a4/3a181967294f8876362cc4ba36840d50b8286fa23bb3f5e602b69eb3cb1e/langchain-1.3.11-py3-none-any.whl", hash = "sha256:7ae011f95a09b22feea1e8ae4e43f0b6164aebf4c61b8ad845b45f72ff3a90a2", size = 133639, upload-time = "2026-06-22T23:00:31.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/31/33ba60f3aaa88dc7b97f9c7680f431237466804bf6826efb75400dfd2e6c/langchain-1.3.12-py3-none-any.whl", hash = "sha256:19685eb9dce01ebd71b11281d5489453e7ab3d2caf61eacb2a76d15574246535", size = 136863, upload-time = "2026-07-08T22:38:11.003Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1459,7 +1459,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "langchain-aws"
|
||||
version = "1.6.1"
|
||||
version = "1.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "boto3" },
|
||||
|
|
@ -1468,14 +1468,14 @@ dependencies = [
|
|||
{ name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0a/c6/4065908dc2f113324d1ac31ecf5c907a23863f7aeaab7b9361efd7109563/langchain_aws-1.6.1.tar.gz", hash = "sha256:b5b054f48e2697fa1b96733e9de2accfdd1a4948d9b4e3712e8180c4585cfd2f", size = 538265, upload-time = "2026-06-25T19:02:23.364Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/59/04f8572e65581d032b2df2516c0059fa064e680f5caa5cabcd03d525092a/langchain_aws-1.6.2.tar.gz", hash = "sha256:df2c15b463c068031af40460dbea6efb7999832263ff6eaec6b99c5f6b291acd", size = 537522, upload-time = "2026-07-07T01:24:58.11Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/ab/d85b915c12394385459bb536202227d0fd81377929d71b16f177559ba074/langchain_aws-1.6.1-py3-none-any.whl", hash = "sha256:a121f687b36678239dd96ee9d0503a0b7d5fc4570b3af6d19983ef6ebfaf115e", size = 206315, upload-time = "2026-06-25T19:02:21.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/ed/bb5211637276616e7b176f3ff08e9d0bf1ce61d1a00c99d26348d6236a3d/langchain_aws-1.6.2-py3-none-any.whl", hash = "sha256:4755c60ce88fff2b829d96027068b7930f335cd85da41fda061769439c288251", size = 206507, upload-time = "2026-07-07T01:24:56.557Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.4.8"
|
||||
version = "1.4.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
|
|
@ -1488,9 +1488,9 @@ dependencies = [
|
|||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/b9/e937d0a90b26540bff07e7a7c64349f3b29c2dcc36257cd1cd3fdce17f2a/langchain_core-1.4.9.tar.gz", hash = "sha256:f8078901145bed0466755277500a5a22822a7b628808c4c0a28d4fc88895fcf2", size = 967294, upload-time = "2026-07-08T20:06:54.191Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416, upload-time = "2026-06-18T19:39:21.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/70/ade2fada52772798ef815b6352b59e71b116aa0c32c3aef5be3dc2cbed12/langchain_core-1.4.9-py3-none-any.whl", hash = "sha256:28e3909e2a10cc81504952d795ac0a9e014c0018121ef89d48dd396fa09ec624", size = 558293, upload-time = "2026-07-08T20:06:52.382Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1524,7 +1524,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "langchain-google-genai"
|
||||
version = "4.2.6"
|
||||
version = "4.2.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "filetype" },
|
||||
|
|
@ -1532,9 +1532,9 @@ dependencies = [
|
|||
{ name = "langchain-core" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/08/68e4f90b273d1d8dc3a7a948e6535e60baac726adbb2a8341dcf17662f54/langchain_google_genai-4.2.6.tar.gz", hash = "sha256:653dc331e691ddd79784d9ff6a4082749e0f873394c6dc782c414eb4409850eb", size = 280074, upload-time = "2026-06-26T06:18:58.807Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/0c/bc60dabc362ca7c6ffe8c4bcc2f724c7e566b43eb230cee51419f88f784c/langchain_google_genai-4.2.7.tar.gz", hash = "sha256:03b1463ffe4d42435f43c7870467f2215f684bb46400d2543435d10157c80ac7", size = 281605, upload-time = "2026-07-06T13:51:58.724Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/85/3c/9a6b43bced0238f95640555e14423fe4a8268e3f4f536f67e26e79e633d4/langchain_google_genai-4.2.6-py3-none-any.whl", hash = "sha256:c40db0c2d033a5fb6db8e2cc3fb6d49c5678b89b337a64da095fb73ec9f72021", size = 70457, upload-time = "2026-06-26T06:18:57.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/f9/d73d1e712591723aaddb7a7b1e94978cd2320c29acfe0d26b6169a2f26f0/langchain_google_genai-4.2.7-py3-none-any.whl", hash = "sha256:0d9c388d0e6c629718fca6abb19c6fdca728a9a7873d0324c1ec821288b5b571", size = 70702, upload-time = "2026-07-06T13:51:57.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1566,16 +1566,16 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "langchain-openai"
|
||||
version = "1.3.3"
|
||||
version = "1.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
{ name = "openai" },
|
||||
{ name = "tiktoken" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/f3/b38e052f943a75ba0e6762c0a50b6f1d6bcd52a9ce63386d8803c2ff506e/langchain_openai-1.3.3.tar.gz", hash = "sha256:143769bf943820b80db769e47ca8fd0aac08ed18714519333b044c4431e9aa67", size = 3256559, upload-time = "2026-06-22T22:54:05.445Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/fe/cfd11b9ebc54f7667e3c8f847f64246fad169ad998b8a08d7c3c9d9bccaf/langchain_openai-1.3.4.tar.gz", hash = "sha256:d888d5f39c2a8c3d0d8aa88f5cf50e58a8e7d242f3f15e39422add520eec8e31", size = 3258207, upload-time = "2026-07-08T22:59:50.651Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/4b/7520114de1ce36bb17cbc98d0fcda048a9f755bedaeb73b92137aeaaf1db/langchain_openai-1.3.3-py3-none-any.whl", hash = "sha256:e469659862c8aabba4f6653df973206e7be54f98cf2275c86be7f06b7abe20d7", size = 120437, upload-time = "2026-06-22T22:54:03.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/6e/f8f47f2c6976a4520b5416eaeeaee5e6aa7dd906b39685dc1ad4ef6d1ba2/langchain_openai-1.3.4-py3-none-any.whl", hash = "sha256:3241b8392b29c1af233b902b7d9a84bfc5fe26ccb210a7febdfc3972af7e5771", size = 120529, upload-time = "2026-07-08T22:59:49.451Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1605,7 +1605,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.7"
|
||||
version = "1.2.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
|
@ -1615,9 +1615,9 @@ dependencies = [
|
|||
{ name = "pydantic" },
|
||||
{ name = "xxhash" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/11/c3961537ac7577125aabba68effc33ecc3dc0962e2d287d734dbd61caad7/langgraph-1.2.7.tar.gz", hash = "sha256:dcdf5b441bf8c7c7c154e603b302c9dbfbfd2d11e1b7ae7d93a5aba979dc87bd", size = 721385, upload-time = "2026-06-30T01:24:12.528Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/ad/583fda4c69501390b989770a465ccd0bdab1c1612eba582c012002ddf9b6/langgraph-1.2.8.tar.gz", hash = "sha256:f79d3575f45b404899358976e4fac0294eb75f8df1bfe8cd11286be7539c4548", size = 722464, upload-time = "2026-07-06T20:40:19.487Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/16/5d/19de0c674cf2d8bc95b25cdb4c13ba3f4fbe0f35bf2224ef19cc46bbb4cd/langgraph-1.2.7-py3-none-any.whl", hash = "sha256:249349a5d6a32cb0aa2304a8fb6529bd0cf56db8780d21db9e086e5383668f89", size = 246930, upload-time = "2026-06-30T01:24:11.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/49/b958a9963606807e5a20cc75fced14aa77c5cbcc470d5bf8ae13277cd298/langgraph-1.2.8-py3-none-any.whl", hash = "sha256:aa8de1d4df44162353d117589ae0bf6930ca009b62d2d6e26cc32580794c5be6", size = 246983, upload-time = "2026-07-06T20:40:18.242Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1747,7 +1747,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.9.7"
|
||||
version = "0.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
|
|
@ -1765,9 +1765,9 @@ dependencies = [
|
|||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/8b/a56b97a3b8a94f850fe2ec42351d4c508cc6bd7ca96644906c311e32ec5b/langsmith-0.9.7.tar.gz", hash = "sha256:40f8a66a466a7abd991a9df1b50de0a9d30c48ee0d3da46ce00516b68e41c9d7", size = 4711142, upload-time = "2026-07-02T20:11:09.619Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/53/18bc9169517c274681402f3714d1975ff687e8e175a9fea2b8087db99747/langsmith-0.10.0.tar.gz", hash = "sha256:1bc97a28e4b7a0f2e2f5419668592b3bf460ba5d886991ad725fb2778aa2e236", size = 4708392, upload-time = "2026-07-08T13:28:28.081Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/95/0abb8647123c9ebbb31162bcdba3183f5f3d3bb2f752ef3d777ab715e88c/langsmith-0.9.7-py3-none-any.whl", hash = "sha256:a5ff9179b77eb0c3a0129294d30924eab13e51e2720f374372dbbc014c892911", size = 671052, upload-time = "2026-07-02T20:11:07.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/cf/41d4077d82dce88fb8f6c02938dd5937b5a74cd67bfd7bdb5206277752ca/langsmith-0.10.0-py3-none-any.whl", hash = "sha256:63aec1105b776b8c65a32b002b29ada02469cd26c91beeb2fc2831b247f4da27", size = 652557, upload-time = "2026-07-08T13:28:26.366Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -2221,29 +2221,29 @@ requires-dist = [
|
|||
{ name = "deepagents", specifier = "==0.6.12" },
|
||||
{ name = "exa-py", specifier = ">=2.16.0" },
|
||||
{ name = "fastapi", specifier = ">=0.139.0" },
|
||||
{ name = "fireworks-ai", specifier = ">=1.2.0a85" },
|
||||
{ name = "fireworks-ai", specifier = ">=1.2.0a88" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "langchain", specifier = ">=1.3.9" },
|
||||
{ name = "langchain", specifier = ">=1.3.12" },
|
||||
{ name = "langchain-anthropic", specifier = ">=1.4.6" },
|
||||
{ name = "langchain-aws", specifier = ">=0.2.0" },
|
||||
{ name = "langchain-aws", specifier = ">=1.6.2" },
|
||||
{ name = "langchain-daytona", specifier = ">=0.0.7" },
|
||||
{ name = "langchain-fireworks", specifier = ">=1.4.3" },
|
||||
{ name = "langchain-google-genai", specifier = ">=4.2.4" },
|
||||
{ name = "langchain-google-genai", specifier = ">=4.2.7" },
|
||||
{ name = "langchain-mcp-adapters", specifier = ">=0.3.0" },
|
||||
{ name = "langchain-modal", specifier = ">=0.0.5" },
|
||||
{ name = "langchain-openai", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-openai", specifier = ">=1.3.4" },
|
||||
{ name = "langchain-runloop", specifier = ">=0.0.6" },
|
||||
{ name = "langgraph", specifier = ">=1.1.10" },
|
||||
{ name = "langgraph", specifier = ">=1.2.8" },
|
||||
{ name = "langgraph-cli", extras = ["inmem"], specifier = ">=0.4.30" },
|
||||
{ name = "langgraph-sdk", specifier = ">=0.4.2" },
|
||||
{ name = "langsmith", specifier = "==0.9.7" },
|
||||
{ name = "langsmith", specifier = "==0.10.0" },
|
||||
{ name = "markdownify", specifier = ">=1.2.3" },
|
||||
{ name = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.13.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.1.1" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.4.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.20" },
|
||||
{ name = "uvicorn", specifier = ">=0.49.0" },
|
||||
{ name = "uvicorn", specifier = ">=0.51.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
|
|
@ -3589,15 +3589,15 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.49.0"
|
||||
version = "0.51.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue