Files
EvoScientist-Multi/EvoScientist/langgraph_dev/http.py
T
ouyangbo 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
审批暂停与继续:锚点恢复准入 + 不批准终止本轮(含 HITL 装配收敛与工作区范围)
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 装配收敛与工作区范围,
以及对应测试调整。
2026-09-14 16:53:30 +08:00

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"]),
]
)