mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-07 16:19:09 +00:00
Compare commits
3 commits
3fb2bf28d2
...
2f848f24e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f848f24e5 | ||
|
|
2b01652754 | ||
|
|
9f7a1cc481 |
37 changed files with 3206 additions and 2241 deletions
|
|
@ -40,7 +40,7 @@ The FastAPI app is `agent.webapp:app`.
|
|||
- **`agent/server.py` → `get_agent(config)`** — main graph factory. Called per-thread. Resolves the GitHub token, gets-or-creates the sandbox for the thread, resolves the team/profile/per-thread model + effort, then constructs a fresh `create_deep_agent(...)` with the curated tool list and middleware stack. The agent itself is stateless — all per-thread state lives in the sandbox + thread metadata.
|
||||
- **`agent/reviewer.py` → `get_reviewer_agent(config)`** — reviewer graph factory. Shares `ensure_sandbox_for_thread` with the main agent but wires a reviewer-only toolset (`add_finding`, `update_finding`, `list_findings`, `publish_review`, `web_search`, `fetch_url`, `http_request`) and a different system prompt that pins the single-evolving-findings model and the diff-anchored bar for filing a finding. Read-only: no commit/push/PR-opening tools.
|
||||
- **`agent/analyzer.py` → `get_analyzer(config)`** — small graph that emits a per-repo style prompt via the `save_review_style_prompt` tool, consumed by the reviewer as a "repository-specific review style" appendix. It runs in one of two modes (`analyzer_mode` in `configurable`): **bootstrap** (cold-start: crawl historical PR reviews) and **continual** (nightly: refine using this reviewer's own finding outcomes via `read_finding_outcomes`). Each mode's procedure lives in a deepagents **skill** (`agent/skills/bootstrap-repo-analysis/`, `agent/skills/continual-learning/`) served as virtual files via a `CompositeBackend` `/skills/` route + `StateBackend` (seeded into the run's `files` channel by the launcher — never written to the sandbox). Launchers and the per-repo nightly cron live in `agent/dashboard/review_style_jobs.py` and `agent/dashboard/analyzer_cron.py`; the cron is registered when bootstrap completes.
|
||||
- **`agent/webapp.py`** — custom FastAPI routes mounted alongside the LangGraph server. Webhooks land here (GitHub, Linear, Slack). Each webhook resolves a deterministic `thread_id` (so follow-up messages route to the same agent run) and triggers/streams a run via the `langgraph_sdk` client. Also auto-reviews PRs on `opened` / `ready_for_review` events when the repo+author opt in.
|
||||
- **`agent/webapp.py`** — thin FastAPI routing layer mounted alongside the LangGraph server. Defines the webhook routes (GitHub, Linear, Slack) plus `/webhooks/run-complete`, and keeps the shared helpers/constants; the per-source handlers live in **`agent/webhooks/{github,slack,linear}.py`** (re-exported from `webapp` so existing call sites and tests keep working). Each webhook resolves a deterministic `thread_id` (so follow-up messages route to the same agent run) and triggers a run through the single durable dispatch contract in **`agent/dispatch.py`** (`dispatch_agent_run`: `multitask_strategy="interrupt"` + `durability="sync"` + completion webhook); `agent/completion.py` posts a failure reply if a run dies, and `agent/reconcile.py` (a `scheduler`-graph sweep) catches stragglers. The GitHub handler also auto-reviews PRs on `opened` / `ready_for_review` and drives the CI auto-fix flow (`agent/ci_autofix.py`).
|
||||
- **`agent/dashboard/`** — `router` mounted under the FastAPI app at startup (`app.include_router(dashboard_router)`). Owns GitHub OAuth, per-user profiles, admin endpoints, team defaults, enabled-repo lists, review-style management, and the Agents chat thread API used by the UI in `ui/`.
|
||||
|
||||
### Sandbox lifecycle (the tricky part)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from langgraph_sdk import get_client
|
|||
from .dashboard.agent_overrides import load_profile, resolve_login_from_email_async
|
||||
from .dashboard.autofix_state import is_pr_autofix_disabled
|
||||
from .dashboard.enabled_repos import is_review_repo_enabled
|
||||
from .dispatch import dispatch_agent_run
|
||||
from .reviewer_findings import REVIEWER_THREAD_KIND
|
||||
from .utils.dashboard_links import dashboard_thread_url
|
||||
from .utils.github_app import get_github_app_installation_token
|
||||
|
|
@ -40,7 +41,7 @@ from .utils.github_ci import (
|
|||
)
|
||||
from .utils.github_org_membership import INTERNAL_BOT_LOGINS
|
||||
from .utils.thread_ops import (
|
||||
is_thread_active,
|
||||
get_thread_active_status,
|
||||
langgraph_client,
|
||||
)
|
||||
|
||||
|
|
@ -271,17 +272,25 @@ async def _mark_pending_autofix_event(thread_id: str, reason: str, detail: str =
|
|||
async def _dispatch_or_batch(
|
||||
thread_id: str, prompt: str, *, configurable: dict[str, Any], reason: str, detail: str = ""
|
||||
) -> str:
|
||||
if await is_thread_active(thread_id):
|
||||
# Deliberate skip-rule: batch auto-fix events while the agent thread is
|
||||
# actively running so we don't interrupt an in-progress fix. ``interrupt``
|
||||
# is fine for human follow-ups but undesirable for autofix, so we keep the
|
||||
# busy-check here even though the webhook hot-path no longer needs one.
|
||||
if await get_thread_active_status(thread_id) is True:
|
||||
logger.info("Agent thread %s busy; batching auto-fix event %s", thread_id, reason)
|
||||
await _mark_pending_autofix_event(thread_id, reason, detail)
|
||||
return "batched"
|
||||
client = langgraph_client()
|
||||
await client.runs.create(
|
||||
# The busy-check above has a TOCTOU window (the dedupe SHA is only recorded
|
||||
# after dispatch), so a burst of near-simultaneous CI events for one head SHA
|
||||
# can all pass the gate. Dispatch with ``reject`` — matching ``dev``'s prior
|
||||
# platform default — so the platform drops the duplicate concurrent creates
|
||||
# instead of letting them interrupt each other.
|
||||
await dispatch_agent_run(
|
||||
thread_id,
|
||||
"agent",
|
||||
input={"messages": [{"role": "user", "content": prompt}]},
|
||||
config={"configurable": configurable},
|
||||
if_not_exists="create",
|
||||
prompt,
|
||||
configurable,
|
||||
source=str(configurable.get("source") or "github_autofix"),
|
||||
multitask_strategy="reject",
|
||||
)
|
||||
logger.info(
|
||||
"Created auto-fix run for thread %s (source=%s)", thread_id, configurable.get("source")
|
||||
|
|
|
|||
173
agent/completion.py
Normal file
173
agent/completion.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""Run-completion webhook handler — guarantees every run ends with a signal.
|
||||
|
||||
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
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from .utils.github_app import get_github_app_installation_token
|
||||
from .utils.github_comments import post_github_comment
|
||||
from .utils.linear import comment_on_linear_issue
|
||||
from .utils.slack import post_slack_thread_reply
|
||||
from .utils.thread_ops import langgraph_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Run statuses that mean the user will otherwise get nothing back. "interrupted"
|
||||
# is intentionally excluded: with multitask_strategy="interrupt", a normal
|
||||
# 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"
|
||||
|
||||
|
||||
class _ClaimFailed(Exception):
|
||||
"""Raised when the dedup flag couldn't be claimed, so we skip the post."""
|
||||
|
||||
|
||||
# Shared-secret bearer token proving a /webhooks/run-complete call came from our
|
||||
# own dispatch (which appends ?token= when this is set) rather than from an
|
||||
# attacker hitting the public route. Fail closed when unset: the route rejects
|
||||
# every call, so completion replies stay off until the secret is configured.
|
||||
RUN_COMPLETE_WEBHOOK_SECRET = os.environ.get("RUN_COMPLETE_WEBHOOK_SECRET")
|
||||
if not RUN_COMPLETE_WEBHOOK_SECRET:
|
||||
logger.warning(
|
||||
"RUN_COMPLETE_WEBHOOK_SECRET is not set; /webhooks/run-complete is fail-closed "
|
||||
"(all calls rejected) and run-failure replies are disabled. Set it to enable them."
|
||||
)
|
||||
|
||||
|
||||
def verify_run_complete_token(token: str | None) -> bool:
|
||||
"""Return whether a run-completion webhook token is acceptable.
|
||||
|
||||
Fail closed: with no secret configured, reject every call rather than accept
|
||||
unauthenticated requests on a publicly reachable route.
|
||||
"""
|
||||
secret = RUN_COMPLETE_WEBHOOK_SECRET
|
||||
if not secret:
|
||||
return False
|
||||
return token is not None and hmac.compare_digest(token, secret)
|
||||
|
||||
|
||||
def _failure_text(status: str) -> str:
|
||||
reason = "timed out" if status == "timeout" else "hit an unexpected error"
|
||||
return (
|
||||
f"⚠️ I wasn't able to finish that — the run {reason}. "
|
||||
"Send another message and I'll pick it back up."
|
||||
)
|
||||
|
||||
|
||||
async def _post_failure_reply(
|
||||
thread_id: str,
|
||||
metadata: dict[str, Any],
|
||||
status: str,
|
||||
*,
|
||||
claim: Callable[[], Awaitable[None]],
|
||||
) -> bool:
|
||||
"""Post a failure reply to the run's originating channel. Best-effort.
|
||||
|
||||
``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.
|
||||
"""
|
||||
source = metadata.get("source")
|
||||
ctx = metadata.get("source_context")
|
||||
ctx = ctx if isinstance(ctx, dict) else {}
|
||||
text = _failure_text(status)
|
||||
|
||||
if source == "slack":
|
||||
slack_thread = ctx.get("slack_thread")
|
||||
if isinstance(slack_thread, dict):
|
||||
channel_id = slack_thread.get("channel_id")
|
||||
thread_ts = slack_thread.get("thread_ts")
|
||||
if channel_id and thread_ts:
|
||||
await claim()
|
||||
return await post_slack_thread_reply(channel_id, thread_ts, text)
|
||||
return False
|
||||
|
||||
if source == "linear":
|
||||
linear_issue = ctx.get("linear_issue")
|
||||
if isinstance(linear_issue, dict):
|
||||
issue_id = linear_issue.get("id")
|
||||
if issue_id:
|
||||
await claim()
|
||||
return await comment_on_linear_issue(issue_id, text)
|
||||
return False
|
||||
|
||||
if source in ("github", "github_issue"):
|
||||
repo_config = metadata.get("repo")
|
||||
number = ctx.get("pr_number")
|
||||
if number is None:
|
||||
github_issue = ctx.get("github_issue")
|
||||
if isinstance(github_issue, dict):
|
||||
number = github_issue.get("number")
|
||||
if isinstance(repo_config, dict) and isinstance(number, int):
|
||||
token = await get_github_app_installation_token()
|
||||
if token:
|
||||
await claim()
|
||||
return await post_github_comment(repo_config, number, text, token=token)
|
||||
return False
|
||||
|
||||
logger.info("No failure-reply channel for thread %s (source=%s)", thread_id, source)
|
||||
return False
|
||||
|
||||
|
||||
async def handle_run_completion(payload: dict[str, Any]) -> dict[str, str]:
|
||||
"""Handle a platform run-completion webhook POST.
|
||||
|
||||
Posts a failure reply only when the run ended in a failure state and we
|
||||
haven't already replied for this thread.
|
||||
"""
|
||||
status = payload.get("status")
|
||||
thread_id = payload.get("thread_id")
|
||||
if not isinstance(thread_id, str) or not thread_id:
|
||||
return {"status": "ignored", "reason": "missing thread_id"}
|
||||
if status not in _TERMINAL_FAILURE_STATUSES:
|
||||
return {"status": "ignored", "reason": f"non-failure status: {status}"}
|
||||
|
||||
client = langgraph_client()
|
||||
try:
|
||||
thread = await client.threads.get(thread_id)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("run-complete: could not load thread %s", thread_id, exc_info=True)
|
||||
return {"status": "error", "reason": "thread fetch failed"}
|
||||
|
||||
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"}
|
||||
|
||||
# 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.
|
||||
async def _claim() -> None:
|
||||
try:
|
||||
await client.threads.update(thread_id=thread_id, metadata={_FAILURE_REPLY_FLAG: True})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("run-complete: could not flag thread %s", thread_id, exc_info=True)
|
||||
raise _ClaimFailed from exc
|
||||
|
||||
try:
|
||||
posted = await _post_failure_reply(thread_id, metadata, status, claim=_claim)
|
||||
except _ClaimFailed:
|
||||
return {"status": "error", "reason": "could not claim failure reply"}
|
||||
if not posted:
|
||||
return {"status": "ignored", "reason": "no reply posted"}
|
||||
logger.info("Posted failure reply for thread %s (status=%s)", thread_id, status)
|
||||
return {"status": "ok", "reason": "failure reply posted"}
|
||||
|
|
@ -21,6 +21,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||
from langgraph_sdk import get_client
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..dispatch import dispatch_agent_run
|
||||
from .oauth import require_same_origin_for_mutations, require_session
|
||||
from .plan_store import (
|
||||
PLAN_STATUS_APPROVED,
|
||||
|
|
@ -212,11 +213,9 @@ async def _dispatch_followup(
|
|||
# mode (implement), reject stays in plan mode (revise the plan).
|
||||
configurable["plan_mode"] = plan_mode
|
||||
|
||||
client = get_client()
|
||||
await client.runs.create(
|
||||
await dispatch_agent_run(
|
||||
thread_id,
|
||||
"agent",
|
||||
input={"messages": [{"role": "user", "content": text}]},
|
||||
config={"configurable": configurable},
|
||||
if_not_exists="create",
|
||||
text,
|
||||
configurable,
|
||||
source=configurable["source"],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
|
@ -22,6 +23,7 @@ SCHEDULES_NAMESPACE: list[str] = ["agent_schedules"]
|
|||
_AGENT_ASSISTANT_ID = "agent"
|
||||
_SCHEDULER_ASSISTANT_ID = "scheduler"
|
||||
_CRON_FIELD_RANGES = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 7))
|
||||
_SLACK_CHANNEL_ID_RE = re.compile(r"^[A-Z][A-Z0-9]{5,}$")
|
||||
|
||||
|
||||
class ScheduleCreateBody(BaseModel):
|
||||
|
|
@ -31,12 +33,18 @@ class ScheduleCreateBody(BaseModel):
|
|||
repo: str | None = None
|
||||
model_id: str | None = None
|
||||
effort: str | None = None
|
||||
slack_report_channel: str | None = Field(default=None, max_length=120)
|
||||
|
||||
@field_validator("schedule")
|
||||
@classmethod
|
||||
def _valid_schedule(cls, value: str) -> str:
|
||||
return normalize_cron_schedule(value)
|
||||
|
||||
@field_validator("slack_report_channel")
|
||||
@classmethod
|
||||
def _valid_slack_report_channel(cls, value: str | None) -> str | None:
|
||||
return normalize_slack_channel_id(value)
|
||||
|
||||
|
||||
class ScheduleUpdateBody(BaseModel):
|
||||
prompt: str | None = Field(default=None, min_length=1, max_length=20_000)
|
||||
|
|
@ -46,12 +54,18 @@ class ScheduleUpdateBody(BaseModel):
|
|||
model_id: str | None = None
|
||||
effort: str | None = None
|
||||
enabled: bool | None = None
|
||||
slack_report_channel: str | None = Field(default=None, max_length=120)
|
||||
|
||||
@field_validator("schedule")
|
||||
@classmethod
|
||||
def _valid_schedule(cls, value: str | None) -> str | None:
|
||||
return normalize_cron_schedule(value) if value is not None else None
|
||||
|
||||
@field_validator("slack_report_channel")
|
||||
@classmethod
|
||||
def _valid_slack_report_channel(cls, value: str | None) -> str | None:
|
||||
return normalize_slack_channel_id(value)
|
||||
|
||||
|
||||
def _client():
|
||||
return langgraph_client()
|
||||
|
|
@ -109,6 +123,18 @@ def normalize_cron_schedule(raw: str) -> str:
|
|||
return value
|
||||
|
||||
|
||||
def normalize_slack_channel_id(raw: str | None) -> str | None:
|
||||
"""Normalize a Slack channel ID; blank becomes None (no report channel)."""
|
||||
if raw is None:
|
||||
return None
|
||||
value = raw.strip().lstrip("#")
|
||||
if not value:
|
||||
return None
|
||||
if not _SLACK_CHANNEL_ID_RE.match(value):
|
||||
raise ValueError("slack_report_channel must be a Slack channel ID (e.g. C0123ABCD)")
|
||||
return value
|
||||
|
||||
|
||||
def _derive_name(prompt: str) -> str:
|
||||
return prompt.strip().splitlines()[0][:80] or "Scheduled agent"
|
||||
|
||||
|
|
@ -131,6 +157,7 @@ def _schedule_summary(record: dict[str, Any]) -> dict[str, Any]:
|
|||
"repo": _repo_full_name(repo),
|
||||
"model": record.get("model"),
|
||||
"effort": record.get("effort"),
|
||||
"slackReportChannel": record.get("slack_report_channel"),
|
||||
"enabled": bool(record.get("enabled")),
|
||||
"cronId": record.get("cron_id"),
|
||||
"lastThreadId": record.get("last_thread_id"),
|
||||
|
|
@ -274,6 +301,7 @@ async def create_agent_schedule(
|
|||
"repo": repo,
|
||||
"model": chosen_model or profile.get("default_model") or "Default",
|
||||
"effort": chosen_effort or profile.get("reasoning_effort"),
|
||||
"slack_report_channel": body.slack_report_channel,
|
||||
"base_branch": profile.get("base_branch") or "main",
|
||||
"branch_prefix": profile.get("branch_prefix"),
|
||||
"enabled": True,
|
||||
|
|
@ -322,6 +350,8 @@ async def update_agent_schedule(
|
|||
patch["effort"] = effort
|
||||
if body.enabled is not None:
|
||||
patch["enabled"] = body.enabled
|
||||
if "slack_report_channel" in body.model_fields_set:
|
||||
patch["slack_report_channel"] = body.slack_report_channel
|
||||
|
||||
updated = {**existing, **patch}
|
||||
schedule_changed = updated.get("schedule") != existing.get("schedule")
|
||||
|
|
@ -390,6 +420,9 @@ def _agent_run_config(record: dict[str, Any], thread_id: str) -> dict[str, Any]:
|
|||
if model and effort:
|
||||
configurable["agent_model_id"] = model
|
||||
configurable["agent_effort"] = effort
|
||||
report_channel = record.get("slack_report_channel")
|
||||
if isinstance(report_channel, str) and report_channel.strip():
|
||||
configurable["slack_thread"] = {"channel_id": report_channel.strip()}
|
||||
return {"configurable": configurable, "metadata": _agent_version_metadata()}
|
||||
|
||||
|
||||
|
|
|
|||
93
agent/dispatch.py
Normal file
93
agent/dispatch.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Single durable dispatch contract behind every agent/reviewer run trigger.
|
||||
|
||||
Replaces the per-site ``runs.create`` calls (plus the ``is_thread_active``
|
||||
busy-check and the custom store-queue) with one function that always uses:
|
||||
|
||||
- ``multitask_strategy="interrupt"`` — a follow-up halts the active run
|
||||
(progress preserved by the sync checkpoint) and resumes the agent with full
|
||||
history + the new message; on an idle thread it just starts. This is the
|
||||
platform-native, cross-process replacement for the racy busy-check + queue.
|
||||
- ``durability="sync"`` — checkpoint before each step so a crash/recycle
|
||||
resumes from the last checkpoint instead of losing all work.
|
||||
- ``webhook=COMPLETION_WEBHOOK_URL`` — the platform calls us on completion or
|
||||
failure so every run ends with a signal even if the agent died.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from langgraph_sdk import get_client
|
||||
from langgraph_sdk.client import LangGraphClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ContentBlocks = str | list[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.
|
||||
_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 _langgraph_url() -> str:
|
||||
return os.environ.get("LANGGRAPH_URL") or os.environ.get(
|
||||
"LANGGRAPH_URL_PROD", "http://localhost:2024"
|
||||
)
|
||||
|
||||
|
||||
def dispatch_client() -> LangGraphClient:
|
||||
return get_client(url=_langgraph_url())
|
||||
|
||||
|
||||
async def dispatch_agent_run(
|
||||
thread_id: str,
|
||||
content: ContentBlocks,
|
||||
configurable: dict[str, Any],
|
||||
*,
|
||||
source: str,
|
||||
assistant_id: str = "agent",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
client: LangGraphClient | None = None,
|
||||
multitask_strategy: str = "interrupt",
|
||||
) -> dict[str, Any]:
|
||||
"""Create (or interrupt-and-resume) a run for ``thread_id``.
|
||||
|
||||
Routes every Slack / Linear / GitHub / dashboard trigger through one
|
||||
contract. ``source`` is for logging/metadata only; ``assistant_id`` selects
|
||||
the graph (``"agent"`` or ``"reviewer"``). ``multitask_strategy`` defaults to
|
||||
``"interrupt"`` (human follow-ups halt + resume); autofix passes ``"reject"``
|
||||
so a burst of concurrent CI events for one head SHA can't interrupt each other.
|
||||
"""
|
||||
client = client or dispatch_client()
|
||||
run = await client.runs.create(
|
||||
thread_id,
|
||||
assistant_id,
|
||||
input={"messages": [{"role": "user", "content": content}]},
|
||||
config={"configurable": configurable, "metadata": metadata or {}},
|
||||
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
|
||||
|
|
@ -368,6 +368,23 @@ ALWAYS_CREATE_PR_SECTION = """---
|
|||
The user's dashboard setting **Always Create PRs** is enabled. For code-change tasks, always open or update a draft pull request after committing and pushing the branch. This does not apply to questions, explanations, status checks, or other information-only requests where no files are changed."""
|
||||
|
||||
|
||||
def _render_scheduled_report_section(channel_id: str | None) -> str:
|
||||
if not channel_id or not channel_id.strip():
|
||||
return ""
|
||||
return (
|
||||
"---\n\n"
|
||||
"### Scheduled Run Report\n\n"
|
||||
"This is a scheduled (automated) run with a configured Slack report channel. "
|
||||
"When you finish, post your final summary to that channel by calling "
|
||||
"`slack_thread_reply` with your report — it posts a top-level message to the "
|
||||
f"configured channel (`{channel_id.strip()}`) as the bot. Post exactly one "
|
||||
"final report. If the run produced a pull request, include its link. If "
|
||||
"`slack_thread_reply` reports a failure (for example `not_in_channel`, meaning "
|
||||
"the bot is not a member of the channel), do not retry repeatedly — surface the "
|
||||
"error in your final output instead."
|
||||
)
|
||||
|
||||
|
||||
def _render_repo_instructions_section(instructions: str | None) -> str:
|
||||
if not instructions or not instructions.strip():
|
||||
return ""
|
||||
|
|
@ -398,6 +415,7 @@ SYSTEM_PROMPT_TEMPLATE = (
|
|||
+ EXTERNAL_UNTRUSTED_COMMENTS_SECTION
|
||||
+ COMMIT_PR_SECTION
|
||||
+ "{pr_policy_override_section}"
|
||||
+ "{scheduled_report_section}"
|
||||
+ "{collaboration_section}"
|
||||
+ "{repo_instructions_section}"
|
||||
)
|
||||
|
|
@ -415,6 +433,7 @@ def construct_system_prompt(
|
|||
repo_custom_instructions: str | None = None,
|
||||
thread_url: str | None = None,
|
||||
corridor_enabled: bool = False,
|
||||
slack_report_channel: str | None = None,
|
||||
) -> str:
|
||||
default_prompt_section = _load_default_prompt()
|
||||
if default_repo and default_repo.get("owner") and default_repo.get("name"):
|
||||
|
|
@ -444,6 +463,7 @@ def construct_system_prompt(
|
|||
default_prompt_section=default_prompt_section,
|
||||
corridor_prompt_section=CORRIDOR_PROMPT if corridor_enabled else "",
|
||||
pr_policy_override_section=ALWAYS_CREATE_PR_SECTION if create_prs else "",
|
||||
scheduled_report_section=_render_scheduled_report_section(slack_report_channel),
|
||||
collaboration_section=_render_collaboration_section(triggering_user_identity, thread_url),
|
||||
repo_instructions_section=_render_repo_instructions_section(repo_custom_instructions),
|
||||
commit_identity_name=commit_identity_name,
|
||||
|
|
|
|||
121
agent/reconcile.py
Normal file
121
agent/reconcile.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Reconciliation sweep: cancel runs stuck in ``pending`` past their deadline.
|
||||
|
||||
The durable-dispatch contract relies on the platform's completion webhook to
|
||||
end every run. When that webhook never fires (crash, lost delivery), a run can
|
||||
sit in ``pending`` forever and hold its thread ``busy``. This sweep is the
|
||||
safety net: find busy threads, look for stale ``pending`` runs on them, and
|
||||
cancel the ones older than ``max_age_seconds`` so the thread frees up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from .utils.thread_ops import langgraph_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SEARCH_PAGE_SIZE = 100
|
||||
|
||||
|
||||
def _parse_created_at(value: Any) -> datetime | None:
|
||||
"""Parse a run's ``created_at`` into an aware UTC datetime, or None."""
|
||||
if isinstance(value, datetime):
|
||||
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
text = value.strip()
|
||||
if text.endswith("Z"):
|
||||
text = f"{text[:-1]}+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
async def reconcile_stale_runs(*, max_age_seconds: int = 1800) -> dict[str, int]:
|
||||
"""Cancel ``pending`` runs older than ``max_age_seconds`` on busy threads.
|
||||
|
||||
Walks every ``busy`` thread (paginated), lists its ``pending`` runs, and
|
||||
cancels those whose ``created_at`` is older than the cutoff. Per-thread work
|
||||
is wrapped in try/except so one bad thread never aborts the sweep.
|
||||
|
||||
Returns counts: ``{"threads_checked", "stale_runs", "cancelled"}``.
|
||||
"""
|
||||
client = langgraph_client()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
threads_checked = 0
|
||||
stale_runs = 0
|
||||
cancelled = 0
|
||||
|
||||
offset = 0
|
||||
while True:
|
||||
try:
|
||||
threads = await client.threads.search(
|
||||
metadata=None,
|
||||
status="busy",
|
||||
limit=_SEARCH_PAGE_SIZE,
|
||||
offset=offset,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Reconcile sweep: thread search failed at offset %d", offset)
|
||||
break
|
||||
if not threads:
|
||||
break
|
||||
|
||||
for thread in threads:
|
||||
thread_id = thread.get("thread_id") if isinstance(thread, dict) else None
|
||||
if not thread_id:
|
||||
continue
|
||||
threads_checked += 1
|
||||
try:
|
||||
runs = await client.runs.list(thread_id, status="pending")
|
||||
stale_run_ids: list[str] = []
|
||||
for run in runs:
|
||||
created = _parse_created_at(run.get("created_at"))
|
||||
if created is None:
|
||||
logger.warning(
|
||||
"Reconcile sweep: unparseable created_at on run %s (thread %s)",
|
||||
run.get("run_id"),
|
||||
thread_id,
|
||||
)
|
||||
continue
|
||||
if (now - created).total_seconds() <= max_age_seconds:
|
||||
continue
|
||||
run_id = run.get("run_id")
|
||||
if run_id:
|
||||
stale_run_ids.append(run_id)
|
||||
|
||||
if not stale_run_ids:
|
||||
continue
|
||||
stale_runs += len(stale_run_ids)
|
||||
await client.runs.cancel_many(
|
||||
thread_id=thread_id,
|
||||
run_ids=stale_run_ids,
|
||||
action="interrupt",
|
||||
)
|
||||
cancelled += len(stale_run_ids)
|
||||
logger.info(
|
||||
"Reconcile sweep: cancelled %d stale pending run(s) on thread %s",
|
||||
len(stale_run_ids),
|
||||
thread_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Reconcile sweep: failed to reconcile thread %s", thread_id)
|
||||
continue
|
||||
|
||||
if len(threads) < _SEARCH_PAGE_SIZE:
|
||||
break
|
||||
offset += _SEARCH_PAGE_SIZE
|
||||
|
||||
counts = {
|
||||
"threads_checked": threads_checked,
|
||||
"stale_runs": stale_runs,
|
||||
"cancelled": cancelled,
|
||||
}
|
||||
logger.info("Reconcile sweep complete: %s", counts)
|
||||
return counts
|
||||
|
|
@ -9,17 +9,22 @@ from langgraph.graph import END, START, StateGraph
|
|||
from langgraph.graph.state import RunnableConfig
|
||||
|
||||
from .dashboard.schedules import launch_scheduled_agent_run
|
||||
from .reconcile import reconcile_stale_runs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SchedulerState(TypedDict, total=False):
|
||||
schedule_id: str
|
||||
task: str
|
||||
result: dict[str, Any]
|
||||
|
||||
|
||||
async def _launch(state: SchedulerState, config: RunnableConfig) -> dict[str, Any]:
|
||||
configurable = config.get("configurable") or {}
|
||||
task = state.get("task") or configurable.get("task")
|
||||
if task == "reconcile":
|
||||
return {"result": await reconcile_stale_runs()}
|
||||
schedule_id = state.get("schedule_id") or configurable.get("schedule_id")
|
||||
if not isinstance(schedule_id, str) or not schedule_id:
|
||||
logger.warning("Scheduled agent tick missing schedule_id")
|
||||
|
|
|
|||
|
|
@ -647,6 +647,17 @@ def _get_cached_sandbox_backend(thread_id: str) -> SandboxBackendProtocol:
|
|||
return sandbox_backend
|
||||
|
||||
|
||||
def _scheduled_report_channel(configurable: dict[str, Any]) -> str | None:
|
||||
"""Slack channel a scheduled run should post its final report to, if configured."""
|
||||
if (configurable or {}).get("source") != "schedule":
|
||||
return None
|
||||
slack_thread = (configurable or {}).get("slack_thread") or {}
|
||||
channel_id = slack_thread.get("channel_id")
|
||||
if isinstance(channel_id, str) and channel_id.strip():
|
||||
return channel_id.strip()
|
||||
return None
|
||||
|
||||
|
||||
async def _observability_authorized(config: RunnableConfig, profile_login: str | None) -> bool:
|
||||
"""Whether the triggering user may use the team observability tools.
|
||||
|
||||
|
|
@ -928,6 +939,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
repo_custom_instructions=repo_custom_instructions,
|
||||
thread_url=dashboard_thread_url(thread_id),
|
||||
corridor_enabled=bool(corridor_tools),
|
||||
slack_report_channel=_scheduled_report_channel(configurable),
|
||||
),
|
||||
tools=[
|
||||
http_request,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
|
|
@ -23,7 +22,7 @@ _ENTERED_MESSAGE = (
|
|||
)
|
||||
|
||||
|
||||
def enter_plan_mode(tool_call_id: Annotated[str, InjectedToolCallId]) -> Command:
|
||||
async def enter_plan_mode(tool_call_id: Annotated[str, InjectedToolCallId]) -> Command:
|
||||
"""Activate plan mode mid-run.
|
||||
|
||||
Call this when you believe the task would benefit from a structured
|
||||
|
|
@ -41,7 +40,7 @@ def enter_plan_mode(tool_call_id: Annotated[str, InjectedToolCallId]) -> Command
|
|||
thread_id = _thread_id_from_config()
|
||||
if thread_id:
|
||||
try:
|
||||
asyncio.run(set_plan_status(thread_id, PLAN_STATUS_PLANNING, plan_mode=True))
|
||||
await set_plan_status(thread_id, PLAN_STATUS_PLANNING, plan_mode=True)
|
||||
except Exception:
|
||||
logger.warning("Failed to persist plan-mode entry for %s", thread_id, exc_info=True)
|
||||
return Command(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from langgraph.config import get_config
|
||||
|
|
@ -7,7 +6,7 @@ from agent.utils.slack import parse_github_pr_url
|
|||
from agent.webapp import trigger_pr_review_from_ref
|
||||
|
||||
|
||||
def request_pr_review(pr_url: str) -> dict[str, Any]:
|
||||
async def request_pr_review(pr_url: str) -> dict[str, Any]:
|
||||
"""Start the reviewer agent for a GitHub pull request URL."""
|
||||
pr_ref = parse_github_pr_url(pr_url)
|
||||
if not pr_ref:
|
||||
|
|
@ -19,13 +18,11 @@ def request_pr_review(pr_url: str) -> dict[str, Any]:
|
|||
configurable = get_config().get("configurable", {})
|
||||
source = configurable.get("source") or "agent"
|
||||
slack_thread = configurable.get("slack_thread") or {}
|
||||
return asyncio.run(
|
||||
trigger_pr_review_from_ref(
|
||||
pr_ref,
|
||||
source=source,
|
||||
github_login=configurable.get("github_login", ""),
|
||||
github_user_id=configurable.get("github_user_id"),
|
||||
slack_channel_id=slack_thread.get("channel_id", ""),
|
||||
slack_thread_ts=slack_thread.get("thread_ts", ""),
|
||||
)
|
||||
return await trigger_pr_review_from_ref(
|
||||
pr_ref,
|
||||
source=source,
|
||||
github_login=configurable.get("github_login", ""),
|
||||
github_user_id=configurable.get("github_user_id"),
|
||||
slack_channel_id=slack_thread.get("channel_id", ""),
|
||||
slack_thread_ts=slack_thread.get("thread_ts", ""),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ changes. Available in plan mode (it does not modify the repository under review)
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -21,7 +20,7 @@ logger = logging.getLogger(__name__)
|
|||
PLAN_FILE_PATH = "plan.md"
|
||||
|
||||
|
||||
def save_plan(plan_markdown: str) -> dict[str, Any]:
|
||||
async def save_plan(plan_markdown: str) -> dict[str, Any]:
|
||||
"""Write your implementation plan as a markdown file and publish it for review.
|
||||
|
||||
Use this in plan mode once your plan is ready. The plan is saved as
|
||||
|
|
@ -54,7 +53,7 @@ def save_plan(plan_markdown: str) -> dict[str, Any]:
|
|||
return {"success": False, "error": "no thread_id in run config"}
|
||||
|
||||
try:
|
||||
path = asyncio.run(_save(str(thread_id), content))
|
||||
path = await _save(str(thread_id), content)
|
||||
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}"}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from typing import Any
|
||||
|
||||
from langgraph.config import get_config
|
||||
|
|
@ -8,6 +9,7 @@ from langgraph_sdk import get_client
|
|||
from ..utils.slack import (
|
||||
convert_mentions_to_slack_format,
|
||||
post_slack_thread_reply_with_ts,
|
||||
post_slack_top_level_message_with_ts,
|
||||
store_slack_message_run_mapping,
|
||||
)
|
||||
|
||||
|
|
@ -15,6 +17,12 @@ LANGGRAPH_URL = os.environ.get("LANGGRAPH_URL") or os.environ.get(
|
|||
"LANGGRAPH_URL_PROD", "http://localhost:2024"
|
||||
)
|
||||
|
||||
# Runs that have already posted their single top-level (channel) message. Scheduled
|
||||
# runs seed `slack_thread` with a channel but no `thread_ts`, so every reply would
|
||||
# otherwise spray a new top-level message into the report channel; cap it at one.
|
||||
_MAX_TRACKED_RUNS = 2048
|
||||
_top_level_posts: "OrderedDict[str, None]" = OrderedDict()
|
||||
|
||||
|
||||
async def slack_thread_reply(
|
||||
message: str,
|
||||
|
|
@ -50,17 +58,33 @@ async def slack_thread_reply(
|
|||
|
||||
channel_id = slack_thread.get("channel_id")
|
||||
thread_ts = slack_thread.get("thread_ts")
|
||||
if not channel_id or not thread_ts:
|
||||
if not channel_id:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Missing slack_thread.channel_id or slack_thread.thread_ts in config",
|
||||
"error": "Missing slack_thread.channel_id in config",
|
||||
}
|
||||
|
||||
if not message.strip():
|
||||
return {"success": False, "error": "Message cannot be empty"}
|
||||
|
||||
top_level = not thread_ts
|
||||
run_key = _run_key(config) if top_level else None
|
||||
if top_level and run_key is not None and run_key in _top_level_posts:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "A message was already posted to this channel for this run",
|
||||
"hint": (
|
||||
"Only one top-level message per run is allowed for the configured "
|
||||
"report channel; post a single final report and do not call this again."
|
||||
),
|
||||
}
|
||||
|
||||
message = convert_mentions_to_slack_format(message)
|
||||
if plan_approval:
|
||||
if top_level:
|
||||
# Interactive blocks (options / plan_approval) render dead buttons in a
|
||||
# report channel where no run is driving the approval/option flow.
|
||||
slack_blocks = blocks
|
||||
elif plan_approval:
|
||||
slack_blocks = _build_plan_approval_blocks(message)
|
||||
else:
|
||||
slack_blocks = blocks or _build_option_blocks(message, options)
|
||||
|
|
@ -75,9 +99,25 @@ async def slack_thread_reply(
|
|||
"message_chars": len(message),
|
||||
"hint": _slack_reply_failure_hint(slack_error),
|
||||
}
|
||||
if top_level and run_key is not None:
|
||||
_top_level_posts[run_key] = None
|
||||
if len(_top_level_posts) > _MAX_TRACKED_RUNS:
|
||||
_top_level_posts.popitem(last=False)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
def _run_key(config: dict[str, Any]) -> str | None:
|
||||
candidates = [config.get("run_id")]
|
||||
configurable = config.get("configurable")
|
||||
if isinstance(configurable, dict):
|
||||
candidates.append(configurable.get("run_id"))
|
||||
candidates.append(configurable.get("thread_id"))
|
||||
for candidate in candidates:
|
||||
if isinstance(candidate, str) and candidate:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _build_option_blocks(message: str, options: list[str] | None) -> list[dict[str, Any]] | None:
|
||||
if not options:
|
||||
return None
|
||||
|
|
@ -188,11 +228,15 @@ def _slack_reply_failure_hint(slack_error: str | None) -> str:
|
|||
|
||||
async def _post_and_store_mapping(
|
||||
channel_id: str,
|
||||
thread_ts: str,
|
||||
thread_ts: str | None,
|
||||
message: str,
|
||||
*,
|
||||
blocks: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
if not thread_ts:
|
||||
# Top-level report posts are fire-and-forget: a scheduled run is one-shot, so
|
||||
# there is no live run to route channel replies back to (no mapping stored).
|
||||
return await post_slack_top_level_message_with_ts(channel_id, message, blocks=blocks)
|
||||
message_ts, slack_error = await post_slack_thread_reply_with_ts(
|
||||
channel_id, thread_ts, message, blocks=blocks
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
"""Shared LangGraph thread helpers for webhooks and the dashboard."""
|
||||
"""Shared LangGraph thread helpers for the dashboard.
|
||||
|
||||
The webhook triggers (Slack / Linear / GitHub) dispatch through
|
||||
``agent.dispatch.dispatch_agent_run`` with ``multitask_strategy="interrupt"``,
|
||||
so they no longer need a busy-check or an in-process lock. The store-queue
|
||||
below is retained for the dashboard's deliberate "inject a follow-up into a
|
||||
run that's already in flight" path (``thread_api.send_dashboard_message``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from langgraph_sdk import get_client
|
||||
|
|
@ -15,25 +19,6 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
MAX_QUEUED_MESSAGES = 100
|
||||
|
||||
_THREAD_RUN_LOCKS: dict[str, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def get_thread_run_lock(thread_id: str) -> asyncio.Lock:
|
||||
"""Return a per-thread-id asyncio.Lock, creating one lazily if needed."""
|
||||
lock = _THREAD_RUN_LOCKS.get(thread_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_THREAD_RUN_LOCKS[thread_id] = lock
|
||||
return lock
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def thread_run_lock(thread_id: str) -> AsyncIterator[None]:
|
||||
"""Serialize run dispatch for a thread."""
|
||||
lock = get_thread_run_lock(thread_id)
|
||||
async with lock:
|
||||
yield
|
||||
|
||||
|
||||
def langgraph_url() -> str:
|
||||
return os.environ.get("LANGGRAPH_URL") or os.environ.get(
|
||||
|
|
@ -57,15 +42,14 @@ async def get_thread_active_status(thread_id: str) -> bool | None:
|
|||
return None
|
||||
|
||||
|
||||
async def is_thread_active(thread_id: str) -> bool:
|
||||
"""Return whether the thread currently has a running run."""
|
||||
return await get_thread_active_status(thread_id) is True
|
||||
|
||||
|
||||
async def queue_message_for_thread(
|
||||
thread_id: str, message_content: str | list[dict[str, Any]] | dict[str, Any]
|
||||
) -> bool:
|
||||
"""Queue a follow-up message for a busy thread (FIFO store namespace)."""
|
||||
"""Queue a follow-up message for a busy thread (FIFO store namespace).
|
||||
|
||||
Used by the dashboard to inject a follow-up into a run that's already in
|
||||
flight; webhook triggers use ``multitask_strategy="interrupt"`` instead.
|
||||
"""
|
||||
client = langgraph_client()
|
||||
try:
|
||||
namespace = ("queue", thread_id)
|
||||
|
|
|
|||
1962
agent/webapp.py
1962
agent/webapp.py
File diff suppressed because it is too large
Load diff
0
agent/webhooks/__init__.py
Normal file
0
agent/webhooks/__init__.py
Normal file
1223
agent/webhooks/github.py
Normal file
1223
agent/webhooks/github.py
Normal file
File diff suppressed because it is too large
Load diff
235
agent/webhooks/linear.py
Normal file
235
agent/webhooks/linear.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
"""Linear webhook handler — moved out of webapp.py (behavior-identical).
|
||||
|
||||
Helpers and constants stay in webapp.py; they are accessed through the module
|
||||
object (``webapp.X``) so tests that monkeypatch them keep working.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from langchain_core.messages.content import create_text_block
|
||||
|
||||
from agent import webapp
|
||||
|
||||
|
||||
async def process_linear_issue( # noqa: PLR0912, PLR0915
|
||||
issue_data: dict[str, Any], repo_config: dict[str, str]
|
||||
) -> None:
|
||||
"""Process a Linear issue by creating a new LangGraph thread and run.
|
||||
|
||||
Args:
|
||||
issue_data: The Linear issue data from webhook (basic info only).
|
||||
repo_config: The repo configuration with owner and name.
|
||||
"""
|
||||
issue_id = issue_data.get("id", "")
|
||||
webapp.logger.info(
|
||||
"Processing Linear issue %s for repo %s/%s",
|
||||
issue_id,
|
||||
repo_config.get("owner"),
|
||||
repo_config.get("name"),
|
||||
)
|
||||
|
||||
triggering_comment_id = issue_data.get("triggering_comment_id", "")
|
||||
if triggering_comment_id:
|
||||
await webapp.react_to_linear_comment(triggering_comment_id, "👀")
|
||||
|
||||
thread_id = webapp.generate_thread_id_from_issue(issue_id)
|
||||
|
||||
full_issue = await webapp.fetch_linear_issue_details(issue_id)
|
||||
if not full_issue:
|
||||
full_issue = issue_data
|
||||
|
||||
user_email = None
|
||||
user_name = None
|
||||
comment_author = issue_data.get("comment_author", {})
|
||||
if comment_author:
|
||||
user_email = comment_author.get("email")
|
||||
user_name = comment_author.get("name")
|
||||
if not user_email:
|
||||
creator = full_issue.get("creator", {})
|
||||
if creator:
|
||||
user_email = creator.get("email")
|
||||
user_name = user_name or creator.get("name")
|
||||
if not user_email:
|
||||
assignee = full_issue.get("assignee", {})
|
||||
if assignee:
|
||||
user_email = assignee.get("email")
|
||||
user_name = user_name or assignee.get("name")
|
||||
|
||||
webapp.logger.info("User email for issue %s: %s", issue_id, user_email)
|
||||
|
||||
title = full_issue.get("title", "No title")
|
||||
description = full_issue.get("description") or "No description"
|
||||
image_urls: list[str] = []
|
||||
description_image_urls = webapp.extract_image_urls(description)
|
||||
if description_image_urls:
|
||||
image_urls.extend(description_image_urls)
|
||||
webapp.logger.debug(
|
||||
"Found %d image URL(s) in issue description",
|
||||
len(description_image_urls),
|
||||
)
|
||||
|
||||
comments = full_issue.get("comments", {}).get("nodes", [])
|
||||
comments_text = ""
|
||||
triggering_comment = issue_data.get("triggering_comment", "")
|
||||
triggering_comment_id = issue_data.get("triggering_comment_id", "")
|
||||
|
||||
bot_message_prefixes = (
|
||||
"🔐 **GitHub Authentication Required**",
|
||||
"✅ **Pull Request Created**",
|
||||
"✅ **Pull Request Updated**",
|
||||
"**Pull Request Created**",
|
||||
"**Pull Request Updated**",
|
||||
"🤖 **Agent Response**",
|
||||
"❌ **Agent Error**",
|
||||
)
|
||||
|
||||
comment_ids: set[str] = set()
|
||||
comment_id_to_index: dict[str, int] = {}
|
||||
if comments:
|
||||
for i, comment in enumerate(comments):
|
||||
comment_id = comment.get("id", "")
|
||||
if comment_id:
|
||||
comment_ids.add(comment_id)
|
||||
comment_id_to_index[comment_id] = i
|
||||
|
||||
relevant_comments = []
|
||||
trigger_index = None
|
||||
if triggering_comment_id:
|
||||
trigger_index = comment_id_to_index.get(triggering_comment_id)
|
||||
if trigger_index is not None:
|
||||
relevant_comments = comments[trigger_index:]
|
||||
webapp.logger.debug(
|
||||
"Using triggering comment index %d to build relevant comments",
|
||||
trigger_index,
|
||||
)
|
||||
else:
|
||||
relevant_comments = webapp.get_recent_comments(comments, bot_message_prefixes)
|
||||
|
||||
if relevant_comments:
|
||||
comments_text = "\n\n## Comments:\n"
|
||||
for comment in relevant_comments:
|
||||
user = comment.get("user") or {}
|
||||
author = user.get("name", "User")
|
||||
body = comment.get("body", "")
|
||||
body_image_urls = webapp.extract_image_urls(body)
|
||||
if body_image_urls:
|
||||
image_urls.extend(body_image_urls)
|
||||
webapp.logger.debug(
|
||||
"Found %d image URL(s) in comment by %s",
|
||||
len(body_image_urls),
|
||||
author,
|
||||
)
|
||||
if any(body.startswith(prefix) for prefix in bot_message_prefixes):
|
||||
continue
|
||||
comments_text += f"\n**{author}:** {body}\n"
|
||||
|
||||
if triggering_comment and triggering_comment_id not in comment_ids:
|
||||
if not comments_text:
|
||||
comments_text = "\n\n## Comments:\n"
|
||||
trigger_author = comment_author.get("name", "Unknown")
|
||||
trigger_body = triggering_comment
|
||||
trigger_image_urls = webapp.extract_image_urls(trigger_body)
|
||||
if trigger_image_urls:
|
||||
image_urls.extend(trigger_image_urls)
|
||||
webapp.logger.debug(
|
||||
"Found %d image URL(s) in triggering comment by %s",
|
||||
len(trigger_image_urls),
|
||||
trigger_author,
|
||||
)
|
||||
comments_text += f"\n**{trigger_author}:** {trigger_body}\n"
|
||||
webapp.logger.debug(
|
||||
"Appended triggering comment %s not present in issue comments list",
|
||||
triggering_comment_id or "<missing-id>",
|
||||
)
|
||||
|
||||
identifier = full_issue.get("identifier", "") or issue_data.get("identifier", "")
|
||||
|
||||
triggered_by_line = f"## Triggered by: {user_name}\n\n" if user_name else ""
|
||||
tag_instruction = (
|
||||
f"When calling linear_comment, tag @{user_name} if you are asking them a question, need their input, or are notifying them of something important (e.g. a completed PR). For simple answers, tagging is not required."
|
||||
if user_name
|
||||
else ""
|
||||
)
|
||||
prompt = (
|
||||
f"Please work on the following issue:\n\n"
|
||||
f"## Repository: {repo_config.get('owner')}/{repo_config.get('name')}\n\n"
|
||||
f"## Title: {title}\n\n"
|
||||
f"{triggered_by_line}"
|
||||
f"## Linear Ticket: {identifier} - Ticket ID: {issue_id}\n\n"
|
||||
f"## Description:\n{description}\n"
|
||||
f"{comments_text}\n\n"
|
||||
f"Please analyze this issue and implement the necessary changes. "
|
||||
f"When you're done, commit and push your changes. {tag_instruction}"
|
||||
)
|
||||
content_blocks: list[dict[str, Any]] = [create_text_block(prompt)]
|
||||
if image_urls:
|
||||
image_urls = webapp.dedupe_urls(image_urls)
|
||||
linear_login = (
|
||||
await webapp.resolve_login_from_email_async(user_email) if user_email else None
|
||||
)
|
||||
resolved_model_id = await webapp.resolve_agent_model_id(linear_login)
|
||||
if webapp.model_supports_images(resolved_model_id):
|
||||
webapp.logger.info("Preparing %d image(s) for multimodal content", len(image_urls))
|
||||
webapp.logger.debug("Image URLs: %s", image_urls)
|
||||
|
||||
async with httpx.AsyncClient(timeout=webapp.DEFAULT_HTTP_TIMEOUT) as client:
|
||||
for image_url in image_urls:
|
||||
image_block = await webapp.fetch_image_block(image_url, client)
|
||||
if image_block:
|
||||
content_blocks.append(image_block)
|
||||
webapp.logger.info("Built %d content block(s) for prompt", len(content_blocks))
|
||||
else:
|
||||
webapp.logger.warning(
|
||||
"Skipping %d image(s) for Linear issue: model %s does not support images",
|
||||
len(image_urls),
|
||||
resolved_model_id,
|
||||
)
|
||||
prompt += webapp.vision_not_supported_warning(resolved_model_id, len(image_urls))
|
||||
content_blocks[0] = create_text_block(prompt)
|
||||
image_urls = []
|
||||
|
||||
linear_project_id = ""
|
||||
linear_issue_number = ""
|
||||
if identifier and "-" in identifier:
|
||||
parts = identifier.split("-", 1)
|
||||
linear_project_id = parts[0]
|
||||
linear_issue_number = parts[1]
|
||||
|
||||
configurable: dict[str, Any] = {
|
||||
"repo": repo_config,
|
||||
"linear_issue": {
|
||||
"id": issue_id,
|
||||
"title": title,
|
||||
"url": full_issue.get("url", "") or issue_data.get("url", ""),
|
||||
"identifier": identifier,
|
||||
"linear_project_id": linear_project_id,
|
||||
"linear_issue_number": linear_issue_number,
|
||||
"triggering_user_name": user_name or "",
|
||||
},
|
||||
"user_email": user_email,
|
||||
"source": "linear",
|
||||
}
|
||||
|
||||
await webapp.upsert_agent_thread_owner_metadata(
|
||||
thread_id,
|
||||
source="linear",
|
||||
repo_config=repo_config,
|
||||
user_email=user_email or "",
|
||||
title=title or identifier or "Linear issue",
|
||||
source_context={"linear_issue": configurable["linear_issue"]},
|
||||
)
|
||||
|
||||
run = await webapp.dispatch_agent_run(
|
||||
thread_id,
|
||||
content_blocks,
|
||||
configurable,
|
||||
source="linear",
|
||||
metadata=webapp._AGENT_VERSION_METADATA,
|
||||
)
|
||||
webapp.logger.info(
|
||||
"LangGraph run dispatched for thread %s (run=%s)",
|
||||
thread_id,
|
||||
run.get("run_id") if isinstance(run, dict) else None,
|
||||
)
|
||||
await webapp.post_linear_trace_comment(issue_id, thread_id, triggering_comment_id)
|
||||
269
agent/webhooks/slack.py
Normal file
269
agent/webhooks/slack.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
"""Slack webhook handler — moved out of webapp.py (behavior-identical).
|
||||
|
||||
Helpers and constants stay in webapp.py; they are accessed through the module
|
||||
object (``webapp.X``) so tests that monkeypatch them keep working.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from langchain_core.messages.content import create_text_block
|
||||
|
||||
from agent import webapp
|
||||
|
||||
|
||||
async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[str, str]) -> None:
|
||||
"""Process a Slack app mention by creating a run or queuing a mid-run message."""
|
||||
channel_id = event_data.get("channel_id", "")
|
||||
thread_ts = event_data.get("thread_ts", "")
|
||||
event_ts = event_data.get("event_ts", "")
|
||||
user_id = event_data.get("user_id", "")
|
||||
text = event_data.get("text", "")
|
||||
bot_user_id = event_data.get("bot_user_id", "")
|
||||
|
||||
if not channel_id or not thread_ts or not event_ts:
|
||||
webapp.logger.warning(
|
||||
"Missing Slack event fields (channel_id=%s, thread_ts=%s, event_ts=%s)",
|
||||
channel_id,
|
||||
thread_ts,
|
||||
event_ts,
|
||||
)
|
||||
return
|
||||
|
||||
await webapp.set_slack_assistant_status(channel_id, thread_ts)
|
||||
|
||||
thread_id = webapp.generate_thread_id_from_slack_thread(channel_id, thread_ts)
|
||||
|
||||
# Prime the user-mapping cache so login/email/slack-id lookups below are warm.
|
||||
try:
|
||||
await webapp.refresh_user_mapping_cache()
|
||||
except Exception: # noqa: BLE001
|
||||
webapp.logger.debug("Could not refresh user mapping cache for Slack mention", exc_info=True)
|
||||
|
||||
user_email = None
|
||||
user_name = ""
|
||||
if user_id:
|
||||
slack_user = await webapp.get_slack_user_info(user_id)
|
||||
if slack_user:
|
||||
profile = slack_user.get("profile", {})
|
||||
if isinstance(profile, dict):
|
||||
user_email = profile.get("email")
|
||||
user_name = (
|
||||
profile.get("display_name")
|
||||
or profile.get("real_name")
|
||||
or slack_user.get("real_name")
|
||||
or slack_user.get("name")
|
||||
or ""
|
||||
)
|
||||
|
||||
thread_messages = await webapp.fetch_slack_thread_messages(channel_id, thread_ts)
|
||||
if not any(str(message.get("ts")) == str(event_ts) for message in thread_messages):
|
||||
thread_messages.append({"ts": event_ts, "text": text, "user": user_id})
|
||||
|
||||
context_messages, context_mode = webapp.select_slack_context_messages(
|
||||
thread_messages, event_ts, bot_user_id, webapp.SLACK_BOT_USERNAME
|
||||
)
|
||||
context_user_ids = [
|
||||
value
|
||||
for value in (message.get("user") for message in context_messages)
|
||||
if isinstance(value, str) and value
|
||||
]
|
||||
user_names_by_id = await webapp.get_slack_user_names(context_user_ids)
|
||||
if user_id and user_name and user_id not in user_names_by_id:
|
||||
user_names_by_id[user_id] = user_name
|
||||
context_text = webapp.format_slack_messages_for_prompt(
|
||||
context_messages,
|
||||
user_names_by_id,
|
||||
bot_user_id=bot_user_id,
|
||||
bot_username=webapp.SLACK_BOT_USERNAME,
|
||||
)
|
||||
context_source = (
|
||||
"the previous message where I was tagged"
|
||||
if context_mode == "last_mention"
|
||||
else "the beginning of the thread"
|
||||
)
|
||||
clean_text = (
|
||||
webapp.strip_bot_mention(text, bot_user_id, bot_username=webapp.SLACK_BOT_USERNAME)
|
||||
or "(no text in mention)"
|
||||
)
|
||||
trigger_user = user_name or (f"<@{user_id}>" if user_id else "Unknown user")
|
||||
|
||||
# Auto-resolve cross-posted Slack message links in context
|
||||
resolved_links_section, image_urls_from_links = await webapp.resolve_slack_links_in_context(
|
||||
context_messages, user_names_by_id
|
||||
)
|
||||
|
||||
prompt = (
|
||||
"You were mentioned in Slack.\n\n"
|
||||
"## Default Repository Hint\n"
|
||||
f"{repo_config.get('owner')}/{repo_config.get('name')}\n"
|
||||
"Use this only if the Slack conversation does not identify a different repository.\n\n"
|
||||
f"## Triggered by\n{trigger_user}\n\n"
|
||||
f"## Slack Thread\n- Channel: {channel_id}\n- Thread TS: {thread_ts}\n"
|
||||
f"- Context starts at: {context_source}\n\n"
|
||||
f"## Conversation Context\n{context_text}\n\n"
|
||||
f"## Latest Mention Request\n{clean_text}\n\n"
|
||||
+ (f"{resolved_links_section}\n\n" if resolved_links_section else "")
|
||||
+ "Use `slack_thread_reply` to communicate in this Slack thread for clarifications, "
|
||||
"status updates, and final summaries. Use `slack_read_thread_messages` to read any "
|
||||
"Slack messages by providing channel_id and message_ts."
|
||||
)
|
||||
content_blocks: list[dict[str, Any]] = [create_text_block(prompt)]
|
||||
|
||||
image_urls = webapp.dedupe_urls(
|
||||
[url for msg in context_messages for url in webapp.extract_image_urls(msg.get("text", ""))]
|
||||
+ [
|
||||
f["url_private"]
|
||||
for msg in context_messages
|
||||
for f in msg.get("files", [])
|
||||
if isinstance(f, dict)
|
||||
and f.get("mimetype", "").startswith("image/")
|
||||
and f.get("url_private")
|
||||
]
|
||||
+ image_urls_from_links
|
||||
)
|
||||
|
||||
mapped_login = await webapp.login_for_slack_id(user_id)
|
||||
if not mapped_login and user_email:
|
||||
mapped_login = await webapp.login_for_email(user_email)
|
||||
|
||||
if image_urls:
|
||||
resolved_model_id = await webapp.resolve_agent_model_id(mapped_login)
|
||||
if webapp.model_supports_images(resolved_model_id):
|
||||
webapp.logger.info("Preparing %d image(s) for Slack mention", len(image_urls))
|
||||
async with httpx.AsyncClient(timeout=webapp.DEFAULT_HTTP_TIMEOUT) as http_client:
|
||||
for image_url in image_urls:
|
||||
image_block = await webapp.fetch_image_block(image_url, http_client)
|
||||
if image_block:
|
||||
content_blocks.append(image_block)
|
||||
else:
|
||||
webapp.logger.warning(
|
||||
"Skipping %d image(s) for Slack mention: model %s does not support images",
|
||||
len(image_urls),
|
||||
resolved_model_id,
|
||||
)
|
||||
prompt += webapp.vision_not_supported_warning(resolved_model_id, len(image_urls))
|
||||
content_blocks[0] = create_text_block(prompt)
|
||||
image_urls = []
|
||||
|
||||
# Open SWE opens PRs as the triggering user, so a run only proceeds when we
|
||||
# have a valid user GitHub token. Users who have never signed in with
|
||||
# GitHub, and users whose stored authorization is no longer usable, are
|
||||
# blocked and prompted to set up via the dashboard. Bot-token-only
|
||||
# deployments are exempt — they run on the installation token.
|
||||
user_token: str | None = None
|
||||
if mapped_login:
|
||||
try:
|
||||
user_token = await webapp.get_valid_access_token(mapped_login)
|
||||
except Exception: # noqa: BLE001
|
||||
webapp.logger.debug(
|
||||
"Failed to resolve GitHub token for %s; treating as unauthenticated",
|
||||
mapped_login,
|
||||
exc_info=True,
|
||||
)
|
||||
user_token = None
|
||||
has_valid_user_token = bool(user_token)
|
||||
|
||||
if not has_valid_user_token and not webapp.is_bot_token_only_mode():
|
||||
# A stored-but-unusable token means "sign in again"; no record at all
|
||||
# means the user has never connected GitHub + Slack via the dashboard.
|
||||
# Guard the store read like token resolution above so a transient
|
||||
# failure still yields an actionable prompt and clears the status.
|
||||
has_token_record = False
|
||||
if mapped_login:
|
||||
try:
|
||||
has_token_record = await webapp.has_access_token_record(mapped_login)
|
||||
except Exception: # noqa: BLE001
|
||||
webapp.logger.debug(
|
||||
"Failed to check GitHub token record for %s; prompting sign-in",
|
||||
mapped_login,
|
||||
exc_info=True,
|
||||
)
|
||||
reason = "revoked" if has_token_record else "unlinked"
|
||||
webapp.logger.info(
|
||||
"Blocking Slack run for thread %s: no valid user GitHub token (%s)",
|
||||
thread_id,
|
||||
reason,
|
||||
)
|
||||
if user_id:
|
||||
await webapp._post_account_link_prompt(
|
||||
channel_id, thread_ts, user_id, user_email, reason=reason
|
||||
)
|
||||
await webapp.set_slack_assistant_status(channel_id, thread_ts, status="")
|
||||
return
|
||||
|
||||
configurable: dict[str, Any] = {
|
||||
"repo": repo_config,
|
||||
"slack_thread": {
|
||||
"channel_id": channel_id,
|
||||
"thread_ts": thread_ts,
|
||||
"triggering_user_id": user_id,
|
||||
"triggering_user_name": user_name,
|
||||
"triggering_user_email": user_email,
|
||||
"triggering_event_ts": event_ts,
|
||||
},
|
||||
"user_email": user_email,
|
||||
"source": "slack",
|
||||
}
|
||||
if mapped_login:
|
||||
configurable["github_login"] = mapped_login
|
||||
|
||||
thread_plan_mode = await webapp._get_thread_plan_mode(thread_id)
|
||||
if thread_plan_mode is not None:
|
||||
configurable["plan_mode"] = thread_plan_mode
|
||||
|
||||
langgraph_client = webapp.get_client(url=webapp.LANGGRAPH_URL)
|
||||
is_first_mention = not await webapp._thread_exists(thread_id)
|
||||
await webapp._upsert_slack_thread_repo_metadata(thread_id, repo_config, langgraph_client)
|
||||
# Pass the login resolved above (from the stable Slack user id) so the thread is
|
||||
# always tagged with github_login — the key the dashboard searches by. Without
|
||||
# it, upsert re-resolves from the Slack profile email, which can miss.
|
||||
await webapp.upsert_agent_thread_owner_metadata(
|
||||
thread_id,
|
||||
source="slack",
|
||||
repo_config=repo_config,
|
||||
github_login=mapped_login or "",
|
||||
user_email=user_email or "",
|
||||
title=clean_text if is_first_mention else "",
|
||||
source_context={"slack_thread": configurable["slack_thread"]},
|
||||
)
|
||||
|
||||
run = await webapp.dispatch_agent_run(
|
||||
thread_id,
|
||||
content_blocks,
|
||||
configurable,
|
||||
source="slack",
|
||||
metadata=webapp._AGENT_VERSION_METADATA,
|
||||
client=langgraph_client,
|
||||
)
|
||||
webapp.logger.info(
|
||||
"Slack LangGraph run %s dispatched for thread %s",
|
||||
webapp._run_id_for_logging(run),
|
||||
thread_id,
|
||||
)
|
||||
run_id = run.get("run_id")
|
||||
if is_first_mention:
|
||||
trace_message_ts = await webapp.post_slack_trace_reply(channel_id, thread_ts, thread_id)
|
||||
await webapp.set_slack_assistant_status(channel_id, thread_ts)
|
||||
if isinstance(run_id, str) and run_id:
|
||||
await webapp.store_slack_run_mapping(
|
||||
langgraph_client,
|
||||
channel_id,
|
||||
thread_ts,
|
||||
run_id,
|
||||
message_ts=trace_message_ts,
|
||||
triggering_user_id=user_id,
|
||||
)
|
||||
else:
|
||||
webapp.logger.info(
|
||||
"Skipping Slack trace reply for thread %s — agent will reply when run completes",
|
||||
thread_id,
|
||||
)
|
||||
if isinstance(run_id, str) and run_id:
|
||||
await webapp.store_slack_run_mapping(
|
||||
langgraph_client,
|
||||
channel_id,
|
||||
thread_ts,
|
||||
run_id,
|
||||
triggering_user_id=user_id,
|
||||
)
|
||||
|
|
@ -303,20 +303,20 @@ Mappings can't be fully created from the dashboard → must write the Store dire
|
|||
- **Fix (Phase B):** add the `work_email` field to the Admin mappings form so mappings are fully creatable from the UI.
|
||||
|
||||
### Fix #5 — GitHub webhook path doesn't refresh the user-mapping cache (multi-replica break)
|
||||
**Files:** `agent/webapp.py:3052` (GitHub path) vs `agent/webapp.py:1091` (Slack path).
|
||||
**Files:** `agent/webhooks/github.py` (GitHub handlers: `process_github_pr_comment`, `process_github_issue`) vs `agent/webhooks/slack.py` (`process_slack_mention`). (Pre-modular-refactor these all lived in `agent/webapp.py`.)
|
||||
The Slack path refreshes before lookup:
|
||||
```python
|
||||
# agent/webapp.py:1089-1093 (Slack)
|
||||
await refresh_user_mapping_cache()
|
||||
# agent/webhooks/slack.py (process_slack_mention)
|
||||
await webapp.refresh_user_mapping_cache()
|
||||
...
|
||||
```
|
||||
The GitHub path does **not** — it calls `email = await email_for_login(github_login)` (`webapp.py:3052`, again at `:3331`) cold. The cache (`user_mappings.py` `_ensure_cache_loaded`, line 197) is **one-shot per process** (`_cache_loaded` flag). On self-host single-process this was fine; on managed's **multi-replica autoscaling**, a freshly-added mapping isn't seen by a replica whose cache loaded earlier — until restart.
|
||||
- **Fix:** refresh-before-lookup on the GitHub path (mirror the Slack path), or add a TTL / cross-replica invalidation to the cache.
|
||||
- **Generalize (Phase B5):** audit ALL in-process caches for the single-process → multi-replica assumption — `SANDBOX_BACKENDS` dict (`agent/utils/sandbox_state.py`), `_THREAD_RUN_LOCKS` (`thread_ops.py:18`), `_by_login`/`_by_email`/`_by_slack_id` (`user_mappings.py:67-69`). Sandbox affinity is already thread-keyed + persisted in thread metadata (`sandbox_id`), so it's the cache/lock state that needs the multi-replica review.
|
||||
The GitHub path historically did **not** — it called `email = await email_for_login(github_login)` cold. The cache (`user_mappings.py` `_ensure_cache_loaded`) is **one-shot per process** (`_cache_loaded` flag). On self-host single-process this was fine; on managed's **multi-replica autoscaling**, a freshly-added mapping isn't seen by a replica whose cache loaded earlier — until restart.
|
||||
- **Fix (applied):** the GitHub issue and PR-comment handlers now call `webapp.refresh_user_mapping_cache()` before email resolution, mirroring the Slack path.
|
||||
- **Generalize (Phase B5):** audit ALL in-process caches for the single-process → multi-replica assumption — `SANDBOX_BACKENDS` dict (`agent/utils/sandbox_state.py`), `_by_login`/`_by_email`/`_by_slack_id` (`user_mappings.py`). Sandbox affinity is already thread-keyed + persisted in thread metadata (`sandbox_id`), so it's the cache state that needs the multi-replica review. (The legacy in-process thread lock has been removed: webhook triggers now serialize through `dispatch_agent_run`'s `multitask_strategy="interrupt"` instead.)
|
||||
|
||||
### Fix #6 — Slow custom-app import (~8s startup)
|
||||
**Symptom:** "exceeded expected startup time" → risks the deployment being marked unhealthy / slow to scale out.
|
||||
- **Fix (Phase B6):** lazy imports / reduce import-time work in `agent/webapp.py` and the graph factories. Profile with `FF_PROFILE_IMPORTS` (the import-profiling flag) to find the heavy modules.
|
||||
- **Fix (Phase B6):** lazy imports / reduce import-time work in `agent/webapp.py` (+ `agent/webhooks/*.py`) and the graph factories. Profile with `FF_PROFILE_IMPORTS` (the import-profiling flag) to find the heavy modules.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
"reviewer": "agent.reviewer:traced_reviewer_agent",
|
||||
"analyzer": "agent.analyzer:traced_analyzer",
|
||||
"chat": "agent.chat:traced_chat_agent",
|
||||
"scheduler": "agent.scheduler:get_scheduler"
|
||||
"scheduler": "agent.scheduler:get_scheduler",
|
||||
"ci_monitor": "agent.ci_monitor:get_ci_monitor"
|
||||
},
|
||||
"dependencies": [
|
||||
"."
|
||||
|
|
|
|||
|
|
@ -6,37 +6,37 @@ readme = "README.md"
|
|||
requires-python = ">=3.11"
|
||||
license = { text = "MIT" }
|
||||
dependencies = [
|
||||
"deepagents==0.6.8",
|
||||
"fastapi>=0.136.3",
|
||||
"uvicorn>=0.48.0",
|
||||
"deepagents==0.6.12",
|
||||
"fastapi>=0.138.2",
|
||||
"uvicorn>=0.49.0",
|
||||
"httpx>=0.28.1",
|
||||
"PyJWT>=2.13.0",
|
||||
"cryptography>=48.0.1",
|
||||
"langgraph-sdk>=0.4.2",
|
||||
"langchain>=1.3.9",
|
||||
"langgraph>=1.1.10",
|
||||
"markdownify>=1.2.2",
|
||||
"markdownify>=1.2.3",
|
||||
"langchain-anthropic>=1.4.6",
|
||||
"langchain-aws>=0.2.0",
|
||||
"langgraph-cli[inmem]>=0.4.30",
|
||||
"langsmith==0.9.3",
|
||||
"langchain-openai>=1.2.2",
|
||||
"langchain-fireworks>=1.4.2",
|
||||
"langsmith==0.9.4",
|
||||
"langchain-openai>=1.3.3",
|
||||
"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",
|
||||
"langchain-daytona>=0.0.5",
|
||||
"langchain-modal>=0.0.3",
|
||||
"langchain-runloop>=0.0.4",
|
||||
"exa-py>=2.13.0",
|
||||
"langchain-daytona>=0.0.7",
|
||||
"langchain-modal>=0.0.5",
|
||||
"langchain-runloop>=0.0.6",
|
||||
"exa-py>=2.15.0",
|
||||
"langchain-google-genai>=4.2.4",
|
||||
"langchain-mcp-adapters>=0.2.2",
|
||||
"langchain-mcp-adapters>=0.3.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=9.0.3",
|
||||
"pytest>=9.1.1",
|
||||
"pytest-asyncio>=1.4.0",
|
||||
"ruff>=0.15.15",
|
||||
"ruff>=0.15.20",
|
||||
"Pygments>=2.20.0",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -136,6 +136,96 @@ def test_cron_validation_accepts_steps_ranges_and_lists() -> None:
|
|||
assert body.schedule == "*/15 9-17 * * 1,3,5"
|
||||
|
||||
|
||||
def test_slack_report_channel_normalizes_and_validates() -> None:
|
||||
body = ScheduleCreateBody(
|
||||
prompt="hello", schedule="0 9 * * 1", slack_report_channel=" #C0123ABCD "
|
||||
)
|
||||
assert body.slack_report_channel == "C0123ABCD"
|
||||
|
||||
blank = ScheduleCreateBody(prompt="hello", schedule="0 9 * * 1", slack_report_channel=" ")
|
||||
assert blank.slack_report_channel is None
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
ScheduleCreateBody(prompt="hello", schedule="0 9 * * 1", slack_report_channel="not a chan")
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
ScheduleCreateBody(prompt="hello", schedule="0 9 * * 1", slack_report_channel="123456")
|
||||
|
||||
|
||||
async def test_create_agent_schedule_persists_slack_report_channel(fake_client, auth) -> None: # noqa: ANN001, ARG001
|
||||
body = ScheduleCreateBody(
|
||||
name="Daily report",
|
||||
prompt="Summarize merged PRs",
|
||||
schedule="0 9 * * 1-5",
|
||||
slack_report_channel="C0123ABCD",
|
||||
)
|
||||
|
||||
result = await schedules.create_agent_schedule("alice", body, email="alice@example.com")
|
||||
|
||||
assert result["slackReportChannel"] == "C0123ABCD"
|
||||
stored = fake_client.store.items[(tuple(schedules.SCHEDULES_NAMESPACE), result["id"])]
|
||||
assert stored["slack_report_channel"] == "C0123ABCD"
|
||||
|
||||
|
||||
async def test_update_agent_schedule_clears_slack_report_channel(fake_client) -> None: # noqa: ANN001
|
||||
record = {
|
||||
"id": "sched_1",
|
||||
"name": "Daily",
|
||||
"prompt": "Run daily",
|
||||
"schedule": "0 9 * * *",
|
||||
"repo": None,
|
||||
"model": "Default",
|
||||
"effort": None,
|
||||
"slack_report_channel": "C0123ABCD",
|
||||
"enabled": True,
|
||||
"cron_id": "cron_old",
|
||||
"created_by": "alice",
|
||||
"user_email": "alice@example.com",
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
"updated_at": "2026-01-01T00:00:00+00:00",
|
||||
}
|
||||
await fake_client.store.put_item(schedules.SCHEDULES_NAMESPACE, "sched_1", record)
|
||||
|
||||
result = await schedules.update_agent_schedule(
|
||||
"sched_1",
|
||||
"alice",
|
||||
ScheduleUpdateBody(slack_report_channel=""),
|
||||
email="alice@example.com",
|
||||
)
|
||||
|
||||
assert result["slackReportChannel"] is None
|
||||
|
||||
|
||||
def test_agent_run_config_seeds_slack_thread_channel() -> None:
|
||||
record = {
|
||||
"id": "sched_1",
|
||||
"model": "Default",
|
||||
"effort": None,
|
||||
"created_by": "alice",
|
||||
"user_email": "alice@example.com",
|
||||
"slack_report_channel": "C0123ABCD",
|
||||
}
|
||||
|
||||
config = schedules._agent_run_config(record, "thread_1")
|
||||
|
||||
assert config["configurable"]["slack_thread"] == {"channel_id": "C0123ABCD"}
|
||||
|
||||
|
||||
def test_agent_run_config_omits_slack_thread_without_channel() -> None:
|
||||
record = {
|
||||
"id": "sched_1",
|
||||
"model": "Default",
|
||||
"effort": None,
|
||||
"created_by": "alice",
|
||||
"user_email": "alice@example.com",
|
||||
"slack_report_channel": None,
|
||||
}
|
||||
|
||||
config = schedules._agent_run_config(record, "thread_1")
|
||||
|
||||
assert "slack_thread" not in config["configurable"]
|
||||
|
||||
|
||||
async def test_create_agent_schedule_registers_scheduler_cron(fake_client, auth) -> None: # noqa: ANN001, ARG001
|
||||
body = ScheduleCreateBody(
|
||||
name="Daily report",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch
|
|||
import pytest
|
||||
|
||||
from agent import webapp
|
||||
from agent.webhooks import github as webhooks_github
|
||||
|
||||
|
||||
def test_parse_autofix_command() -> None:
|
||||
|
|
@ -119,7 +120,7 @@ async def test_process_github_ci_event_dispatches() -> None:
|
|||
},
|
||||
}
|
||||
handle = AsyncMock(return_value="dispatched")
|
||||
with patch.object(webapp, "handle_ci_failure", handle):
|
||||
with patch.object(webhooks_github, "handle_ci_failure", handle):
|
||||
await webapp.process_github_ci_event(payload, "check_run")
|
||||
handle.assert_awaited_once()
|
||||
kwargs = handle.await_args.kwargs
|
||||
|
|
@ -135,7 +136,7 @@ async def test_process_github_ci_event_ignores_success() -> None:
|
|||
"check_run": {"status": "completed", "conclusion": "success", "head_sha": "s"},
|
||||
}
|
||||
handle = AsyncMock()
|
||||
with patch.object(webapp, "handle_ci_failure", handle):
|
||||
with patch.object(webhooks_github, "handle_ci_failure", handle):
|
||||
await webapp.process_github_ci_event(payload, "check_run")
|
||||
handle.assert_not_called()
|
||||
|
||||
|
|
@ -149,7 +150,7 @@ async def test_process_autofix_command_sets_flag() -> None:
|
|||
}
|
||||
setter = AsyncMock()
|
||||
with (
|
||||
patch.object(webapp, "set_pr_autofix_disabled", setter),
|
||||
patch.object(webhooks_github, "set_pr_autofix_disabled", setter),
|
||||
patch.object(webapp, "get_github_app_installation_token", AsyncMock(return_value="")),
|
||||
):
|
||||
await webapp.process_github_autofix_command(payload, "issue_comment", disabled=True)
|
||||
|
|
@ -164,7 +165,7 @@ async def test_autofix_review_dispatches_for_writer() -> None:
|
|||
"review": {"body": "rename to userId", "user": {"login": "alice"}},
|
||||
}
|
||||
handle = AsyncMock(return_value="dispatched")
|
||||
with patch.object(webapp, "handle_review_feedback", handle):
|
||||
with patch.object(webhooks_github, "handle_review_feedback", handle):
|
||||
await webapp.process_github_autofix_review(payload, "pull_request_review")
|
||||
handle.assert_awaited_once()
|
||||
|
||||
|
|
@ -177,7 +178,7 @@ async def test_autofix_review_delegates_permission_check_to_core() -> None:
|
|||
"review": {"body": "inject code", "user": {"login": "attacker"}},
|
||||
}
|
||||
handle = AsyncMock(return_value="reviewer_no_write_permission")
|
||||
with patch.object(webapp, "handle_review_feedback", handle):
|
||||
with patch.object(webhooks_github, "handle_review_feedback", handle):
|
||||
await webapp.process_github_autofix_review(payload, "pull_request_review")
|
||||
handle.assert_awaited_once()
|
||||
|
||||
|
|
|
|||
|
|
@ -29,9 +29,12 @@ def happy(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
|||
threads_update = AsyncMock()
|
||||
store_client = MagicMock()
|
||||
store_client.threads.update = threads_update
|
||||
# Auto-fix runs now dispatch through the durable dispatch_agent_run contract
|
||||
# rather than a raw runs.create; assert against that.
|
||||
dispatch_run = AsyncMock(return_value={"run_id": "r1"})
|
||||
|
||||
mocks: dict[str, Any] = {
|
||||
"runs_create": runs_create,
|
||||
"runs_create": dispatch_run,
|
||||
"threads_update": threads_update,
|
||||
"status_check": AsyncMock(return_value=True),
|
||||
"store_put": store_put,
|
||||
|
|
@ -58,9 +61,10 @@ def happy(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
|||
monkeypatch.setattr(
|
||||
ci_autofix, "head_commit_author_login", AsyncMock(return_value="open-swe[bot]")
|
||||
)
|
||||
monkeypatch.setattr(ci_autofix, "is_thread_active", AsyncMock(return_value=False))
|
||||
monkeypatch.setattr(ci_autofix, "get_thread_active_status", AsyncMock(return_value=False))
|
||||
monkeypatch.setattr(ci_autofix, "post_autofix_status_check", mocks["status_check"])
|
||||
monkeypatch.setattr(ci_autofix, "langgraph_client", lambda: lg_client)
|
||||
monkeypatch.setattr(ci_autofix, "dispatch_agent_run", mocks["runs_create"])
|
||||
monkeypatch.setattr(ci_autofix, "get_client", lambda: store_client)
|
||||
return mocks
|
||||
|
||||
|
|
@ -85,9 +89,18 @@ async def test_dispatch_happy_path(happy: dict[str, Any]) -> None:
|
|||
happy["status_check"].assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autofix_dispatch_uses_reject_strategy(happy: dict[str, Any]) -> None:
|
||||
# A burst of concurrent CI events for one head SHA can slip past the busy-check
|
||||
# before the dedupe SHA is recorded; dispatching with "reject" lets the platform
|
||||
# drop the duplicate concurrent creates instead of interrupting each other.
|
||||
await _run()
|
||||
assert happy["runs_create"].await_args.kwargs["multitask_strategy"] == "reject"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batches_when_thread_busy(happy: dict[str, Any], monkeypatch) -> None:
|
||||
monkeypatch.setattr(ci_autofix, "is_thread_active", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(ci_autofix, "get_thread_active_status", AsyncMock(return_value=True))
|
||||
result = await _run()
|
||||
assert result == "batched"
|
||||
happy["store_put"].assert_awaited()
|
||||
|
|
@ -194,7 +207,7 @@ async def test_review_feedback_skips_user_disabled(happy: dict[str, Any], monkey
|
|||
@pytest.mark.asyncio
|
||||
async def test_review_feedback_batches_when_thread_busy(happy: dict[str, Any], monkeypatch) -> None:
|
||||
monkeypatch.setattr(ci_autofix, "has_repo_write_permission", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(ci_autofix, "is_thread_active", AsyncMock(return_value=True))
|
||||
monkeypatch.setattr(ci_autofix, "get_thread_active_status", AsyncMock(return_value=True))
|
||||
result = await ci_autofix.handle_review_feedback(
|
||||
repo_config={"owner": "o", "name": "r"},
|
||||
pr_number=5,
|
||||
|
|
|
|||
168
tests/test_completion_webhook.py
Normal file
168
tests/test_completion_webhook.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import completion
|
||||
|
||||
|
||||
class _FakeThreads:
|
||||
def __init__(self, metadata: dict[str, Any]) -> None:
|
||||
self._metadata = metadata
|
||||
self.updates: list[dict[str, Any]] = []
|
||||
|
||||
async def get(self, thread_id: str) -> dict[str, Any]:
|
||||
return {"thread_id": thread_id, "metadata": self._metadata}
|
||||
|
||||
async def update(self, *, thread_id: str, metadata: dict[str, Any]) -> None:
|
||||
self.updates.append(metadata)
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, metadata: dict[str, Any]) -> None:
|
||||
self.threads = _FakeThreads(metadata)
|
||||
|
||||
|
||||
def _slack_metadata() -> dict[str, Any]:
|
||||
return {
|
||||
"source": "slack",
|
||||
"source_context": {"slack_thread": {"channel_id": "C1", "thread_ts": "123.45"}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_status_posts_slack_failure_reply(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _FakeClient(_slack_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": "error"})
|
||||
|
||||
assert result["status"] == "ok"
|
||||
reply.assert_awaited_once()
|
||||
args = reply.await_args.args
|
||||
assert args[0] == "C1"
|
||||
assert args[1] == "123.45"
|
||||
assert client.threads.updates == [{"failure_reply_posted": True}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_status_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _FakeClient(_slack_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": "success"})
|
||||
|
||||
assert result["status"] == "ignored"
|
||||
reply.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idempotent_when_already_replied(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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", "status": "timeout"})
|
||||
|
||||
assert result["status"] == "ignored"
|
||||
reply.assert_not_called()
|
||||
assert client.threads.updates == []
|
||||
|
||||
|
||||
@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"}}})
|
||||
monkeypatch.setattr(completion, "langgraph_client", lambda: client)
|
||||
comment = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(completion, "comment_on_linear_issue", comment)
|
||||
|
||||
result = await completion.handle_run_completion({"thread_id": "t1", "status": "timeout"})
|
||||
|
||||
assert result["status"] == "ok"
|
||||
comment.assert_awaited_once()
|
||||
assert comment.await_args.args[0] == "iss_1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_thread_id_is_ignored() -> None:
|
||||
result = await completion.handle_run_completion({"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.
|
||||
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"
|
||||
reply.assert_not_called()
|
||||
|
||||
|
||||
@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"})
|
||||
|
||||
assert result["status"] == "ignored"
|
||||
assert client.threads.updates == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interrupted_status_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Follow-ups use multitask_strategy="interrupt", so an interrupted run is a
|
||||
# healthy hand-off, not a failure to report.
|
||||
client = _FakeClient(_slack_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": "interrupted"})
|
||||
|
||||
assert result["status"] == "ignored"
|
||||
reply.assert_not_called()
|
||||
assert client.threads.updates == []
|
||||
|
||||
|
||||
def test_verify_run_complete_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# No secret configured: fail closed (reject everything).
|
||||
monkeypatch.setattr(completion, "RUN_COMPLETE_WEBHOOK_SECRET", None)
|
||||
assert completion.verify_run_complete_token(None) is False
|
||||
assert completion.verify_run_complete_token("whatever") is False
|
||||
|
||||
# Secret configured: require an exact match.
|
||||
monkeypatch.setattr(completion, "RUN_COMPLETE_WEBHOOK_SECRET", "s3cret")
|
||||
assert completion.verify_run_complete_token("s3cret") is True
|
||||
assert completion.verify_run_complete_token("wrong") is False
|
||||
assert completion.verify_run_complete_token(None) is False
|
||||
|
|
@ -323,9 +323,6 @@ def test_process_github_review_finding_reply_uses_rereview_config(monkeypatch) -
|
|||
captured["interaction"] = (finding_id, interaction)
|
||||
return {}
|
||||
|
||||
async def fake_is_thread_active(_thread_id: str) -> bool:
|
||||
return False
|
||||
|
||||
async def fake_store_current_run_id(_thread_id: str, _run: object) -> None:
|
||||
return None
|
||||
|
||||
|
|
@ -348,7 +345,6 @@ def test_process_github_review_finding_reply_uses_rereview_config(monkeypatch) -
|
|||
monkeypatch.setattr(webapp, "reconcile_findings_with_review_threads", fake_reconcile)
|
||||
monkeypatch.setattr(webapp, "list_reviewer_findings", fake_list_findings)
|
||||
monkeypatch.setattr(webapp, "append_finding_interaction", fake_append_interaction)
|
||||
monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active)
|
||||
monkeypatch.setattr(webapp, "_store_current_reviewer_run_id", fake_store_current_run_id)
|
||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient())
|
||||
|
||||
|
|
@ -381,7 +377,7 @@ def test_process_github_review_finding_reply_uses_rereview_config(monkeypatch) -
|
|||
assert config["finding_reply_id"] == "f_1"
|
||||
|
||||
|
||||
def test_process_github_review_finding_reply_queues_reply_body_when_active(monkeypatch) -> None:
|
||||
def test_process_github_review_finding_reply_dispatches_sanitized_reply_body(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_get_thread_metadata_safe(_thread_id: str) -> dict[str, object]:
|
||||
|
|
@ -407,15 +403,16 @@ def test_process_github_review_finding_reply_queues_reply_body_when_active(monke
|
|||
) -> dict[str, object]:
|
||||
return {}
|
||||
|
||||
async def fake_is_thread_active(_thread_id: str) -> bool:
|
||||
return True
|
||||
async def fake_store_current_run_id(_thread_id: str, _run: object) -> None:
|
||||
return None
|
||||
|
||||
async def fake_queue_message_for_thread(thread_id: str, message_content: object) -> bool:
|
||||
captured["queued"] = {"thread_id": thread_id, "message_content": message_content}
|
||||
return True
|
||||
class _FakeRunsClient:
|
||||
async def create(self, thread_id: str, graph: str, **kwargs) -> dict[str, str]:
|
||||
captured["kwargs"] = kwargs
|
||||
return {"run_id": "run-1"}
|
||||
|
||||
def fail_get_client(*_args: object, **_kwargs: object) -> None:
|
||||
raise AssertionError("active reviewer thread should not create a new run")
|
||||
class _FakeLangGraphClient:
|
||||
runs = _FakeRunsClient()
|
||||
|
||||
monkeypatch.setattr(webapp, "_get_thread_metadata_safe", fake_get_thread_metadata_safe)
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -426,9 +423,8 @@ def test_process_github_review_finding_reply_queues_reply_body_when_active(monke
|
|||
monkeypatch.setattr(webapp, "reconcile_findings_with_review_threads", fake_reconcile)
|
||||
monkeypatch.setattr(webapp, "list_reviewer_findings", fake_list_findings)
|
||||
monkeypatch.setattr(webapp, "append_finding_interaction", fake_append_interaction)
|
||||
monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active)
|
||||
monkeypatch.setattr(webapp, "queue_message_for_thread", fake_queue_message_for_thread)
|
||||
monkeypatch.setattr(webapp, "get_client", fail_get_client)
|
||||
monkeypatch.setattr(webapp, "_store_current_reviewer_run_id", fake_store_current_run_id)
|
||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient())
|
||||
|
||||
asyncio.run(
|
||||
webapp.process_github_review_finding_reply(
|
||||
|
|
@ -451,9 +447,9 @@ def test_process_github_review_finding_reply_queues_reply_body_when_active(monke
|
|||
)
|
||||
)
|
||||
|
||||
queued = captured["queued"]
|
||||
assert isinstance(queued, dict)
|
||||
message_content = queued["message_content"]
|
||||
kwargs = captured["kwargs"]
|
||||
assert isinstance(kwargs, dict)
|
||||
message_content = kwargs["input"]["messages"][0]["content"]
|
||||
assert isinstance(message_content, str)
|
||||
assert "Open SWE finding f_1" in message_content
|
||||
assert "untrusted data from GitHub" in message_content
|
||||
|
|
@ -815,10 +811,6 @@ def test_process_github_pr_ready_creates_reviewer_run(monkeypatch) -> None:
|
|||
captured["cache_token"] = token
|
||||
captured["cache_expires_at"] = expires_at
|
||||
|
||||
async def fake_is_thread_active(thread_id: str) -> bool:
|
||||
captured["active_thread_id"] = thread_id
|
||||
return False
|
||||
|
||||
class _FakeRunsClient:
|
||||
async def create(self, thread_id: str, graph: str, **kwargs) -> None:
|
||||
captured["thread_id"] = thread_id
|
||||
|
|
@ -848,7 +840,6 @@ def test_process_github_pr_ready_creates_reviewer_run(monkeypatch) -> None:
|
|||
return 1
|
||||
|
||||
monkeypatch.setattr(webapp, "cache_github_token_for_thread", fake_cache_github_token)
|
||||
monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active)
|
||||
monkeypatch.setattr(webapp, "set_reviewer_thread_metadata", fake_set_reviewer_thread_metadata)
|
||||
monkeypatch.setattr(webapp, "post_review_started_comment", fake_post_review_started_comment)
|
||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient())
|
||||
|
|
@ -913,10 +904,6 @@ def test_trigger_pr_review_from_ref_creates_reviewer_run(monkeypatch) -> None:
|
|||
captured["cache_token"] = token
|
||||
captured["cache_expires_at"] = expires_at
|
||||
|
||||
async def fake_is_thread_active(thread_id: str) -> bool:
|
||||
captured["active_thread_id"] = thread_id
|
||||
return False
|
||||
|
||||
class _FakeRunsClient:
|
||||
async def create(self, thread_id: str, graph: str, **kwargs) -> None:
|
||||
captured["thread_id"] = thread_id
|
||||
|
|
@ -950,7 +937,6 @@ def test_trigger_pr_review_from_ref_creates_reviewer_run(monkeypatch) -> None:
|
|||
|
||||
monkeypatch.setattr(webapp, "fetch_github_pr_metadata", fake_fetch_github_pr_metadata)
|
||||
monkeypatch.setattr(webapp, "cache_github_token_for_thread", fake_cache_github_token)
|
||||
monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active)
|
||||
monkeypatch.setattr(webapp, "set_reviewer_thread_metadata", fake_set_reviewer_thread_metadata)
|
||||
monkeypatch.setattr(webapp, "post_review_started_comment", fake_post_review_started_comment)
|
||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient())
|
||||
|
|
@ -1029,7 +1015,7 @@ def test_trigger_pr_review_from_ref_respects_dashboard_opt_in(monkeypatch) -> No
|
|||
assert called is False
|
||||
|
||||
|
||||
def test_request_pr_review_tool_uses_shared_trigger(monkeypatch) -> None:
|
||||
async def test_request_pr_review_tool_uses_shared_trigger(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_trigger_pr_review_from_ref(
|
||||
|
|
@ -1065,7 +1051,7 @@ def test_request_pr_review_tool_uses_shared_trigger(monkeypatch) -> None:
|
|||
},
|
||||
)
|
||||
|
||||
result = request_pr_review_tool("https://github.com/langchain-ai/open-swe/pull/1244")
|
||||
result = await request_pr_review_tool("https://github.com/langchain-ai/open-swe/pull/1244")
|
||||
|
||||
pr_ref = captured["pr_ref"]
|
||||
assert isinstance(pr_ref, GitHubPrRef)
|
||||
|
|
@ -1156,9 +1142,6 @@ def test_process_github_issue_uses_resolved_user_token_for_reaction(monkeypatch)
|
|||
captured["fetch_token"] = token
|
||||
return []
|
||||
|
||||
async def fake_is_thread_active(thread_id: str) -> bool:
|
||||
return False
|
||||
|
||||
class _FakeRunsClient:
|
||||
async def create(self, *args, **kwargs) -> None:
|
||||
captured["run_created"] = True
|
||||
|
|
@ -1175,7 +1158,6 @@ def test_process_github_issue_uses_resolved_user_token_for_reaction(monkeypatch)
|
|||
monkeypatch.setattr(webapp, "_thread_exists", lambda thread_id: asyncio.sleep(0, result=False))
|
||||
monkeypatch.setattr(webapp, "react_to_github_comment", fake_react_to_github_comment)
|
||||
monkeypatch.setattr(webapp, "fetch_issue_comments", fake_fetch_issue_comments)
|
||||
monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active)
|
||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient())
|
||||
monkeypatch.setattr(
|
||||
webapp,
|
||||
|
|
@ -1239,9 +1221,6 @@ def test_process_github_issue_existing_thread_uses_followup_prompt(monkeypatch)
|
|||
async def fake_thread_exists(thread_id: str) -> bool:
|
||||
return True
|
||||
|
||||
async def fake_is_thread_active(thread_id: str) -> bool:
|
||||
return False
|
||||
|
||||
class _FakeRunsClient:
|
||||
async def create(self, *args, **kwargs) -> None:
|
||||
captured["prompt"] = kwargs["input"]["messages"][0]["content"]
|
||||
|
|
@ -1258,7 +1237,6 @@ def test_process_github_issue_existing_thread_uses_followup_prompt(monkeypatch)
|
|||
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
|
||||
monkeypatch.setattr(webapp, "react_to_github_comment", fake_react_to_github_comment)
|
||||
monkeypatch.setattr(webapp, "fetch_issue_comments", fake_fetch_issue_comments)
|
||||
monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active)
|
||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient())
|
||||
monkeypatch.setattr(
|
||||
webapp,
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ def test_plan_mode_guidance_section_present_when_enabled() -> None:
|
|||
assert "Plan Mode (ACTIVE)" in prompt
|
||||
|
||||
|
||||
def test_enter_plan_mode_tool_returns_command() -> None:
|
||||
async def test_enter_plan_mode_tool_returns_command() -> None:
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langchain_core.tools import tool as as_tool
|
||||
from langgraph.types import Command
|
||||
|
|
@ -178,7 +178,7 @@ def test_enter_plan_mode_tool_returns_command() -> None:
|
|||
|
||||
# Wrap as the agent does so the InjectedToolCallId is supplied from the call.
|
||||
wrapped = as_tool(enter_plan_mode)
|
||||
result = wrapped.invoke(
|
||||
result = await wrapped.ainvoke(
|
||||
{"name": "enter_plan_mode", "args": {}, "id": "call-1", "type": "tool_call"}
|
||||
)
|
||||
assert isinstance(result, Command)
|
||||
|
|
|
|||
|
|
@ -95,19 +95,19 @@ async def test_clear_plan_comments_deletes_each(monkeypatch: pytest.MonkeyPatch)
|
|||
assert deleted == ["a", "b"]
|
||||
|
||||
|
||||
def test_save_plan_requires_run_context() -> None:
|
||||
async def test_save_plan_requires_run_context() -> None:
|
||||
from agent.tools.save_plan import save_plan
|
||||
|
||||
# No LangGraph run context → no thread_id → graceful error, not a crash.
|
||||
result = save_plan("## Plan")
|
||||
result = await save_plan("## Plan")
|
||||
assert result["success"] is False
|
||||
assert "thread_id" in result["error"]
|
||||
|
||||
|
||||
def test_save_plan_rejects_empty_markdown() -> None:
|
||||
async def test_save_plan_rejects_empty_markdown() -> None:
|
||||
from agent.tools.save_plan import save_plan
|
||||
|
||||
result = save_plan(" ")
|
||||
result = await save_plan(" ")
|
||||
assert result["success"] is False
|
||||
assert "empty" in result["error"]
|
||||
|
||||
|
|
|
|||
158
tests/test_reconcile_sweep.py
Normal file
158
tests/test_reconcile_sweep.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import reconcile
|
||||
|
||||
|
||||
def _run(run_id: str, thread_id: str, age_seconds: float) -> dict[str, Any]:
|
||||
created = datetime.now(UTC) - timedelta(seconds=age_seconds)
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"thread_id": thread_id,
|
||||
"status": "pending",
|
||||
"created_at": created.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class _FakeThreads:
|
||||
def __init__(self, pages: list[list[dict[str, Any]]]) -> None:
|
||||
self._pages = pages
|
||||
self.search_calls: list[dict[str, Any]] = []
|
||||
|
||||
async def search(self, **kwargs: Any) -> list[dict[str, Any]]:
|
||||
self.search_calls.append(kwargs)
|
||||
offset = kwargs.get("offset", 0)
|
||||
limit = kwargs.get("limit", 100)
|
||||
index = offset // limit if limit else 0
|
||||
if index < len(self._pages):
|
||||
return self._pages[index]
|
||||
return []
|
||||
|
||||
|
||||
class _FakeRuns:
|
||||
def __init__(self, runs_by_thread: dict[str, Any]) -> None:
|
||||
self._runs_by_thread = runs_by_thread
|
||||
self.cancel_many = AsyncMock(return_value=None)
|
||||
self.list_calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
async def list(self, thread_id: str, **kwargs: Any) -> list[dict[str, Any]]:
|
||||
self.list_calls.append((thread_id, kwargs))
|
||||
value = self._runs_by_thread.get(thread_id, [])
|
||||
if isinstance(value, Exception):
|
||||
raise value
|
||||
return value
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, threads: _FakeThreads, runs: _FakeRuns) -> None:
|
||||
self.threads = threads
|
||||
self.runs = runs
|
||||
|
||||
|
||||
def _patch(monkeypatch: pytest.MonkeyPatch, client: _FakeClient) -> None:
|
||||
monkeypatch.setattr(reconcile, "langgraph_client", lambda: client)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancels_only_stale_pending_runs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
threads = _FakeThreads([[{"thread_id": "t1"}]])
|
||||
runs = _FakeRuns(
|
||||
{
|
||||
"t1": [
|
||||
_run("old1", "t1", age_seconds=4000),
|
||||
_run("fresh1", "t1", age_seconds=60),
|
||||
_run("old2", "t1", age_seconds=10000),
|
||||
]
|
||||
}
|
||||
)
|
||||
_patch(monkeypatch, _FakeClient(threads, runs))
|
||||
|
||||
counts = await reconcile.reconcile_stale_runs(max_age_seconds=1800)
|
||||
|
||||
assert counts == {"threads_checked": 1, "stale_runs": 2, "cancelled": 2}
|
||||
runs.cancel_many.assert_awaited_once()
|
||||
kwargs = runs.cancel_many.await_args.kwargs
|
||||
assert kwargs["thread_id"] == "t1"
|
||||
assert sorted(kwargs["run_ids"]) == ["old1", "old2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_stale_runs_means_no_cancel(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
threads = _FakeThreads([[{"thread_id": "t1"}]])
|
||||
runs = _FakeRuns({"t1": [_run("fresh1", "t1", age_seconds=30)]})
|
||||
_patch(monkeypatch, _FakeClient(threads, runs))
|
||||
|
||||
counts = await reconcile.reconcile_stale_runs(max_age_seconds=1800)
|
||||
|
||||
assert counts == {"threads_checked": 1, "stale_runs": 0, "cancelled": 0}
|
||||
runs.cancel_many.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bad_thread_does_not_abort_sweep(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
threads = _FakeThreads([[{"thread_id": "bad"}, {"thread_id": "good"}]])
|
||||
runs = _FakeRuns(
|
||||
{
|
||||
"bad": RuntimeError("runs.list exploded"),
|
||||
"good": [_run("old1", "good", age_seconds=5000)],
|
||||
}
|
||||
)
|
||||
_patch(monkeypatch, _FakeClient(threads, runs))
|
||||
|
||||
counts = await reconcile.reconcile_stale_runs(max_age_seconds=1800)
|
||||
|
||||
# Both threads counted; the good thread is still reconciled despite the bad one.
|
||||
assert counts == {"threads_checked": 2, "stale_runs": 1, "cancelled": 1}
|
||||
runs.cancel_many.assert_awaited_once()
|
||||
assert runs.cancel_many.await_args.kwargs["thread_id"] == "good"
|
||||
assert runs.cancel_many.await_args.kwargs["run_ids"] == ["old1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paginates_busy_threads(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
full_page = [{"thread_id": f"t{i}"} for i in range(reconcile._SEARCH_PAGE_SIZE)]
|
||||
second_page = [{"thread_id": "tail"}]
|
||||
threads = _FakeThreads([full_page, second_page])
|
||||
runs_by_thread: dict[str, Any] = {t["thread_id"]: [] for t in full_page}
|
||||
runs_by_thread["tail"] = [_run("old", "tail", age_seconds=9000)]
|
||||
runs = _FakeRuns(runs_by_thread)
|
||||
_patch(monkeypatch, _FakeClient(threads, runs))
|
||||
|
||||
counts = await reconcile.reconcile_stale_runs(max_age_seconds=1800)
|
||||
|
||||
assert counts["threads_checked"] == reconcile._SEARCH_PAGE_SIZE + 1
|
||||
assert counts["cancelled"] == 1
|
||||
# Two search calls: first full page triggers a second page fetch.
|
||||
assert len(threads.search_calls) == 2
|
||||
assert threads.search_calls[0]["offset"] == 0
|
||||
assert threads.search_calls[1]["offset"] == reconcile._SEARCH_PAGE_SIZE
|
||||
assert threads.search_calls[0]["status"] == "busy"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unparseable_created_at_is_skipped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
threads = _FakeThreads([[{"thread_id": "t1"}]])
|
||||
runs = _FakeRuns(
|
||||
{
|
||||
"t1": [
|
||||
{
|
||||
"run_id": "bad",
|
||||
"thread_id": "t1",
|
||||
"status": "pending",
|
||||
"created_at": "not-a-date",
|
||||
},
|
||||
_run("old", "t1", age_seconds=5000),
|
||||
]
|
||||
}
|
||||
)
|
||||
_patch(monkeypatch, _FakeClient(threads, runs))
|
||||
|
||||
counts = await reconcile.reconcile_stale_runs(max_age_seconds=1800)
|
||||
|
||||
assert counts == {"threads_checked": 1, "stale_runs": 1, "cancelled": 1}
|
||||
assert runs.cancel_many.await_args.kwargs["run_ids"] == ["old"]
|
||||
|
|
@ -454,10 +454,6 @@ def _setup_slack_mention_fakes(
|
|||
captured["user_names_by_id"] = user_names_by_id
|
||||
return "", []
|
||||
|
||||
async def fake_is_thread_active(thread_id: str) -> bool:
|
||||
captured["active_thread_id"] = thread_id
|
||||
return False
|
||||
|
||||
async def fake_post_slack_trace_reply(channel_id: str, thread_ts: str, thread_id: str) -> None:
|
||||
captured["trace_reply"] = {
|
||||
"channel_id": channel_id,
|
||||
|
|
@ -505,7 +501,6 @@ def _setup_slack_mention_fakes(
|
|||
async def fake_post_prompt(*args, **kwargs) -> None:
|
||||
captured["prompt"] = {"args": args, "kwargs": kwargs}
|
||||
|
||||
monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active)
|
||||
monkeypatch.setattr(webapp, "post_slack_trace_reply", fake_post_slack_trace_reply)
|
||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClientForProcess())
|
||||
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
|
||||
|
|
@ -547,7 +542,6 @@ def test_process_slack_mention_creates_thread_first_run_with_trace_reply(
|
|||
|
||||
assert captured["thread_exists_check"] == expected_thread_id
|
||||
assert captured["fetch_thread"] == {"channel_id": "C123", "thread_ts": thread_ts}
|
||||
assert captured["active_thread_id"] == expected_thread_id
|
||||
assert captured["metadata_update"] == {
|
||||
"thread_id": expected_thread_id,
|
||||
"metadata": {"repo": {"owner": "langchain-ai", "name": "open-swe"}},
|
||||
|
|
@ -564,7 +558,8 @@ def test_process_slack_mention_creates_thread_first_run_with_trace_reply(
|
|||
assert run_create["graph"] == "agent"
|
||||
kwargs = run_create["kwargs"]
|
||||
assert kwargs["if_not_exists"] == "create"
|
||||
assert "multitask_strategy" not in kwargs
|
||||
assert kwargs["multitask_strategy"] == "interrupt"
|
||||
assert kwargs["durability"] == "sync"
|
||||
assert kwargs["config"]["configurable"]["slack_thread"]["thread_ts"] == thread_ts
|
||||
prompt_block = kwargs["input"]["messages"][0]["content"][0]
|
||||
assert "## Default Repository Hint\nlangchain-ai/open-swe" in prompt_block["text"]
|
||||
|
|
@ -615,217 +610,6 @@ def test_process_slack_mention_skips_trace_reply_on_followup_mention(
|
|||
assert run_create["thread_id"] == expected_thread_id
|
||||
|
||||
|
||||
def test_process_slack_mention_queues_active_thread_message(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_get_slack_user_info(user_id: str) -> dict:
|
||||
return {
|
||||
"profile": {
|
||||
"email": "mason@example.com",
|
||||
"display_name": "Mason",
|
||||
}
|
||||
}
|
||||
|
||||
async def fake_fetch_slack_thread_messages(channel_id: str, thread_ts: str) -> list[dict]:
|
||||
return [
|
||||
{"ts": "1700000000.000100", "text": "<@UBOT> first request", "user": "U123"},
|
||||
{
|
||||
"ts": "1700000000.000200",
|
||||
"text": "<@UBOT> include this screenshot https://example.com/image.png",
|
||||
"user": "U123",
|
||||
},
|
||||
]
|
||||
|
||||
async def fake_get_slack_user_names(user_ids: list[str]) -> dict[str, str]:
|
||||
captured["user_ids"] = user_ids
|
||||
return {"U123": "Mason"}
|
||||
|
||||
async def fake_resolve_slack_links_in_context(
|
||||
context_messages: list[dict], user_names_by_id: dict[str, str]
|
||||
) -> tuple[str, list[str]]:
|
||||
captured["context_messages"] = context_messages
|
||||
return "", []
|
||||
|
||||
async def fake_fetch_image_block(image_url: str, http_client: object) -> None:
|
||||
captured["image_url"] = image_url
|
||||
return None
|
||||
|
||||
async def fake_is_thread_active(thread_id: str) -> bool:
|
||||
captured["active_thread_id"] = thread_id
|
||||
return True
|
||||
|
||||
async def fake_queue_message_for_thread(thread_id: str, message_content: object) -> bool:
|
||||
captured["queued"] = {"thread_id": thread_id, "message_content": message_content}
|
||||
return True
|
||||
|
||||
async def fake_post_slack_trace_reply(*args, **kwargs) -> None:
|
||||
raise AssertionError("trace reply should not be posted for queued mid-run Slack messages")
|
||||
|
||||
async def fake_thread_exists(thread_id: str) -> bool:
|
||||
return True
|
||||
|
||||
class _FakeRunsClient:
|
||||
async def create(self, *args, **kwargs) -> None:
|
||||
raise AssertionError("run should not be created for active Slack threads")
|
||||
|
||||
class _FakeThreadsClientForProcess:
|
||||
async def update(self, *, thread_id: str, metadata: dict) -> None:
|
||||
captured["metadata_update"] = {"thread_id": thread_id, "metadata": metadata}
|
||||
|
||||
class _FakeLangGraphClientForProcess:
|
||||
runs = _FakeRunsClient()
|
||||
threads = _FakeThreadsClientForProcess()
|
||||
|
||||
monkeypatch.setattr(webapp, "SLACK_BOT_USERNAME", "open-swe")
|
||||
monkeypatch.setattr(webapp, "get_slack_user_info", fake_get_slack_user_info)
|
||||
monkeypatch.setattr(webapp, "fetch_slack_thread_messages", fake_fetch_slack_thread_messages)
|
||||
monkeypatch.setattr(webapp, "get_slack_user_names", fake_get_slack_user_names)
|
||||
monkeypatch.setattr(
|
||||
webapp, "resolve_slack_links_in_context", fake_resolve_slack_links_in_context
|
||||
)
|
||||
monkeypatch.setattr(webapp, "fetch_image_block", fake_fetch_image_block)
|
||||
monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active)
|
||||
monkeypatch.setattr(webapp, "queue_message_for_thread", fake_queue_message_for_thread)
|
||||
monkeypatch.setattr(webapp, "post_slack_trace_reply", fake_post_slack_trace_reply)
|
||||
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
|
||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClientForProcess())
|
||||
|
||||
async def fake_login_for_slack_id(slack_user_id):
|
||||
return "mason-gh"
|
||||
|
||||
async def fake_login_for_email(email):
|
||||
return None
|
||||
|
||||
async def fake_refresh_cache() -> list:
|
||||
return []
|
||||
|
||||
async def fake_get_valid_access_token(login):
|
||||
return "user-token"
|
||||
|
||||
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
|
||||
monkeypatch.setattr(webapp, "login_for_email", fake_login_for_email)
|
||||
monkeypatch.setattr(webapp, "refresh_user_mapping_cache", fake_refresh_cache)
|
||||
monkeypatch.setattr(webapp, "get_valid_access_token", fake_get_valid_access_token)
|
||||
|
||||
async def fake_resolve_agent_model_id(github_login, per_thread_model_id=None):
|
||||
return "bedrock_converse:us.anthropic.claude-opus-4-8"
|
||||
|
||||
monkeypatch.setattr(webapp, "resolve_agent_model_id", fake_resolve_agent_model_id)
|
||||
|
||||
thread_ts = "1700000000.000100"
|
||||
event_ts = "1700000000.000200"
|
||||
expected_thread_id = generate_thread_id_from_slack_thread("C123", thread_ts)
|
||||
|
||||
asyncio.run(
|
||||
webapp.process_slack_mention(
|
||||
{
|
||||
"channel_id": "C123",
|
||||
"thread_ts": thread_ts,
|
||||
"event_ts": event_ts,
|
||||
"user_id": "U123",
|
||||
"text": "<@UBOT> include this screenshot https://example.com/image.png",
|
||||
"bot_user_id": "UBOT",
|
||||
},
|
||||
{"owner": "langchain-ai", "name": "open-swe"},
|
||||
)
|
||||
)
|
||||
|
||||
assert captured["active_thread_id"] == expected_thread_id
|
||||
assert captured["queued"]["thread_id"] == expected_thread_id
|
||||
queued_payload = captured["queued"]["message_content"]
|
||||
assert queued_payload["image_urls"] == ["https://example.com/image.png"]
|
||||
assert "## Latest Mention Request\ninclude this screenshot" in queued_payload["text"]
|
||||
|
||||
|
||||
def test_process_slack_mention_serializes_concurrent_run_dispatch(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
_setup_slack_mention_fakes(monkeypatch, captured)
|
||||
|
||||
thread_ts = "1700000001.000100"
|
||||
expected_thread_id = generate_thread_id_from_slack_thread("C123", thread_ts)
|
||||
first_active_started = asyncio.Event()
|
||||
finish_first_active = asyncio.Event()
|
||||
active_calls: list[str] = []
|
||||
run_creates: list[dict[str, object]] = []
|
||||
queued_messages: list[dict[str, object]] = []
|
||||
|
||||
async def fake_thread_exists(thread_id: str) -> bool:
|
||||
return False
|
||||
|
||||
async def fake_is_thread_active(thread_id: str) -> bool:
|
||||
active_calls.append(thread_id)
|
||||
if len(active_calls) == 1:
|
||||
first_active_started.set()
|
||||
await finish_first_active.wait()
|
||||
return bool(run_creates)
|
||||
|
||||
async def fake_queue_message_for_thread(thread_id: str, message_content: object) -> bool:
|
||||
queued_messages.append({"thread_id": thread_id, "message_content": message_content})
|
||||
return True
|
||||
|
||||
class _FakeRunsClient:
|
||||
async def create(self, thread_id: str, graph: str, **kwargs) -> dict[str, str]:
|
||||
run_creates.append({"thread_id": thread_id, "graph": graph, "kwargs": kwargs})
|
||||
return {"run_id": f"run-{len(run_creates)}"}
|
||||
|
||||
class _FakeThreadsClientForProcess:
|
||||
async def update(self, *, thread_id: str, metadata: dict) -> None:
|
||||
captured["metadata_update"] = {"thread_id": thread_id, "metadata": metadata}
|
||||
|
||||
class _FakeLangGraphClientForProcess:
|
||||
runs = _FakeRunsClient()
|
||||
threads = _FakeThreadsClientForProcess()
|
||||
|
||||
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
|
||||
monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active)
|
||||
monkeypatch.setattr(webapp, "queue_message_for_thread", fake_queue_message_for_thread)
|
||||
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClientForProcess())
|
||||
|
||||
async def run_concurrent_mentions() -> None:
|
||||
first = asyncio.create_task(
|
||||
webapp.process_slack_mention(
|
||||
{
|
||||
"channel_id": "C123",
|
||||
"thread_ts": thread_ts,
|
||||
"event_ts": "1700000000.000200",
|
||||
"user_id": "U123",
|
||||
"text": "<@UBOT> first request",
|
||||
"bot_user_id": "UBOT",
|
||||
},
|
||||
{"owner": "langchain-ai", "name": "open-swe"},
|
||||
)
|
||||
)
|
||||
await first_active_started.wait()
|
||||
second = asyncio.create_task(
|
||||
webapp.process_slack_mention(
|
||||
{
|
||||
"channel_id": "C123",
|
||||
"thread_ts": thread_ts,
|
||||
"event_ts": "1700000000.000300",
|
||||
"user_id": "U123",
|
||||
"text": "<@UBOT> second request",
|
||||
"bot_user_id": "UBOT",
|
||||
},
|
||||
{"owner": "langchain-ai", "name": "open-swe"},
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
assert active_calls == [expected_thread_id]
|
||||
finish_first_active.set()
|
||||
await asyncio.gather(first, second)
|
||||
|
||||
asyncio.run(run_concurrent_mentions())
|
||||
|
||||
assert active_calls == [expected_thread_id, expected_thread_id]
|
||||
assert len(run_creates) == 1
|
||||
assert run_creates[0]["thread_id"] == expected_thread_id
|
||||
assert queued_messages[0]["thread_id"] == expected_thread_id
|
||||
|
||||
|
||||
def test_process_slack_mention_unmapped_user_blocked_and_prompted(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -169,3 +169,157 @@ async def test_slack_thread_reply_builds_option_blocks(monkeypatch: pytest.Monke
|
|||
assert actions["type"] == "actions"
|
||||
assert [button["text"]["text"] for button in actions["elements"]] == ["A", "B"]
|
||||
assert actions["elements"][0]["action_id"] == "open_swe_option_select"
|
||||
|
||||
|
||||
def _channel_only_config() -> dict[str, Any]:
|
||||
return {"configurable": {"slack_thread": {"channel_id": "C9"}}}
|
||||
|
||||
|
||||
def _channel_run_config() -> dict[str, Any]:
|
||||
return {
|
||||
"configurable": {
|
||||
"slack_thread": {"channel_id": "C9"},
|
||||
"thread_id": "run-1",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_top_level_posts() -> Any:
|
||||
slack_reply_tool._top_level_posts.clear()
|
||||
yield
|
||||
slack_reply_tool._top_level_posts.clear()
|
||||
|
||||
|
||||
async def test_slack_thread_reply_requires_channel_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(slack_reply_tool, "get_config", lambda: {"configurable": {}})
|
||||
|
||||
result = await slack_reply_tool.slack_thread_reply("hello")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "Missing slack_thread.channel_id in config"
|
||||
|
||||
|
||||
async def test_slack_thread_reply_posts_top_level_when_no_thread_ts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_top_level(
|
||||
channel_id: str,
|
||||
text: str,
|
||||
*,
|
||||
blocks: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
captured.update({"channel_id": channel_id, "text": text, "blocks": blocks})
|
||||
return "3.0", None
|
||||
|
||||
async def fail_thread_reply(*args: Any, **kwargs: Any) -> tuple[str | None, str | None]:
|
||||
raise AssertionError("should not post a thread reply without thread_ts")
|
||||
|
||||
monkeypatch.setattr(slack_reply_tool, "get_config", _channel_only_config)
|
||||
monkeypatch.setattr(slack_reply_tool, "post_slack_top_level_message_with_ts", fake_top_level)
|
||||
monkeypatch.setattr(slack_reply_tool, "post_slack_thread_reply_with_ts", fail_thread_reply)
|
||||
|
||||
result = await slack_reply_tool.slack_thread_reply("Scheduled report")
|
||||
|
||||
assert result == {"success": True}
|
||||
assert captured["channel_id"] == "C9"
|
||||
assert captured["text"] == "Scheduled report"
|
||||
|
||||
|
||||
async def test_slack_thread_reply_top_level_surfaces_not_in_channel(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def fake_top_level(
|
||||
channel_id: str,
|
||||
text: str,
|
||||
*,
|
||||
blocks: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
return None, "not_in_channel"
|
||||
|
||||
monkeypatch.setattr(slack_reply_tool, "get_config", _channel_only_config)
|
||||
monkeypatch.setattr(slack_reply_tool, "post_slack_top_level_message_with_ts", fake_top_level)
|
||||
|
||||
result = await slack_reply_tool.slack_thread_reply("Scheduled report")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["slack_error"] == "not_in_channel"
|
||||
assert "do not retry" in result["hint"]
|
||||
|
||||
|
||||
async def test_slack_thread_reply_top_level_drops_interactive_blocks(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_top_level(
|
||||
channel_id: str,
|
||||
text: str,
|
||||
*,
|
||||
blocks: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
captured["blocks"] = blocks
|
||||
return "3.0", None
|
||||
|
||||
monkeypatch.setattr(slack_reply_tool, "get_config", _channel_only_config)
|
||||
monkeypatch.setattr(slack_reply_tool, "post_slack_top_level_message_with_ts", fake_top_level)
|
||||
|
||||
options_result = await slack_reply_tool.slack_thread_reply("Pick", options=["A", "B"])
|
||||
assert options_result == {"success": True}
|
||||
assert captured["blocks"] is None
|
||||
|
||||
approval_result = await slack_reply_tool.slack_thread_reply("Plan?", plan_approval=True)
|
||||
assert approval_result == {"success": True}
|
||||
assert captured["blocks"] is None
|
||||
|
||||
|
||||
async def test_slack_thread_reply_allows_only_one_top_level_post_per_run(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls = 0
|
||||
|
||||
async def fake_top_level(
|
||||
channel_id: str,
|
||||
text: str,
|
||||
*,
|
||||
blocks: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return "3.0", None
|
||||
|
||||
monkeypatch.setattr(slack_reply_tool, "get_config", _channel_run_config)
|
||||
monkeypatch.setattr(slack_reply_tool, "post_slack_top_level_message_with_ts", fake_top_level)
|
||||
|
||||
first = await slack_reply_tool.slack_thread_reply("First")
|
||||
second = await slack_reply_tool.slack_thread_reply("Second")
|
||||
|
||||
assert first == {"success": True}
|
||||
assert second["success"] is False
|
||||
assert "Only one top-level message per run" in second["hint"]
|
||||
assert calls == 1
|
||||
|
||||
|
||||
async def test_slack_thread_reply_failed_top_level_post_does_not_consume_slot(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
results = iter([(None, "rate_limited"), ("3.0", None)])
|
||||
|
||||
async def fake_top_level(
|
||||
channel_id: str,
|
||||
text: str,
|
||||
*,
|
||||
blocks: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
return next(results)
|
||||
|
||||
monkeypatch.setattr(slack_reply_tool, "get_config", _channel_run_config)
|
||||
monkeypatch.setattr(slack_reply_tool, "post_slack_top_level_message_with_ts", fake_top_level)
|
||||
|
||||
first = await slack_reply_tool.slack_thread_reply("First")
|
||||
second = await slack_reply_tool.slack_thread_reply("Retry")
|
||||
|
||||
assert first["success"] is False
|
||||
assert second == {"success": True}
|
||||
|
|
|
|||
|
|
@ -44,9 +44,7 @@ function scheduleToSelection(
|
|||
(model) =>
|
||||
model.id === schedule.model && model.efforts.includes(schedule.effort!)
|
||||
)
|
||||
return supported
|
||||
? { modelId: schedule.model, effort: schedule.effort }
|
||||
: null
|
||||
return supported ? { modelId: schedule.model, effort: schedule.effort } : null
|
||||
}
|
||||
|
||||
export function AutomationEditor({
|
||||
|
|
@ -73,6 +71,9 @@ export function AutomationEditor({
|
|||
)
|
||||
const [repo, setRepo] = useState<string | null>(schedule?.repo ?? null)
|
||||
const [enabled, setEnabled] = useState(schedule?.enabled ?? true)
|
||||
const [slackReportChannel, setSlackReportChannel] = useState(
|
||||
schedule?.slackReportChannel ?? ""
|
||||
)
|
||||
// undefined = untouched (derive from the schedule / default as models load).
|
||||
const [selectionOverride, setSelectionOverride] = useState<
|
||||
ModelSelection | null | undefined
|
||||
|
|
@ -115,6 +116,7 @@ export function AutomationEditor({
|
|||
repo,
|
||||
model_id: modelId,
|
||||
effort,
|
||||
slack_report_channel: slackReportChannel.trim() || null,
|
||||
},
|
||||
{ onSuccess: () => navigate({ to: "/agents/automations" }) }
|
||||
)
|
||||
|
|
@ -132,6 +134,7 @@ export function AutomationEditor({
|
|||
model_id: modelId,
|
||||
effort,
|
||||
enabled,
|
||||
slack_report_channel: slackReportChannel.trim() || null,
|
||||
},
|
||||
},
|
||||
{ onSuccess: () => navigate({ to: "/agents/automations" }) }
|
||||
|
|
@ -264,6 +267,21 @@ export function AutomationEditor({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<SectionLabel>Report to Slack channel</SectionLabel>
|
||||
<div className="rounded-xl border border-[var(--ui-border)] bg-[var(--ui-surface)] p-3">
|
||||
<input
|
||||
value={slackReportChannel}
|
||||
onChange={(e) => setSlackReportChannel(e.target.value)}
|
||||
placeholder="Channel ID (e.g. C0123ABCD)"
|
||||
className="w-full bg-transparent font-mono text-sm text-[var(--ui-text)] outline-none placeholder:text-[var(--ui-text-dim)]"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-[var(--ui-text-dim)]">
|
||||
When set, each run posts its final summary to this channel as the
|
||||
Open SWE bot. The bot must be a member of the channel. Leave blank
|
||||
for no Slack report.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<p className="mt-4 text-xs text-[var(--ui-danger)]">{errorMessage}</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export interface ScheduleCreateRequest {
|
|||
repo?: string | null
|
||||
model_id?: string | null
|
||||
effort?: string | null
|
||||
slack_report_channel?: string | null
|
||||
}
|
||||
|
||||
export interface ScheduleUpdateRequest {
|
||||
|
|
@ -37,6 +38,7 @@ export interface ScheduleUpdateRequest {
|
|||
model_id?: string | null
|
||||
effort?: string | null
|
||||
enabled?: boolean | null
|
||||
slack_report_channel?: string | null
|
||||
}
|
||||
|
||||
export interface ThreadPrDiffFile {
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ export interface AgentSchedule {
|
|||
repo: string | null
|
||||
model: string
|
||||
effort?: string | null
|
||||
slackReportChannel?: string | null
|
||||
enabled: boolean
|
||||
cronId?: string | null
|
||||
lastThreadId?: string | null
|
||||
|
|
|
|||
180
uv.lock
generated
180
uv.lock
generated
|
|
@ -694,7 +694,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "deepagents"
|
||||
version = "0.6.8"
|
||||
version = "0.6.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain" },
|
||||
|
|
@ -704,9 +704,9 @@ dependencies = [
|
|||
{ name = "langsmith" },
|
||||
{ name = "wcmatch" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/97/77/e3b7efd9bff9cd101c085a5a3bf74180c13ab6c41a96f725cd1cb1bf53e8/deepagents-0.6.8.tar.gz", hash = "sha256:70cdd4da920cc420a8a0f729792ec559688bbbff39f7ab1508110cce9f901c06", size = 196927, upload-time = "2026-06-03T17:08:36.724Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/db/a6acdc72a9e90c3f07ed10de35c951734a02d4facb693bb59684ad368801/deepagents-0.6.12.tar.gz", hash = "sha256:1f281c0bc5a63132f62e2ee345c1dc593b23188da6e23016401f6879fbe54b5f", size = 211364, upload-time = "2026-06-25T17:26:52.775Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/19/1b7b76e958ac7f4e40886edc70f67aff4d7188770ab68105c9c48cbeb769/deepagents-0.6.8-py3-none-any.whl", hash = "sha256:087bdc1458202a3436854cf180f7ec059d07d2114a6c232819e9ad6533a5174a", size = 221469, upload-time = "2026-06-03T17:08:35.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/49/af7219b3c13520fee047bb807cfaefba17f8e4584c551d946773589a4f08/deepagents-0.6.12-py3-none-any.whl", hash = "sha256:28b8fa0119ca0a689e3e18e288c4634e4046062acfc87a1cb34289d3af3a1c88", size = 236120, upload-time = "2026-06-25T17:26:51.736Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -741,7 +741,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "exa-py"
|
||||
version = "2.13.0"
|
||||
version = "2.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpcore" },
|
||||
|
|
@ -752,14 +752,14 @@ dependencies = [
|
|||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/b3/cb701e7e5885a34d6f30af1b58498b52e9b82c29c0ea0cf711db459627c5/exa_py-2.13.0.tar.gz", hash = "sha256:423d20772af3c158ad62a41cb7cea427adeb6766c49f59862f9be6b2fd3b7d66", size = 62154, upload-time = "2026-05-13T19:12:12.032Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b7/4e/baca6add49c57eb1edc62bd2b2a1574b66bdb559a51f6e8c295e7d419e35/exa_py-2.15.0.tar.gz", hash = "sha256:8b3c3dbfe78e57b116e1ef50dec35c921519fb6cee94ed6c986d707b89de44fb", size = 66274, upload-time = "2026-06-24T23:29:07.932Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/10/eb8c09ea8ef43a324dc5f49066bffbe6bea744b4d8c1741311ac73ee9ecb/exa_py-2.13.0-py3-none-any.whl", hash = "sha256:a8e1fd192627a529501603bf43a384636c1e005f6194b85f72dd85fe8bcc138b", size = 85289, upload-time = "2026-05-13T19:12:10.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1c/335681b69de8f2659d7555892b35b64fd586739a95623e03e9e92d57f3be/exa_py-2.15.0-py3-none-any.whl", hash = "sha256:12b8f7df70253df4474c9587e8e518934f311199f0e56be267e07268a49a487e", size = 85628, upload-time = "2026-06-24T23:29:06.587Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.136.3"
|
||||
version = "0.138.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
|
|
@ -768,9 +768,9 @@ dependencies = [
|
|||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0c/a9/9f8f7e00195c29836e9bf58bbbaf579e29878b8a67851efff93d9b6d4eb7/fastapi-0.138.2.tar.gz", hash = "sha256:6432359d067a432134620e7c5e4c6e5063e7f37815bbbbf20acef14b0d2e3fc8", size = 420423, upload-time = "2026-06-29T12:44:12.556Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/b3/38be2c074bdd0c986340db1d72d7b2321b805b1c5a68069aa00b5d31fd02/fastapi-0.138.2-py3-none-any.whl", hash = "sha256:db90c1ffb5517fba5d4a9f80e866daa008747e646310c9ce155c8c535f9d1615", size = 129271, upload-time = "2026-06-29T12:44:13.905Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1434,30 +1434,30 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "langchain"
|
||||
version = "1.3.9"
|
||||
version = "1.3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
{ name = "langgraph" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/56/7c/651d0dc4913a7a892156c03dd343b99cfe19ee729e6911ab1f4fe7567b8b/langchain-1.3.9.tar.gz", hash = "sha256:9b14ef0db9ef314299ded858b22ca2a40b8f1b05c8c9cb6b82d53a53075fef00", size = 631514, upload-time = "2026-06-12T16:53:27.083Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/55/3481619d21b9bdfbfda8680fba5cfc6cfe926789b8eaaad95353078cfa20/langchain-1.3.9-py3-none-any.whl", hash = "sha256:4af49ad1095799e4408b489fb79d4b8b49292453618b202d8a697fca59bb6871", size = 132873, upload-time = "2026-06-12T16:53:25.489Z" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-anthropic"
|
||||
version = "1.4.6"
|
||||
version = "1.4.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anthropic" },
|
||||
{ name = "langchain-core" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/f5/cd397b94aeed5fa0e8ab9595b9fb578ac99f424d42220defe6626e6a1a7b/langchain_anthropic-1.4.6.tar.gz", hash = "sha256:78942d4458d883b7d362438a095ed501ed84f44d402622404482481fc973b9da", size = 706540, upload-time = "2026-06-12T16:54:15.352Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/22/40ab129b08329ca295b391aa1d48267692b42594757084c6918e22b655ac/langchain_anthropic-1.4.8.tar.gz", hash = "sha256:c76891b2044d56105ff13c106ed12650637b53bd598a4bdf15b4796eefa2a4ec", size = 708524, upload-time = "2026-06-26T21:28:46.916Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/26/af/927dbbc5a1f5fea1a69adc2883f034cbd1430004e36f4eacd302d500393a/langchain_anthropic-1.4.6-py3-none-any.whl", hash = "sha256:dbd412a956b6b8b0716d9d8460ef71f834a6731cdbfc59e6160482a4a9fb5200", size = 51797, upload-time = "2026-06-12T16:54:14.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/14/746235c4da89d9bc6a608c5f489f628e03feb8f697195c146e452c8f23c8/langchain_anthropic-1.4.8-py3-none-any.whl", hash = "sha256:778e9301b6fd517824f76ec1776975ce8add97a1f6a36c50ae3c2f4b03a66f7f", size = 52366, upload-time = "2026-06-26T21:28:45.535Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1478,7 +1478,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.4.7"
|
||||
version = "1.4.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
|
|
@ -1491,27 +1491,27 @@ dependencies = [
|
|||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/2b/fffaff399d20a56d40b9562fa19701e91abd72d8c9d9bc8c2673077b56b6/langchain_core-1.4.7.tar.gz", hash = "sha256:7a825d77de0a3f39adbd9d09612a75e85527e14a52c1601089bcc062972d9f2b", size = 952522, upload-time = "2026-06-12T19:23:57.588Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/3e/dcdffa60078ae7b3a00ebb4cbbf1a204a14c3609983c604886523a7d4418/langchain_core-1.4.7-py3-none-any.whl", hash = "sha256:bcadd51951140ecdcba98311dbd931ba5de02a5ba8a2288dad5069c1eea2a13d", size = 554941, upload-time = "2026-06-12T19:23:55.826Z" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-daytona"
|
||||
version = "0.0.6"
|
||||
version = "0.0.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "daytona" },
|
||||
{ name = "deepagents" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/30/e1/c5b26d2d45df2693bc2cbf37b454e032c0dd9004172587a32954e9af6ff0/langchain_daytona-0.0.6.tar.gz", hash = "sha256:36d6697d442de6d50b57973a25ad98369548ccf2b031d09617bc56806365713b", size = 187891, upload-time = "2026-05-12T17:11:09.091Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/64/f1/aceaf3372c2ff0ff2842612cacd3fc7ed990c23c29610920d1a457aaf294/langchain_daytona-0.0.7.tar.gz", hash = "sha256:7faeface3fe42a4799927a631a142f08150fef3710ba29299312a1501a19e258", size = 188027, upload-time = "2026-06-03T20:25:59.806Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/05/f082495ae192021676cf9e311d16b99133a58a0f96b4485ec3c90d0614ca/langchain_daytona-0.0.6-py3-none-any.whl", hash = "sha256:bd85e3e4f95f6dd93d3930f3740018909094dee80dc6a33e54ba67b636f7d094", size = 5377, upload-time = "2026-05-12T17:11:07.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/00/7a203f464e5ce08390129c51b60598a23b4aba5fde4b02ed7521da284368/langchain_daytona-0.0.7-py3-none-any.whl", hash = "sha256:3374c99afc51b290539dcc127f4d4a61deb986e06c2d3a8cdc342eb868246ddb", size = 5456, upload-time = "2026-06-03T20:25:58.859Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-fireworks"
|
||||
version = "1.4.2"
|
||||
version = "1.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
|
@ -1520,14 +1520,14 @@ dependencies = [
|
|||
{ name = "openai" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/f3/2cd800aef4200b1a5a41ef206b8009fb9a500677dfcc089b2b1fb6e6c343/langchain_fireworks-1.4.2.tar.gz", hash = "sha256:aad14fee4267540a93f148e4e4bd0fdad72571dc308d8b227f436c9333d0bd86", size = 195160, upload-time = "2026-05-27T18:10:11.29Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/2d/8e8f5876ee0b209214b23db9d202fd310767d306a3bfae4667eccac30881/langchain_fireworks-1.4.3.tar.gz", hash = "sha256:320e07fd7021b7c01f28ed802a88b4c7cbe66972695a57f64a3d46ba006c44ee", size = 214372, upload-time = "2026-06-26T06:51:51.681Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/b0/be9f620a8ed291d7f3cb096496fbb0b44f3f81fe2405014f57badcb62aa2/langchain_fireworks-1.4.2-py3-none-any.whl", hash = "sha256:54110a930498947fb9fc0625bee2f77d8d67017b4c7b1e21d81d04bc19736902", size = 24361, upload-time = "2026-05-27T18:10:10.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/32/b6cc530726e62313fb9d53264b6c82013cc0ec27937087fe45aca4615e69/langchain_fireworks-1.4.3-py3-none-any.whl", hash = "sha256:6c07d3c30e53cc3dc4142bb6dd4b628abbfae6b1efc65ff91c1855ea7b96a52c", size = 25181, upload-time = "2026-06-26T06:51:50.751Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-google-genai"
|
||||
version = "4.2.4"
|
||||
version = "4.2.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "filetype" },
|
||||
|
|
@ -1535,80 +1535,80 @@ dependencies = [
|
|||
{ name = "langchain-core" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/54/52/de168715eb092c920531d418b8b9aafdff9e37ee80e5fc88106211ccbd47/langchain_google_genai-4.2.4.tar.gz", hash = "sha256:2f5de7a8a6552ffb64b907aca7503fd5e34d1a3240e280abcdc5f7eef480edd5", size = 270054, upload-time = "2026-05-28T21:23:00.503Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/a2/5feaf21cfe6fac80eae944f3ac5348d9e5e986813256f74f8dd104617474/langchain_google_genai-4.2.4-py3-none-any.whl", hash = "sha256:0e2c1021a15c91e60b68d813bb3e793bd1d9396b3f8639b943ab4e56e5652e04", size = 68832, upload-time = "2026-05-28T21:22:59.291Z" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-mcp-adapters"
|
||||
version = "0.2.2"
|
||||
version = "0.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
{ name = "mcp" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/66/1cc7039e2daaddcdea9d8887851fe6eb67401925999b2aa394aa855c7132/langchain_mcp_adapters-0.2.2.tar.gz", hash = "sha256:12d39e91ae4389c54b61b221094e53850b6e152934d8bc10c80665d600e76530", size = 37942, upload-time = "2026-03-16T17:13:30.35Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/1c/b179d8650d2349a342bc1fd1aab41b34154e79c7fc86fc42bdf0bb110d6f/langchain_mcp_adapters-0.3.0.tar.gz", hash = "sha256:fa6c9497015eb2807de5d0c341a36e1d2445cecbae1f4a24e922fc5b94f1a36c", size = 42774, upload-time = "2026-06-10T01:10:28.552Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/2f/15d5e6c1765d8404a9cce38d8c81d7b33fb3392f9db5b992c000dddbd2a3/langchain_mcp_adapters-0.2.2-py3-none-any.whl", hash = "sha256:d08e64954e86281002653071b7430e0377c9a577cb4ac3143abfeb3e24ef8797", size = 23288, upload-time = "2026-03-16T17:13:29.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/89/b4869db84ce529de6c7548319197df50c24d0f8a7412f74f889e22324036/langchain_mcp_adapters-0.3.0-py3-none-any.whl", hash = "sha256:1af511a95e028d9546502e360f95698ae8b691dbc07981fc48170c2cb1ebd7a9", size = 25855, upload-time = "2026-06-10T01:10:27.524Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-modal"
|
||||
version = "0.0.4"
|
||||
version = "0.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deepagents" },
|
||||
{ name = "modal" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/e4/6d2fa6e63eb315fdc6da138697b96eb470e94e914046293018b563826539/langchain_modal-0.0.4.tar.gz", hash = "sha256:7e739c04a6093cbdad64dcf112b35ed0eaa80f7c1964dad5338157409c4da5e6", size = 188906, upload-time = "2026-05-12T17:34:13.729Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/a7/4c65b5204837dd1d26b93aeb01768d053bf6f92988f476842b7b9a5bf61c/langchain_modal-0.0.5.tar.gz", hash = "sha256:acd3ee16264ad97d0040f20b7c711948cf90bbf1fd791e9effdf2be3c82f38bf", size = 190600, upload-time = "2026-06-03T21:26:52.553Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/85/1c/b8fa9d259ca9bd7dd2ea6d7ad7afdd455b27e27a0f98239028fe52f227fb/langchain_modal-0.0.4-py3-none-any.whl", hash = "sha256:37eabbf457f2dccd534cd2023d08990cb27fe8d4b0e42205dbd74246ed68570e", size = 4552, upload-time = "2026-05-12T17:34:12.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/94/ce0d8423c85d61422f00022852185296bcd43c98e7bc2ba9075a1dcf6adc/langchain_modal-0.0.5-py3-none-any.whl", hash = "sha256:123d9b3ce781a3cf59d87e5297d1ea8608a1f2042327593321cb8ab6257d8599", size = 4633, upload-time = "2026-06-03T21:26:51.591Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-openai"
|
||||
version = "1.2.2"
|
||||
version = "1.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
{ name = "openai" },
|
||||
{ name = "tiktoken" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/1b/c506c7f41156d3a6b4582b4c487f480001b8741deecc6e2d4931fdf4cf2c/langchain_openai-1.2.2.tar.gz", hash = "sha256:8698ffcee9a086e91ab6d207f0026181a03effcbf86bf9aee1808ee35af69dcc", size = 1147539, upload-time = "2026-05-21T22:08:31.123Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/8e/7406c99afacafc8c2ce0fa4152f9f8b9598c93ceb291959821abd053b982/langchain_openai-1.2.2-py3-none-any.whl", hash = "sha256:7da39a3c70cbafa93853456199e39a264dc70651be79b12ac49b4f6a448bce2d", size = 99631, upload-time = "2026-05-21T22:08:29.527Z" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.16"
|
||||
version = "0.0.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/e7/8300ba22d968653051fd06e3117d783872dddf3dcebdd6b1d386836eb43c/langchain_protocol-0.0.16.tar.gz", hash = "sha256:806c7cdd951b1c4f692fa40fce60821ff0f221d4360e27673ddf2c2b99c2b7ff", size = 5969, upload-time = "2026-05-28T23:05:11.121Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/9c/06dfcc88d02a6364e8d864c421ddd3736305cb0a6c853f75c302c80fe17c/langchain_protocol-0.0.16-py3-none-any.whl", hash = "sha256:3658c142c5d0fb3a023a4be442ce4c15c6d626aab6135eb79a76dc64ad19c3c3", size = 7037, upload-time = "2026-05-28T23:05:10.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langchain-runloop"
|
||||
version = "0.0.5"
|
||||
version = "0.0.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deepagents" },
|
||||
{ name = "runloop-api-client" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/bb/23672d280d9d4bfcacb7c52430da728843f128f9db96396241908e52796b/langchain_runloop-0.0.5.tar.gz", hash = "sha256:aec3d9b096a401cd0a975280891fc4977f0e65b552e744f40afa2548d53a0d7a", size = 125823, upload-time = "2026-05-12T17:16:01.115Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/87/05856339962c03959058fe238de899df71e65b042ffbe0a1979e5d0a5bcc/langchain_runloop-0.0.6.tar.gz", hash = "sha256:d8d947e847e833ab592ea8fac112c2b6f6ed9e16a7da191fd163c0be88e0edf3", size = 131409, upload-time = "2026-06-03T21:05:04.237Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f7/caeaed7f0936cf06639c679a2b0b8f3e1d45fd63135ea1aab94d7c479bdc/langchain_runloop-0.0.5-py3-none-any.whl", hash = "sha256:70936110deecd1610539bc42823a71b3038d3baee0c5c20a525db7280b6525c9", size = 4342, upload-time = "2026-05-12T17:15:59.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/51/cb2a70a0f26f8baba64425c7c2bb08279d5387093faee4c8daf2b523ce19/langchain_runloop-0.0.6-py3-none-any.whl", hash = "sha256:c886cecbe4d1e197a34865ca42c81bcbb1ffd99cd18e164cb092c5ac95f6dcbf", size = 7612, upload-time = "2026-06-03T21:05:03.077Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.4"
|
||||
version = "1.2.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
|
@ -1618,9 +1618,9 @@ dependencies = [
|
|||
{ name = "pydantic" },
|
||||
{ name = "xxhash" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/43/dac5a2621c1e57f8eb7f0703f6f6fe34a5caf62f8f0fb4d2bb395bb454ea/langgraph-1.2.4.tar.gz", hash = "sha256:5df076973a2d23efb13eceb279d1e5b46feebcbbeded0a86a2ef669abd9e4399", size = 720374, upload-time = "2026-06-02T17:07:37.347Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/9e/31ca236104966d7bb14ea9e93cfd73350aea8c41008ddf057b65794ed10d/langgraph-1.2.4-py3-none-any.whl", hash = "sha256:ffe3e1e31dce28907640f82525858470f293506d2b272d07ea3b3ce97974b067", size = 245402, upload-time = "2026-06-02T17:07:35.977Z" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1750,7 +1750,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.9.3"
|
||||
version = "0.9.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
|
|
@ -1768,9 +1768,9 @@ dependencies = [
|
|||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/b7/43f80940a758ceb48714ccc10c7acb9564be4f0d4786e8ed5d46deb0be39/langsmith-0.9.3.tar.gz", hash = "sha256:868a007b3ecda002914b21212a953c77fcb1d71a8a09a94562c3b57941d3df3a", size = 4577380, upload-time = "2026-06-26T15:56:30.45Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/a7/a78b4105d1c01ee8d404d122d9acc047b8b9f628f3733cb837a321b2f6c6/langsmith-0.9.4.tar.gz", hash = "sha256:36c15e5a692e7812ee927e43d777c6166c839eb02b1d482ba9a805b438b1d91c", size = 4576594, upload-time = "2026-06-30T10:20:43.373Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/fb/dc9f70b5581371fba7b01ba0704fe859bfcd12c4dad82b42ec12bd4160b1/langsmith-0.9.3-py3-none-any.whl", hash = "sha256:aac85686868a7e61d77858b880230e510583c61ea48ac0a197e6ceff64ffb22b", size = 619927, upload-time = "2026-06-26T15:56:28.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/15/79367a3d159141fe4fc138f41db4ef21eb4759cd64cf8f635f41d55ded67/langsmith-0.9.4-py3-none-any.whl", hash = "sha256:f10f07438c7c6f9f907e8c810d8f338268fe863a74c37ded3d8a90830bb25451", size = 603804, upload-time = "2026-06-30T10:20:41.052Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -1794,15 +1794,15 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "markdownify"
|
||||
version = "1.2.2"
|
||||
version = "1.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/92/ab/d1297139c0e2ceb151ae564c8c4f57ac0155d8f1f8b4cbd5d6523c82ea36/markdownify-1.2.3.tar.gz", hash = "sha256:1a176f05522c8a2cb1dd3ab9d307dcdadbed5c26ae717855bfc42b3b6d38d937", size = 18852, upload-time = "2026-06-30T20:27:39.06Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/10/fa543d484e8b1199243fe20eedd02cc5af050edebce98a7293a5773df592/markdownify-1.2.3-py3-none-any.whl", hash = "sha256:a189a0bedfd14009030fde5f85bb6f77c56897cb839b5c25315dd7d4e3e290ba", size = 15732, upload-time = "2026-06-30T20:27:38.094Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2221,32 +2221,32 @@ dev = [
|
|||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "cryptography", specifier = ">=48.0.1" },
|
||||
{ name = "deepagents", specifier = "==0.6.8" },
|
||||
{ name = "exa-py", specifier = ">=2.13.0" },
|
||||
{ name = "fastapi", specifier = ">=0.136.3" },
|
||||
{ name = "deepagents", specifier = "==0.6.12" },
|
||||
{ name = "exa-py", specifier = ">=2.15.0" },
|
||||
{ name = "fastapi", specifier = ">=0.138.2" },
|
||||
{ name = "fireworks-ai", specifier = ">=1.2.0a85" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "langchain", specifier = ">=1.3.9" },
|
||||
{ name = "langchain-anthropic", specifier = ">=1.4.6" },
|
||||
{ name = "langchain-aws", specifier = ">=0.2.0" },
|
||||
{ name = "langchain-daytona", specifier = ">=0.0.5" },
|
||||
{ name = "langchain-fireworks", specifier = ">=1.4.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-mcp-adapters", specifier = ">=0.2.2" },
|
||||
{ name = "langchain-modal", specifier = ">=0.0.3" },
|
||||
{ name = "langchain-openai", specifier = ">=1.2.2" },
|
||||
{ name = "langchain-runloop", specifier = ">=0.0.4" },
|
||||
{ name = "langchain-mcp-adapters", specifier = ">=0.3.0" },
|
||||
{ name = "langchain-modal", specifier = ">=0.0.5" },
|
||||
{ name = "langchain-openai", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-runloop", specifier = ">=0.0.6" },
|
||||
{ name = "langgraph", specifier = ">=1.1.10" },
|
||||
{ name = "langgraph-cli", extras = ["inmem"], specifier = ">=0.4.30" },
|
||||
{ name = "langgraph-sdk", specifier = ">=0.4.2" },
|
||||
{ name = "langsmith", specifier = "==0.9.3" },
|
||||
{ name = "markdownify", specifier = ">=1.2.2" },
|
||||
{ name = "langsmith", specifier = "==0.9.4" },
|
||||
{ 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.0.3" },
|
||||
{ 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.15" },
|
||||
{ name = "uvicorn", specifier = ">=0.48.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.20" },
|
||||
{ name = "uvicorn", specifier = ">=0.49.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
|
|
@ -2829,7 +2829,7 @@ crypto = [
|
|||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
|
|
@ -2838,9 +2838,9 @@ dependencies = [
|
|||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3260,27 +3260,27 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.15"
|
||||
version = "0.15.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3592,15 +3592,15 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.48.0"
|
||||
version = "0.49.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue