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 && (
+
+
+
+
{readinessCopy.title}
+
{readinessCopy.description}
+
+ {blockedChecks.map((check) => {
+ const copy = readinessCopy.checks[check.id];
+ return - {copy?.title ?? check.id}: {copy?.details[check.detail] ?? check.detail}
;
+ })}
+
+
+ {blockedChecks.some((check) => check.settings_page === "settings") && (
+
+ )}
+
+ )}
{children}
)}
+ {selected.proposal.agent_change && (
+
+ {t.agentProposalDiff}
+ {agentDiff?.proposalId === selected.proposal_id && agentDiff.value ? : {t.noAgentProposalChanges}
}
+
+ )}
+ {selected.proposal.agent_change && (
+
+ {t.agentProposalBranch}
+ {selected.proposal_branch || "—"}
+ {selected.proposal_commit && {t.committed}: {selected.proposal_commit}
}
+ {selected.proposal_action === "pending" && (
+
+
+
+
+ )}
+
+ )}