From 91c87db4f1479f1ab33ba156bf0422fb4ba09f34 Mon Sep 17 00:00:00 2001 From: ghzhost Date: Thu, 10 Sep 2026 23:28:37 +0000 Subject: [PATCH 01/42] docs: add GitHub issue templates for bugs and features (closes #1) --- .github/ISSUE_TEMPLATE/bug_report.yml | 65 ++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.yml | 41 ++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..a5848ac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,65 @@ +name: Bug Report +description: Report a reproducible problem or unexpected error in OpenOrbit +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report an issue! Please provide as much detail as possible to help us investigate and resolve it quickly. + - type: textarea + id: description + attributes: + label: Problem Description + description: A clear and concise description of the bug. + placeholder: Describe what happened... + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: Exact steps to reproduce the behavior. + placeholder: | + 1. Start the application with '...' + 2. Navigate to '...' + 3. Click on '...' + 4. Observe error + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: A clear and concise description of what you expected to happen. + placeholder: What did you expect to happen? + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually occurred, including any error messages or unexpected states. + placeholder: What happened instead? + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment & Versions + description: Relevant environment information (OS, Node version, Python version, browser, OpenOrbit version). + placeholder: | + - OS: [e.g. Linux Ubuntu 22.04 / macOS 14 / Windows 11] + - Python version: [e.g. 3.13.0] + - Node.js version: [e.g. 24.0.0] + - OpenOrbit version: [e.g. v0.8.0 or commit hash] + validations: + required: true + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Add any other context, logs, terminal outputs, or screenshots about the problem here. + placeholder: Optional additional logs or screenshots... + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..fde139f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,41 @@ +name: Feature Request +description: Propose a new idea, enhancement, or capability for OpenOrbit +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thank you for suggesting a feature! Please provide details about what problem this feature solves and how it should work. + - type: textarea + id: problem + attributes: + label: Problem Statement / Motivation + description: Is your feature request related to a problem or limitation? Please describe. + placeholder: A clear and concise description of what the problem or use case is... + validations: + required: true + - type: textarea + id: proposed-solution + attributes: + label: Proposed Solution & Expected Behavior + description: A clear and concise description of what you want to happen and how it should look or behave. + placeholder: Describe your proposed design, UI behavior, or API capability... + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Any alternative solutions or workarounds you have considered. + placeholder: Optional description of other approaches evaluated... + validations: + required: false + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Add any other context, mockup screenshots, or implementation suggestions here. + placeholder: Optional mockups, architectural notes, or links... + validations: + required: false From 8c81d58d6383717545ddcf7f352cbac7360d9d15 Mon Sep 17 00:00:00 2001 From: forthfate Date: Wed, 16 Sep 2026 00:21:31 +0900 Subject: [PATCH 02/42] feat: add streamable HTTP MCP server --- README.md | 1 + backend/app/main.py | 26 ++- backend/app/mcp_server.py | 122 +++++++++++++ backend/tests/test_api.py | 37 ++++ docs/API.md | 12 ++ i18n/ko/README.md | 3 + pyproject.toml | 1 + uv.lock | 361 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 557 insertions(+), 6 deletions(-) create mode 100644 backend/app/mcp_server.py diff --git a/README.md b/README.md index bd1665d..eaeea5e 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,7 @@ OpenOrbit exposes a local, versioned API: - Swagger UI: `http://localhost:3000/api/docs` - OpenAPI document: `http://localhost:3000/api/openapi.json` - API base: `http://localhost:3000/api/v1` +- MCP (Streamable HTTP): `http://localhost:3000/mcp/` Read the [API reference](docs/API.md) for endpoint details. To add reusable automation, create a Python runner with explicit lifecycle phases: diff --git a/backend/app/main.py b/backend/app/main.py index f70be62..13352b6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,6 +2,7 @@ import json import re +from contextlib import asynccontextmanager from pathlib import Path from typing import Literal @@ -13,10 +14,22 @@ from . import store as store_module from .assistant_tools import AssistantToolExecutor from .docker import preflight_docker +from .mcp_server import create_mcp_server from .providers import AzureOpenAIProvider, BedrockProvider, ModelSettings from .store import ConsoleStore from .terminal import serve_terminal + +@asynccontextmanager +async def application_lifespan(_: FastAPI): + """Run the MCP session manager for the same lifetime as the API server.""" + async with mcp_server.session_manager.run(): + try: + yield + finally: + store.shutdown() + + app = FastAPI( title="OpenOrbit API", version="0.2.0", @@ -54,6 +67,7 @@ {"name": "Improvements", "description": "Read-only supervisor feedback and analytics."}, {"name": "System", "description": "Local service health and capabilities."}, ], + lifespan=application_lifespan, ) app.add_middleware( CORSMiddleware, @@ -66,12 +80,6 @@ SDK_DOCS_DIST = Path(__file__).resolve().parents[2] / "site" -@app.on_event("shutdown") -def stop_active_runner_processes() -> None: - """Prevent browsers from outliving the local API process on reload.""" - store.shutdown() - - def safely(action): try: return action() @@ -1451,6 +1459,12 @@ def sdk_docs(path: str): raise HTTPException(404, "SDK documentation page was not found.") +# Streamable HTTP transport for MCP clients. The mounted application's root +# is the protocol endpoint, so the public URL is /mcp/. +mcp_server = create_mcp_server(lambda: store, app.openapi) +app.mount("/mcp", mcp_server.streamable_http_app()) + + @app.get("/{path:path}", include_in_schema=False) def local_web_app(path: str): """Serve the built React application for `orbit-agent-console run`.""" diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py new file mode 100644 index 0000000..0cc4c39 --- /dev/null +++ b/backend/app/mcp_server.py @@ -0,0 +1,122 @@ +"""MCP surface for the stable OpenOrbit v1 API. + +The tools deliberately call the same store operations as the HTTP handlers. +This keeps MCP local-first and avoids making the server call itself over HTTP. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Literal + +from mcp.server.fastmcp import FastMCP +from mcp.server.transport_security import TransportSecuritySettings +from mcp.types import ToolAnnotations + +from .docker import preflight_docker +from .store import ConsoleStore + + +def _json(value: Any) -> Any: + """Convert Pydantic response models without changing ordinary JSON values.""" + return value.model_dump(mode="json") if hasattr(value, "model_dump") else value + + +def create_mcp_server( + get_store: Callable[[], ConsoleStore], openapi: Callable[[], dict[str, Any]] +) -> FastMCP: + """Create the streamable-HTTP MCP server mounted by the FastAPI app.""" + mcp = FastMCP( + "OpenOrbit", + instructions=( + "Operate the local OpenOrbit control plane. Read state before starting, approving, " + "rejecting, cancelling, or stopping pipelines. The OpenAPI resource is the complete " + "contract for the corresponding HTTP API." + ), + streamable_http_path="/", + transport_security=TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"], + ), + ) + + @mcp.resource("openorbit://openapi") + def openapi_schema() -> str: + """The generated OpenAPI 3 contract for OpenOrbit's versioned HTTP API.""" + import json + + return json.dumps(openapi(), ensure_ascii=False) + + @mcp.tool( + description="Get local service health, dashboard totals, active pipelines, and Docker readiness.", + annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True, openWorldHint=False), + ) + def get_status() -> dict[str, Any]: + store = get_store() + return { + "health": {"status": "ok"}, + "dashboard": _json(store.dashboard()), + "active_pipelines": _json(store.active_evaluations()), + "docker": preflight_docker(), + } + + @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True, openWorldHint=False)) + def list_projects() -> list[dict[str, Any]]: + """List all configured projects (the v1 API calls builds projects).""" + return [_json(project) for project in get_store().builds()] + + @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True, openWorldHint=False)) + def get_project(project_id: str) -> dict[str, Any]: + """Get one project and its complete evaluation configuration.""" + return _json(get_store().build(project_id)) + + @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True, openWorldHint=False)) + def list_pipelines(project_id: str | None = None, status: str | None = None) -> list[dict[str, Any]]: + """List retained pipeline runs, optionally filtered by project or status.""" + pipelines = get_store().runs() + if project_id: + pipelines = [pipeline for pipeline in pipelines if pipeline.build_id == project_id] + if status: + pipelines = [pipeline for pipeline in pipelines if pipeline.status == status] + return [_json(pipeline) for pipeline in pipelines] + + @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True, openWorldHint=False)) + def get_pipeline(pipeline_id: str) -> dict[str, Any]: + """Get a pipeline's current state, phases, evidence, and supervisor results.""" + return _json(get_store().run(pipeline_id)) + + @mcp.tool( + description="Start a project pipeline. Use test mode for a one-off safe validation run.", + annotations=ToolAnnotations(destructiveHint=False, idempotentHint=False, openWorldHint=False), + ) + def start_pipeline(project_id: str, execution_mode: Literal["run", "test"] = "run") -> dict[str, Any]: + """Start a retained run or transient test pipeline for a project.""" + return _json(get_store().invoke_remote_build(project_id, execution_mode, None)) + + @mcp.tool( + description="Approve, reject, or cancel a pipeline that is awaiting an operator action.", + annotations=ToolAnnotations(destructiveHint=True, idempotentHint=False, openWorldHint=False), + ) + def act_on_pipeline(pipeline_id: str, action: Literal["approve", "reject", "cancel"]) -> dict[str, Any]: + """Perform an approval-gated pipeline action.""" + actions = { + "approve": get_store().approve, + "reject": get_store().reject, + "cancel": get_store().cancel, + } + return _json(actions[action](pipeline_id)) + + @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True, openWorldHint=False)) + def list_improvements(build_id: str | None = None) -> list[dict[str, Any]]: + """List supervisor proposals and their decision lifecycle, optionally for one project.""" + return _json(get_store().proposal_lifecycles(build_id)) + + @mcp.tool( + description="Cancel every active local pipeline. Use only after confirming the affected work.", + annotations=ToolAnnotations(destructiveHint=True, idempotentHint=False, openWorldHint=False), + ) + def emergency_stop_pipelines() -> dict[str, Any]: + """Cancel all active pipelines immediately.""" + return _json(get_store().emergency_stop()) + + return mcp diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 0ed3f35..16bc82f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -27,6 +27,43 @@ def test_health_is_available(): assert response.json() == {"status": "ok"} +def test_mcp_server_exposes_openapi_backed_control_room_tools(): + headers = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"} + initialize = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "openorbit-test", "version": "1"}, + }, + } + with TestClient(app, base_url="http://localhost:3000") as client: + connected = client.post("/mcp/", headers=headers, json=initialize) + assert connected.status_code == 200 + session_id = connected.headers["mcp-session-id"] + session_headers = {**headers, "mcp-session-id": session_id} + + tools = client.post( + "/mcp/", + headers=session_headers, + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, + ) + resources = client.post( + "/mcp/", + headers=session_headers, + json={"jsonrpc": "2.0", "id": 3, "method": "resources/list", "params": {}}, + ) + + assert tools.status_code == 200 + assert '"name":"get_status"' in tools.text + assert '"name":"start_pipeline"' in tools.text + assert '"name":"act_on_pipeline"' in tools.text + assert resources.status_code == 200 + assert "openorbit://openapi" in resources.text + + def test_request_locale_prefers_the_browser_accept_language_priority(): request = Request( { diff --git a/docs/API.md b/docs/API.md index 0c50cb0..d5e9082 100644 --- a/docs/API.md +++ b/docs/API.md @@ -6,6 +6,18 @@ each execution is a **pipeline**. The interactive OpenAPI documentation is available from a running server at `/api/docs`; the raw contract is `/api/openapi.json`. +## MCP server + +The same local process exposes a Streamable HTTP [Model Context Protocol +(MCP)](https://modelcontextprotocol.io/) endpoint at `/mcp/`. Point an MCP +client at `http://localhost:3000/mcp/`. It provides project and pipeline +inspection, pipeline start and approval actions, improvement lifecycle lookup, +and an emergency-stop tool. The `openorbit://openapi` MCP resource exposes the +generated OpenAPI contract for the full v1 HTTP API. + +MCP has the same local-first security boundary as the HTTP API: it has no +built-in authentication and must not be exposed directly to untrusted networks. + ## Local-first security The API has no built-in authentication in this release. It is intended for the diff --git a/i18n/ko/README.md b/i18n/ko/README.md index b68b554..bd26619 100644 --- a/i18n/ko/README.md +++ b/i18n/ko/README.md @@ -243,6 +243,9 @@ OpenOrbit는 로컬의, 버전이 관리되는 API를 노출합니다. - Swagger UI: `http://localhost:3000/api/docs` - OpenAPI 문서: `http://localhost:3000/api/openapi.json` - API base: `http://localhost:3000/api/v1` +- MCP(Streamable HTTP): `http://localhost:3000/mcp/` + +MCP 클라이언트는 `http://localhost:3000/mcp/`에 연결하면 프로젝트와 파이프라인을 조회하고, 실행·승인 작업 및 개선안 수명주기를 다룰 수 있습니다. `openorbit://openapi` 리소스는 전체 v1 HTTP API의 OpenAPI 계약을 제공합니다. MCP도 로컬 전용이며 기본 인증을 제공하지 않으므로, 신뢰할 수 없는 네트워크에 직접 노출하지 마세요. 엔드포인트 세부 정보는 [API 레퍼런스](../../docs/API.md)를 참고하세요. 재사용 가능한 자동화를 추가하려면 명시적인 라이프사이클 단계를 갖는 Python 러너를 생성하세요. diff --git a/pyproject.toml b/pyproject.toml index 2b5504b..37e5a31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "opentelemetry-api>=1.28", "opentelemetry-sdk>=1.28", "langgraph>=0.2", + "mcp>=1.9,<2", "requests>=2.31", "pywinpty>=2.0; sys_platform == 'win32'", "python-multipart>=0.0.20", diff --git a/uv.lock b/uv.lock index 4521e11..750842a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,12 @@ version = 1 revision = 3 requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform != 'win32'", +] [[package]] name = "annotated-doc" @@ -33,6 +39,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "babel" version = "2.18.0" @@ -64,6 +79,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -206,6 +294,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, +] + [[package]] name = "distlib" version = "0.4.3" @@ -349,6 +487,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "httpx2" version = "2.12.0" @@ -434,6 +581,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "langchain-core" version = "1.6.2" @@ -612,6 +786,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mcp" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/93/0142dc84a666daf8ad51a34268f34c12fd6fda4f3810c4be2504eecc8212/mcp-1.30.0.tar.gz", hash = "sha256:445414625fce5c295faa505bb11bacece661ab6f4028d57c935db57820b7a3e4", size = 680511, upload-time = "2026-09-07T14:34:15.845Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/f4/e58bc33317c92a0203664daaf00bf6f41166cc0149e5d6870a03f7cd004a/mcp-1.30.0-py3-none-any.whl", hash = "sha256:666edb5009503e1047c9d60346a756f94b261f05cc2625f23d41c728ffc484d0", size = 234581, upload-time = "2026-09-07T14:34:14.266Z" }, +] + [[package]] name = "mergedeep" version = "1.3.4" @@ -756,6 +955,7 @@ source = { editable = "." } dependencies = [ { name = "fastapi" }, { name = "langgraph" }, + { name = "mcp" }, { name = "opentelemetry-api" }, { name = "opentelemetry-sdk" }, { name = "python-multipart" }, @@ -783,6 +983,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, { name = "langgraph", specifier = ">=0.2" }, + { name = "mcp", specifier = ">=1.9,<2" }, { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6,<2" }, { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.6" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.28" }, @@ -970,6 +1171,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.5" @@ -1041,6 +1251,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -1050,6 +1274,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pyjwt" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pymdown-extensions" version = "11.0.2" @@ -1121,6 +1359,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pywinpty" version = "3.0.5" @@ -1185,6 +1439,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1212,6 +1479,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, +] + [[package]] name = "ruff" version = "0.16.6" @@ -1255,6 +1603,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" }, +] + [[package]] name = "starlette" version = "1.6.0" From 25621bc60da6b0b1445a67a91884270a2dd90ee7 Mon Sep 17 00:00:00 2001 From: forthfate Date: Wed, 16 Sep 2026 01:06:33 +0900 Subject: [PATCH 03/42] feat: run Orbit assistant through LangGraph MCP tools --- backend/app/assistant_graph.py | 132 ++++++++++++++++++++++++++ backend/app/main.py | 22 ++--- backend/tests/test_assistant_graph.py | 43 +++++++++ frontend/e2e/orbit-ui.spec.ts | 13 +++ 4 files changed, 194 insertions(+), 16 deletions(-) create mode 100644 backend/app/assistant_graph.py create mode 100644 backend/tests/test_assistant_graph.py diff --git a/backend/app/assistant_graph.py b/backend/app/assistant_graph.py new file mode 100644 index 0000000..3678fd4 --- /dev/null +++ b/backend/app/assistant_graph.py @@ -0,0 +1,132 @@ +"""LangGraph orchestration for the local Orbit assistant.""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any, TypedDict + +from langgraph.graph import END, START, StateGraph +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + +from .assistant_tools import AssistantToolExecutor +from .providers import AzureOpenAIProvider, BedrockProvider, ModelSettings + +DEFAULT_MCP_URL = "http://127.0.0.1:3000/mcp/" + + +class AssistantState(TypedDict, total=False): + prompt: str + response: str + + +class LocalMcpTools: + """Expose the mounted OpenOrbit MCP server as native model function tools.""" + + def __init__(self, url: str = DEFAULT_MCP_URL): + self.url = url + + async def _list_tools(self) -> list[dict[str, Any]]: + async with streamablehttp_client(self.url) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.list_tools() + return [ + { + "name": tool.name, + "description": tool.description or "OpenOrbit MCP tool.", + "parameters": tool.inputSchema, + } + for tool in result.tools + ] + + async def _call_tool(self, name: str, arguments: dict[str, Any]) -> str: + async with streamablehttp_client(self.url) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.call_tool(name, arguments) + if result.isError: + return json.dumps({"error": "MCP tool failed", "content": result.content}, default=str) + return json.dumps({"content": result.content}, default=str, ensure_ascii=False) + + @staticmethod + def _run(coro: Any) -> Any: + return asyncio.run(coro) + + def definitions(self) -> list[dict[str, Any]]: + try: + return self._run(self._list_tools()) + except Exception: + # The assistant remains usable while the local server is starting. + return [] + + def execute(self, name: str, arguments: dict[str, Any]) -> str: + try: + return self._run(self._call_tool(name, arguments)) + except Exception as error: + return json.dumps({"error": f"MCP unavailable: {error}"}) + + +class OrbitAssistantGraph: + """A small, explicit LangGraph for one bounded assistant turn. + + The model node can use both the local bounded workspace tools and the + mounted MCP control-room tools. Provider adapters retain the native tool + call protocol, while LangGraph owns the turn state and final response. + """ + + def __init__( + self, + provider: AzureOpenAIProvider | BedrockProvider, + settings: ModelSettings, + local_tools: AssistantToolExecutor, + mcp_tools: LocalMcpTools | None = None, + ): + self.provider = provider + self.settings = settings + self.local_tools = local_tools + self.mcp_tools = mcp_tools or LocalMcpTools() + graph = StateGraph(AssistantState) + graph.add_node("model", self._model) + graph.add_edge(START, "model") + graph.add_edge("model", END) + self.graph = graph.compile() + + def _model(self, state: AssistantState) -> AssistantState: + local_definitions = self.local_tools.definitions() + mcp_definitions = self.mcp_tools.definitions() + definitions = [*mcp_definitions, *local_definitions] + + def execute(name: str, arguments: dict[str, Any]) -> str: + if any(tool["name"] == name for tool in mcp_definitions): + return self.mcp_tools.execute(name, arguments) + return self.local_tools.execute(name, arguments) + + response = ( + self.provider.complete_with_tools(self.settings, state["prompt"], definitions, execute) + if definitions + else self.provider.complete(self.settings, state["prompt"]) + ) + return {"response": response} + + def invoke(self, prompt: str) -> str: + result = self.graph.invoke({"prompt": prompt}) + return str(result["response"]) + + +def build_assistant_prompt(content: str, history: list[tuple[str, str]], locale: str | None) -> str: + prompt = ( + "You are Orbit, a concise assistant for the local OpenOrbit control room.\n" + "Use the OpenOrbit MCP tools as the source of truth for local control-room state. " + "Read state before suggesting or taking any state-changing action.\n" + ) + if locale: + prompt += f"Respond in BCP 47 locale '{locale}'.\n" + if history: + prompt += ( + "Conversation so far:\n" + + "\n".join(f"{role.title()}: {message}" for role, message in history) + + "\n\n" + ) + return prompt + f"User: {content}\nAssistant:" diff --git a/backend/app/main.py b/backend/app/main.py index 13352b6..f303ba7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from pydantic import BaseModel, Field from . import store as store_module +from .assistant_graph import OrbitAssistantGraph, build_assistant_prompt from .assistant_tools import AssistantToolExecutor from .docker import preflight_docker from .mcp_server import create_mcp_server @@ -1176,24 +1177,13 @@ def chat(values: ChatMessage, request: Request): ) try: provider = AzureOpenAIProvider() if settings.provider == "azure-openai" else BedrockProvider() - history = "\n".join(f"{turn.role.title()}: {turn.content}" for turn in values.history) - prompt = ( - "You are Orbit, a concise assistant for the local OpenOrbit control room.\n" - "The local OpenAPI contract is available at /api/openapi.json; use it as the source of truth " - "when explaining API endpoints, parameters, and response shapes.\n" - ) - if locale := request_locale(request): - prompt += f"Respond in BCP 47 locale '{locale}'.\n" - if history: - prompt += f"Conversation so far:\n{history}\n\n" - prompt += f"User: {values.content}\nAssistant:" tool_executor = AssistantToolExecutor(store.application_settings()["assistant_tools"]) - definitions = tool_executor.definitions() - response = ( - provider.complete_with_tools(settings, prompt, definitions, tool_executor.execute) - if definitions - else provider.complete(settings, prompt) + prompt = build_assistant_prompt( + values.content, + [(turn.role, turn.content) for turn in values.history], + request_locale(request), ) + response = OrbitAssistantGraph(provider, settings, tool_executor).invoke(prompt) return {"response": response, "profile_name": profile_name} except RuntimeError as error: raise HTTPException(409, str(error)) diff --git a/backend/tests/test_assistant_graph.py b/backend/tests/test_assistant_graph.py new file mode 100644 index 0000000..d8862ee --- /dev/null +++ b/backend/tests/test_assistant_graph.py @@ -0,0 +1,43 @@ +from app.assistant_graph import DEFAULT_MCP_URL, OrbitAssistantGraph, build_assistant_prompt +from app.providers import ModelSettings + + +class FakeProvider: + def complete(self, settings, prompt): + return f"plain:{prompt}" + + def complete_with_tools(self, settings, prompt, tools, execute): + assert tools == [{"name": "get_status", "description": "status", "parameters": {}}] + return f"tools:{execute('get_status', {})}" + + +class FakeLocalTools: + def definitions(self): + return [] + + def execute(self, name, arguments): + raise AssertionError("local tools should not receive an MCP tool call") + + +class FakeMcpTools: + def definitions(self): + return [{"name": "get_status", "description": "status", "parameters": {}}] + + def execute(self, name, arguments): + assert (name, arguments) == ("get_status", {}) + return '{"health":"ok"}' + + +def test_assistant_graph_routes_mcp_tools_through_the_langgraph_model_node(): + graph = OrbitAssistantGraph(FakeProvider(), ModelSettings(), FakeLocalTools(), FakeMcpTools()) + + assert graph.invoke("status") == 'tools:{"health":"ok"}' + + +def test_assistant_prompt_uses_local_mcp_as_the_control_room_source_of_truth(): + prompt = build_assistant_prompt("What changed?", [("assistant", "Earlier answer")], "ko-KR") + + assert DEFAULT_MCP_URL == "http://127.0.0.1:3000/mcp/" + assert "OpenOrbit MCP tools as the source of truth" in prompt + assert "Respond in BCP 47 locale 'ko-KR'." in prompt + assert "Assistant: Earlier answer" in prompt diff --git a/frontend/e2e/orbit-ui.spec.ts b/frontend/e2e/orbit-ui.spec.ts index 99c6d62..07fa0c4 100644 --- a/frontend/e2e/orbit-ui.spec.ts +++ b/frontend/e2e/orbit-ui.spec.ts @@ -37,6 +37,19 @@ test('collapses the sidebar and preserves the preference after reload', async ({ await page.getByRole('button', { name: 'Expand navigation' }).click() }) +test('uses the default local MCP connection through the Orbit assistant', async ({ page }) => { + await page.goto('/dashboard') + await page.getByRole('button', { name: 'Open Orbit assistant' }).click() + const assistant = page.getByLabel('Orbit assistant') + await assistant.getByRole('textbox', { name: 'Message' }).fill( + 'Use the OpenOrbit MCP get_status tool and briefly report the local service health.', + ) + await assistant.getByRole('button', { name: 'Send message' }).click() + const response = assistant.locator('.chat-message--assistant').last() + await expect(response).not.toContainText('Chat request failed.', { timeout: 30_000 }) + await expect(response).toContainText(/health|healthy|status|ok/i, { timeout: 30_000 }) +}) + test('opens the build and profile editors with editable lifecycle fields', async ({ page }) => { await page.goto('/builds') await expect(page.getByRole('heading', { name: 'Builds' })).toBeVisible() From ac216585cd5822f2ab59412f6cf01581d073a808 Mon Sep 17 00:00:00 2001 From: forthfate Date: Wed, 16 Sep 2026 07:17:36 +0900 Subject: [PATCH 04/42] Fix chat assistant input typography --- frontend/src/theme-overrides.css | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/theme-overrides.css b/frontend/src/theme-overrides.css index c7619da..0bf4fef 100644 --- a/frontend/src/theme-overrides.css +++ b/frontend/src/theme-overrides.css @@ -83,6 +83,7 @@ footer { display:flex; align-items:center; justify-content:space-between; margin .profile-picker { display:flex; align-items:center; justify-content:space-between; gap:18px; margin-bottom:16px; }.profile-picker .panel-head { margin:0; }.profile-picker > div { display:flex; align-items:center; gap:10px; }.profile-picker select { min-width:240px; } .profile-form--rows { grid-template-columns:1fr; } .chat-launcher { position:fixed; z-index:30; display:grid; width:48px; height:48px; place-items:center; border:1px solid color-mix(in srgb,var(--accent) 55%,var(--line)); border-radius:50%; color:#182318; background:var(--accent); box-shadow:0 10px 28px rgb(0 0 0 / .3); touch-action:none; }.chat-launcher:focus-visible { outline:3px solid color-mix(in srgb,var(--accent) 45%,transparent); outline-offset:3px; }.chat-assistant { position:fixed; z-index:30; right:24px; bottom:24px; display:grid; grid-template-rows:auto auto minmax(180px,1fr) auto; width:min(380px,calc(100vw - 32px)); height:min(560px,calc(100vh - 32px)); overflow:hidden; border:1px solid var(--line); border-radius:14px; background:var(--surface); box-shadow:0 18px 60px rgb(0 0 0 / .42); }.chat-assistant > header { display:flex; align-items:center; justify-content:space-between; margin:0; padding:14px 15px; border-bottom:1px solid var(--line); }.chat-assistant > header div { display:flex; align-items:center; gap:8px; }.chat-assistant > header svg { color:var(--accent); }.chat-header-actions { display:flex; gap:3px; }.chat-tool-summary { overflow:hidden; margin:8px 12px 0; padding:7px 10px; border:1px solid var(--line); border-radius:7px; font-size:10px; text-overflow:ellipsis; white-space:nowrap; }.chat-tool-summary--empty { color:var(--muted); background:color-mix(in srgb,var(--surface-raised) 82%,var(--line)); }.chat-tool-summary--ready { color:color-mix(in srgb,var(--accent) 72%,white); border-color:color-mix(in srgb,var(--accent) 35%,var(--line)); background:color-mix(in srgb,var(--accent) 12%,var(--surface-raised)); }.chat-tool-settings { display:grid; gap:8px; padding:12px 14px; border-bottom:1px solid var(--line); font-size:11px; }.chat-tool-settings p { margin:0; color:var(--muted); }.chat-tool-settings label { display:grid; gap:4px; color:var(--muted); }.chat-tool-settings input:not([type="checkbox"]) { width:100%; box-sizing:border-box; }.chat-tool-settings .chat-tool-toggle { display:flex; align-items:center; gap:7px; color:var(--text); }.chat-tool-settings .approve { justify-self:end; }.chat-messages { display:grid; align-content:start; gap:10px; overflow:auto; padding:14px; }.chat-empty { margin:0; color:var(--muted); font-size:12px; line-height:1.6; }.chat-message { width:fit-content; max-width:88%; margin:0; padding:9px 11px; border-radius:10px; white-space:pre-wrap; overflow-wrap:anywhere; font-size:12px; line-height:1.55; }.chat-message--user { justify-self:end; color:#172116; background:var(--accent); }.chat-message--assistant { color:var(--text); background:var(--surface-raised); }.chat-assistant form { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; padding:12px; border-top:1px solid var(--line); }.chat-assistant textarea { min-height:42px; max-height:104px; resize:vertical; padding:9px; }.chat-assistant form .approve { display:grid; width:38px; height:38px; place-items:center; padding:0; align-self:end; } +.chat-assistant textarea { font:12px/1.55 Manrope,sans-serif; } @media(max-width:560px){.chat-assistant { right:16px; bottom:16px; }.chat-launcher { width:46px; height:46px; }} .chat-assistant > header { position:relative; z-index:2; cursor:grab; user-select:none; }.chat-assistant > header:active { cursor:grabbing; } .chat-tools-modal { width:min(430px,calc(100vw - 32px)); }.chat-tools-modal-summary { margin:0; padding:9px 10px; color:var(--muted); border:1px solid var(--line); border-radius:7px; background:var(--surface-raised); font-size:11px; }.chat-tool-modal-toggle { display:flex; align-items:center; justify-content:space-between; padding:10px 0; border-bottom:1px solid var(--line); color:var(--text); }.chat-tool-modal-toggle:last-of-type { border-bottom:0; } From 46e7d97bf8c7da5598e04edfb5ffbce926a13d2b Mon Sep 17 00:00:00 2001 From: forthfate Date: Wed, 16 Sep 2026 07:43:41 +0900 Subject: [PATCH 05/42] Add assistant activity and MCP configuration --- backend/app/assistant_graph.py | 23 +++-- backend/app/assistant_tools.py | 5 + backend/app/main.py | 56 ++++++++++- backend/tests/test_api.py | 1 + backend/tests/test_assistant_graph.py | 10 +- frontend/src/components/chat-assistant.tsx | 111 +++++++++++++++++++-- frontend/src/locales/languages/en.json | 7 +- frontend/src/locales/languages/ko.json | 7 +- frontend/src/theme-overrides.css | 1 + 9 files changed, 201 insertions(+), 20 deletions(-) diff --git a/backend/app/assistant_graph.py b/backend/app/assistant_graph.py index 3678fd4..779965a 100644 --- a/backend/app/assistant_graph.py +++ b/backend/app/assistant_graph.py @@ -4,17 +4,15 @@ import asyncio import json -from typing import Any, TypedDict +from typing import Any, Callable, TypedDict from langgraph.graph import END, START, StateGraph from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client -from .assistant_tools import AssistantToolExecutor +from .assistant_tools import DEFAULT_MCP_URL, AssistantToolExecutor from .providers import AzureOpenAIProvider, BedrockProvider, ModelSettings -DEFAULT_MCP_URL = "http://127.0.0.1:3000/mcp/" - class AssistantState(TypedDict, total=False): prompt: str @@ -82,11 +80,13 @@ def __init__( settings: ModelSettings, local_tools: AssistantToolExecutor, mcp_tools: LocalMcpTools | None = None, + on_activity: Callable[[str, str | None], None] | None = None, ): self.provider = provider self.settings = settings self.local_tools = local_tools - self.mcp_tools = mcp_tools or LocalMcpTools() + self.mcp_tools = mcp_tools or LocalMcpTools(local_tools.settings["mcp_server_url"]) + self.on_activity = on_activity graph = StateGraph(AssistantState) graph.add_node("model", self._model) graph.add_edge(START, "model") @@ -94,14 +94,19 @@ def __init__( self.graph = graph.compile() def _model(self, state: AssistantState) -> AssistantState: + self._activity("thinking") local_definitions = self.local_tools.definitions() mcp_definitions = self.mcp_tools.definitions() definitions = [*mcp_definitions, *local_definitions] def execute(name: str, arguments: dict[str, Any]) -> str: + self._activity("working", name) if any(tool["name"] == name for tool in mcp_definitions): - return self.mcp_tools.execute(name, arguments) - return self.local_tools.execute(name, arguments) + result = self.mcp_tools.execute(name, arguments) + else: + result = self.local_tools.execute(name, arguments) + self._activity("thinking") + return result response = ( self.provider.complete_with_tools(self.settings, state["prompt"], definitions, execute) @@ -110,6 +115,10 @@ def execute(name: str, arguments: dict[str, Any]) -> str: ) return {"response": response} + def _activity(self, phase: str, tool: str | None = None) -> None: + if self.on_activity: + self.on_activity(phase, tool) + def invoke(self, prompt: str) -> str: result = self.graph.invoke({"prompt": prompt}) return str(result["response"]) diff --git a/backend/app/assistant_tools.py b/backend/app/assistant_tools.py index e0b97a1..c0163d1 100644 --- a/backend/app/assistant_tools.py +++ b/backend/app/assistant_tools.py @@ -13,6 +13,7 @@ MAX_FILE_LINES = 120 MAX_SEARCH_RESULTS = 12 BLOCKED_NAMES = {".env", ".git", ".ssh", "id_rsa", "id_ed25519"} +DEFAULT_MCP_URL = "http://127.0.0.1:3000/mcp/" def defaults() -> dict[str, Any]: @@ -23,6 +24,7 @@ def defaults() -> dict[str, Any]: "run_process_enabled": True, "terminal_enabled": True, "terminal_visible": True, + "mcp_server_url": DEFAULT_MCP_URL, } @@ -40,6 +42,9 @@ def normalize_settings(value: object) -> dict[str, Any]: "terminal_visible", ): result[key] = bool(value.get(key, result[key])) + result["mcp_server_url"] = ( + str(value.get("mcp_server_url", result["mcp_server_url"])).strip() or result["mcp_server_url"] + ) return result diff --git a/backend/app/main.py b/backend/app/main.py index f303ba7..6d50de0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json import re from contextlib import asynccontextmanager @@ -8,7 +9,7 @@ from fastapi import FastAPI, File, HTTPException, Query, Request, Response, UploadFile, WebSocket from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, StreamingResponse from pydantic import BaseModel, Field from . import store as store_module @@ -1189,6 +1190,59 @@ def chat(values: ChatMessage, request: Request): raise HTTPException(409, str(error)) +@app.post("/api/chat/stream") +async def chat_stream(values: ChatMessage, request: Request): + profile_name = store.application_settings()["chat_model_profile_name"] + if not profile_name: + raise HTTPException(409, "Select an AI model profile for the chat assistant in Settings.") + configured = profile(store.profiles(), profile_name) + settings = ModelSettings( + **{key: value for key, value in configured.items() if key in ModelSettings.__dataclass_fields__} + ) + provider = AzureOpenAIProvider() if settings.provider == "azure-openai" else BedrockProvider() + tool_executor = AssistantToolExecutor(store.application_settings()["assistant_tools"]) + prompt = build_assistant_prompt( + values.content, + [(turn.role, turn.content) for turn in values.history], + request_locale(request), + ) + + async def events(): + queue: asyncio.Queue[dict[str, str]] = asyncio.Queue() + loop = asyncio.get_running_loop() + + def activity(phase: str, tool: str | None = None) -> None: + event = {"type": "activity", "phase": phase} + if tool: + event["tool"] = tool + loop.call_soon_threadsafe(queue.put_nowait, event) + + async def run() -> None: + try: + response = await asyncio.to_thread( + OrbitAssistantGraph(provider, settings, tool_executor, on_activity=activity).invoke, + prompt, + ) + await queue.put({"type": "response", "response": response}) + except RuntimeError as error: + await queue.put({"type": "error", "message": str(error)}) + except Exception: + await queue.put({"type": "error", "message": "Chat request failed."}) + + task = asyncio.create_task(run()) + try: + while True: + event = await queue.get() + yield json.dumps(event, ensure_ascii=False) + "\n" + if event["type"] in {"response", "error"}: + break + finally: + if not task.done(): + task.cancel() + + return StreamingResponse(events(), media_type="application/x-ndjson") + + @app.websocket("/api/terminal") async def terminal(websocket: WebSocket): await serve_terminal(websocket, store.application_settings()["assistant_tools"]) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 16bc82f..b35b8f7 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1989,6 +1989,7 @@ def test_application_manager_prompt_is_separate_from_model_profiles(tmp_path, mo "run_process_enabled": True, "terminal_enabled": True, "terminal_visible": True, + "mcp_server_url": "http://127.0.0.1:3000/mcp/", }, } store.save_settings( diff --git a/backend/tests/test_assistant_graph.py b/backend/tests/test_assistant_graph.py index d8862ee..e9f9f4b 100644 --- a/backend/tests/test_assistant_graph.py +++ b/backend/tests/test_assistant_graph.py @@ -29,9 +29,17 @@ def execute(self, name, arguments): def test_assistant_graph_routes_mcp_tools_through_the_langgraph_model_node(): - graph = OrbitAssistantGraph(FakeProvider(), ModelSettings(), FakeLocalTools(), FakeMcpTools()) + activity = [] + graph = OrbitAssistantGraph( + FakeProvider(), + ModelSettings(), + FakeLocalTools(), + FakeMcpTools(), + on_activity=lambda phase, tool=None: activity.append((phase, tool)), + ) assert graph.invoke("status") == 'tools:{"health":"ok"}' + assert activity == [("thinking", None), ("working", "get_status"), ("thinking", None)] def test_assistant_prompt_uses_local_mcp_as_the_control_room_source_of_truth(): diff --git a/frontend/src/components/chat-assistant.tsx b/frontend/src/components/chat-assistant.tsx index d129647..bb0c563 100644 --- a/frontend/src/components/chat-assistant.tsx +++ b/frontend/src/components/chat-assistant.tsx @@ -14,6 +14,13 @@ import { api } from "../services/api"; import { AssistantTerminal } from "./assistant-terminal"; type Message = { role: "user" | "assistant"; content: string }; +type ChatStreamEvent = { + type: "activity" | "response" | "error"; + phase?: "thinking" | "working"; + tool?: string; + response?: string; + message?: string; +}; type Position = { x: number; y: number }; type DragTarget = "launcher" | "window"; type ToolSettings = { @@ -23,6 +30,7 @@ type ToolSettings = { run_process_enabled: boolean; terminal_enabled: boolean; terminal_visible: boolean; + mcp_server_url: string; }; type ApplicationSettings = { assistant_tools: ToolSettings }; type ChatAssistantCopy = { @@ -35,6 +43,7 @@ type ChatAssistantCopy = { toolsAvailable: string; noToolsAvailable: string; workspace: string; + mcpServerUrl: string; fileRead: string; fileSearch: string; runProcess: string; @@ -42,7 +51,11 @@ type ChatAssistantCopy = { saveTools: string; empty: string; requestFailed: string; - thinking: string; + thinkingStatus: string; + workingStatus: string; + reviewingRequest: string; + reviewingResults: string; + usingTool: string; message: string; placeholder: string; send: string; @@ -56,6 +69,7 @@ const defaultToolSettings: ToolSettings = { run_process_enabled: true, terminal_enabled: true, terminal_visible: true, + mcp_server_url: "http://127.0.0.1:3000/mcp/", }; const normalizedToolSettings = ( value: Partial | undefined, @@ -115,6 +129,9 @@ export function ChatAssistant() { [messages, setMessages] = useState([]), [draft, setDraft] = useState(""), [sending, setSending] = useState(false), + [activityPhase, setActivityPhase] = useState<"thinking" | "working">("thinking"), + [activityLines, setActivityLines] = useState([]), + [activityDots, setActivityDots] = useState(1), [position, setPosition] = useState(initialPosition), [windowPosition, setWindowPosition] = useState( initialWindowPosition, @@ -139,9 +156,18 @@ export function ChatAssistant() { const suppressClick = useRef(false); const messageList = useRef(null); const chatWindow = useRef(null); + const hasUsedTool = useRef(false); useEffect(() => { messageList.current?.scrollTo({ top: messageList.current.scrollHeight }); - }, [messages, sending]); + }, [messages, sending, activityLines]); + useEffect(() => { + if (!sending) return; + const interval = window.setInterval( + () => setActivityDots((current) => (current % 3) + 1), + 420, + ); + return () => window.clearInterval(interval); + }, [sending]); useEffect(() => { localStorage.setItem(positionKey, JSON.stringify(position)); }, [position]); @@ -212,14 +238,56 @@ export function ChatAssistant() { setDraft(""); setMessages((current) => [...current, { role: "user", content }]); setSending(true); + setActivityPhase("thinking"); + setActivityLines([]); + setActivityDots(1); + hasUsedTool.current = false; try { - const result = await api<{ response: string }>("/api/chat", "POST", { - content, - history, + const response = await fetch("/api/chat/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content, history }), }); + if (!response.ok || !response.body) + throw new Error(await response.text()); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffered = ""; + let answer = ""; + const addActivity = (line: string) => + setActivityLines((current) => [...current, line].slice(-3)); + while (true) { + const { done, value } = await reader.read(); + buffered += decoder.decode(value, { stream: !done }); + const lines = buffered.split("\n"); + buffered = lines.pop() ?? ""; + for (const line of lines) { + if (!line) continue; + const event = JSON.parse(line) as ChatStreamEvent; + if (event.type === "activity" && event.phase) { + setActivityPhase(event.phase); + if (event.phase === "working") { + hasUsedTool.current = true; + addActivity( + copy.usingTool.replace("{tool}", event.tool ?? "tool"), + ); + } else { + addActivity( + hasUsedTool.current ? copy.reviewingResults : copy.reviewingRequest, + ); + } + } else if (event.type === "response") { + answer = event.response ?? ""; + } else if (event.type === "error") { + throw new Error(event.message ?? copy.requestFailed); + } + } + if (done) break; + } + if (!answer) throw new Error(copy.requestFailed); setMessages((current) => [ ...current, - { role: "assistant", content: result.response }, + { role: "assistant", content: answer }, ]); } catch (error) { setMessages((current) => [ @@ -394,9 +462,21 @@ export function ChatAssistant() { ))} {sending && ( -
- {copy.thinking} -
+
+ + {activityPhase === "working" + ? copy.workingStatus + : copy.thinkingStatus} + {".".repeat(activityDots)} + + {activityLines.length > 0 && ( +
+ {activityLines.map((line, index) => ( + {line} + ))} +
+ )} +
)}
+ {( [ ["file_read_enabled", copy.fileRead], diff --git a/frontend/src/locales/languages/en.json b/frontend/src/locales/languages/en.json index ef03915..20efe63 100644 --- a/frontend/src/locales/languages/en.json +++ b/frontend/src/locales/languages/en.json @@ -56,6 +56,7 @@ "toolsAvailable": "Available tools", "noToolsAvailable": "No tools are currently available.", "workspace": "Workspace", + "mcpServerUrl": "MCP server URL", "fileRead": "Read files", "fileSearch": "Search files", "runProcess": "Run processes", @@ -63,7 +64,11 @@ "saveTools": "Save tools", "empty": "Ask about your evaluation work, runners, or the control room.", "requestFailed": "Chat request failed.", - "thinking": "Thinking…", + "thinkingStatus": "Thinking", + "workingStatus": "Working", + "reviewingRequest": "Reviewing your request", + "reviewingResults": "Reviewing the results", + "usingTool": "Using tool: {tool}", "message": "Message", "placeholder": "Message Orbit…", "send": "Send message" diff --git a/frontend/src/locales/languages/ko.json b/frontend/src/locales/languages/ko.json index 71bd8fd..ce741d8 100644 --- a/frontend/src/locales/languages/ko.json +++ b/frontend/src/locales/languages/ko.json @@ -55,6 +55,7 @@ "toolsAvailable": "사용 가능한 도구", "noToolsAvailable": "현재 사용 가능한 도구가 없습니다.", "workspace": "작업 공간", + "mcpServerUrl": "MCP 서버 URL", "fileRead": "파일 읽기", "fileSearch": "파일 검색", "runProcess": "프로세스 실행", @@ -62,7 +63,11 @@ "saveTools": "도구 저장", "empty": "평가 작업, 러너 또는 컨트롤 룸에 관해 물어보세요.", "requestFailed": "채팅 요청에 실패했습니다.", - "thinking": "생각 중…", + "thinkingStatus": "생각 중", + "workingStatus": "작업 중", + "reviewingRequest": "요청을 검토하고 있습니다", + "reviewingResults": "결과를 검토하고 있습니다", + "usingTool": "도구 사용: {tool}", "message": "메시지", "placeholder": "Orbit에 메시지 보내기…", "send": "메시지 전송" diff --git a/frontend/src/theme-overrides.css b/frontend/src/theme-overrides.css index 0bf4fef..8dffcec 100644 --- a/frontend/src/theme-overrides.css +++ b/frontend/src/theme-overrides.css @@ -84,6 +84,7 @@ footer { display:flex; align-items:center; justify-content:space-between; margin .profile-form--rows { grid-template-columns:1fr; } .chat-launcher { position:fixed; z-index:30; display:grid; width:48px; height:48px; place-items:center; border:1px solid color-mix(in srgb,var(--accent) 55%,var(--line)); border-radius:50%; color:#182318; background:var(--accent); box-shadow:0 10px 28px rgb(0 0 0 / .3); touch-action:none; }.chat-launcher:focus-visible { outline:3px solid color-mix(in srgb,var(--accent) 45%,transparent); outline-offset:3px; }.chat-assistant { position:fixed; z-index:30; right:24px; bottom:24px; display:grid; grid-template-rows:auto auto minmax(180px,1fr) auto; width:min(380px,calc(100vw - 32px)); height:min(560px,calc(100vh - 32px)); overflow:hidden; border:1px solid var(--line); border-radius:14px; background:var(--surface); box-shadow:0 18px 60px rgb(0 0 0 / .42); }.chat-assistant > header { display:flex; align-items:center; justify-content:space-between; margin:0; padding:14px 15px; border-bottom:1px solid var(--line); }.chat-assistant > header div { display:flex; align-items:center; gap:8px; }.chat-assistant > header svg { color:var(--accent); }.chat-header-actions { display:flex; gap:3px; }.chat-tool-summary { overflow:hidden; margin:8px 12px 0; padding:7px 10px; border:1px solid var(--line); border-radius:7px; font-size:10px; text-overflow:ellipsis; white-space:nowrap; }.chat-tool-summary--empty { color:var(--muted); background:color-mix(in srgb,var(--surface-raised) 82%,var(--line)); }.chat-tool-summary--ready { color:color-mix(in srgb,var(--accent) 72%,white); border-color:color-mix(in srgb,var(--accent) 35%,var(--line)); background:color-mix(in srgb,var(--accent) 12%,var(--surface-raised)); }.chat-tool-settings { display:grid; gap:8px; padding:12px 14px; border-bottom:1px solid var(--line); font-size:11px; }.chat-tool-settings p { margin:0; color:var(--muted); }.chat-tool-settings label { display:grid; gap:4px; color:var(--muted); }.chat-tool-settings input:not([type="checkbox"]) { width:100%; box-sizing:border-box; }.chat-tool-settings .chat-tool-toggle { display:flex; align-items:center; gap:7px; color:var(--text); }.chat-tool-settings .approve { justify-self:end; }.chat-messages { display:grid; align-content:start; gap:10px; overflow:auto; padding:14px; }.chat-empty { margin:0; color:var(--muted); font-size:12px; line-height:1.6; }.chat-message { width:fit-content; max-width:88%; margin:0; padding:9px 11px; border-radius:10px; white-space:pre-wrap; overflow-wrap:anywhere; font-size:12px; line-height:1.55; }.chat-message--user { justify-self:end; color:#172116; background:var(--accent); }.chat-message--assistant { color:var(--text); background:var(--surface-raised); }.chat-assistant form { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; padding:12px; border-top:1px solid var(--line); }.chat-assistant textarea { min-height:42px; max-height:104px; resize:vertical; padding:9px; }.chat-assistant form .approve { display:grid; width:38px; height:38px; place-items:center; padding:0; align-self:end; } .chat-assistant textarea { font:12px/1.55 Manrope,sans-serif; } +.chat-activity { display:grid; gap:7px; width:fit-content; max-width:88%; padding:9px 11px; border:1px solid color-mix(in srgb,var(--accent) 32%,var(--line)); border-radius:10px; color:var(--text); background:color-mix(in srgb,var(--accent) 7%,var(--surface-raised)); font-size:11px; line-height:1.45; }.chat-activity strong { color:var(--accent); font-weight:600; }.chat-activity > div { display:grid; gap:3px; }.chat-activity span { color:var(--muted); }.chat-activity span::before { content:'›'; margin-right:5px; color:var(--accent); } @media(max-width:560px){.chat-assistant { right:16px; bottom:16px; }.chat-launcher { width:46px; height:46px; }} .chat-assistant > header { position:relative; z-index:2; cursor:grab; user-select:none; }.chat-assistant > header:active { cursor:grabbing; } .chat-tools-modal { width:min(430px,calc(100vw - 32px)); }.chat-tools-modal-summary { margin:0; padding:9px 10px; color:var(--muted); border:1px solid var(--line); border-radius:7px; background:var(--surface-raised); font-size:11px; }.chat-tool-modal-toggle { display:flex; align-items:center; justify-content:space-between; padding:10px 0; border-bottom:1px solid var(--line); color:var(--text); }.chat-tool-modal-toggle:last-of-type { border-bottom:0; } From 9af87af089323046a23b087bddbf470c80b0d299 Mon Sep 17 00:00:00 2001 From: forthfate Date: Wed, 16 Sep 2026 08:46:57 +0900 Subject: [PATCH 06/42] feat: add high-level runner recipes --- backend/orbit_runner_kit.py | 631 ++++++++++++++++++ backend/orbit_runner_primitives.py | 31 + backend/tests/test_api.py | 14 +- pyproject.toml | 2 + .../autonomous-persona-journey.py | 48 +- .../runner.py | 67 +- .../runner.py | 147 +--- .../openorbit.ai-slo-drift-monitor/runner.py | 154 +---- .../runner.py | 48 +- .../openorbit.critical-flow-proof/runner.py | 134 +--- .../runner.py | 131 +--- .../runner.py | 55 +- .../evidence-gated-probe-cycle/runner.py | 156 +---- .../external-command-adapter/runner.py | 106 +-- .../json-agent-cycle/runner.py | 148 +--- .../native-improvement-cycle/runner.py | 67 +- .../site-exploration/runner.py | 131 +--- .../user-journey-cycle/runner.py | 210 +----- 18 files changed, 941 insertions(+), 1339 deletions(-) create mode 100644 backend/orbit_runner_kit.py create mode 100644 backend/orbit_runner_primitives.py diff --git a/backend/orbit_runner_kit.py b/backend/orbit_runner_kit.py new file mode 100644 index 0000000..8305b4a --- /dev/null +++ b/backend/orbit_runner_kit.py @@ -0,0 +1,631 @@ +"""High-level authoring recipes for concise OpenOrbit runner assets. + +This module deliberately sits *above* :mod:`orbit_sdk`. The SDK owns the +stable console protocol and low-level capabilities; this kit turns repeated +runner patterns into small, readable declarations. +""" + +from __future__ import annotations + +import json +import os +import shlex +from dataclasses import dataclass +from typing import Any + +from orbit_runner_primitives import RunnerRequirements, focused_cases + + +@dataclass(frozen=True) +class CallbackCycle: + """Register a declarative lifecycle around product-specific callbacks.""" + + steps: tuple[tuple[str, str, str, tuple[str, ...], tuple[str, ...]], ...] + edges: tuple[tuple[str, str, str, str | None], ...] + + def install(self, callbacks: dict[str, Any]) -> None: + import orbit_sdk + + graph, runner = orbit_sdk.graph, orbit_sdk.runner + for source, target, kind, label in self.edges: + graph.connect(source, target, kind=kind, label=label) + phases: dict[str, list[Any]] = {} + for identifier, title, phase, inputs, outputs in self.steps: + callback = callbacks[identifier] + + @graph.step(identifier, title=title, phase=phase, inputs=inputs, outputs=outputs) + def step(ctx: Any, callback: Any = callback) -> Any: + return callback(ctx) + + phases.setdefault(phase, []).append(step) + for phase, handlers in phases.items(): + + @runner.phase(phase) + def invoke(ctx: Any, handlers: list[Any] = handlers) -> None: + for handler in handlers: + handler(ctx) + + +@dataclass(frozen=True) +class RecurringBrowserJourney: + """Install a complete, evidence-backed recurring browser journey.""" + + namespace: str = "user_journey" + title: str = "User journey" + history_limit: int = 24 + + def install(self) -> None: + # Resolve these at installation time so graph previews can use a fresh graph. + import orbit_sdk + + graph, runner = orbit_sdk.graph, orbit_sdk.runner + prefix = self.namespace.replace("_", "-") + validate_id, plan_id, run_id = f"validate-{prefix}", f"plan-{prefix}", f"run-{prefix}" + review_id, retain_id, finalize_id = f"review-{prefix}", f"retain-{prefix}", f"finalize-{prefix}" + requirements = RunnerRequirements(build_fields=("browser_base_url",), require_test_cases=True) + + graph.connect(validate_id, plan_id) + graph.connect(plan_id, run_id, label="focused cases") + graph.connect(run_id, review_id, kind="data", label="browser evidence") + graph.connect(review_id, retain_id) + graph.connect(retain_id, plan_id, kind="loop", label="next iteration") + graph.connect(retain_id, finalize_id, kind="condition", label="completed") + + @graph.step( + validate_id, + title=f"Validate {self.title.lower()} contract", + phase="before_all", + outputs=["journey_contract"], + ) + @runner.phase("before_all") + def before_all(ctx: Any) -> None: + requirements.validate(ctx) + ctx.log(f"Validated the {self.title.lower()} contract") + + @graph.step( + plan_id, + title=f"Plan focused {self.title.lower()}", + phase="before_each", + inputs=["journey_contract"], + outputs=["journey_plan"], + ) + @runner.phase("before_each") + def before_each(ctx: Any) -> None: + state = ctx.load_state( + self.namespace, {"next_case_index": 0, "failed_case_ids": [], "history": []} + ) + chosen = focused_cases(ctx, state) + failed = bool(state.get("failed_case_ids")) + rules = [ + "Preserve observable evidence for every browser action.", + "Do not infer a result that the page did not expose.", + ] + if failed: + rules.insert(0, "Revisit previously failed journeys before exploring a new route.") + feedback = ctx.previous_supervisor_feedback + if feedback.get("reported_issues"): + rules.insert(0, "Prioritize the supervisor's previously reported issues.") + plan = { + "case_ids": [str(case.get("id")) for case in chosen], + "rules": rules, + "reason": "Previously failed journeys require confirmation." + if failed + else "Rotate one fixed journey to retain broad, bounded coverage.", + "supervisor_feedback": feedback, + } + state["plan"] = plan + ctx.save_state(self.namespace, state) + ctx.emit_result( + { + self.namespace: { + "iteration": ctx.loop_index, + "case_count": len(ctx.test_cases), + "plan": plan, + } + } + ) + ctx.log(f"Planned {len(chosen)} focused journey case(s): {plan['reason']}") + + @graph.step( + run_id, + title=f"Run {self.title.lower()}", + phase="execute", + inputs=["journey_plan"], + outputs=["journey_evidence"], + ) + @runner.phase("execute") + def execute(ctx: Any) -> None: + state = ctx.load_state(self.namespace, {}) + plan = state.get("plan") or {} + chosen_ids = set(plan.get("case_ids", [])) + cases = [case for case in ctx.test_cases if str(case.get("id")) in chosen_ids] + evidence = ctx.playwright_journey(cases) + results = list(evidence.get("results", [])) + failed_case_ids = [str(item.get("id")) for item in results if not item.get("passed")] + state["failed_case_ids"] = failed_case_ids + state["next_case_index"] = (int(state.get("next_case_index", 0)) + 1) % len(ctx.test_cases) + handoff = { + "iteration": ctx.loop_index, + "reason": plan.get("reason", ""), + "rules": plan.get("rules", []), + "passed": len(results) - len(failed_case_ids), + "failed": len(failed_case_ids), + "failed_case_ids": failed_case_ids, + } + state.setdefault("history", []).append(handoff) + state["history"] = state["history"][-self.history_limit :] + state["handoff"] = handoff + ctx.save_state(self.namespace, state) + artifact = ctx.save_data_file( + f"{prefix}/iteration-{ctx.loop_index}.json", + json.dumps( + {"plan": plan, "evidence": evidence, "handoff": handoff}, ensure_ascii=False, indent=2 + ), + label=f"{self.title} iteration evidence", + content_type="application/json", + ) + ctx.emit_result( + { + self.namespace: { + "iteration": ctx.loop_index, + "plan": plan, + "results": results, + "evidence": evidence, + "handoff": handoff, + "artifact": artifact, + } + } + ) + + @graph.step( + review_id, + title=f"Review {self.title.lower()} evidence", + phase="verify", + inputs=["journey_evidence"], + outputs=["journey_handoff"], + ) + @runner.phase("verify") + def verify(ctx: Any) -> None: + ctx.emit_result( + {self.namespace: {"next_iteration": ctx.load_state(self.namespace, {}).get("handoff", {})}} + ) + ctx.log("Stored the journey summary and next-iteration handoff") + + @graph.step( + retain_id, + title=f"Retain {self.title.lower()} result", + phase="after_each", + inputs=["journey_handoff"], + outputs=["iteration_complete"], + ) + @runner.phase("after_each") + def after_each(ctx: Any) -> None: + ctx.log(f"Closed this bounded {self.title.lower()}") + + @graph.step( + finalize_id, + title=f"Finalize {self.title.lower()} evaluation", + phase="after_all", + inputs=["iteration_complete"], + outputs=["final_status"], + ) + @runner.phase("after_all") + def after_all(ctx: Any) -> None: + ctx.log(f"Finalized the {self.title.lower()} evaluation") + + +@dataclass(frozen=True) +class JsonActionCycle: + """Run a bounded external JSON action contract through the standard cycle.""" + + command_env: str + namespace: str + title: str + actions: tuple[str, str, str, str] = ("status", "prepare", "run-once", "collect-evidence") + payload_key: str = "test_cases" + + def install(self) -> None: + import orbit_sdk + + graph, runner = orbit_sdk.graph, orbit_sdk.runner + prefix = self.namespace.replace("_", "-") + ids = ( + f"preflight-{prefix}", + f"prepare-{prefix}", + f"run-{prefix}", + f"collect-{prefix}", + f"close-{prefix}", + f"finalize-{prefix}", + ) + graph.connect(ids[0], ids[1]) + graph.connect(ids[1], ids[2], label="prepared inputs") + graph.connect(ids[2], ids[3], kind="data", label="action result") + graph.connect(ids[3], ids[4]) + graph.connect(ids[4], ids[1], kind="loop", label="next cycle") + graph.connect(ids[4], ids[5], kind="condition", label="completed") + + def invoke(ctx: Any, action: str) -> dict[str, Any]: + configured = os.environ.get(self.command_env, "").strip() + if not configured: + raise ValueError(f"Set {self.command_env} to an external tool command") + command = json.loads(configured) if configured.startswith("[") else shlex.split(configured) + if not isinstance(command, list) or not all(isinstance(item, str) for item in command): + raise ValueError(f"{self.command_env} must be a JSON string array or command") + payload = json.dumps( + { + "action": action, + "iteration": ctx.loop_index, + "build": ctx.build, + self.payload_key: ctx.test_cases, + }, + ensure_ascii=False, + ) + output = ctx.exec( + [*command, action], + cwd=ctx.project_root, + timeout=3600, + env={"ORBIT_CYCLE_INPUT": payload}, + target_log_source=self.namespace.replace("_", "-"), + ) + try: + result = json.loads(output) + except json.JSONDecodeError as error: + raise RuntimeError(f"Action {action!r} did not return JSON") from error + if not isinstance(result, dict): + raise RuntimeError(f"Action {action!r} must return a JSON object") + return result + + @graph.step(ids[0], title=f"Preflight {self.title.lower()}", phase="before_all", outputs=["contract"]) + @runner.phase("before_all") + def before_all(ctx: Any) -> None: + RunnerRequirements(require_test_cases=True).validate(ctx) + ctx.emit_result({self.namespace: {"preflight": invoke(ctx, self.actions[0])}}) + + @graph.step( + ids[1], + title=f"Prepare {self.title.lower()}", + phase="before_each", + inputs=["contract"], + outputs=["prepared"], + ) + @runner.phase("before_each") + def before_each(ctx: Any) -> None: + ctx.emit_result( + {self.namespace: {"iteration": ctx.loop_index, "prepared": invoke(ctx, self.actions[1])}} + ) + + @graph.step( + ids[2], + title=f"Run {self.title.lower()}", + phase="execute", + inputs=["prepared"], + outputs=["result"], + ) + @runner.phase("execute") + def execute(ctx: Any) -> None: + ctx.emit_result( + {self.namespace: {"iteration": ctx.loop_index, "result": invoke(ctx, self.actions[2])}} + ) + + @graph.step( + ids[3], + title=f"Collect {self.title.lower()} evidence", + phase="verify", + inputs=["result"], + outputs=["evidence"], + ) + @runner.phase("verify") + def verify(ctx: Any) -> None: + ctx.emit_result( + {self.namespace: {"iteration": ctx.loop_index, "evidence": invoke(ctx, self.actions[3])}} + ) + + @graph.step( + ids[4], + title=f"Close {self.title.lower()} cycle", + phase="after_each", + inputs=["evidence"], + outputs=["complete"], + ) + @runner.phase("after_each") + def after_each(ctx: Any) -> None: + ctx.log(f"Completed one bounded {self.title.lower()} cycle") + + @graph.step( + ids[5], + title=f"Finalize {self.title.lower()}", + phase="after_all", + inputs=["complete"], + outputs=["final"], + ) + @runner.phase("after_all") + def after_all(ctx: Any) -> None: + ctx.log(f"Finalized the {self.title.lower()}") + + +@dataclass(frozen=True) +class CommandActionCycle: + """Standard lifecycle for external tools whose output need not be JSON.""" + + command_env: str + namespace: str = "external_adapter" + + def install(self) -> None: + import orbit_sdk + + graph, runner = orbit_sdk.graph, orbit_sdk.runner + ids = ( + "check-adapter", + "prepare-adapter", + "run-adapter", + "collect-adapter-evidence", + "close-adapter-cycle", + "finalize-adapter", + ) + for source, target, kwargs in ( + (ids[0], ids[1], {}), + (ids[1], ids[2], {"label": "prepared target"}), + (ids[2], ids[3], {"kind": "data", "label": "adapter output"}), + (ids[3], ids[4], {}), + (ids[4], ids[1], {"kind": "loop", "label": "next cycle"}), + (ids[4], ids[5], {"kind": "condition", "label": "completed"}), + ): + graph.connect(source, target, **kwargs) + + def invoke(ctx: Any, action: str) -> str: + raw = os.environ.get(self.command_env, "").strip() + if not raw: + raise ValueError(f"Set {self.command_env} to an external tool command") + command = json.loads(raw) if raw.startswith("[") else shlex.split(raw) + return ctx.exec( + [*command, action], + cwd=ctx.project_root, + timeout=3600, + target_log_source=self.namespace.replace("_", "-"), + ) + + def phase(phase_name: str, node: str, title: str, key: str, action: str, inputs=(), outputs=()): + @graph.step(node, title=title, phase=phase_name, inputs=inputs, outputs=outputs) + @runner.phase(phase_name) + def handler(ctx: Any) -> None: + ctx.emit_result({self.namespace: {key: invoke(ctx, action), "iteration": ctx.loop_index}}) + + phase("before_all", ids[0], "Check adapter readiness", "status", "status", outputs=["adapter_status"]) + phase( + "before_each", + ids[1], + "Prepare adapter cycle", + "prepared", + "prepare", + inputs=["adapter_status"], + outputs=["prepared_target"], + ) + phase( + "execute", + ids[2], + "Run bounded adapter task", + "result", + "run-once", + inputs=["prepared_target"], + outputs=["adapter_result"], + ) + phase( + "verify", + ids[3], + "Collect adapter evidence", + "evidence", + "collect-evidence", + inputs=["adapter_result"], + outputs=["adapter_evidence"], + ) + + @graph.step( + ids[4], + title="Close adapter cycle", + phase="after_each", + inputs=["adapter_evidence"], + outputs=["cycle_complete"], + ) + @runner.phase("after_each") + def after_each(ctx: Any) -> None: + ctx.log("Completed one bounded external adapter cycle") + + @graph.step( + ids[5], + title="Finalize external automation", + phase="after_all", + inputs=["cycle_complete"], + outputs=["final_status"], + ) + @runner.phase("after_all") + def after_all(ctx: Any) -> None: + ctx.log("Finalized the external automation evaluation") + + +@dataclass(frozen=True) +class BrowserSmokeTest: + """Install a one-shot browser journey with evidence and a failure gate.""" + + namespace: str = "browser_smoke" + + def install(self) -> None: + import orbit_sdk + + graph, runner = orbit_sdk.graph, orbit_sdk.runner + ids = ( + "validate-browser", + "run-browser-journey", + "verify-browser-evidence", + "finalize-browser-evaluation", + ) + graph.connect(ids[0], ids[1]) + graph.connect(ids[1], ids[2], kind="data", label="journey evidence") + graph.connect(ids[2], ids[3]) + + @graph.step(ids[0], title="Validate browser target", phase="before_all", outputs=["browser_target"]) + @runner.phase("before_all") + def before_all(ctx: Any) -> None: + RunnerRequirements(build_fields=("browser_base_url",), require_test_cases=True).validate(ctx) + + @graph.step( + ids[1], + title="Run browser journey", + phase="execute", + inputs=["browser_target"], + outputs=["journey_evidence"], + ) + @runner.phase("execute") + def execute(ctx: Any) -> None: + evidence = ctx.playwright_journey() + ctx.emit_result({self.namespace: {"iteration": ctx.loop_index, "evidence": evidence}}) + if not all(item.get("passed") for item in evidence.get("results", [])): + raise SystemExit("A browser journey failed") + + @graph.step( + ids[2], + title="Verify journey evidence", + phase="verify", + inputs=["journey_evidence"], + outputs=["journey_verdict"], + ) + @runner.phase("verify") + def verify(ctx: Any) -> None: + ctx.log("Quick start browser evaluation completed") + + @graph.step( + ids[3], + title="Finalize browser evaluation", + phase="after_all", + inputs=["journey_verdict"], + outputs=["completed_evaluation"], + ) + @runner.phase("after_all") + def after_all(ctx: Any) -> None: + ctx.log("Finalized the one-shot browser evaluation") + + +@dataclass(frozen=True) +class SiteExplorationReview: + """Safely explore same-origin pages and retain rendered review evidence.""" + + max_clicks: int = 3 + + def install(self) -> None: + import orbit_sdk + + graph, runner = orbit_sdk.graph, orbit_sdk.runner + graph.connect("validate-site", "explore-site") + graph.connect("explore-site", "review-evidence", kind="data", label="rendered pages") + graph.connect("review-evidence", "finalize-review") + + @graph.step("validate-site", title="Validate site", phase="before_all", outputs=["site_target"]) + @runner.phase("before_all") + def before_all(ctx: Any) -> None: + RunnerRequirements(build_fields=("browser_base_url",)).validate(ctx) + + @graph.step( + "explore-site", + title="Explore rendered site", + phase="execute", + inputs=["site_target"], + outputs=["rendered_pages"], + ) + @runner.phase("execute") + def execute(ctx: Any) -> None: + module = str( + __import__("pathlib").Path(orbit_sdk.__file__).resolve().parents[1] + / "frontend" + / "node_modules" + / "playwright" + ) + screenshot = ( + ctx.app_data + / "artifacts" + / ctx.environment.get("ORBIT_RUN_ID", "manual") + / f"loop-{ctx.loop_index}" + / "site-exploration.png" + ) + screenshot.parent.mkdir(parents=True, exist_ok=True) + script = """const {chromium}=require(process.argv[1]),i=JSON.parse(process.argv[2]),blocked=/(logout|signout|delete|remove|destroy|payment|checkout|purchase|upgrade|unsubscribe)/i;(async()=>{const b=await chromium.launch({headless:true}),p=await b.newPage(),v=[],o=new URL(i.baseUrl).origin;try{await p.goto(i.baseUrl,{waitUntil:'domcontentloaded',timeout:30000});for(let n=0;n<=i.maxClicks;n++){v.push({url:p.url(),title:await p.title(),text:(await p.locator('body').innerText().catch(()=>'' )).replace(/\\s+/g,' ').slice(0,1200)});if(n===i.maxClicks)break;const a=await p.locator('a[href]').evaluateAll(x=>x.map((e,k)=>({k,href:e.href,text:(e.textContent||'').trim()})));const c=a.filter(x=>{try{return new URL(x.href).origin===o&&!blocked.test(new URL(x.href).pathname+' '+x.text)}catch{return false}});if(!c.length)break;await p.locator('a[href]').nth(c[n%c.length].k).click({timeout:5000});await p.waitForLoadState('domcontentloaded',{timeout:10000}).catch(()=>{})}await p.screenshot({path:i.screenshot,fullPage:true});console.log('__ORBIT_SITE_EXPLORATION_RESULT__'+JSON.stringify({visited:v,screenshot:i.screenshot}))}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)})""" + output = ctx.exec( + [ + "node", + "-e", + script, + module, + json.dumps( + { + "baseUrl": ctx.build["browser_base_url"], + "maxClicks": self.max_clicks, + "screenshot": str(screenshot), + } + ), + ], + timeout=120, + target_log_source="site-exploration", + target_log_exclude_prefixes=("__ORBIT_SITE_EXPLORATION_RESULT__",), + ) + payload = next( + ( + line.removeprefix("__ORBIT_SITE_EXPLORATION_RESULT__") + for line in reversed(output.splitlines()) + if line.startswith("__ORBIT_SITE_EXPLORATION_RESULT__") + ), + "", + ) + if not payload: + raise RuntimeError("site exploration did not return structured evidence") + evidence = json.loads(payload) + titles = [str(page.get("title") or page.get("url")) for page in evidence.get("visited", [])] + ctx.emit_result( + { + "site_exploration": { + "evidence": evidence, + "opinion": f"Explored {len(titles)} rendered page(s): {'; '.join(titles[:3])}. Review the captured pages for clarity, usefulness, and friction.", + } + } + ) + + @graph.step( + "review-evidence", + title="Review exploration evidence", + phase="verify", + inputs=["rendered_pages"], + outputs=["product_review"], + ) + @runner.phase("verify") + def verify(ctx: Any) -> None: + ctx.log("Retained rendered exploration evidence for review") + + @graph.step( + "finalize-review", + title="Finalize site review", + phase="after_all", + inputs=["product_review"], + outputs=["completed_review"], + ) + @runner.phase("after_all") + def after_all(ctx: Any) -> None: + ctx.log("Finalized the bounded site exploration review") + + +@dataclass(frozen=True) +class InsightaPersonaCycle: + """Install Insighta's source-backed persona lifecycle around a simulator. + + The simulator and its product policy remain in the private Insighta bundle; + this recipe owns only OpenOrbit lifecycle registration and evidence flow. + """ + + simulator: Any + required_cases: set[str] + ensure_dependencies: Any = lambda: None + + def install(self) -> None: + from insighta_persona.phases.lifecycle import register + + register( + simulator=self.simulator, + ensure_dependencies=self.ensure_dependencies, + required_cases=self.required_cases, + ) diff --git a/backend/orbit_runner_primitives.py b/backend/orbit_runner_primitives.py new file mode 100644 index 0000000..870a3ea --- /dev/null +++ b/backend/orbit_runner_primitives.py @@ -0,0 +1,31 @@ +"""Low-level, composable building blocks for OpenOrbit runner recipes.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class RunnerRequirements: + """Reusable preflight checks for runner recipes or custom runners.""" + + build_fields: tuple[str, ...] = () + require_test_cases: bool = False + + def validate(self, ctx: Any) -> None: + missing = [field for field in self.build_fields if not ctx.build.get(field)] + if missing: + raise ValueError(f"Set required build field(s): {', '.join(missing)}") + if self.require_test_cases and not ctx.test_cases: + raise ValueError("Select at least one fixed test case before running this runner") + + +def focused_cases(ctx: Any, state: dict[str, Any]) -> list[dict[str, Any]]: + """Retry failed cases before rotating through the declared fixed cases.""" + cases = list(ctx.test_cases) + failed = {str(case_id) for case_id in state.get("failed_case_ids", [])} + retry = [case for case in cases if str(case.get("id")) in failed] + if retry: + return retry + return [cases[int(state.get("next_case_index", 0)) % len(cases)]] diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index b35b8f7..d8f978b 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1330,10 +1330,9 @@ def test_runner_templates_separate_direct_user_journeys_from_external_commands() compile(improvement, "native-improvement-cycle.py", "exec") compile(json_agent, "json-agent-cycle.py", "exec") compile(probe_gate, "evidence-gated-probe-cycle.py", "exec") - assert "playwright_journey" in user_journey + assert "RecurringBrowserJourney" in user_journey assert "ORBIT_ADAPTER_COMMAND" not in user_journey - assert "previous_supervisor_feedback" in user_journey - assert "user-journey-state" in user_journey + assert "RecurringBrowserJourney" in user_journey assert "ORBIT_ADAPTER_COMMAND" in adapter assert "playwright_journey" not in improvement assert "complete_model" in improvement @@ -1354,7 +1353,7 @@ def test_runner_templates_separate_direct_user_journeys_from_external_commands() assert "Jgent" not in probe_gate -def test_site_exploration_quick_start_uses_the_langgraph_runner(): +def test_site_exploration_quick_start_uses_the_high_level_recipe(): store = store_module.ConsoleStore() quick_start = next( item for item in store._built_in_quick_starts() if item["id"] == "openorbit.site-exploration-review" @@ -1362,8 +1361,11 @@ def test_site_exploration_quick_start_uses_the_langgraph_runner(): runner = quick_start["assets"]["runner"] assert "LangGraph" in quick_start["description"] assert runner["template_id"] == "site-exploration" - assert "StateGraph" in runner["source"] - assert "logout|signout|delete" in runner["source"] + assert "SiteExplorationReview" in runner["source"] + assert ( + "logout|signout|delete" + in (Path(store_module.__file__).parents[1] / "orbit_runner_kit.py").read_text() + ) def test_quick_start_workflow_graph_can_be_previewed_before_creation(monkeypatch): diff --git a/pyproject.toml b/pyproject.toml index 37e5a31..b48b12e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,8 @@ include = ["orbit/resources/**/*.yaml", "orbit/resources/**/*.json", "orbit/reso "frontend/dist" = "frontend/dist" "site" = "site" "backend/orbit_sdk.py" = "orbit_sdk.py" +"backend/orbit_runner_kit.py" = "orbit_runner_kit.py" +"backend/orbit_runner_primitives.py" = "orbit_runner_primitives.py" [tool.hatch.build.hooks.custom] diff --git a/runner-templates/autonomous-persona-journey.py b/runner-templates/autonomous-persona-journey.py index bde34b5..30c45a7 100644 --- a/runner-templates/autonomous-persona-journey.py +++ b/runner-templates/autonomous-persona-journey.py @@ -5,7 +5,24 @@ from pathlib import Path import orbit_sdk -from orbit_sdk import graph, runner +from orbit_runner_kit import CallbackCycle +from orbit_sdk import runner as orbit_runner + + +class _LegacyDeclarations: + def connect(self, *_args, **_kwargs): + pass + + def step(self, *_args, **_kwargs): + return lambda handler: handler + + +class _LegacyRunner: + def phase(self, *_args, **_kwargs): + return lambda handler: handler + + +graph, runner = _LegacyDeclarations(), _LegacyRunner() BLOCKED = re.compile( r"logout|signout|delete|remove|destroy|payment|checkout|purchase|upgrade|unsubscribe", re.I @@ -300,5 +317,32 @@ def after_all(ctx): ctx.log("Finalized the autonomous persona journey") +CallbackCycle( + steps=( + ("validate", "Validate autonomous persona", "before_all", (), ("persona_contract",)), + ("observe", "Observe current page", "before_each", ("persona_contract",), ("page_choices",)), + ("decide", "Plan next persona action", "execute", ("page_choices",), ("persona_plan",)), + ("act", "Take one safe persona action", "verify", ("persona_plan",), ("action_evidence",)), + ("reflect", "Reflect persona session", "after_each", ("action_evidence",), ("persona_handoff",)), + ("finalize", "Finalize autonomous persona", "after_all", ("persona_handoff",), ("final_status",)), + ), + edges=( + ("validate", "observe", "execution", None), + ("observe", "decide", "data", "rendered choices"), + ("decide", "act", "data", "one safe action"), + ("act", "reflect", "data", "action evidence"), + ("reflect", "observe", "loop", "next visit"), + ), +).install( + { + "validate": before_all, + "observe": before_each, + "decide": execute, + "act": verify, + "reflect": after_each, + "finalize": after_all, + } +) + if __name__ == "__main__": - runner.main() + orbit_runner.main() diff --git a/templates/quick-starts/openorbit.agent-self-improvement/runner.py b/templates/quick-starts/openorbit.agent-self-improvement/runner.py index b841f2a..5d44cab 100644 --- a/templates/quick-starts/openorbit.agent-self-improvement/runner.py +++ b/templates/quick-starts/openorbit.agent-self-improvement/runner.py @@ -9,7 +9,24 @@ import json import re -from orbit_sdk import graph, runner +from orbit_runner_kit import CallbackCycle +from orbit_sdk import runner as orbit_runner + + +class _LegacyDeclarations: + def connect(self, *_args, **_kwargs): + pass + + def step(self, *_args, **_kwargs): + return lambda handler: handler + + +class _LegacyRunner: + def phase(self, *_args, **_kwargs): + return lambda handler: handler + + +graph, runner = _LegacyDeclarations(), _LegacyRunner() REQUIRED_SUFFICIENT_EVALUATIONS = 3 @@ -297,5 +314,51 @@ def after_all(ctx): ctx.log("Restored the native improvement target without committing changes") +CallbackCycle( + steps=( + ("validate-target", "Validate target", "before_all", (), ("evaluation_contract",)), + ( + "prepare-prompt", + "Prepare prompt candidate", + "before_each", + ("evaluation_contract",), + ("managed_prompt",), + ), + ("exercise-target", "Exercise target AI", "execute", ("managed_prompt",), ("target_responses",)), + ( + "assess-candidate", + "Assess candidate evidence", + "verify", + ("target_responses",), + ("candidate_verdict",), + ), + ( + "retain-iteration", + "Retain iteration evidence", + "after_each", + ("candidate_verdict",), + ("iteration_snapshot",), + ), + ("restore-baseline", "Restore baseline", "after_all", ("iteration_snapshot",), ("restored_target",)), + ), + edges=( + ("validate-target", "prepare-prompt", "execution", None), + ("prepare-prompt", "exercise-target", "execution", "managed prompt"), + ("exercise-target", "assess-candidate", "data", "responses"), + ("assess-candidate", "retain-iteration", "execution", None), + ("retain-iteration", "prepare-prompt", "loop", "next evaluation"), + ("retain-iteration", "restore-baseline", "condition", "completed"), + ), +).install( + { + "validate-target": before_all, + "prepare-prompt": before_each, + "exercise-target": execute, + "assess-candidate": verify, + "retain-iteration": after_each, + "restore-baseline": after_all, + } +) + if __name__ == "__main__": - runner.main() + orbit_runner.main() diff --git a/templates/quick-starts/openorbit.ai-experience-improvement/runner.py b/templates/quick-starts/openorbit.ai-experience-improvement/runner.py index 6f6d228..d5b329f 100644 --- a/templates/quick-starts/openorbit.ai-experience-improvement/runner.py +++ b/templates/quick-starts/openorbit.ai-experience-improvement/runner.py @@ -1,148 +1,7 @@ -"""Run a portable, bounded external agent cycle. - -Set ORBIT_AGENT_COMMAND to a JSON argument array or a shell-like command -prefix. The external tool receives one action at a time: ``status`` or -``run-once``. It must write one JSON object to stdout and must never start a -daemon or scheduler; OpenOrbit owns repetition, timing, and supervision. -""" - -import json -import os -import shlex - -from orbit_sdk import graph, runner - -graph.connect("check-agent", "record-inputs") -graph.connect("record-inputs", "run-agent", label="bounded input") -graph.connect("run-agent", "confirm-agent-state", kind="data", label="agent result") -graph.connect("confirm-agent-state", "close-cycle") -graph.connect("close-cycle", "record-inputs", kind="loop", label="next cycle") -graph.connect("close-cycle", "finalize-agent", kind="condition", label="completed") - - -def agent_command(): - """Read an explicit command prefix without depending on target source files.""" - configured = os.environ.get("ORBIT_AGENT_COMMAND", "").strip() - if not configured: - raise ValueError("Set ORBIT_AGENT_COMMAND to the external agent command") - if configured.startswith("["): - value = json.loads(configured) - if not isinstance(value, list) or not all(isinstance(item, str) for item in value): - raise ValueError("ORBIT_AGENT_COMMAND JSON must be an array of strings") - return value - return shlex.split(configured) - - -def cycle_input(ctx, action): - """Expose non-secret evaluation context through one documented JSON contract.""" - return json.dumps( - { - "action": action, - "iteration": ctx.loop_index, - "build": ctx.build, - "test_cases": ctx.test_cases, - }, - ensure_ascii=False, - ) - - -def invoke(ctx, action): - """Run one bounded action and require structured evidence from the agent.""" - output = ctx.exec( - [*agent_command(), action], - cwd=ctx.project_root, - timeout=3600, - env={"ORBIT_CYCLE_INPUT": cycle_input(ctx, action)}, - ) - try: - result = json.loads(output) - except json.JSONDecodeError as error: - raise RuntimeError(f"External agent action {action!r} did not return JSON") from error - if not isinstance(result, dict): - raise RuntimeError(f"External agent action {action!r} must return a JSON object") - return result - - -@graph.step("check-agent", title="Check agent readiness", phase="before_all", outputs=["agent_status"]) -@runner.phase("before_all") -def before_all(ctx): - # Check availability once; later phases must not start an independent loop. - status = invoke(ctx, "status") - ctx.emit_result({"agent_cycle": {"status": status}}) - - -@graph.step( - "record-inputs", - title="Record cycle inputs", - phase="before_each", - inputs=["agent_status"], - outputs=["cycle_input"], -) -@runner.phase("before_each") -def before_each(ctx): - # Record the fixed inputs so every external action is auditable. - ctx.emit_result( - { - "agent_cycle": { - "iteration": ctx.loop_index, - "test_case_ids": [str(case.get("id", "")) for case in ctx.test_cases], - } - } - ) - - -@graph.step( - "run-agent", - title="Run bounded agent cycle", - phase="execute", - inputs=["cycle_input"], - outputs=["agent_result"], -) -@runner.phase("execute") -def execute(ctx): - # Exactly one unit of agent work; OpenOrbit schedules a future iteration. - result = invoke(ctx, "run-once") - ctx.emit_result({"agent_cycle": {"iteration": ctx.loop_index, "result": result}}) - - -@graph.step( - "confirm-agent-state", - title="Confirm agent state", - phase="verify", - inputs=["agent_result"], - outputs=["verified_status"], -) -@runner.phase("verify") -def verify(ctx): - # Re-read status rather than assuming the prior action completed correctly. - status = invoke(ctx, "status") - ctx.emit_result({"agent_cycle": {"iteration": ctx.loop_index, "status": status}}) - - -@graph.step( - "close-cycle", - title="Close cycle", - phase="after_each", - inputs=["verified_status"], - outputs=["cycle_complete"], -) -@runner.phase("after_each") -def after_each(ctx): - # The external process has already returned; no daemon cleanup is required. - ctx.log("Completed one bounded external agent cycle") - - -@graph.step( - "finalize-agent", - title="Finalize agent evaluation", - phase="after_all", - inputs=["cycle_complete"], - outputs=["final_status"], -) -@runner.phase("after_all") -def after_all(ctx): - ctx.log("Finalized the external agent evaluation") +from orbit_runner_kit import JsonActionCycle +from orbit_sdk import runner +JsonActionCycle("ORBIT_AGENT_COMMAND", "agent_cycle", "External agent").install() if __name__ == "__main__": runner.main() diff --git a/templates/quick-starts/openorbit.ai-slo-drift-monitor/runner.py b/templates/quick-starts/openorbit.ai-slo-drift-monitor/runner.py index fff73d0..1dfde5f 100644 --- a/templates/quick-starts/openorbit.ai-slo-drift-monitor/runner.py +++ b/templates/quick-starts/openorbit.ai-slo-drift-monitor/runner.py @@ -1,147 +1,13 @@ -"""Run a portable evidence-gated probe matrix through an external tool. - -Set ORBIT_PROBE_COMMAND to a JSON argument array or a shell-like command -prefix. The tool must support ``preflight``, ``prepare``, ``run-probes``, and -``collect-evidence`` actions. Every action receives ORBIT_CYCLE_INPUT and -returns one JSON object. The tool may create disposable workspaces, but it -must not schedule itself or commit changes to the target repository. -""" - -import json -import os -import shlex - -from orbit_sdk import graph, runner - -graph.connect("preflight-probes", "prepare-probes") -graph.connect("prepare-probes", "run-probe-matrix", label="prepared inputs") -graph.connect("run-probe-matrix", "collect-probe-evidence", kind="data", label="probe report") -graph.connect("collect-probe-evidence", "close-probe-cycle") -graph.connect("close-probe-cycle", "prepare-probes", kind="loop", label="next cycle") -graph.connect("close-probe-cycle", "finalize-probe-monitor", kind="condition", label="completed") - - -def probe_command(): - """Read the explicit probe command prefix configured by the operator.""" - configured = os.environ.get("ORBIT_PROBE_COMMAND", "").strip() - if not configured: - raise ValueError("Set ORBIT_PROBE_COMMAND to the evidence-gate command") - if configured.startswith("["): - value = json.loads(configured) - if not isinstance(value, list) or not all(isinstance(item, str) for item in value): - raise ValueError("ORBIT_PROBE_COMMAND JSON must be an array of strings") - return value - return shlex.split(configured) - - -def cycle_input(ctx, action): - """Pass selected probes and non-secret evaluation context to the tool.""" - return json.dumps( - { - "action": action, - "iteration": ctx.loop_index, - "build": ctx.build, - "probes": ctx.test_cases, - }, - ensure_ascii=False, - ) - - -def invoke(ctx, action): - """Run one gate action and reject unstructured evidence early.""" - output = ctx.exec( - [*probe_command(), action], - cwd=ctx.project_root, - timeout=3600, - env={"ORBIT_CYCLE_INPUT": cycle_input(ctx, action)}, - target_log_source="evidence-probe", - ) - try: - result = json.loads(output) - except json.JSONDecodeError as error: - raise RuntimeError(f"Probe action {action!r} did not return JSON") from error - if not isinstance(result, dict): - raise RuntimeError(f"Probe action {action!r} must return a JSON object") - return result - - -@graph.step( - "preflight-probes", title="Preflight probe matrix", phase="before_all", outputs=["probe_contract"] -) -@runner.phase("before_all") -def before_all(ctx): - # A fixed probe set keeps the gate repeatable and its evidence comparable. - if not ctx.test_cases: - raise ValueError("Select a fixed test case set before running an evidence gate") - preflight = invoke(ctx, "preflight") - ctx.emit_result({"probe_gate": {"preflight": preflight}}) - - -@graph.step( - "prepare-probes", - title="Prepare probes", - phase="before_each", - inputs=["probe_contract"], - outputs=["prepared_probes"], -) -@runner.phase("before_each") -def before_each(ctx): - # Prepare disposable inputs without mutating the target repository. - prepared = invoke(ctx, "prepare") - ctx.emit_result({"probe_gate": {"iteration": ctx.loop_index, "prepared": prepared}}) - - -@graph.step( - "run-probe-matrix", - title="Run probe matrix", - phase="execute", - inputs=["prepared_probes"], - outputs=["probe_report"], -) -@runner.phase("execute") -def execute(ctx): - # Run the complete fixed matrix once and retain the tool's structured report. - report = invoke(ctx, "run-probes") - ctx.emit_result({"probe_gate": {"iteration": ctx.loop_index, "report": report}}) - - -@graph.step( - "collect-probe-evidence", - title="Collect probe evidence", - phase="verify", - inputs=["probe_report"], - outputs=["evidence_gate"], -) -@runner.phase("verify") -def verify(ctx): - # Collect final evidence separately so a supervisor can make an independent decision. - evidence = invoke(ctx, "collect-evidence") - ctx.emit_result({"probe_gate": {"iteration": ctx.loop_index, "evidence": evidence}}) - - -@graph.step( - "close-probe-cycle", - title="Close probe cycle", - phase="after_each", - inputs=["evidence_gate"], - outputs=["cycle_complete"], -) -@runner.phase("after_each") -def after_each(ctx): - ctx.log("Completed one evidence-gated probe matrix") - - -@graph.step( - "finalize-probe-monitor", - title="Finalize drift monitor", - phase="after_all", - inputs=["cycle_complete"], - outputs=["final_status"], -) -@runner.phase("after_all") -def after_all(ctx): - ctx.log("Finalized the evidence-gated probe evaluation") - +from orbit_runner_kit import JsonActionCycle +from orbit_sdk import runner + +JsonActionCycle( + "ORBIT_PROBE_COMMAND", + "probe_gate", + "Probe matrix", + actions=("preflight", "prepare", "run-probes", "collect-evidence"), + payload_key="probes", +).install() if __name__ == "__main__": runner.main() diff --git a/templates/quick-starts/openorbit.continuous-user-journey/runner.py b/templates/quick-starts/openorbit.continuous-user-journey/runner.py index 3586a8b..0ebdcc5 100644 --- a/templates/quick-starts/openorbit.continuous-user-journey/runner.py +++ b/templates/quick-starts/openorbit.continuous-user-journey/runner.py @@ -5,7 +5,24 @@ from pathlib import Path import orbit_sdk -from orbit_sdk import graph, runner +from orbit_runner_kit import CallbackCycle +from orbit_sdk import runner as orbit_runner + + +class _LegacyDeclarations: + def connect(self, *_args, **_kwargs): + pass + + def step(self, *_args, **_kwargs): + return lambda handler: handler + + +class _LegacyRunner: + def phase(self, *_args, **_kwargs): + return lambda handler: handler + + +graph, runner = _LegacyDeclarations(), _LegacyRunner() BLOCKED = re.compile( r"logout|signout|delete|remove|destroy|payment|checkout|purchase|upgrade|unsubscribe", re.I @@ -286,5 +303,32 @@ def after_all(ctx): ctx.log("Finalized the autonomous persona journey") +CallbackCycle( + steps=( + ("validate", "Validate autonomous persona", "before_all", (), ("persona_contract",)), + ("observe", "Observe current page", "before_each", ("persona_contract",), ("page_choices",)), + ("decide", "Plan next persona action", "execute", ("page_choices",), ("persona_plan",)), + ("act", "Take one safe persona action", "verify", ("persona_plan",), ("action_evidence",)), + ("reflect", "Reflect persona session", "after_each", ("action_evidence",), ("persona_handoff",)), + ("finalize", "Finalize autonomous persona", "after_all", ("persona_handoff",), ("final_status",)), + ), + edges=( + ("validate", "observe", "execution", None), + ("observe", "decide", "data", "rendered choices"), + ("decide", "act", "data", "one safe action"), + ("act", "reflect", "data", "action evidence"), + ("reflect", "observe", "loop", "next visit"), + ), +).install( + { + "validate": before_all, + "observe": before_each, + "decide": execute, + "act": verify, + "reflect": after_each, + "finalize": after_all, + } +) + if __name__ == "__main__": - runner.main() + orbit_runner.main() diff --git a/templates/quick-starts/openorbit.critical-flow-proof/runner.py b/templates/quick-starts/openorbit.critical-flow-proof/runner.py index e385207..43983f4 100644 --- a/templates/quick-starts/openorbit.critical-flow-proof/runner.py +++ b/templates/quick-starts/openorbit.critical-flow-proof/runner.py @@ -1,135 +1,7 @@ -"""A reference runner for recurring, evidence-led browser journeys.""" - -import json -import re - -from orbit_sdk import graph, runner - -graph.connect("validate", "plan") -graph.connect("plan", "exercise", kind="data", label="focused journey") -graph.connect("exercise", "retain") -graph.connect("retain", "plan", kind="loop", label="next iteration") - - -def state_path(ctx): - """Keep durable journey state in OpenOrbit AppData, never in the target repo.""" - build_id = re.sub(r"[^a-zA-Z0-9_-]+", "-", str(ctx.build.get("id") or "journey")) - directory = ctx.app_data / "continuous-journeys" - directory.mkdir(parents=True, exist_ok=True) - return directory / f"{build_id}.json" - - -def load_state(ctx): - path = state_path(ctx) - return ( - json.loads(path.read_text(encoding="utf-8")) - if path.exists() - else {"next_case": 0, "failed": [], "history": []} - ) - - -def save_state(ctx, state): - state["history"] = state.get("history", [])[-24:] - state_path(ctx).write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") - - -@graph.step( - "validate", title="Validate recurring browser journey", phase="before_all", outputs=["journey_contract"] -) -@runner.phase("before_all") -def before_all(ctx): - # The SDK owns browser execution; callers supply only a URL and fixed cases. - if not ctx.build.get("browser_base_url") or not ctx.test_cases: - raise ValueError("A browser base URL and at least one journey case are required") - ctx.log("Validated the continuous Playwright journey contract") - - -@graph.step( - "plan", - title="Choose next user journey", - phase="before_each", - inputs=["journey_contract"], - outputs=["journey_plan"], -) -@runner.phase("before_each") -def before_each(ctx): - state = load_state(ctx) - # Failed paths are retried first; otherwise rotate to retain broad coverage. - focused = [case for case in ctx.test_cases if str(case.get("id")) in set(state["failed"])] - if not focused: - focused = [ctx.test_cases[int(state["next_case"]) % len(ctx.test_cases)]] - state["plan"] = { - "case_ids": [str(case.get("id")) for case in focused], - "prior_feedback": ctx.previous_supervisor_feedback, - } - save_state(ctx, state) - ctx.emit_result({"continuous_journey": {"iteration": ctx.loop_index, "plan": state["plan"]}}) - ctx.log(f"Planned journey case(s): {', '.join(state['plan']['case_ids'])}") - - -@graph.step( - "exercise", - title="Exercise rendered journey", - phase="execute", - inputs=["journey_plan"], - outputs=["browser_evidence"], -) -@runner.phase("execute") -def execute(ctx): - state = load_state(ctx) - chosen = set(state["plan"]["case_ids"]) - evidence = ctx.playwright_journey([case for case in ctx.test_cases if str(case.get("id")) in chosen]) - state["failed"] = [str(item.get("id")) for item in evidence["results"] if not item["passed"]] - state["next_case"] = int(state["next_case"]) + 1 - state["history"].append( - {"iteration": ctx.loop_index, "case_ids": state["plan"]["case_ids"], "failed": state["failed"]} - ) - save_state(ctx, state) - ctx.emit_result( - { - "continuous_journey": { - "iteration": ctx.loop_index, - "evidence": evidence, - "handoff": state["history"][-1], - } - } - ) - # Preserve a small, downloadable iteration summary alongside the richer - # Playwright evidence retained by the SDK. - ctx.save_data_file( - f"continuous-journey/iteration-{ctx.loop_index}.json", - json.dumps(state["history"][-1], ensure_ascii=False, indent=2), - label="Continuous journey handoff", - content_type="application/json", - ) - ctx.log(f"Executed {len(evidence['results'])} browser case(s); failed: {len(state['failed'])}") - - -@graph.step( - "retain", - title="Retain journey handoff", - phase="verify", - inputs=["browser_evidence"], - outputs=["next_iteration"], -) -@runner.phase("verify") -def verify(ctx): - state = load_state(ctx) - ctx.emit_result( - {"continuous_journey": {"next_iteration": state["history"][-1], "state_path": str(state_path(ctx))}} - ) - ctx.log("Retained browser evidence and the next-iteration handoff") - - -@runner.phase("after_each") -def after_each(ctx): - ctx.log("Completed one bounded continuous browser journey") - - -@runner.phase("after_all") -def after_all(ctx): - ctx.log("Finalized the continuous browser journey") +from orbit_runner_kit import RecurringBrowserJourney +from orbit_sdk import runner +RecurringBrowserJourney(namespace="continuous_journey", title="Critical flow").install() if __name__ == "__main__": runner.main() diff --git a/templates/quick-starts/openorbit.site-exploration-review/runner.py b/templates/quick-starts/openorbit.site-exploration-review/runner.py index 9314eda..a3b1ce0 100644 --- a/templates/quick-starts/openorbit.site-exploration-review/runner.py +++ b/templates/quick-starts/openorbit.site-exploration-review/runner.py @@ -1,134 +1,7 @@ -# Requirements -# - The target application is running at the build's browser base URL. -# - Playwright Chromium and LangGraph are available. -# This runner follows only same-site links and excludes destructive-looking routes. - -import json -from pathlib import Path -from typing import TypedDict - -import orbit_sdk -from langgraph.graph import END, START, StateGraph -from orbit_sdk import graph as orbit_graph +from orbit_runner_kit import SiteExplorationReview from orbit_sdk import runner -orbit_graph.connect("validate-site", "explore-site") -orbit_graph.connect("explore-site", "review-evidence", kind="data", label="rendered pages") -orbit_graph.connect("review-evidence", "finalize-review") - - -class ExplorerState(TypedDict, total=False): - base_url: str - max_clicks: int - evidence: dict - opinion: str - - -def explore_browser(ctx, state): - module = str(Path(orbit_sdk.__file__).resolve().parents[1] / "frontend" / "node_modules" / "playwright") - artifacts = ( - ctx.app_data / "artifacts" / ctx.environment.get("ORBIT_RUN_ID", "manual") / f"loop-{ctx.loop_index}" - ) - artifacts.mkdir(parents=True, exist_ok=True) - payload = { - "baseUrl": state["base_url"], - "maxClicks": state["max_clicks"], - "screenshot": str(artifacts / "site-exploration.png"), - } - script = r"""const { chromium } = require(process.argv[1]); const input = JSON.parse(process.argv[2]); -const blocked = /(logout|signout|delete|remove|destroy|payment|checkout|purchase|upgrade|unsubscribe)/i; -(async () => { const browser = await chromium.launch({headless:true}); const page = await browser.newPage(); const visited = []; const origin = new URL(input.baseUrl).origin; - try { await page.goto(input.baseUrl, {waitUntil:"domcontentloaded", timeout:30000}); - for (let step = 0; step <= input.maxClicks; step++) { const text = (await page.locator("body").innerText().catch(() => "")).replace(/\s+/g, " ").slice(0, 1200); visited.push({url:page.url(), title:await page.title(), text}); - if (step === input.maxClicks) break; - const links = await page.locator("a[href]").evaluateAll(items => items.map((item, index) => ({index, href:item.href, text:(item.textContent || "").trim()})).filter(item => item.href)); - const candidates = links.filter(item => { try { const url = new URL(item.href); return url.origin === origin && !blocked.test(url.pathname + " " + item.text); } catch { return false; } }); - if (!candidates.length) break; const target = candidates[step % candidates.length]; await page.locator("a[href]").nth(target.index).click({timeout:5000}); await page.waitForLoadState("domcontentloaded", {timeout:10000}).catch(() => {}); await page.waitForTimeout(300); - } - await page.screenshot({path:input.screenshot, fullPage:true}); console.log(`site exploration completed: ${visited.length} page(s)`); console.log('__ORBIT_SITE_EXPLORATION_RESULT__'+JSON.stringify({visited, screenshot:input.screenshot})); - } finally { await browser.close(); } })().catch(error => { console.error(error); process.exit(1); });""" - output = ctx.exec( - ["node", "-e", script, module, json.dumps(payload)], - timeout=120, - target_log_source="site-exploration", - target_log_exclude_prefixes=("__ORBIT_SITE_EXPLORATION_RESULT__",), - ) - result = next( - ( - line.removeprefix("__ORBIT_SITE_EXPLORATION_RESULT__") - for line in reversed(output.splitlines()) - if line.startswith("__ORBIT_SITE_EXPLORATION_RESULT__") - ), - "", - ) - if not result: - raise RuntimeError("site exploration did not return structured evidence") - return json.loads(result) - - -def form_opinion(state): - pages = state["evidence"].get("visited", []) - titles = [str(page.get("title") or page.get("url")) for page in pages] - return { - "opinion": f"Explored {len(pages)} rendered page(s): " - + "; ".join(titles[:3]) - + ". Review the captured pages for clarity, usefulness, and friction." - } - - -def graph(ctx): - workflow = StateGraph(ExplorerState) - workflow.add_node("explore", lambda state: {"evidence": explore_browser(ctx, state)}) - workflow.add_node("form_opinion", form_opinion) - workflow.add_edge(START, "explore") - workflow.add_edge("explore", "form_opinion") - workflow.add_edge("form_opinion", END) - return workflow.compile() - - -@orbit_graph.step("validate-site", title="Validate site", phase="before_all", outputs=["site_target"]) -@runner.phase("before_all") -def before_all(ctx): - if not ctx.build.get("browser_base_url"): - raise ValueError("Set a browser base URL before exploring a site") - - -@orbit_graph.step( - "explore-site", - title="Explore rendered site", - phase="execute", - inputs=["site_target"], - outputs=["rendered_pages"], -) -@runner.phase("execute") -def execute(ctx): - result = graph(ctx).invoke({"base_url": ctx.build["browser_base_url"], "max_clicks": 3}) - ctx.emit_result({"site_exploration": {"opinion": result["opinion"], "evidence": result["evidence"]}}) - - -@orbit_graph.step( - "review-evidence", - title="Review exploration evidence", - phase="verify", - inputs=["rendered_pages"], - outputs=["product_review"], -) -@runner.phase("verify") -def verify(ctx): - ctx.log("Retained rendered exploration evidence for review") - - -@orbit_graph.step( - "finalize-review", - title="Finalize site review", - phase="after_all", - inputs=["product_review"], - outputs=["completed_review"], -) -@runner.phase("after_all") -def after_all(ctx): - ctx.log("Finalized the bounded site exploration review") - +SiteExplorationReview().install() if __name__ == "__main__": runner.main() diff --git a/templates/quick-starts/openorbit.user-journey-smoke-test/runner.py b/templates/quick-starts/openorbit.user-journey-smoke-test/runner.py index 2974265..4477459 100644 --- a/templates/quick-starts/openorbit.user-journey-smoke-test/runner.py +++ b/templates/quick-starts/openorbit.user-journey-smoke-test/runner.py @@ -1,56 +1,7 @@ -from orbit_sdk import graph, runner - -graph.connect("validate-browser", "run-browser-journey") -graph.connect("run-browser-journey", "verify-browser-evidence", kind="data", label="journey evidence") -graph.connect("verify-browser-evidence", "finalize-browser-evaluation") - - -@graph.step( - "validate-browser", title="Validate browser target", phase="before_all", outputs=["browser_target"] -) -@runner.phase("before_all") -def before_all(ctx): - if not ctx.build.get("browser_base_url"): - raise ValueError("Quick start browser evaluation requires a browser base URL") - - -@graph.step( - "run-browser-journey", - title="Run browser journey", - phase="execute", - inputs=["browser_target"], - outputs=["journey_evidence"], -) -@runner.phase("execute") -def execute(ctx): - evidence = ctx.playwright_journey() - if not all(item["passed"] for item in evidence["results"]): - raise SystemExit("A browser journey failed") - - -@graph.step( - "verify-browser-evidence", - title="Verify journey evidence", - phase="verify", - inputs=["journey_evidence"], - outputs=["journey_verdict"], -) -@runner.phase("verify") -def verify(ctx): - ctx.log("Quick start browser evaluation completed") - - -@graph.step( - "finalize-browser-evaluation", - title="Finalize browser evaluation", - phase="after_all", - inputs=["journey_verdict"], - outputs=["completed_evaluation"], -) -@runner.phase("after_all") -def after_all(ctx): - ctx.log("Finalized the one-shot browser evaluation") +from orbit_runner_kit import BrowserSmokeTest +from orbit_sdk import runner +BrowserSmokeTest().install() if __name__ == "__main__": runner.main() diff --git a/templates/runner-templates/evidence-gated-probe-cycle/runner.py b/templates/runner-templates/evidence-gated-probe-cycle/runner.py index fff73d0..c45743d 100644 --- a/templates/runner-templates/evidence-gated-probe-cycle/runner.py +++ b/templates/runner-templates/evidence-gated-probe-cycle/runner.py @@ -1,147 +1,15 @@ -"""Run a portable evidence-gated probe matrix through an external tool. - -Set ORBIT_PROBE_COMMAND to a JSON argument array or a shell-like command -prefix. The tool must support ``preflight``, ``prepare``, ``run-probes``, and -``collect-evidence`` actions. Every action receives ORBIT_CYCLE_INPUT and -returns one JSON object. The tool may create disposable workspaces, but it -must not schedule itself or commit changes to the target repository. -""" - -import json -import os -import shlex - -from orbit_sdk import graph, runner - -graph.connect("preflight-probes", "prepare-probes") -graph.connect("prepare-probes", "run-probe-matrix", label="prepared inputs") -graph.connect("run-probe-matrix", "collect-probe-evidence", kind="data", label="probe report") -graph.connect("collect-probe-evidence", "close-probe-cycle") -graph.connect("close-probe-cycle", "prepare-probes", kind="loop", label="next cycle") -graph.connect("close-probe-cycle", "finalize-probe-monitor", kind="condition", label="completed") - - -def probe_command(): - """Read the explicit probe command prefix configured by the operator.""" - configured = os.environ.get("ORBIT_PROBE_COMMAND", "").strip() - if not configured: - raise ValueError("Set ORBIT_PROBE_COMMAND to the evidence-gate command") - if configured.startswith("["): - value = json.loads(configured) - if not isinstance(value, list) or not all(isinstance(item, str) for item in value): - raise ValueError("ORBIT_PROBE_COMMAND JSON must be an array of strings") - return value - return shlex.split(configured) - - -def cycle_input(ctx, action): - """Pass selected probes and non-secret evaluation context to the tool.""" - return json.dumps( - { - "action": action, - "iteration": ctx.loop_index, - "build": ctx.build, - "probes": ctx.test_cases, - }, - ensure_ascii=False, - ) - - -def invoke(ctx, action): - """Run one gate action and reject unstructured evidence early.""" - output = ctx.exec( - [*probe_command(), action], - cwd=ctx.project_root, - timeout=3600, - env={"ORBIT_CYCLE_INPUT": cycle_input(ctx, action)}, - target_log_source="evidence-probe", - ) - try: - result = json.loads(output) - except json.JSONDecodeError as error: - raise RuntimeError(f"Probe action {action!r} did not return JSON") from error - if not isinstance(result, dict): - raise RuntimeError(f"Probe action {action!r} must return a JSON object") - return result - - -@graph.step( - "preflight-probes", title="Preflight probe matrix", phase="before_all", outputs=["probe_contract"] -) -@runner.phase("before_all") -def before_all(ctx): - # A fixed probe set keeps the gate repeatable and its evidence comparable. - if not ctx.test_cases: - raise ValueError("Select a fixed test case set before running an evidence gate") - preflight = invoke(ctx, "preflight") - ctx.emit_result({"probe_gate": {"preflight": preflight}}) - - -@graph.step( - "prepare-probes", - title="Prepare probes", - phase="before_each", - inputs=["probe_contract"], - outputs=["prepared_probes"], -) -@runner.phase("before_each") -def before_each(ctx): - # Prepare disposable inputs without mutating the target repository. - prepared = invoke(ctx, "prepare") - ctx.emit_result({"probe_gate": {"iteration": ctx.loop_index, "prepared": prepared}}) - - -@graph.step( - "run-probe-matrix", - title="Run probe matrix", - phase="execute", - inputs=["prepared_probes"], - outputs=["probe_report"], -) -@runner.phase("execute") -def execute(ctx): - # Run the complete fixed matrix once and retain the tool's structured report. - report = invoke(ctx, "run-probes") - ctx.emit_result({"probe_gate": {"iteration": ctx.loop_index, "report": report}}) - - -@graph.step( - "collect-probe-evidence", - title="Collect probe evidence", - phase="verify", - inputs=["probe_report"], - outputs=["evidence_gate"], -) -@runner.phase("verify") -def verify(ctx): - # Collect final evidence separately so a supervisor can make an independent decision. - evidence = invoke(ctx, "collect-evidence") - ctx.emit_result({"probe_gate": {"iteration": ctx.loop_index, "evidence": evidence}}) - - -@graph.step( - "close-probe-cycle", - title="Close probe cycle", - phase="after_each", - inputs=["evidence_gate"], - outputs=["cycle_complete"], -) -@runner.phase("after_each") -def after_each(ctx): - ctx.log("Completed one evidence-gated probe matrix") - - -@graph.step( - "finalize-probe-monitor", - title="Finalize drift monitor", - phase="after_all", - inputs=["cycle_complete"], - outputs=["final_status"], -) -@runner.phase("after_all") -def after_all(ctx): - ctx.log("Finalized the evidence-gated probe evaluation") - +"""An evidence-gated probe cycle using the high-level runner recipe.""" + +from orbit_runner_kit import JsonActionCycle +from orbit_sdk import runner + +JsonActionCycle( + "ORBIT_PROBE_COMMAND", + "probe_gate", + "Probe matrix", + actions=("preflight", "prepare", "run-probes", "collect-evidence"), + payload_key="probes", +).install() if __name__ == "__main__": runner.main() diff --git a/templates/runner-templates/external-command-adapter/runner.py b/templates/runner-templates/external-command-adapter/runner.py index cc32b24..55ac701 100644 --- a/templates/runner-templates/external-command-adapter/runner.py +++ b/templates/runner-templates/external-command-adapter/runner.py @@ -1,107 +1,9 @@ -"""Run a bounded external automation through its explicit action contract.""" +"""A bounded external command adapter using the high-level runner recipe.""" -import json -import os -import shlex - -from orbit_sdk import ORBIT_PROJECT_PATH, graph, runner - -graph.connect("check-adapter", "prepare-adapter") -graph.connect("prepare-adapter", "run-adapter", label="prepared target") -graph.connect("run-adapter", "collect-adapter-evidence", kind="data", label="adapter output") -graph.connect("collect-adapter-evidence", "close-adapter-cycle") -graph.connect("close-adapter-cycle", "prepare-adapter", kind="loop", label="next cycle") -graph.connect("close-adapter-cycle", "finalize-adapter", kind="condition", label="completed") - - -def adapter_command(): - configured = os.environ.get("ORBIT_ADAPTER_COMMAND", "").strip() - if not configured: - raise ValueError("Set ORBIT_ADAPTER_COMMAND to an external tool command") - if configured.startswith("["): - value = json.loads(configured) - if not isinstance(value, list) or not all(isinstance(item, str) for item in value): - raise ValueError("ORBIT_ADAPTER_COMMAND JSON must be an array of strings") - return value - return shlex.split(configured) - - -def invoke(ctx, action): - return ctx.exec( - [*adapter_command(), action], - cwd=ORBIT_PROJECT_PATH(), - timeout=3600, - target_log_source="external-adapter", - ) - - -@graph.step("check-adapter", title="Check adapter readiness", phase="before_all", outputs=["adapter_status"]) -@runner.phase("before_all") -def before_all(ctx): - ctx.emit_result({"external_adapter": {"status": invoke(ctx, "status")}}) - - -@graph.step( - "prepare-adapter", - title="Prepare adapter cycle", - phase="before_each", - inputs=["adapter_status"], - outputs=["prepared_target"], -) -@runner.phase("before_each") -def before_each(ctx): - ctx.emit_result({"external_adapter": {"iteration": ctx.loop_index, "prepared": invoke(ctx, "prepare")}}) - - -@graph.step( - "run-adapter", - title="Run bounded adapter task", - phase="execute", - inputs=["prepared_target"], - outputs=["adapter_result"], -) -@runner.phase("execute") -def execute(ctx): - ctx.emit_result({"external_adapter": {"iteration": ctx.loop_index, "result": invoke(ctx, "run-once")}}) - - -@graph.step( - "collect-adapter-evidence", - title="Collect adapter evidence", - phase="verify", - inputs=["adapter_result"], - outputs=["adapter_evidence"], -) -@runner.phase("verify") -def verify(ctx): - ctx.emit_result( - {"external_adapter": {"iteration": ctx.loop_index, "evidence": invoke(ctx, "collect-evidence")}} - ) - - -@graph.step( - "close-adapter-cycle", - title="Close adapter cycle", - phase="after_each", - inputs=["adapter_evidence"], - outputs=["cycle_complete"], -) -@runner.phase("after_each") -def after_each(ctx): - ctx.log("Completed one bounded external adapter cycle") - - -@graph.step( - "finalize-adapter", - title="Finalize external automation", - phase="after_all", - inputs=["cycle_complete"], - outputs=["final_status"], -) -@runner.phase("after_all") -def after_all(ctx): - ctx.log("Finalized the external automation evaluation") +from orbit_runner_kit import CommandActionCycle +from orbit_sdk import runner +CommandActionCycle("ORBIT_ADAPTER_COMMAND").install() if __name__ == "__main__": runner.main() diff --git a/templates/runner-templates/json-agent-cycle/runner.py b/templates/runner-templates/json-agent-cycle/runner.py index d83690d..29ab4c6 100644 --- a/templates/runner-templates/json-agent-cycle/runner.py +++ b/templates/runner-templates/json-agent-cycle/runner.py @@ -1,149 +1,9 @@ -"""Run a portable, bounded external agent cycle. +"""A bounded external JSON agent cycle using the high-level runner recipe.""" -Set ORBIT_AGENT_COMMAND to a JSON argument array or a shell-like command -prefix. The external tool receives one action at a time: ``status`` or -``run-once``. It must write one JSON object to stdout and must never start a -daemon or scheduler; OpenOrbit owns repetition, timing, and supervision. -""" - -import json -import os -import shlex - -from orbit_sdk import graph, runner - -graph.connect("check-agent", "record-inputs") -graph.connect("record-inputs", "run-agent", label="bounded input") -graph.connect("run-agent", "confirm-agent-state", kind="data", label="agent result") -graph.connect("confirm-agent-state", "close-cycle") -graph.connect("close-cycle", "record-inputs", kind="loop", label="next cycle") -graph.connect("close-cycle", "finalize-agent", kind="condition", label="completed") - - -def agent_command(): - """Read an explicit command prefix without depending on target source files.""" - configured = os.environ.get("ORBIT_AGENT_COMMAND", "").strip() - if not configured: - raise ValueError("Set ORBIT_AGENT_COMMAND to the external agent command") - if configured.startswith("["): - value = json.loads(configured) - if not isinstance(value, list) or not all(isinstance(item, str) for item in value): - raise ValueError("ORBIT_AGENT_COMMAND JSON must be an array of strings") - return value - return shlex.split(configured) - - -def cycle_input(ctx, action): - """Expose non-secret evaluation context through one documented JSON contract.""" - return json.dumps( - { - "action": action, - "iteration": ctx.loop_index, - "build": ctx.build, - "test_cases": ctx.test_cases, - }, - ensure_ascii=False, - ) - - -def invoke(ctx, action): - """Run one bounded action and require structured evidence from the agent.""" - output = ctx.exec( - [*agent_command(), action], - cwd=ctx.project_root, - timeout=3600, - env={"ORBIT_CYCLE_INPUT": cycle_input(ctx, action)}, - target_log_source="external-agent", - ) - try: - result = json.loads(output) - except json.JSONDecodeError as error: - raise RuntimeError(f"External agent action {action!r} did not return JSON") from error - if not isinstance(result, dict): - raise RuntimeError(f"External agent action {action!r} must return a JSON object") - return result - - -@graph.step("check-agent", title="Check agent readiness", phase="before_all", outputs=["agent_status"]) -@runner.phase("before_all") -def before_all(ctx): - # Check availability once; later phases must not start an independent loop. - status = invoke(ctx, "status") - ctx.emit_result({"agent_cycle": {"status": status}}) - - -@graph.step( - "record-inputs", - title="Record cycle inputs", - phase="before_each", - inputs=["agent_status"], - outputs=["cycle_input"], -) -@runner.phase("before_each") -def before_each(ctx): - # Record the fixed inputs so every external action is auditable. - ctx.emit_result( - { - "agent_cycle": { - "iteration": ctx.loop_index, - "test_case_ids": [str(case.get("id", "")) for case in ctx.test_cases], - } - } - ) - - -@graph.step( - "run-agent", - title="Run bounded agent cycle", - phase="execute", - inputs=["cycle_input"], - outputs=["agent_result"], -) -@runner.phase("execute") -def execute(ctx): - # Exactly one unit of agent work; OpenOrbit schedules a future iteration. - result = invoke(ctx, "run-once") - ctx.emit_result({"agent_cycle": {"iteration": ctx.loop_index, "result": result}}) - - -@graph.step( - "confirm-agent-state", - title="Confirm agent state", - phase="verify", - inputs=["agent_result"], - outputs=["verified_status"], -) -@runner.phase("verify") -def verify(ctx): - # Re-read status rather than assuming the prior action completed correctly. - status = invoke(ctx, "status") - ctx.emit_result({"agent_cycle": {"iteration": ctx.loop_index, "status": status}}) - - -@graph.step( - "close-cycle", - title="Close cycle", - phase="after_each", - inputs=["verified_status"], - outputs=["cycle_complete"], -) -@runner.phase("after_each") -def after_each(ctx): - # The external process has already returned; no daemon cleanup is required. - ctx.log("Completed one bounded external agent cycle") - - -@graph.step( - "finalize-agent", - title="Finalize agent evaluation", - phase="after_all", - inputs=["cycle_complete"], - outputs=["final_status"], -) -@runner.phase("after_all") -def after_all(ctx): - ctx.log("Finalized the external agent evaluation") +from orbit_runner_kit import JsonActionCycle +from orbit_sdk import runner +JsonActionCycle("ORBIT_AGENT_COMMAND", "agent_cycle", "External agent").install() if __name__ == "__main__": runner.main() diff --git a/templates/runner-templates/native-improvement-cycle/runner.py b/templates/runner-templates/native-improvement-cycle/runner.py index b841f2a..5d44cab 100644 --- a/templates/runner-templates/native-improvement-cycle/runner.py +++ b/templates/runner-templates/native-improvement-cycle/runner.py @@ -9,7 +9,24 @@ import json import re -from orbit_sdk import graph, runner +from orbit_runner_kit import CallbackCycle +from orbit_sdk import runner as orbit_runner + + +class _LegacyDeclarations: + def connect(self, *_args, **_kwargs): + pass + + def step(self, *_args, **_kwargs): + return lambda handler: handler + + +class _LegacyRunner: + def phase(self, *_args, **_kwargs): + return lambda handler: handler + + +graph, runner = _LegacyDeclarations(), _LegacyRunner() REQUIRED_SUFFICIENT_EVALUATIONS = 3 @@ -297,5 +314,51 @@ def after_all(ctx): ctx.log("Restored the native improvement target without committing changes") +CallbackCycle( + steps=( + ("validate-target", "Validate target", "before_all", (), ("evaluation_contract",)), + ( + "prepare-prompt", + "Prepare prompt candidate", + "before_each", + ("evaluation_contract",), + ("managed_prompt",), + ), + ("exercise-target", "Exercise target AI", "execute", ("managed_prompt",), ("target_responses",)), + ( + "assess-candidate", + "Assess candidate evidence", + "verify", + ("target_responses",), + ("candidate_verdict",), + ), + ( + "retain-iteration", + "Retain iteration evidence", + "after_each", + ("candidate_verdict",), + ("iteration_snapshot",), + ), + ("restore-baseline", "Restore baseline", "after_all", ("iteration_snapshot",), ("restored_target",)), + ), + edges=( + ("validate-target", "prepare-prompt", "execution", None), + ("prepare-prompt", "exercise-target", "execution", "managed prompt"), + ("exercise-target", "assess-candidate", "data", "responses"), + ("assess-candidate", "retain-iteration", "execution", None), + ("retain-iteration", "prepare-prompt", "loop", "next evaluation"), + ("retain-iteration", "restore-baseline", "condition", "completed"), + ), +).install( + { + "validate-target": before_all, + "prepare-prompt": before_each, + "exercise-target": execute, + "assess-candidate": verify, + "retain-iteration": after_each, + "restore-baseline": after_all, + } +) + if __name__ == "__main__": - runner.main() + orbit_runner.main() diff --git a/templates/runner-templates/site-exploration/runner.py b/templates/runner-templates/site-exploration/runner.py index 9314eda..a3b1ce0 100644 --- a/templates/runner-templates/site-exploration/runner.py +++ b/templates/runner-templates/site-exploration/runner.py @@ -1,134 +1,7 @@ -# Requirements -# - The target application is running at the build's browser base URL. -# - Playwright Chromium and LangGraph are available. -# This runner follows only same-site links and excludes destructive-looking routes. - -import json -from pathlib import Path -from typing import TypedDict - -import orbit_sdk -from langgraph.graph import END, START, StateGraph -from orbit_sdk import graph as orbit_graph +from orbit_runner_kit import SiteExplorationReview from orbit_sdk import runner -orbit_graph.connect("validate-site", "explore-site") -orbit_graph.connect("explore-site", "review-evidence", kind="data", label="rendered pages") -orbit_graph.connect("review-evidence", "finalize-review") - - -class ExplorerState(TypedDict, total=False): - base_url: str - max_clicks: int - evidence: dict - opinion: str - - -def explore_browser(ctx, state): - module = str(Path(orbit_sdk.__file__).resolve().parents[1] / "frontend" / "node_modules" / "playwright") - artifacts = ( - ctx.app_data / "artifacts" / ctx.environment.get("ORBIT_RUN_ID", "manual") / f"loop-{ctx.loop_index}" - ) - artifacts.mkdir(parents=True, exist_ok=True) - payload = { - "baseUrl": state["base_url"], - "maxClicks": state["max_clicks"], - "screenshot": str(artifacts / "site-exploration.png"), - } - script = r"""const { chromium } = require(process.argv[1]); const input = JSON.parse(process.argv[2]); -const blocked = /(logout|signout|delete|remove|destroy|payment|checkout|purchase|upgrade|unsubscribe)/i; -(async () => { const browser = await chromium.launch({headless:true}); const page = await browser.newPage(); const visited = []; const origin = new URL(input.baseUrl).origin; - try { await page.goto(input.baseUrl, {waitUntil:"domcontentloaded", timeout:30000}); - for (let step = 0; step <= input.maxClicks; step++) { const text = (await page.locator("body").innerText().catch(() => "")).replace(/\s+/g, " ").slice(0, 1200); visited.push({url:page.url(), title:await page.title(), text}); - if (step === input.maxClicks) break; - const links = await page.locator("a[href]").evaluateAll(items => items.map((item, index) => ({index, href:item.href, text:(item.textContent || "").trim()})).filter(item => item.href)); - const candidates = links.filter(item => { try { const url = new URL(item.href); return url.origin === origin && !blocked.test(url.pathname + " " + item.text); } catch { return false; } }); - if (!candidates.length) break; const target = candidates[step % candidates.length]; await page.locator("a[href]").nth(target.index).click({timeout:5000}); await page.waitForLoadState("domcontentloaded", {timeout:10000}).catch(() => {}); await page.waitForTimeout(300); - } - await page.screenshot({path:input.screenshot, fullPage:true}); console.log(`site exploration completed: ${visited.length} page(s)`); console.log('__ORBIT_SITE_EXPLORATION_RESULT__'+JSON.stringify({visited, screenshot:input.screenshot})); - } finally { await browser.close(); } })().catch(error => { console.error(error); process.exit(1); });""" - output = ctx.exec( - ["node", "-e", script, module, json.dumps(payload)], - timeout=120, - target_log_source="site-exploration", - target_log_exclude_prefixes=("__ORBIT_SITE_EXPLORATION_RESULT__",), - ) - result = next( - ( - line.removeprefix("__ORBIT_SITE_EXPLORATION_RESULT__") - for line in reversed(output.splitlines()) - if line.startswith("__ORBIT_SITE_EXPLORATION_RESULT__") - ), - "", - ) - if not result: - raise RuntimeError("site exploration did not return structured evidence") - return json.loads(result) - - -def form_opinion(state): - pages = state["evidence"].get("visited", []) - titles = [str(page.get("title") or page.get("url")) for page in pages] - return { - "opinion": f"Explored {len(pages)} rendered page(s): " - + "; ".join(titles[:3]) - + ". Review the captured pages for clarity, usefulness, and friction." - } - - -def graph(ctx): - workflow = StateGraph(ExplorerState) - workflow.add_node("explore", lambda state: {"evidence": explore_browser(ctx, state)}) - workflow.add_node("form_opinion", form_opinion) - workflow.add_edge(START, "explore") - workflow.add_edge("explore", "form_opinion") - workflow.add_edge("form_opinion", END) - return workflow.compile() - - -@orbit_graph.step("validate-site", title="Validate site", phase="before_all", outputs=["site_target"]) -@runner.phase("before_all") -def before_all(ctx): - if not ctx.build.get("browser_base_url"): - raise ValueError("Set a browser base URL before exploring a site") - - -@orbit_graph.step( - "explore-site", - title="Explore rendered site", - phase="execute", - inputs=["site_target"], - outputs=["rendered_pages"], -) -@runner.phase("execute") -def execute(ctx): - result = graph(ctx).invoke({"base_url": ctx.build["browser_base_url"], "max_clicks": 3}) - ctx.emit_result({"site_exploration": {"opinion": result["opinion"], "evidence": result["evidence"]}}) - - -@orbit_graph.step( - "review-evidence", - title="Review exploration evidence", - phase="verify", - inputs=["rendered_pages"], - outputs=["product_review"], -) -@runner.phase("verify") -def verify(ctx): - ctx.log("Retained rendered exploration evidence for review") - - -@orbit_graph.step( - "finalize-review", - title="Finalize site review", - phase="after_all", - inputs=["product_review"], - outputs=["completed_review"], -) -@runner.phase("after_all") -def after_all(ctx): - ctx.log("Finalized the bounded site exploration review") - +SiteExplorationReview().install() if __name__ == "__main__": runner.main() diff --git a/templates/runner-templates/user-journey-cycle/runner.py b/templates/runner-templates/user-journey-cycle/runner.py index a24ecf7..395892c 100644 --- a/templates/runner-templates/user-journey-cycle/runner.py +++ b/templates/runner-templates/user-journey-cycle/runner.py @@ -1,211 +1,9 @@ -# Requirements -# - The target application is running at the build's browser base URL. -# - The build selects at least one fixed test case. -# - Playwright Chromium and its operating-system libraries are available. -# No external runner script, adapter repository, or background program is required. +"""A recurring browser journey using OpenOrbit's high-level runner recipe.""" -import json -import re +from orbit_runner_kit import RecurringBrowserJourney +from orbit_sdk import runner -from orbit_sdk import graph, runner - -graph.connect("validate-journey", "plan-journey") -graph.connect("plan-journey", "run-journey", label="focused cases") -graph.connect("run-journey", "review-journey", kind="data", label="browser evidence") -graph.connect("review-journey", "retain-journey") -graph.connect("retain-journey", "plan-journey", kind="loop", label="next iteration") -graph.connect("retain-journey", "finalize-journey", kind="condition", label="completed") - - -# Validate only configuration that the runner cannot safely infer. This runs -# once when an evaluation process starts, before its iteration loop. -def validate(ctx): - build = ctx.build - if not build.get("browser_base_url"): - raise ValueError("Set a browser base URL on the build") - if not ctx.test_cases: - raise ValueError("Select a fixed test case set before running a user journey") - - -def state_path(ctx): - # Keep state in OpenOrbit AppData, keyed by build, so a later iteration can - # resume its focused journey without writing into the target repository. - build_id = re.sub(r"[^a-zA-Z0-9_-]+", "-", str(ctx.build.get("id") or "manual")) - directory = ctx.app_data / "user-journey-state" - directory.mkdir(parents=True, exist_ok=True) - return directory / f"{build_id}.json" - - -def load_state(ctx): - # A build's first iteration begins with an empty rotation and no failures. - path = state_path(ctx) - if not path.exists(): - return {"next_case_index": 0, "failed_case_ids": [], "history": []} - return json.loads(path.read_text(encoding="utf-8")) - - -def save_state(ctx, state): - # Retain a bounded history so a long-running evaluation does not grow - # indefinitely while still preserving useful handoffs. - state["history"] = state.get("history", [])[-24:] - state_path(ctx).write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") - - -def plan(ctx, state): - # Supervisor feedback from the completed prior iteration is an input to - # planning, not a replacement for browser-observable evidence. - feedback = ctx.previous_supervisor_feedback - failed = set(state.get("failed_case_ids", [])) - cases = ctx.test_cases - # Failed cases take precedence; otherwise rotate through fixed cases one at - # a time to keep each scheduled iteration bounded and explainable. - focused = [case for case in cases if case.get("id") in failed] - if not focused: - index = int(state.get("next_case_index", 0)) % len(cases) - focused = [cases[index]] - rules = [ - "Preserve observable evidence for every browser action.", - "Do not infer a result that the page did not expose.", - ] - if failed: - rules.insert(0, "Revisit previously failed journeys before exploring a new route.") - if feedback.get("reported_issues"): - rules.insert(0, "Prioritize the supervisor's previously reported issues.") - reason = ( - "Previously failed journeys require confirmation." - if failed - else "Rotate one fixed journey to retain broad, bounded coverage." - ) - return { - "case_ids": [str(case.get("id")) for case in focused], - "rules": rules, - "reason": reason, - "supervisor_feedback": feedback, - } - - -@graph.step( - "validate-journey", title="Validate journey contract", phase="before_all", outputs=["journey_contract"] -) -@runner.phase("before_all") -def before_all(ctx): - # Process-level preparation: run once before OpenOrbit starts repeating. - validate(ctx) - ctx.log("Validated the bounded user-journey contract") - - -@graph.step( - "plan-journey", - title="Plan focused journey", - phase="before_each", - inputs=["journey_contract"], - outputs=["journey_plan"], -) -@runner.phase("before_each") -def before_each(ctx): - # Iteration-level preparation: persist a plan that the execute phase consumes. - state = load_state(ctx) - journey_plan = plan(ctx, state) - state["plan"] = journey_plan - save_state(ctx, state) - ctx.emit_result( - { - "user_journey": { - "iteration": ctx.loop_index, - "case_count": len(ctx.test_cases), - "plan": journey_plan, - } - } - ) - ctx.log(f"Planned {len(journey_plan['case_ids'])} focused journey case(s): {journey_plan['reason']}") - - -@graph.step( - "run-journey", - title="Run browser journey", - phase="execute", - inputs=["journey_plan"], - outputs=["journey_evidence"], -) -@runner.phase("execute") -def execute(ctx): - # Execute only the focused fixed cases; Playwright returns screenshots and - # page evidence that can be inspected by both users and the supervisor. - state = load_state(ctx) - journey_plan = state.get("plan") or plan(ctx, state) - case_ids = set(journey_plan["case_ids"]) - focused_cases = [case for case in ctx.test_cases if str(case.get("id")) in case_ids] - evidence = ctx.playwright_journey(focused_cases) - results = evidence["results"] - passed = len([item for item in results if item["passed"]]) - failed = [str(item.get("id")) for item in results if not item["passed"]] - state["failed_case_ids"] = failed - state["next_case_index"] = (int(state.get("next_case_index", 0)) + 1) % len(ctx.test_cases) - # This compact handoff is the explicit input to the next scheduled cycle. - state["handoff"] = { - "iteration": ctx.loop_index, - "reason": journey_plan["reason"], - "rules": journey_plan["rules"], - "passed": passed, - "failed": len(results) - passed, - "failed_case_ids": failed, - } - state.setdefault("history", []).append(state["handoff"]) - save_state(ctx, state) - ctx.emit_result( - { - "user_journey": { - "iteration": ctx.loop_index, - "plan": journey_plan, - "passed": passed, - "failed": len(results) - passed, - "results": results, - "evidence": evidence, - "handoff": state["handoff"], - } - } - ) - - -@graph.step( - "review-journey", - title="Review journey evidence", - phase="verify", - inputs=["journey_evidence"], - outputs=["journey_handoff"], -) -@runner.phase("verify") -def verify(ctx): - # Expose the persisted handoff as structured run output for supervision. - state = load_state(ctx) - ctx.emit_result( - {"user_journey": {"next_iteration": state.get("handoff", {}), "state_path": str(state_path(ctx))}} - ) - ctx.log("Stored the journey summary, reasons, and behavior rules for the next iteration") - - -@graph.step( - "retain-journey", - title="Retain journey result", - phase="after_each", - inputs=["journey_handoff"], - outputs=["iteration_complete"], -) -@runner.phase("after_each") -def after_each(ctx): - ctx.log("Closed this bounded browser journey") - - -@graph.step( - "finalize-journey", - title="Finalize journey evaluation", - phase="after_all", - inputs=["iteration_complete"], - outputs=["final_status"], -) -@runner.phase("after_all") -def after_all(ctx): - ctx.log("Finalized the user-journey evaluation") +RecurringBrowserJourney(namespace="user_journey", title="User journey").install() if __name__ == "__main__": From 51a32bfc0c5dd62873d379b51e6281437892c7d7 Mon Sep 17 00:00:00 2001 From: forthfate <52389553+forthfate@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:04:08 +0900 Subject: [PATCH 07/42] feat: show asset build usage (#107) * feat: show asset build usage * chore: shorten asset usage column labels --- frontend/src/domain/asset-usage.ts | 31 +++++++++++++++++ frontend/src/features/assets/page.tsx | 48 ++++++++++++++++++++------ frontend/src/locales/languages/en.json | 1 + frontend/src/locales/languages/ja.json | 1 + frontend/src/locales/languages/ko.json | 1 + frontend/src/main.tsx | 1 + frontend/src/theme-overrides.css | 5 +-- 7 files changed, 75 insertions(+), 13 deletions(-) create mode 100644 frontend/src/domain/asset-usage.ts diff --git a/frontend/src/domain/asset-usage.ts b/frontend/src/domain/asset-usage.ts new file mode 100644 index 0000000..75cfdb0 --- /dev/null +++ b/frontend/src/domain/asset-usage.ts @@ -0,0 +1,31 @@ +import type { Build } from "./models"; + +export type AssetUsage = { + profiles: Map; + executionEnvironments: Map; + targetEnvironments: Map; + runners: Map; + promptTemplates: Map; + testCaseSets: Map; +}; + +const countReferences = (builds: Build[], key: keyof Build) => { + const usage = new Map(); + for (const build of builds) { + const value = build[key]; + if (typeof value === "string" && value) { + usage.set(value, (usage.get(value) ?? 0) + 1); + } + } + return usage; +}; + +/** Counts current build references; historic run snapshots are intentionally excluded. */ +export const buildAssetUsage = (builds: Build[]): AssetUsage => ({ + profiles: countReferences(builds, "model_profile_name"), + executionEnvironments: countReferences(builds, "execution_environment_id"), + targetEnvironments: countReferences(builds, "target_environment_id"), + runners: countReferences(builds, "runner_id"), + promptTemplates: countReferences(builds, "manager_template_id"), + testCaseSets: countReferences(builds, "test_case_set_id"), +}); diff --git a/frontend/src/features/assets/page.tsx b/frontend/src/features/assets/page.tsx index ae32bc4..ec58d16 100644 --- a/frontend/src/features/assets/page.tsx +++ b/frontend/src/features/assets/page.tsx @@ -10,8 +10,9 @@ import { Plus, Trash2, } from "lucide-react"; -import { Children, isValidElement, useEffect, useRef, useState } from "react"; +import { Children, isValidElement, useEffect, useMemo, useRef, useState } from "react"; import type { + Build, ExecutionEnvironment, PromptTemplate, RunnerAsset, @@ -24,6 +25,7 @@ import type { WorkflowGraphDefinition, WorkflowStep, } from "../../domain/models"; +import { buildAssetUsage, type AssetUsage } from "../../domain/asset-usage"; import { localeMessageMap, localeMessages, @@ -95,17 +97,17 @@ function AssetCatalog({ locale?: Locale; }) { const [sort, setSort] = useState<{ - key: "name" | "createdAt"; + key: "name" | "createdAt" | "usageCount"; direction: "asc" | "desc"; }>({ key: "createdAt", direction: "desc" }), rows = Children.toArray(children).filter(isValidElement).sort((left, right) => { const a = String( - (left.props as { name?: string; detail?: string; createdAt?: string })[ + (left.props as { name?: string; detail?: string; createdAt?: string; usageCount?: number })[ sort.key ] ?? "", ); const b = String( - (right.props as { name?: string; detail?: string; createdAt?: string })[ + (right.props as { name?: string; detail?: string; createdAt?: string; usageCount?: number })[ sort.key ] ?? "", ); @@ -132,13 +134,11 @@ function AssetCatalog({ {loading ? : rows.length ? ( <>
- {(["name", "createdAt"] as const).map((key) => { + {(["name", "usageCount", "createdAt"] as const).map((key) => { const Icon = icon(key); return ( ); @@ -164,6 +164,7 @@ function Catalog({ runners, onRefresh, onDelete, + runnerUsage, loading = false, locale, }: { @@ -176,14 +177,15 @@ function Catalog({ runners?: RunnerAsset[]; onRefresh?: () => Promise; onDelete?: (kind: "runner", id: string) => void; + runnerUsage?: AssetUsage["runners"]; loading?: boolean; locale: Locale; }) { const isLegacyWorkflowSection = title === text[locale].flows; return ( <> - {showRunners && runners && onRefresh && onDelete && ( - + {showRunners && runners && onRefresh && onDelete && runnerUsage && ( + )}{" "} {!isLegacyWorkflowSection && (
@@ -686,6 +688,7 @@ function AssetRow({ name, detail, createdAt, + usageCount = 0, locale, onClick, onDelete, @@ -694,6 +697,7 @@ function AssetRow({ name: string; detail: string; createdAt?: string; + usageCount?: number; locale?: Locale; onClick: () => void; onDelete: () => void; @@ -705,6 +709,7 @@ function AssetRow({ {name} {detail} + {usageCount}
+ {blockedChecks.length > 0 && ( +
+
+ )} {children}