ddf337fc40
Docker / build (push) Has been cancelled
Test / pytest (windows-latest, 3.12) (push) Has been cancelled
Build / build (push) Has been cancelled
Lint / ruff (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.11) (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.12) (push) Has been cancelled
Test / pytest (windows-latest, 3.11) (push) Has been cancelled
langgraph_dev/http.py - _compatible_checkpoint/_history_admission 支持带锚点准入:以承载 __interrupt__ 的检查点 为父状态读取并校验中断标识;payload 保留锚点作为父状态 - 不带锚点时行为与原来完全一致(兼容性只靠这一条) middleware/dynamic_review.py - _abort_requested/_finish_turn + hook_config(can_jump_to=["end"]):在库消费中断后结束本轮 (工具不执行、不再发起模型调用);无标记时完全休眠 随本批一并落定(早前改动):EvoScientist.py / workspace_scope.py 的 HITL 装配收敛与工作区范围, 以及对应测试调整。
700 lines
29 KiB
Python
700 lines
29 KiB
Python
"""Custom HTTP routes mounted alongside the langgraph dev server.
|
|
|
|
The langgraph-api host supports a top-level ``http`` key in
|
|
``langgraph.json`` that names an ASGI app to mount on the same
|
|
process as the graph. We use it to surface the registry the WebUI's
|
|
``/model`` picker needs.
|
|
|
|
Why this lives here and not as a separate sidecar: the WebUI talks to
|
|
``EvoSci deploy``'s langgraph endpoint anyway, so one origin keeps the
|
|
WebUI's fetch logic simple — no CORS dance, no extra port to configure.
|
|
|
|
Why Starlette and not FastAPI: ``langgraph_api`` already depends on
|
|
Starlette; adding FastAPI would pull in pydantic v1-vs-v2 reconciliation
|
|
the deploy doesn't need. The one route here has no input model, just a
|
|
JSON body, so the lower-level surface is sufficient.
|
|
|
|
Lightweight by design — module-level imports stick to ``config``,
|
|
``llm.models`` (registry only; no chat-model construction), and
|
|
Starlette itself. Nothing on this surface should pull the agent into
|
|
memory.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import secrets
|
|
from typing import Any, cast
|
|
from uuid import UUID
|
|
|
|
from starlette.applications import Starlette
|
|
from starlette.requests import Request
|
|
from starlette.responses import JSONResponse
|
|
from starlette.routing import Route
|
|
|
|
from EvoScientist.config import get_effective_config
|
|
from EvoScientist.llm.models import list_model_picker_entries
|
|
|
|
_recoverable_run_lock = asyncio.Lock()
|
|
|
|
|
|
def _load_scope_service_token() -> str:
|
|
configured = (
|
|
os.getenv("EVOSCIENTIST_BACKEND_SERVICE_TOKEN", "").strip()
|
|
or os.getenv("AI4SCI_EVO_RUNTIME_GRANT_SECRET", "").strip()
|
|
)
|
|
if configured:
|
|
return configured
|
|
from EvoScientist.scope_registry import get_scope_service_token
|
|
|
|
return get_scope_service_token()
|
|
|
|
|
|
_SCOPE_SERVICE_TOKEN = _load_scope_service_token()
|
|
|
|
|
|
async def get_models(_request: Request) -> JSONResponse:
|
|
"""Return the model registry as ``{entries, default}``.
|
|
|
|
``entries`` preserves the registry order so the WebUI picker can
|
|
rank providers per short name the same way the backend would.
|
|
Mirrors the TUI ``/model`` picker by appending locally-pulled
|
|
Ollama models when ``ollama_base_url`` is configured — same
|
|
``discover_ollama_models()`` call, same 1.5-s timeout, same
|
|
fail-soft semantics (the probe returns ``[]`` on any error, never
|
|
raises). The TUI's "Custom Ollama model…" sentinel is intentionally
|
|
omitted — that's a widget-specific input affordance, not part of
|
|
the registry surface.
|
|
|
|
``default`` reflects the deployment's currently-configured fallback
|
|
(``config.yaml``'s ``model`` / ``provider`` — what ``/model reset``
|
|
would land on). Returned even when the configured pair isn't in
|
|
the registry, so the picker can still label it.
|
|
|
|
Uses ``get_effective_config()`` (not ``load_config()``) so env-var
|
|
overrides like ``OLLAMA_BASE_URL`` from ``_ENV_MAPPINGS`` are
|
|
honored — matching the deploy's actual model-building behavior.
|
|
Offloaded to a thread because ``get_effective_config()`` calls
|
|
``find_dotenv(usecwd=True)`` which invokes ``os.getcwd()`` — a
|
|
blocking syscall that langgraph-dev's ``blockbuster`` middleware
|
|
refuses to allow on the async event loop (would surface as a 500).
|
|
"""
|
|
cfg = await asyncio.to_thread(get_effective_config)
|
|
entries = [
|
|
{"name": name, "model_id": model_id, "provider": provider}
|
|
for name, model_id, provider in await list_model_picker_entries(
|
|
getattr(cfg, "ollama_base_url", None),
|
|
include_custom_ollama=False,
|
|
)
|
|
]
|
|
return JSONResponse(
|
|
{
|
|
"entries": entries,
|
|
"default": {"name": cfg.model, "provider": cfg.provider},
|
|
}
|
|
)
|
|
|
|
|
|
async def recoverable_run_capabilities(_request: Request) -> JSONResponse:
|
|
"""Capabilities required by Ai4Sci's durable dispatch outbox."""
|
|
|
|
return JSONResponse(
|
|
{
|
|
"version": 1,
|
|
"deterministic_run_id": True,
|
|
# The dev adapter uses process-local Runs storage. Checkpoint
|
|
# durability does not make execution identity restart-safe.
|
|
"durable_run_identity": False,
|
|
"worker_exit_confirmation": True,
|
|
"run_not_found_proves_absence": False,
|
|
"stream_resumable": True,
|
|
"durability_sync": True,
|
|
"multitask_enqueue": True,
|
|
"interrupt_resume": True,
|
|
"pending_interrupt_state": True,
|
|
"workspace_scope_v1": os.getenv("EVOSCIENTIST_DEPLOY_MODE", "").lower() == "full",
|
|
}
|
|
)
|
|
|
|
|
|
def _scope_service_authorized(request: Request) -> JSONResponse | None:
|
|
if not _SCOPE_SERVICE_TOKEN:
|
|
return JSONResponse({"code": "WORKSPACE_SERVICE_UNAVAILABLE"}, status_code=503)
|
|
header = request.headers.get("authorization", "")
|
|
if not header.startswith("Bearer ") or not secrets.compare_digest(
|
|
header[7:], _SCOPE_SERVICE_TOKEN
|
|
):
|
|
return JSONResponse({"code": "UNAUTHORIZED"}, status_code=401)
|
|
return None
|
|
|
|
|
|
def _scope_payload(record: Any) -> dict[str, Any]:
|
|
return {
|
|
"deployment_id": record.deployment_id,
|
|
"scope_id": record.scope_id,
|
|
"primary_thread_id": record.primary_thread_id,
|
|
"primary_owner_id": record.primary_owner_id,
|
|
"state": record.state,
|
|
"revision": record.revision,
|
|
}
|
|
|
|
|
|
def _run_payload(run: Any) -> dict[str, Any]:
|
|
return {
|
|
"run_request_id": run.run_request_id,
|
|
"turn_id": run.turn_id,
|
|
"interrupt_key": run.interrupt_key,
|
|
"request_hash": run.request_hash,
|
|
"run_owner_id": run.run_owner_id,
|
|
"run_id": run.run_id,
|
|
"state": run.state,
|
|
}
|
|
|
|
|
|
def _registry_call(method: str, *args: Any, **kwargs: Any) -> Any:
|
|
from EvoScientist.scope_registry import get_scope_registry
|
|
from EvoScientist.workspace_scope import current_deployment_id
|
|
|
|
return getattr(get_scope_registry(), method)(current_deployment_id(), *args, **kwargs)
|
|
|
|
|
|
def _provision_scope(thread_id: str) -> Any:
|
|
from EvoScientist.workspace_scope import (
|
|
current_deployment_id,
|
|
provision_conversation_scope,
|
|
)
|
|
|
|
return provision_conversation_scope(thread_id, deployment_id=current_deployment_id())
|
|
|
|
|
|
async def provision_workspace_scope(request: Request) -> JSONResponse:
|
|
if denied := _scope_service_authorized(request):
|
|
return denied
|
|
try:
|
|
payload = await request.json()
|
|
except json.JSONDecodeError:
|
|
payload = None
|
|
if not isinstance(payload, dict) or not isinstance(payload.get("thread_id"), str):
|
|
return JSONResponse({"code": "INVALID_REQUEST"}, status_code=400)
|
|
try:
|
|
record = await asyncio.to_thread(_provision_scope, payload["thread_id"])
|
|
except Exception as exc:
|
|
return JSONResponse({"code": "WORKSPACE_SCOPE_CONFLICT", "message": str(exc)}, status_code=409)
|
|
return JSONResponse(_scope_payload(record), status_code=201)
|
|
|
|
|
|
async def get_workspace_scope(request: Request) -> JSONResponse:
|
|
if denied := _scope_service_authorized(request):
|
|
return denied
|
|
try:
|
|
record = await asyncio.to_thread(
|
|
_registry_call, "get_by_thread", str(request.path_params["thread_id"])
|
|
)
|
|
except Exception as exc:
|
|
return JSONResponse({"code": "WORKSPACE_SCOPE_NOT_FOUND", "message": str(exc)}, status_code=404)
|
|
return JSONResponse(_scope_payload(record))
|
|
|
|
|
|
async def delete_workspace_scope(request: Request) -> JSONResponse:
|
|
if denied := _scope_service_authorized(request):
|
|
return denied
|
|
try:
|
|
from EvoScientist.workspace_scope import (
|
|
current_deployment_id,
|
|
delete_conversation_scope,
|
|
)
|
|
|
|
record = await asyncio.to_thread(
|
|
delete_conversation_scope,
|
|
str(request.path_params["thread_id"]),
|
|
deployment_id=current_deployment_id(),
|
|
)
|
|
except Exception as exc:
|
|
code = (
|
|
"WORKSPACE_SCOPE_NOT_FOUND"
|
|
if type(exc).__name__ == "ScopeNotFoundError"
|
|
else "WORKSPACE_SCOPE_DELETE_FAILED"
|
|
)
|
|
status = 404 if code == "WORKSPACE_SCOPE_NOT_FOUND" else 409
|
|
return JSONResponse({"code": code, "message": str(exc)}, status_code=status)
|
|
return JSONResponse(_scope_payload(record))
|
|
|
|
|
|
async def reserve_workspace_run(request: Request) -> JSONResponse:
|
|
if denied := _scope_service_authorized(request):
|
|
return denied
|
|
try:
|
|
payload = await request.json()
|
|
except json.JSONDecodeError:
|
|
payload = None
|
|
if not isinstance(payload, dict):
|
|
return JSONResponse({"code": "INVALID_REQUEST"}, status_code=400)
|
|
try:
|
|
run = await asyncio.to_thread(
|
|
_registry_call,
|
|
"reserve_run",
|
|
str(request.path_params["scope_id"]),
|
|
str(payload["run_request_id"]),
|
|
str(payload["turn_id"]),
|
|
str(payload["request_hash"]),
|
|
interrupt_key=(
|
|
str(payload["interrupt_key"]) if payload.get("interrupt_key") else None
|
|
),
|
|
)
|
|
except Exception as exc:
|
|
code = (
|
|
"INTERRUPT_ALREADY_RESOLVED"
|
|
if type(exc).__name__ == "ScopeInterruptResolvedError"
|
|
else "WORKSPACE_RUN_CONFLICT"
|
|
)
|
|
return JSONResponse({"code": code, "message": str(exc)}, status_code=409)
|
|
return JSONResponse(_run_payload(run), status_code=201)
|
|
|
|
|
|
async def bind_workspace_run(request: Request) -> JSONResponse:
|
|
if denied := _scope_service_authorized(request):
|
|
return denied
|
|
try:
|
|
payload = await request.json()
|
|
except json.JSONDecodeError:
|
|
payload = None
|
|
if not isinstance(payload, dict) or not isinstance(payload.get("run_id"), str):
|
|
return JSONResponse({"code": "INVALID_REQUEST"}, status_code=400)
|
|
try:
|
|
run = await asyncio.to_thread(
|
|
_registry_call,
|
|
"bind_run",
|
|
str(request.path_params["scope_id"]),
|
|
str(request.path_params["run_request_id"]),
|
|
payload["run_id"],
|
|
)
|
|
except Exception as exc:
|
|
return JSONResponse({"code": "WORKSPACE_RUN_CONFLICT", "message": str(exc)}, status_code=409)
|
|
return JSONResponse(_run_payload(run))
|
|
|
|
|
|
def _interrupt_ids(value: Any) -> set[str]:
|
|
"""从 __interrupt__ 写入值里取出中断标识(Interrupt 对象或字典都兼容)。"""
|
|
|
|
items = value if isinstance(value, (list, tuple)) else [value]
|
|
found: set[str] = set()
|
|
for item in items:
|
|
ident = getattr(item, "id", None)
|
|
if ident is None and isinstance(item, dict):
|
|
ident = item.get("id") or item.get("interrupt_id")
|
|
if ident:
|
|
found.add(str(ident))
|
|
return found
|
|
|
|
|
|
def _anchor_has_interrupt(checkpoint: Any, expected: str) -> bool:
|
|
"""锚点处是否仍承载待审批中断(并核对中断标识)。
|
|
|
|
这是"按锚点恢复"的准入判据:只要承载该中断的检查点写入还在 PG,暂停就仍然
|
|
有效 —— 与运行时进程是否重启过、距暂停多久都无关。
|
|
"""
|
|
|
|
found: set[str] = set()
|
|
for write in getattr(checkpoint, "pending_writes", None) or ():
|
|
try:
|
|
if len(write) < 3 or str(write[1]) != "__interrupt__":
|
|
continue
|
|
found |= _interrupt_ids(write[2])
|
|
except TypeError:
|
|
continue
|
|
if not found:
|
|
return False
|
|
# 网关未提供标识(或回退值 default)时只做存在性判定。
|
|
if not expected or expected == "default":
|
|
return True
|
|
return expected in found
|
|
|
|
|
|
async def _compatible_checkpoint(conn, thread_id: str, assistant_id: str,
|
|
config: dict, anchor: dict | None = None
|
|
) -> tuple[bool, bool]:
|
|
"""Read through the API-owned saver and graph factory, never execute here."""
|
|
from langgraph_api._checkpointer import get_checkpointer
|
|
from langgraph_api.graph import get_graph, graph_exists
|
|
from langgraph_api.store import get_store
|
|
|
|
saver = await get_checkpointer(conn=conn)
|
|
read_config = {**config, "configurable": {
|
|
**config.get("configurable", {}), "thread_id": thread_id,
|
|
"checkpoint_ns": str((anchor or {}).get("checkpoint_ns") or ""),
|
|
}}
|
|
if anchor and str(anchor.get("checkpoint_id") or ""):
|
|
# 按锚点恢复:调用方(网关)指定了承载该中断的祖先检查点,暂停时就已落库。
|
|
# 只有该锚点处确实还有待审批写入才放行 —— 不依赖运行时当前头部。
|
|
read_config["configurable"]["checkpoint_id"] = str(anchor["checkpoint_id"])
|
|
else:
|
|
# Admission always checks the current head, never a caller-selected ancestor.
|
|
read_config["configurable"].pop("checkpoint_id", None)
|
|
checkpoint = await saver.aget_tuple(read_config)
|
|
if checkpoint is None:
|
|
return False, False
|
|
graph_id = assistant_id
|
|
if not graph_exists(graph_id):
|
|
from langgraph_runtime.ops import Assistants
|
|
from langgraph_api.utils import fetchone
|
|
assistant = await fetchone(await Assistants.get(conn, UUID(assistant_id)))
|
|
graph_id = assistant["graph_id"]
|
|
if checkpoint.metadata.get("graph_id", graph_id) != graph_id:
|
|
raise ValueError("checkpoint graph mismatch")
|
|
if anchor and str(anchor.get("checkpoint_id") or ""):
|
|
return True, _anchor_has_interrupt(
|
|
checkpoint, str(anchor.get("interrupt_id") or "")
|
|
)
|
|
# get_graph enters coroutine/async-context-manager factories and binds the
|
|
# same API saver used by the worker. aget_state also validates delta seeds.
|
|
async with get_graph(graph_id, read_config, checkpointer=saver,
|
|
store=await get_store(), access_context="threads.read") as graph:
|
|
state = await graph.aget_state(read_config, subgraphs=True)
|
|
pending = bool(getattr(state, "interrupts", ())) or any(
|
|
task.interrupts for task in state.tasks
|
|
)
|
|
return True, pending
|
|
|
|
|
|
async def _has_legacy_history(conn, thread_id: str) -> bool:
|
|
"""Resolve Web history from PostgreSQL and the Runtime registry only."""
|
|
dsn = os.getenv("EVOSCIENTIST_WEB_CHECKPOINT_DSN", "")
|
|
if dsn:
|
|
from psycopg import AsyncConnection
|
|
async with await AsyncConnection.connect(
|
|
dsn, autocommit=True, connect_timeout=5,
|
|
options="-c default_transaction_read_only=on -c search_path=public",
|
|
) as pg:
|
|
async with pg.cursor() as cursor:
|
|
await cursor.execute("SELECT EXISTS(SELECT 1 FROM threads WHERE id=%s::uuid)",
|
|
(thread_id,))
|
|
row = await cursor.fetchone()
|
|
if row and row[0]:
|
|
return True
|
|
from langgraph_api.utils import fetchone
|
|
from langgraph_runtime.ops import Threads
|
|
try:
|
|
await fetchone(await Threads.get(conn, UUID(thread_id)))
|
|
except Exception as exc:
|
|
if getattr(exc, "status_code", None) != 404:
|
|
raise
|
|
return False
|
|
return True
|
|
|
|
|
|
async def _history_admission(conn, thread_id, assistant_id, config, operation, history,
|
|
anchor: dict | None = None):
|
|
exists, pending = await _compatible_checkpoint(
|
|
conn, thread_id, assistant_id, config, anchor
|
|
)
|
|
if operation == "resume":
|
|
return "resume" if exists and pending else "CHECKPOINT_RESUME_UNAVAILABLE"
|
|
if pending:
|
|
return "THREAD_AWAITING_INPUT"
|
|
if exists:
|
|
return "append"
|
|
if history is not None:
|
|
return "initialize"
|
|
return "HISTORY_REQUIRED" if await _has_legacy_history(conn, thread_id) else "new"
|
|
|
|
|
|
async def create_recoverable_run(request: Request) -> JSONResponse:
|
|
"""Create a LangGraph Run with a caller-owned deterministic UUID.
|
|
|
|
LangGraph's public create endpoint always generates its own UUID. This
|
|
adapter performs lookup and insertion while holding the process-wide run
|
|
creation lock and passes the durable request UUID to ``create_valid_run``.
|
|
This prevents duplicate creation only while this process retains the Run.
|
|
A lost response followed by a restart MUST NOT be retried as a fresh create:
|
|
the dev backend forgets Runs, and a 404 is not evidence of non-execution.
|
|
"""
|
|
|
|
from EvoScientist.internal_service import internal_service_token
|
|
|
|
token = internal_service_token()
|
|
if not token:
|
|
return JSONResponse({"code": "WORKSPACE_SERVICE_UNAVAILABLE"}, status_code=503)
|
|
header = request.headers.get("authorization", "")
|
|
if not header.startswith("Bearer ") or not secrets.compare_digest(header[7:], token):
|
|
return JSONResponse({"code": "UNAUTHORIZED"}, status_code=401)
|
|
value = await request.json()
|
|
if not isinstance(value, dict):
|
|
return JSONResponse({"code": "INVALID_REQUEST"}, status_code=400)
|
|
try:
|
|
thread_id = str(UUID(str(value["thread_id"])))
|
|
run_id = UUID(str(value["run_id"]))
|
|
run_request_id = str(UUID(str(value["run_request_id"])))
|
|
request_hash = str(value["request_hash"])
|
|
assistant_id = str(value["assistant_id"])
|
|
operation = str(value.get("operation") or "start")
|
|
except (KeyError, TypeError, ValueError):
|
|
return JSONResponse({"code": "INVALID_REQUEST"}, status_code=400)
|
|
if (
|
|
str(run_id) != run_request_id
|
|
or len(request_hash) != 64
|
|
or operation not in {"start", "resume"}
|
|
):
|
|
return JSONResponse({"code": "INVALID_IDEMPOTENCY_KEY"}, status_code=400)
|
|
command = value.get("command")
|
|
if operation == "resume":
|
|
if (
|
|
value.get("input") is not None
|
|
or value.get("history") is not None
|
|
or not isinstance(command, dict)
|
|
or set(command) != {"resume"}
|
|
):
|
|
return JSONResponse({"code": "INVALID_RESUME_REQUEST"}, status_code=400)
|
|
elif command is not None:
|
|
return JSONResponse({"code": "INVALID_START_REQUEST"}, status_code=400)
|
|
# 按锚点恢复(可选,仅 resume):网关在暂停时把"承载该中断的检查点"落库,
|
|
# 继续时回传。这样续接只依赖该检查点仍在 PG,而不依赖运行时当前头部。
|
|
anchor = value.get("anchor") if operation == "resume" else None
|
|
if not isinstance(anchor, dict) or not str(anchor.get("checkpoint_id") or ""):
|
|
anchor = None
|
|
history = value.get("history")
|
|
# History validation is performed under the create lock, after idempotent
|
|
# lookup. Only that branch can attest this request did not create a Run.
|
|
|
|
from langgraph_api.models.run import Runs, create_valid_run
|
|
from langgraph_api.utils import fetchone
|
|
from langgraph_runtime.database import connect
|
|
|
|
from EvoScientist.langgraph_dev import worker_exit
|
|
|
|
worker_exit.install()
|
|
|
|
payload = {
|
|
"assistant_id": assistant_id,
|
|
"input": value.get("input"),
|
|
"command": command,
|
|
"metadata": value.get("metadata") or {},
|
|
"config": value.get("config") or {},
|
|
"stream_mode": value.get("stream_mode") or ["messages", "updates", "tasks", "custom"],
|
|
"stream_resumable": True,
|
|
"durability": "sync",
|
|
"multitask_strategy": "enqueue",
|
|
"if_not_exists": "create",
|
|
}
|
|
payload["metadata"] = {
|
|
**payload["metadata"],
|
|
"run_request_id": run_request_id,
|
|
"request_hash": request_hash,
|
|
}
|
|
# The admission read and the worker must address the same checkpoint.
|
|
# Without an anchor that is the current head (forking from an ancestor is
|
|
# not part of the recoverable-run contract); with an anchor it is the
|
|
# ancestor recorded at pause time — which is exactly what makes the
|
|
# continuation independent of restarts and elapsed time.
|
|
configurable = dict(payload["config"].get("configurable", {}))
|
|
for key in ("checkpoint_id", "checkpoint_map"):
|
|
configurable.pop(key, None)
|
|
configurable.update(thread_id=thread_id, checkpoint_ns="")
|
|
if anchor is not None:
|
|
configurable["checkpoint_id"] = str(anchor["checkpoint_id"])
|
|
configurable["checkpoint_ns"] = str(anchor.get("checkpoint_ns") or "")
|
|
payload["config"] = {**payload["config"], "configurable": configurable}
|
|
async with _recoverable_run_lock:
|
|
async with connect() as conn:
|
|
existing_iter = await Runs.get(conn, run_id, thread_id=UUID(thread_id))
|
|
try:
|
|
existing = await fetchone(existing_iter)
|
|
except Exception as exc:
|
|
if getattr(exc, "status_code", None) != 404:
|
|
raise
|
|
existing = None
|
|
if existing is not None:
|
|
metadata = existing.get("metadata") or {}
|
|
if metadata.get("request_hash") != request_hash:
|
|
return JSONResponse(
|
|
{
|
|
"code": "RUN_REQUEST_CONFLICT",
|
|
"message": "run_request_id is bound to another request hash",
|
|
},
|
|
status_code=409,
|
|
)
|
|
return JSONResponse(
|
|
{"run_id": str(existing["run_id"]), "status": existing["status"], "created": False}
|
|
)
|
|
try:
|
|
admission = await _history_admission(
|
|
conn, thread_id, assistant_id, payload["config"], operation, history,
|
|
anchor,
|
|
)
|
|
except Exception:
|
|
return JSONResponse({
|
|
"code": "CHECKPOINT_UNAVAILABLE", "create_disposition": "not_created",
|
|
"run_id": str(run_id), "request_hash": request_hash,
|
|
}, status_code=503)
|
|
if admission not in {"new", "initialize", "append", "resume"}:
|
|
return JSONResponse({
|
|
"code": admission, "create_disposition": "not_created",
|
|
"run_id": str(run_id), "request_hash": request_hash,
|
|
}, status_code=409)
|
|
if history is not None:
|
|
from EvoScientist.llm.history_rebuild import committed_history_input
|
|
|
|
try:
|
|
if not isinstance(history, dict):
|
|
raise ValueError("history must be an object")
|
|
import hashlib
|
|
|
|
history_hash = hashlib.sha256(json.dumps(
|
|
history, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
|
).encode()).hexdigest()
|
|
if value.get("history_hash") != history_hash:
|
|
raise ValueError("history hash mismatch")
|
|
payload["input"] = committed_history_input(
|
|
history, payload["input"], thread_id=thread_id, run_id=str(run_id),
|
|
checkpoint_exists=admission == "append",
|
|
)
|
|
if admission == "initialize":
|
|
payload["metadata"].update(
|
|
history_hash=history_hash,
|
|
history_revision=history["conversation_revision"],
|
|
history_schema=history["schema"],
|
|
)
|
|
except (ValueError, TypeError, KeyError, AttributeError):
|
|
return JSONResponse({
|
|
"code": "INVALID_HISTORY_REQUEST",
|
|
"create_disposition": "not_created",
|
|
"run_id": str(run_id), "request_hash": request_hash,
|
|
}, status_code=400)
|
|
worker_exit.reserve(thread_id, str(run_id), request_hash)
|
|
created = await create_valid_run(
|
|
conn,
|
|
thread_id,
|
|
payload,
|
|
dict(request.headers),
|
|
run_id=run_id,
|
|
)
|
|
return JSONResponse(
|
|
{"run_id": str(created["run_id"]), "status": created["status"], "created": True},
|
|
status_code=201,
|
|
)
|
|
|
|
|
|
async def cancel_recoverable_run(request: Request) -> JSONResponse:
|
|
from EvoScientist.internal_service import internal_service_token
|
|
|
|
token = internal_service_token()
|
|
if not token:
|
|
return JSONResponse({"code": "WORKSPACE_SERVICE_UNAVAILABLE"}, status_code=503)
|
|
header = request.headers.get("authorization", "")
|
|
if not header.startswith("Bearer ") or not secrets.compare_digest(header[7:], token):
|
|
return JSONResponse({"code": "UNAUTHORIZED"}, status_code=401)
|
|
from EvoScientist.langgraph_dev import worker_exit
|
|
from langgraph_api.models.run import Runs
|
|
from langgraph_runtime.database import connect
|
|
|
|
thread_id = UUID(request.path_params["thread_id"])
|
|
run_id = UUID(request.path_params["run_id"])
|
|
# This service principal controls only pairs admitted by authenticated create.
|
|
if not worker_exit.is_reserved(str(thread_id), str(run_id)):
|
|
return JSONResponse({"code": "RUN_NOT_AUTHORIZED"}, status_code=404)
|
|
# Close worker admission before notifying the original runtime control queue.
|
|
initial = worker_exit.cancel_and_inspect(str(thread_id), str(run_id))
|
|
if initial.get("execution_exited") is not True:
|
|
async with connect() as conn:
|
|
try:
|
|
await Runs.cancel(conn, [run_id], thread_id=thread_id, action="interrupt")
|
|
except Exception as exc:
|
|
if getattr(exc, "status_code", None) not in {404, 409}:
|
|
raise
|
|
receipt = await worker_exit.wait_for_exit(str(thread_id), str(run_id))
|
|
if receipt.get("execution_exited") is True:
|
|
async with connect() as conn:
|
|
try:
|
|
await Runs.delete(cast(Any, conn), run_id, thread_id=thread_id)
|
|
except Exception as exc:
|
|
if getattr(exc, "status_code", None) != 404:
|
|
raise
|
|
receipt = {**receipt, "checkpoint_cleanup": "completed"}
|
|
return JSONResponse(receipt)
|
|
|
|
|
|
async def get_teams(_request: Request) -> JSONResponse:
|
|
"""Return installed expert skills as ``{teams: [...]}`` for the WebUI gallery.
|
|
|
|
A "team" in the WebUI vocabulary is an installed expert skill — a skill
|
|
directory carrying a sibling ``EXPERT.md`` (or, on the deprecated path,
|
|
``type: expert`` SKILL.md frontmatter). The response is a curated,
|
|
gallery-safe projection: name + description, plus optional ``byline`` /
|
|
``capability_tags`` / ``avatar_hint`` when the skill populates them.
|
|
|
|
Cards for experts on the current contract carry name + description only:
|
|
the decoration fields were actor metadata in SKILL.md frontmatter, which
|
|
that contract removes rather than relocates (``EXPERT.md`` has no
|
|
frontmatter to hold them). The omit-when-unpopulated projection below is
|
|
what makes those cards degrade rather than break; restoring richer cards
|
|
means sourcing decoration from index metadata, not re-adding frontmatter
|
|
fields.
|
|
|
|
Backend implementation details (SKILL.md body / system prompt, role
|
|
line, tool list, source tier, filesystem path,
|
|
tags) are intentionally NOT projected. The gallery only needs
|
|
identity + descriptor fields to render the card; anything richer
|
|
belongs in a dedicated info endpoint.
|
|
|
|
Sourced from ``list_expert_skills(include_system=True)`` so
|
|
first-party experts shipped as builtin skills surface alongside
|
|
workspace/global installs.
|
|
|
|
Offloaded to a thread because the skill loader does synchronous
|
|
filesystem walking + yaml parsing, which langgraph-dev's
|
|
``blockbuster`` middleware refuses on the async event loop.
|
|
|
|
Response shape (each entry): ``{name, description, byline?,
|
|
capability_tags?, avatar_hint?}`` — the WebUI gallery consumes these.
|
|
"""
|
|
from EvoScientist.tools.skills_manager import list_expert_skills
|
|
|
|
experts = await asyncio.to_thread(list_expert_skills, True)
|
|
teams = []
|
|
for info in experts:
|
|
entry = {
|
|
"name": info.name,
|
|
"description": info.description,
|
|
}
|
|
# Optional gallery fields — omit when unpopulated so the WebUI
|
|
# card degrades gracefully (SkillInfo defaults `byline` /
|
|
# `avatar_hint` to "" and `capability_tags` to [], which we
|
|
# treat as "not declared").
|
|
if info.byline:
|
|
entry["byline"] = info.byline
|
|
if info.capability_tags:
|
|
entry["capability_tags"] = list(info.capability_tags)
|
|
if info.avatar_hint:
|
|
entry["avatar_hint"] = info.avatar_hint
|
|
teams.append(entry)
|
|
return JSONResponse({"teams": teams})
|
|
|
|
|
|
app = Starlette(
|
|
routes=[
|
|
Route("/api/models", get_models, methods=["GET"]),
|
|
Route(
|
|
"/api/ai4sci/recoverable-runs/{thread_id}/{run_id}/cancel",
|
|
cancel_recoverable_run,
|
|
methods=["POST"],
|
|
),
|
|
Route(
|
|
"/api/ai4sci/recoverable-runs/capabilities",
|
|
recoverable_run_capabilities,
|
|
methods=["GET"],
|
|
),
|
|
Route(
|
|
"/api/ai4sci/recoverable-runs/create",
|
|
create_recoverable_run,
|
|
methods=["POST"],
|
|
),
|
|
Route("/internal/workspace-scopes/provision", provision_workspace_scope, methods=["POST"]),
|
|
Route("/internal/workspace-scopes/by-thread/{thread_id}", get_workspace_scope, methods=["GET"]),
|
|
Route("/internal/workspace-scopes/by-thread/{thread_id}", delete_workspace_scope, methods=["DELETE"]),
|
|
Route("/internal/workspace-scopes/{scope_id}/runs/reserve", reserve_workspace_run, methods=["POST"]),
|
|
Route("/internal/workspace-scopes/{scope_id}/runs/{run_request_id}", bind_workspace_run, methods=["PATCH"]),
|
|
Route("/api/teams", get_teams, methods=["GET"]),
|
|
]
|
|
)
|